From 41c4c151e4ea2269cafbc8de3f2f31fbe39ed677 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 09:01:57 +0000 Subject: [PATCH] Add "pin to assistant" button to notes + Ctrl+click multi-staging The robot button that hands a task to the assistant now lives on note rows too, and both buttons support gathering several items before switching over. - Ctrl/Cmd+click a robot button stages the task/note without opening the assistant, so multiple tasks and/or notes can be queued, then delivered together when the user comes over. A plain click still pins and switches. - Pins accumulate in a localStorage queue (assistantPinQueue) instead of a single key. The bridge drains it only while the assistant iframe is visible, so background staging never delivers early, and posts one `simpler-pin` batch of {kind, id} refs. - Backend on_pin_refs injects each item's context block (task + linked note, or full note content) and seeds the composer with all refs. The legacy single-task path is kept for older bridge builds. - The Assistant nav tab shows a small count badge of staged items, kept in sync via a storage event when the bridge clears the queue. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YJMPjXGqtmikQgaHFHv9Vi --- .opencode/context/topics/chat-assistant.md | 2 +- chat/chainlit_app.py | 47 ++++++++++++ chat/public/simpler-bridge.js | 86 +++++++++++++++------- src/static/css/style.css | 53 ++++++++++++- src/static/js/app.js | 80 +++++++++++++++++--- src/static/js/notes.js | 17 +++++ 6 files changed, 247 insertions(+), 38 deletions(-) diff --git a/.opencode/context/topics/chat-assistant.md b/.opencode/context/topics/chat-assistant.md index 3f537f7..d8a9cb9 100644 --- a/.opencode/context/topics/chat-assistant.md +++ b/.opencode/context/topics/chat-assistant.md @@ -32,7 +32,7 @@ - **Slash commands** (`chat/commands.py::handle_command`): inject workspace entities into the conversation context as a markdown block so the model can act on them without round-trips. Commands: `/task `, `/note `, `/tasks` (board), `/notes` (list), `/skill ` (inject a skill body). `is_known()` gates dispatch. The block is prepended as a system/context message, never stored as a user message. - **Starters** (`chat/commands.py::build_starters`): starter buttons built from the user's Doing tasks plus generic ones; labels open with an emoji char (`▶`/`✨` — no icon-font dependency). Clicking does NOT auto-send: `simpler-bridge.js` intercepts the click (capture phase, before React) and posts the label to the backend (`on_starter_click` via `@cl.on_window_message`); task starters run the `/task ` injection (system message lands in the thread), then the backend answers with the editable `prefill` seed (`# — `) which the bridge writes into the composer (native value setter + `input` event on `#chat-input`) — the user adds their angle and sends. Without the bridge, clicks degrade to sending the bare seed. - **Starter freshness**: Chainlit ships starters inside the config it fetches ONCE per page load, so a board change left them stale until F5. `app.js::publishAssistantStartersRev()` writes a signature of the Doing set to `localStorage.assistantStartersRev` on every `loadTasks()`; the bridge compares it each tick and reloads the iframe **only while the welcome screen is up** (`#starters` present, composer empty, no pin in flight) — the one state where a reload costs nothing. Mid-conversation the starters aren't on screen anyway and the next New Chat gets them fresh. -- **Pin a task to the assistant**: the robot button on a board card (`.board-card-assist`, left of the priority badge) calls `app.js::pinTaskToAssistant()` → `localStorage.assistantPinnedTask` + switch to the Assistant tab. localStorage rather than postMessage because the iframe is lazy-loaded and the first pin predates the bridge. The bridge consumes the key, posts `simpler-pin-task` (re-posting each tick until the backend's prefill arrives — a cold iframe's socket may not be up yet, capped at 5 tries), and `chainlit_app.py::on_pin_task` runs the same `inject_and_prefill('task', '# — ')` path as a starter click — so it works mid-conversation too. +- **Pin tasks/notes to the assistant**: the robot button on a board card (`.board-card-assist`, left of the priority badge) and on a note row (`.note-row-assist`, top-right) both call `app.js::pinToAssistant(kind, id, stay)` → append `{kind, id}` to the `localStorage.assistantPinQueue` array. A plain click switches to the Assistant tab; **Ctrl/Cmd+click stages** the item and stays put (`stay=true`), so several tasks and/or notes pile up before the user comes over — the Assistant nav tab shows a `.nav-pin-badge` count (`updateAssistantPinBadge()`, kept in sync by a `storage` listener that fires when the bridge clears the queue). localStorage-as-queue rather than postMessage because the iframe is lazy-loaded and the first pin predates the bridge. The bridge drains the queue **only while the iframe is visible** (`assistantVisible()` via `frameElement.offsetParent`) — so background staging never delivers early; everything staged lands together the moment the tab opens. It posts one `simpler-pin` batch (`{refs:[{kind,id}…]}`, re-posted each tick until the backend's prefill arrives — a cold iframe's socket may not be up yet, capped at 5 tries), and `chainlit_app.py::on_pin_refs` injects each item's context block (task → `/task` +linked note, note → `/note` full content) then seeds the composer with all refs (`#` for tasks, `note #` for notes) — works mid-conversation too. The legacy single-task `simpler-pin-task` → `on_pin_task` path is kept for older bridge builds. - **Workspace context** (`chat/workspace.py`): turns tasks/notes/spaces into markdown blocks. House rule: whenever a TASK is injected, its linked note is injected too (`format_task` takes an optional `note`). `format_task_board` groups by status (`doing/todo/blocked/done`); `format_spaces_guidance` frames per-space `context_markdown` as guide-not-source (same framing as `src/prompt_context.py`'s `space_guidance_block`). ## Workspace files (Bundle C, PRD 003) diff --git a/chat/chainlit_app.py b/chat/chainlit_app.py index e5bb562..eff130d 100644 --- a/chat/chainlit_app.py +++ b/chat/chainlit_app.py @@ -195,7 +195,10 @@ async def on_window_message(message): cl.user_session.set('space_filter', None) elif data.get('type') == 'simpler-starter-click': await on_starter_click(data.get('label') or '') + elif data.get('type') == 'simpler-pin': + await on_pin_refs(data.get('refs')) elif data.get('type') == 'simpler-pin-task': + # Legacy single-task pin (kept for older bridge builds). await on_pin_task(data.get('task_id')) @@ -268,6 +271,50 @@ async def on_pin_task(task_id): await inject_and_prefill('task', f"#{task_id} — ") +# Composer seed per kind: tasks keep the bare "#id" ref (matches the starters); +# notes get a "note #id" ref so a mixed batch reads unambiguously. +_PIN_SEED = {'task': lambda i: f"#{i}", 'note': lambda i: f"note #{i}"} + + +async def on_pin_refs(refs): + """The shell's robot buttons (board cards and note rows) hand one or more + tasks/notes over at once (src/static/js/app.js → localStorage queue → + chat/public/simpler-bridge.js). Inject each item's context block — a task + brings its linked note along, a note its full content — then seed the + composer with references to everything staged. Works on a running + conversation too, exactly like clicking each item's starter.""" + if not isinstance(refs, list): + return + seeds = [] + injected = False + for ref in refs: + if not isinstance(ref, dict): + continue + command = ref.get('kind') + if command not in _PIN_SEED: + continue + try: + item_id = int(ref.get('id')) + except (TypeError, ValueError): + continue + block, error = await commands.handle_command( + command, f"#{item_id}", selected_space_ids()) + if error: + await cl.Message(content=error).send() + elif block: + await cl.Message(content=block, type='system_message', + author='Workspace context').send() + injected = True + seeds.append(_PIN_SEED[command](item_id)) + if not seeds: + return + # Even if every lookup errored (nothing injected), still answer with a + # prefill so the bridge stops re-posting the batch. + prefill = ' '.join(seeds) + ' — ' + await cl.send_window_message( + {'type': 'simpler-starter-prefill', 'prefill': prefill}) + + async def register_commands(simpler: bool = True): """Publish the composer's slash commands. In Generic mode only the domain-agnostic ones survive (`/skill`) — the workspace injectors would diff --git a/chat/public/simpler-bridge.js b/chat/public/simpler-bridge.js index 7f158e4..83a9321 100644 --- a/chat/public/simpler-bridge.js +++ b/chat/public/simpler-bridge.js @@ -126,55 +126,91 @@ window.addEventListener('load', function () { setTimeout(tick, 500); }); } - /* "Work on this with the assistant": the board card's robot button - * (src/static/js/app.js) hands a task over through localStorage, then - * switches to the Assistant tab. + /* "Work on this with the assistant": the shell's robot buttons (board + * cards and note rows, src/static/js/app.js) hand tasks/notes over through + * a localStorage queue (`assistantPinQueue`, a JSON array of + * {kind:'task'|'note', id}). A plain click also switches to the Assistant + * tab; Ctrl+click stages an item and stays put, so several can pile up + * before the user comes over. * * localStorage rather than postMessage because the shell lazy-loads this * iframe — the very first pin happens BEFORE this script exists, so the - * handoff has to be a value that waits for us, not an event. The key is - * read once and removed; the pin is then re-posted every tick until the - * backend answers with its prefill (the socket may not be up yet on a - * cold iframe), then dropped. + * handoff has to be a value that waits for us, not an event. The queue is + * drained (read once and removed) only while this iframe is actually on + * screen, so background staging never delivers early; everything staged is + * then handed over together the moment the Assistant tab opens. The batch + * is re-posted every tick until the backend answers with its prefill (the + * socket may not be up yet on a cold iframe), then dropped. * - * Backend side: @cl.on_window_message → on_pin_task, same injection the + * Backend side: @cl.on_window_message → on_pin_refs, same injection the * task starters run. */ - var pendingPin = null; + var PIN_QUEUE_KEY = 'assistantPinQueue'; + var pendingPins = null; - function readPinnedTask() { + // Only deliver while the Assistant view is visible: the parent hides this + // iframe (display:none) on other tabs, so a hidden frame has no offsetParent. + function assistantVisible() { + try { + var fe = window.frameElement; + if (!fe) return true; // not framed (tests) → assume visible + return fe.offsetParent !== null; + } catch (e) { + return true; + } + } + + function readPinnedRefs() { var raw; try { - raw = window.localStorage.getItem('assistantPinnedTask'); + raw = window.localStorage.getItem(PIN_QUEUE_KEY); if (!raw) return; - window.localStorage.removeItem('assistantPinnedTask'); + window.localStorage.removeItem(PIN_QUEUE_KEY); } catch (e) { return; } - var pin; - try { pin = JSON.parse(raw); } catch (e) { return; } - if (pin && pin.task_id) pendingPin = { task_id: pin.task_id, tries: 0 }; + var queue; + try { queue = JSON.parse(raw); } catch (e) { return; } + if (!Array.isArray(queue)) return; + var refs = []; + for (var i = 0; i < queue.length; i++) { + var p = queue[i]; + if (p && p.id != null && (p.kind === 'task' || p.kind === 'note')) { + refs.push({ kind: p.kind, id: p.id }); + } + } + if (!refs.length) return; + // A batch that arrives mid-flight (rapid staging) joins the pending one. + if (pendingPins) { + pendingPins.refs = pendingPins.refs.concat(refs); + pendingPins.tries = 0; + } else { + pendingPins = { refs: refs, tries: 0 }; + } } function pumpPinnedTask() { - readPinnedTask(); - if (!pendingPin) return; + // Draining a hidden iframe would deliver staged pins into a conversation + // the user hasn't opened yet — wait until the Assistant view is on screen. + if (!assistantVisible()) return; + readPinnedRefs(); + if (!pendingPins) return; // The composer only exists once the chat session is mounted; posting // before that goes nowhere (nothing relays it to the backend yet). if (!document.getElementById('chat-input')) return; - if (pendingPin.tries++ > 5) { - pendingPin = null; + if (pendingPins.tries++ > 5) { + pendingPins = null; return; } window.postMessage(JSON.stringify({ - type: 'simpler-pin-task', - task_id: pendingPin.task_id + type: 'simpler-pin', + refs: pendingPins.refs }), window.location.origin); } // Same-origin parent writes fire `storage` here: pin without waiting for - // the next poll when the iframe is already loaded. + // the next poll when the iframe is already loaded and visible. window.addEventListener('storage', function (event) { - if (event.key === 'assistantPinnedTask') pumpPinnedTask(); + if (event.key === PIN_QUEUE_KEY) pumpPinnedTask(); }); /* Starters are the tasks in Doing, and Chainlit ships them inside the @@ -195,7 +231,7 @@ var rev = readStartersRev(); if (rev === startersRev) return; startersRev = rev; - if (pendingPin) return; // a pin is mid-flight + if (pendingPins) return; // a pin is mid-flight if (!document.getElementById('starters')) return; var input = document.getElementById('chat-input'); if (input && input.value.trim()) return; // don't eat a draft @@ -263,7 +299,7 @@ try { data = JSON.parse(data); } catch (e) { return; } } if (!data || data.type !== 'simpler-starter-prefill') return; - pendingPin = null; // the backend answered: stop re-posting the pin + pendingPins = null; // the backend answered: stop re-posting the pins setComposerText(data.prefill || ''); } diff --git a/src/static/css/style.css b/src/static/css/style.css index 7dc5bd8..13992dc 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -727,6 +727,25 @@ body { padding: 1px 4px; } +/* Count of tasks/notes staged for the assistant (app.js updateAssistantPinBadge). + Sits on the Assistant tab; a coral pill so it reads as "waiting for you". */ +.nav-pin-badge { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #f6ad55; + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 18px; + text-align: center; +} +.nav-tab.active .nav-pin-badge { + background: #fff; + color: #764ba2; +} + .quick-capture { display: flex; gap: 6px; @@ -1201,8 +1220,8 @@ body { } /* Left of the priority badge: hand this task to the assistant (app.js - pinTaskToAssistant). Dim until the card is hovered — it is a shortcut, not - part of the card's information. */ + pinToAssistant; Ctrl+click stages without opening). Dim until the card is + hovered — it is a shortcut, not part of the card's information. */ .board-card-assist { width: 22px; height: 22px; @@ -1627,8 +1646,38 @@ body { padding: 0.6rem 0.4rem; border-bottom: 1px solid #f1f3f5; cursor: pointer; + /* Anchor for the absolutely-positioned assistant button (top-right). */ + position: relative; } .note-row:hover { background: #f8f9fa; } + +/* Robot button on a note row: hand this note to the assistant (notes.js + pinToAssistant). Mirrors .board-card-assist — dim until the row is hovered, + since it is a shortcut, not part of the note's information. */ +.note-row-assist { + position: absolute; + top: 0.5rem; + right: 0.4rem; + width: 22px; + height: 22px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 50%; + background: none; + color: transparent; + font-size: 11px; + line-height: 1; + cursor: pointer; + transition: color .15s, background .15s; +} +.note-row:hover .note-row-assist { color: #718096; } +.note-row-assist:hover { + color: #667eea; + background: #edf2f7; +} .note-row.active { background: #e7f1ff; } /* Ctrl+click download selection */ .note-row.selected { diff --git a/src/static/js/app.js b/src/static/js/app.js index 15b54bc..9d43f20 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -187,6 +187,13 @@ document.addEventListener('DOMContentLoaded', async function() { initKeyboardShortcuts(); initGlobalCtrlEnterSave(); + + // Staged-pin count on the Assistant tab (survives reloads; the bridge + // clears the queue key when it delivers, which fires a storage event here). + updateAssistantPinBadge(); + window.addEventListener('storage', (e) => { + if (e.key === ASSISTANT_PIN_QUEUE_KEY) updateAssistantPinBadge(); + }); }); // ===== Destination switching (one header, N views) ===== @@ -401,10 +408,12 @@ function wireTaskClickDelegation(containerId, selector) { return; } // Robot button: hand the task to the assistant and go there. + // Ctrl/Cmd+click stages it instead — pin without leaving the board, + // so several tasks/notes can be gathered before opening the assistant. if (e.target.closest('.board-card-assist')) { e.preventDefault(); e.stopPropagation(); - pinTaskToAssistant(taskId); + pinToAssistant('task', taskId, e.ctrlKey || e.metaKey); return; } // Note badge: jump to the source note in the Notes destination. @@ -821,16 +830,67 @@ function assistantEnabled() { return !!document.getElementById('view-assistant'); } -// Board card robot button: hand this task over to the assistant. -// The pin waits in localStorage rather than being posted to the iframe: on the -// first visit the iframe does not exist yet (lazy-loaded by switchDestination). -// chat/public/simpler-bridge.js picks it up, the chat backend injects the task -// (+ its linked note) into the thread and seeds the composer with "#id — ". -function pinTaskToAssistant(taskId) { +// Hand a task or note over to the assistant. The pins wait in localStorage as +// a queue rather than being posted to the iframe directly: on the first visit +// the iframe does not exist yet (lazy-loaded by switchDestination). +// chat/public/simpler-bridge.js drains the queue once the assistant is visible; +// the chat backend injects each task (+ its linked note) / note into the thread +// and seeds the composer with their refs. +// +// `stay` (Ctrl/Cmd+click on the robot button) pins without opening the +// assistant, so several tasks and/or notes can be gathered first; a plain click +// pins and switches over. The queue is only delivered when the assistant is on +// screen, so staging in the background never drains it early. +const ASSISTANT_PIN_QUEUE_KEY = 'assistantPinQueue'; + +function readAssistantPinQueue() { + try { + const parsed = JSON.parse(localStorage.getItem(ASSISTANT_PIN_QUEUE_KEY)); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + return []; + } +} + +function pinToAssistant(kind, id, stay) { if (!assistantEnabled()) return; - localStorage.setItem('assistantPinnedTask', - JSON.stringify({ task_id: taskId, ts: Date.now() })); + const queue = readAssistantPinQueue(); + if (!queue.some(p => p.kind === kind && p.id === id)) { + queue.push({ kind, id, ts: Date.now() }); + } + if (stay) { + // Stage only: write the queue and flag the assistant tab with the count. + localStorage.setItem(ASSISTANT_PIN_QUEUE_KEY, JSON.stringify(queue)); + updateAssistantPinBadge(); + return; + } + // Reveal the iframe first, THEN write — the storage event the write fires + // reaches an already-visible bridge, which drains the queue immediately. switchDestination('assistant'); + localStorage.setItem(ASSISTANT_PIN_QUEUE_KEY, JSON.stringify(queue)); + updateAssistantPinBadge(); +} + +// Small count on the Assistant nav tab: how many tasks/notes are staged and +// waiting. The bridge removes the queue key once it delivers them; that removal +// fires a storage event here (the iframe is a separate browsing context), so +// the badge clears itself when the pins actually land. +function updateAssistantPinBadge() { + const tab = document.querySelector( + '#appNav .nav-tab[data-destination="assistant"]'); + if (!tab) return; + const n = readAssistantPinQueue().length; + let badge = tab.querySelector('.nav-pin-badge'); + if (n > 0) { + if (!badge) { + badge = document.createElement('span'); + badge.className = 'nav-pin-badge'; + tab.appendChild(badge); + } + badge.textContent = String(n); + } else if (badge) { + badge.remove(); + } } // The assistant's starters are the tasks in Doing, and the embedded Chainlit @@ -1028,7 +1088,7 @@ function renderBoardCard(task) {
${task.frozen ? '❄️ ' : ''}${escapeHtml(task.title || '(untitled)')}
${assistantEnabled() ? `` : ''} + title="Work on this with the assistant (Ctrl+click to stage without opening)">` : ''}
${displayPriority(task.priority)}
diff --git a/src/static/js/notes.js b/src/static/js/notes.js index d8763ab..16c1383 100644 --- a/src/static/js/notes.js +++ b/src/static/js/notes.js @@ -210,6 +210,10 @@ window.NotesView = (function () { container.innerHTML = ''; // When viewing several spaces (or all), tag each note with its space. const showSpace = state.selectedSpaceIds === null || state.selectedSpaceIds.length > 1; + // The robot button mirrors the board card's: hand a note to the + // assistant (or Ctrl+click to stage it). Only shown when the assistant + // is mounted — assistantEnabled() is a global from app.js. + const assistantOn = typeof assistantEnabled === 'function' && assistantEnabled(); for (const n of state.notes) { const title = n.title && n.title.trim() ? n.title : 'Untitled'; const row = document.createElement('div'); @@ -219,6 +223,8 @@ window.NotesView = (function () { row.innerHTML = `
${escapeHtml(title)} ${showSpace && spaceName(n.space_id) ? `${escapeHtml(spaceName(n.space_id))}` : ''} + ${assistantOn ? `` : ''}
${escapeHtml(previewContent(n.content_markdown))}
${relativeTime(n.updated_at)}
@@ -235,6 +241,17 @@ window.NotesView = (function () { openNote(n); } }); + // Robot button: plain click hands the note over and opens the + // assistant; Ctrl/Cmd+click stages it without leaving Notes. Its own + // handler stops propagation so the row's open/select never fires. + const assistBtn = row.querySelector('.note-row-assist'); + if (assistBtn) { + assistBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + pinToAssistant('note', n.id, e.ctrlKey || e.metaKey); + }); + } container.appendChild(row); } updateDownloadButton();