Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .opencode/context/topics/chat-assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id|title>`, `/note <id|title>`, `/tasks` (board), `/notes` (list), `/skill <name>` (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 <id>` injection (system message lands in the thread), then the backend answers with the editable `prefill` seed (`#<id> — `) 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', '#<id> — ')` 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 (`#<id>` for tasks, `note #<id>` 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)
Expand Down
47 changes: 47 additions & 0 deletions chat/chainlit_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))


Expand Down Expand Up @@ -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
Expand Down
86 changes: 61 additions & 25 deletions chat/public/simpler-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 || '');
}

Expand Down
53 changes: 51 additions & 2 deletions src/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading