commit 5980d3072e270383a686644e099051568ee249e3 equwal <13551856+equwal@users.noreply.github.com> 2026-09-22 15:17:39 -0700 ideamine 0.9.0: drag a card on the board A card on the board is draggable now, and each lane takes it. A drop in Doing opens Claude Code on the idea, where the Claude Code login is, like the button Build with Claude. A page without Claude Code only moves the card, so the machine with the login can build it. A drop in Do, Maybe, or Skip changes the verdict of the triage and nothing else: the impact, the size, the model, and the text stay. A drop in Inbox, Done, or Dropped sets that status. The new action `verdict` makes the change for the three triage lanes.
.claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- README.md | 13 ++++++++ dashboard/index.html | 66 +++++++++++++++++++++++++++++++++++++++-- package.json | 2 +- src/serve.js | 15 ++++++++++ tests/serve.test.js | 17 +++++++++++ 7 files changed, 112 insertions(+), 5 deletions(-)
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4ddd6cc..b20ab73 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "ideamine", "description": "An idea inbox for Claude Code. /idea saves an idea without a model call. Claude triages the ideas and picks the cheapest model that can build each one.", - "version": "0.8.0", + "version": "0.9.0", "author": { "name": "equwal" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 798f583..e29d470 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ideamine", - "version": "0.8.0", + "version": "0.9.0", "description": "An idea inbox for Claude Code. /idea saves an idea without a model call, so it works at any time, even at your usage limit. Claude triages the ideas and picks the cheapest model that can build each one.", "author": { "name": "equwal", diff --git a/README.md b/README.md index 53f2b34..37915e6 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,19 @@ server { The board, the timeline, the groups, and the search show `/ideas`, `/ideas-ls`, `/ideas-cat`, `/ideas-groups`, and `/ideas-find`. `/ideas-all` has no button, because it needs the chat that it works in. +### Drag a card + +On the board, drag a card into another lane. The lane says what happens: + +| Lane | What the drag does | +|---|---| +| **Doing** | Where Claude Code runs, a new window opens Claude Code on the idea, like **Build with Claude**. On a page without Claude Code, the card only moves to Doing. | +| **Do**, **Maybe**, **Skip** | Only the verdict of the triage changes. The impact, the size, the model, and the text of the triage stay. | +| **Inbox** | The idea waits for a new triage. | +| **Done**, **Dropped** | The same as the **Done** and **Drop** buttons, but without a note. | + +A drag needs a mouse or a trackpad. On a touch screen, use the buttons on the ticket. + Triage, Build, Ask, and the watcher need the Claude Code login, so they show only where Claude Code runs. The commands run on your PC, because your Claude Code login is there. Thus the server listens on `127.0.0.1` only, and it takes commands only from its own page: each command must be JSON from the same origin, and the Host header must name the server. Another web page in your browser cannot send commands to it, and no other page can show it in a frame. After a change, the server uploads the dashboard again when `publish_url` is set, so the copy on your dashboard server stays current. That copy has no buttons: it shows "Read-only copy". `/ideas-web off` stops the server. In a terminal, `ideamine serve` runs it in the foreground. To use another port, run `ideamine config serve_port 5000`. The log is `~/.ideamine/serve.log`. ## One archive for every machine diff --git a/dashboard/index.html b/dashboard/index.html index 8e2ec57..c421779 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -106,6 +106,9 @@ main { padding: 12px; } .column-count { color: var(--text-muted); font-weight: 400; } .column-body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-height: 12px; } .column-empty { color: var(--text-muted); font-size: 12px; padding: 4px; } +.column.drop-over { border-color: var(--accent); background: var(--surface-2); } +.card.dragging { opacity: .45; } +.card[draggable="true"] { cursor: grab; } /* ticket cards */ .card { display: block; width: 100%; text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; cursor: pointer; } @@ -415,6 +418,7 @@ const state = { searchResult: null, busy: false, tableSort: { key: 'rank', dir: 1 }, + drag: null, // the card that the user drags on the board: { id, lane } prompts: { days: 1, list: null, error: null, filter: '' }, memory: { overview: null, error: null, project: null, memories: new Map(), histories: new Map() }, }; @@ -592,11 +596,67 @@ function ideaCard(idea, opts) { if (idea.project) meta.push(el('span', { class: 'card-project' }, idea.project)); for (const t of idea.tags || []) meta.push(el('span', { class: 'chip chip-tag' }, t)); - return el('button', { class: 'card' + (opts.compact ? ' card-compact' : ''), type: 'button', onclick: () => openTicket(idea.key) }, + const card = el('button', { class: 'card' + (opts.compact ? ' card-compact' : ''), type: 'button', onclick: () => openTicket(idea.key) }, top, el('div', { class: 'card-title' }, idea.title), lastNote ? el('div', { class: 'card-note', title: lastNote.text }, lastNote.text) : null, meta.length ? el('div', { class: 'card-meta' }, meta) : null); + if (isLive()) makeDraggable(card, idea); + return card; +} + +/* ---- drag and drop on the board ---- */ + +/** The card tells the board which idea moves. A drop then runs the command for the new lane. */ +function makeDraggable(card, idea) { + card.draggable = true; + card.addEventListener('dragstart', (e) => { + state.drag = { id: idea.id, lane: idea.lane }; + card.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', idea.key); + }); + card.addEventListener('dragend', () => { + card.classList.remove('dragging'); + state.drag = null; + }); +} + +/** + * The command for a card that the user drops in `lane`, or null when the drop changes nothing. + * A drop in Doing starts the build where Claude Code is. A page without Claude Code only moves the + * idea, and the machine with the login builds it. + */ +function dropCommand(idea, lane) { + if (!idea || idea.lane === lane) return null; + const live = state.data.live || {}; + if (lane === 'doing' && live.claude && live.window) { + return { action: 'go', body: { id: idea.id }, busy: 'Opening Claude Code for ' + idea.key + '…' }; + } + if (['doing', 'done', 'dropped', 'inbox'].includes(lane)) return { action: 'update', body: { id: idea.id, status: lane } }; + if (['do', 'maybe', 'skip'].includes(lane)) return { action: 'verdict', body: { id: idea.id, verdict: lane } }; + return null; +} + +/** A lane column takes a card. */ +function makeDropTarget(column, lane) { + column.addEventListener('dragover', (e) => { + if (!state.drag || state.drag.lane === lane) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + column.classList.add('drop-over'); + }); + column.addEventListener('dragleave', (e) => { + if (!column.contains(e.relatedTarget)) column.classList.remove('drop-over'); + }); + column.addEventListener('drop', (e) => { + e.preventDefault(); + column.classList.remove('drop-over'); + const drag = state.drag; + state.drag = null; + const command = drag && dropCommand(state.byId.get(drag.id), lane); + if (command) runAction(command.action, command.body, command.busy); + }); } /* ---- header: counts + relative update time ---- */ @@ -633,12 +693,14 @@ function renderTabsActive() { function renderColumn(lane) { const ideas = state.data.ideas.filter((i) => i.lane === lane).sort((a, b) => a.rank - b.rank); - return el('div', { class: 'column' }, + const column = el('div', { class: 'column' }, el('div', { class: 'column-header' }, el('span', { class: 'column-dot', style: '--chip-color:var(--lane-' + lane + ')' }), el('span', { class: 'column-title' }, LANE_LABELS[lane]), el('span', { class: 'column-count' }, String(ideas.length))), el('div', { class: 'column-body' }, ideas.length ? ideas.map((i) => ideaCard(i)) : el('p', { class: 'column-empty' }, 'No tickets'))); + if (isLive()) makeDropTarget(column, lane); + return column; } function renderBoard() { diff --git a/package.json b/package.json index 1a7dfa5..24896ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ideamine", - "version": "0.8.0", + "version": "0.9.0", "description": "An idea inbox for Claude Code. /idea saves an idea without calling the model; Claude later triages each idea and picks the cheapest model that can build it.", "type": "module", "bin": { diff --git a/src/serve.js b/src/serve.js index 65bd502..55ddbbd 100644 --- a/src/serve.js +++ b/src/serve.js @@ -347,6 +347,21 @@ const ACTIONS = { return `✓ ${bits.join(' · ')}`; }, + /** + * A drag on the board into the Do, Maybe, or Skip lane. Only the verdict changes. The rest of the + * triage (impact, size, model, why, brief) stays as it is. + */ + async verdict({ id, verdict }) { + const db = await archive.fresh(); + const idea = store.findIdea(db, id); + if (!idea) throw new Error(`no idea #${id}`); + const out = await archive.triage([{ ...(idea.triage || {}), id: idea.id, verdict }], { by: 'web' }); + if (out.queued) return archive.queuedText(out); + const failed = Array.isArray(out) && out[0] && out[0].error; + if (failed) throw new Error(failed); + return `✓ #${idea.id} ${clip(idea.title, 60)} → ${verdict}`; + }, + /** /ideas-sort */ async sort() { const { headlessTriage } = await import('./claude.js'); diff --git a/tests/serve.test.js b/tests/serve.test.js index 0e577dc..cbe86c7 100644 --- a/tests/serve.test.js +++ b/tests/serve.test.js @@ -356,3 +356,20 @@ test('a window that failed waits for a key only when a person can press one', () assert.equal(r.status, 1); assert.match(r.stderr, /no idea #9/); }); + +test('a drag to the Do, Maybe, or Skip lane changes only the verdict', async () => { + await api('add', { text: '- alpha' }); + store.applyTriage([{ id: 1, verdict: 'do', impact: 5, size: 'l', model: 'opus', why: 'because', brief: 'plan' }]); + + assert.equal((await api('verdict', { id: 1, verdict: 'maybe' })).message, '✓ #1 alpha → maybe'); + const idea = store.findIdea(store.load(), 1); + assert.equal(store.lane(idea), 'maybe'); + // The triage of Claude stays. Only the lane of the card moves. + assert.deepEqual( + [idea.triage.impact, idea.triage.size, idea.triage.model, idea.triage.why, idea.triage.brief, idea.triage.by], + [5, 'l', 'opus', 'because', 'plan', 'web'], + ); + + assert.equal((await api('verdict', { id: 1, verdict: 'nope' })).error, 'verdict must be one of do/maybe/skip'); + assert.equal((await api('verdict', { id: 9, verdict: 'do' })).error, 'no idea #9'); +});