Skip to content

Keep background sub-agent output in the side panel, not the main chat#151

Merged
pufit merged 1 commit into
mainfrom
pufit/fix-background-subagent-panel-routing
Jun 25, 2026
Merged

Keep background sub-agent output in the side panel, not the main chat#151
pufit merged 1 commit into
mainfrom
pufit/fix-background-subagent-panel-routing

Conversation

@pufit

@pufit pufit commented Jun 25, 2026

Copy link
Copy Markdown
Member

Problem

Background sub-agents (the Agent tool with run_in_background) spilled all of their tools and thoughts into the main chat instead of their dedicated side-panel. Reloading the page only half-fixed it: already-streamed output snapped into the sidebar, but every subsequent event kept spilling into the main chat.

Root cause

The live streaming handlers routed a sub-agent's child events (those carrying parent_tool_use_id) to a side-panel only if a panel with that id was still status === 'running':

if (parentId && state.panels.some(p => p.id === parentId && p.status === 'running'))

A background sub-agent's Agent tool returns immediately with a task id (like Workflow does). So the immediate tool_result and the backend's immediate subagent_complete marked the panel complete — and scheduled its auto-close — right away. The sub-agent's real activity streams afterward, by which point no panel is running, so each child event fell through into the main chat.

Reload looked correct because the replay path (bufferReplay.ts) enforces a stronger invariant — any parent_tool_use_id event belongs to its panel and never the main chat — but new live events still hit the buggy live path.

Fix

Make the live path honor the same invariant the replay path already uses, and treat background sub-agent panels like the existing Workflow pattern (spawning tool returns immediately → keep the panel open until the detached work actually settles):

  • Route any parent_tool_use_id event to its panel by id regardless of status, and never into the main chathandleThinking / handleToken / handleToolUse / handleToolResult.
  • Add a background flag to PanelTab (from input.run_in_background). The immediate Agent result is recorded on the inline chat card, but the panel stays open + running; the premature subagent_complete is ignored; finalizeRunningPanels skips these panels so the launching turn's done doesn't close them.
  • Settle background panels via handleBackgroundTasksUpdate once no background task is still running. This is the only reliable completion signal — a detached sub-agent has no per-tool_use_id "done" event (the CLI's task lifecycle is keyed by task_id, which for Agent tasks doesn't carry the spawning tool_use_id). An explicit /stop settles them too.
  • Carry the background flag through buffer replay so reconnect-then-live behaves consistently.

Testing

  • npm run build (tsc + vite) — clean.
  • eslint on the changed files — no new problems (repo's pre-existing lint state unchanged).
  • Backend test_streaming.py + test_autonomous_turns.py — 37 passed (no backend changes).
  • Manually traced foreground sub-agents, workflows, normal tool calls, and reload-then-live — all unaffected.

Note: there's no frontend test harness in web/ (no vitest/jest), so this is build- and reasoning-verified; the definitive check is a live background-sub-agent run.

Generated by Nerve

Background sub-agents (the Agent tool with run_in_background) spilled all
their tools and thoughts into the main chat instead of their side-panel.

Root cause: the live streaming handlers routed a sub-agent's child events
(those carrying parent_tool_use_id) to a panel only when a panel with that
id was still status === 'running'. A background sub-agent's Agent tool
returns immediately with a task id, so the immediate tool_result and the
backend's subagent_complete marked the panel complete (and scheduled
auto-close) right away. The sub-agent's real activity streams afterward, by
which point no panel is 'running', so every child event fell through into
the main chat. Reload looked correct only because the replay path enforces
a stronger invariant (any parent_tool_use_id event belongs to its panel,
never the main chat) — but new live events kept hitting the buggy path.

Fix, mirroring the replay invariant and the existing Workflow pattern:
- Route any parent_tool_use_id event (thinking/token/tool_use/tool_result)
  to its panel by id regardless of status, and never into the main chat.
- Flag run_in_background sub-agent panels (background). Treat the immediate
  Agent result like a Workflow launch: record it on the inline card but keep
  the panel open and running. Ignore the premature subagent_complete and
  skip these panels in finalizeRunningPanels so the launching turn's done
  doesn't close them.
- Settle background panels when no background task is still running
  (handleBackgroundTasksUpdate) — the natural completion signal, since there
  is no per-tool_use_id done event for a detached sub-agent. An explicit
  /stop settles them too.
- Carry the background flag through buffer replay for reconnect parity.
@pufit pufit merged commit 12fb444 into main Jun 25, 2026
2 checks passed
@pufit pufit deleted the pufit/fix-background-subagent-panel-routing branch June 25, 2026 02:17
constkolesnyak added a commit to constkolesnyak/nerve that referenced this pull request Jun 29, 2026
* Add dynamic-workflow progress UI (Claude Code Workflow tool) (ClickHouse#143)

* Add dynamic-workflow progress UI (Claude Code Workflow tool)

Render Claude Code dynamic-workflow runs in a dedicated side-panel tab:
a live phase -> agent tree with per-agent state/model/tokens/tool, plus a
compact Workflow card in the chat that opens the panel.

The CLI already streams the full progress tree on task_progress system
messages (data['workflow_progress']); the engine parsed it for the
background-task chip but discarded the tree. Now it normalizes the tree,
broadcasts a workflow_progress event, and folds the final snapshot onto
the Workflow tool_call block (merge_workflow_into_call) so the panel
reconstructs after reload — even though workflows settle in the
background past the launching turn.

Backend: streaming.broadcast_workflow_progress, engine workflow registry
+ _emit_workflow_progress/_build_workflow_snapshot/_workflow_status, and
db.merge_workflow_into_call. Frontend: WorkflowSnapshot types, a
workflow PanelTab, handleWorkflowProgress, buffer-replay, WorkflowPanel +
WorkflowToolBlock, and Workflow tool routing. Tests in
test_workflow_progress.py.

* Refine workflow detection from live-capture findings

Validated the data path against a real headless workflow run. Refinements:
- Detect workflow tasks by task_type containing 'workflow' too (CLI reports
  task_type='local_workflow'), not only the captured tool_use_id /
  workflow_progress — robust if the launching tool_use was missed.
- Use the CLI's authoritative workflow_name (on task_started) for the panel
  label instead of guessing from the tool input (fixes inline-script naming).
- Frontend: derive phase count/title from agent phaseIndex when the CLI omits
  workflow_phase rows (single-phase runs emit only agent entries).
- Add _handle_system_message workflow integration test (task_started ->
  task_progress -> terminal task_updated) using real captured payload shapes.

* Persist workflow snapshot for runs that settle within their turn

E2E testing surfaced a timing gap: a workflow that completes inside its
launching turn (fast runs) settles before that turn's message row exists,
so merge_workflow_into_call finds nothing to patch and the tree is lost on
reload (live view is unaffected). Fold the cached snapshot onto the
Workflow tool_call block at _finalize_turn so it persists regardless of
timing; longer workflows that settle post-finalize still use the merge.
Adds _fold_workflow_snapshots + unit tests.

* cron: load drop-in gate plugins from ~/.nerve/cron/gates/ (ClickHouse#142)

Custom cron run-gates previously required editing GATE_REGISTRY in
nerve/cron/gates.py — an additive but still upstream edit that diverges
on every pull. This adds a one-time, generic, upstreamable plugin loader:
drop a .py file defining a CronGate subclass into the gate-plugins
directory and Nerve auto-discovers and registers it at startup, so
jobs.yaml can reference it via run_if: [{type: <name>}] exactly like a
built-in. No edit to gates.py's registry → custom gates never conflict
on an upstream pull.

- nerve/cron/gate_plugins.py (new): load_gate_plugins(dir) scans the
  directory, imports each file via importlib (no pip/package), and
  registers every CronGate subclass with a non-empty type. Fail-safe —
  a file that fails to import (syntax error, module-level raise, even a
  stray sys.exit()), defines an abstract gate, or collides on type is
  logged and skipped; it can never crash daemon startup. Built-in wins
  on a type collision; among plugins the first by filename wins.
- nerve/cron/service.py: load plugins at the top of CronService.start(),
  before jobs are parsed (CronJob builds its gates at construction).
- nerve/config.py: new cron.gate_plugins_dir key (default
  ~/.nerve/cron/gates).
- docs/cron.md: "Custom gate plugins (drop-in)" section, including the
  DB-only-context note and a trust note (files here execute at startup —
  same trust model as config.yaml / MCP servers / cron prompts).
- nerve/cron/gates.py: one-line docstring pointer to the mechanism; the
  GATE_REGISTRY itself is untouched.
- tests/test_cron_gate_plugins.py (new, 21 tests).

A gate still receives GateContext{job_id, db} (DB-only) — enough for an
age- or count-based gate. A liveness/session-registry based gate would
need the context widened and is out of scope for this loader.

Co-authored-by: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com>

* agent/engine: keep cron/hook client alive for live background tasks (ClickHouse#141)

One-shot runs (run_cron / run_hook) discarded the SDK client in their
finally the instant the agent yielded — even with a Bash(run_in_background)
task still running. Discarding kills the subprocess and the idle-stream
watcher that delivers the task's completion turn, so the background task is
orphaned and the run never resumes to finish its work (push, update state,
notify). This stranded isolated cron workers.

Route the three one-shot teardowns through a new _teardown_oneshot_client()
that skips the discard while _has_live_background_tasks() is true — the same
predicate run_idle_client_sweep already uses to keep such clients alive
(ClickHouse#125). The watcher then delivers the completion turn and the idle sweep
reaps the client once the task settles, exactly as for a web/interactive
session.

run_persistent_cron is excluded (keepalive_if_bg=False): its session_id is
stable and reused across runs, so parking the client would let the next run
collide with the still-in-flight task on the same conversation. Keep-alive is
only safe for the unique-per-run isolated paths.

Tests: cron/hook keep the client alive on a live bg task; persistent-cron
still discards; plus an end-to-end test driving the real idle-stream watcher
to resume a parked session when its background task completes.

Co-authored-by: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* cron: fix day-of-week off-by-one in crontab schedules (ClickHouse#138)

APScheduler's numeric day_of_week is 0=Mon..6=Sun, while Unix crontab
is 0=Sun..6=Sat (7 also means Sun). CronTrigger.from_crontab passes the
number straight through without remapping, so every numeric day-of-week
schedule fired one weekday late: "0 13 * * 1" (Monday) actually ran on
Tuesday, and "0 3 * * 0" (Sunday) ran on Monday.

Add _crontab_to_trigger, a from_crontab replacement that translates the
day-of-week field to APScheduler's day-name aliases (preserving *,
ranges like 1-5, lists like 1,4, and step suffixes) and leaves the
other four fields and the timezone handling unchanged. Route the three
trigger-construction sites (job scheduling, source scheduling, and the
overdue check) through it.

Tests cover the day-of-week mapping, parity with from_crontab for
non-DOW schedules, the interval-string fallback, and a weekly
_is_overdue case that is due after exactly one week.

After a restart, every numeric day-of-week cron shifts to the day its
schedule already specifies; that is the intended correction.

* Add a GitHub actor allowlist/denylist guardrail to the notifications source (ClickHouse#137)

The github notifications source already fetched every login involved in a
notification (issue/PR author, assignees, comment & review authors) during
enrichment but only rendered them into content. Surface them as an `actors`
metadata key and add `allow_actors`/`deny_actors` to GitHubSyncConfig, wired
as a second InboxFilter rule in build_source_runners alongside the existing
repo guardrail.

This lets a deployment deterministically restrict *who* can put a GitHub
notification in front of the worker, dropping drive-by @mentions from
untrusted accounts at ingest — before the agent (and the prompt-injection
surface) ever sees them. Matching is list-valued (kept if any involved login
matches), deny-wins, and a non-empty allow_actors is fail-closed. Empty
(the default) preserves existing behavior.

Tests: actor FieldRule semantics (allow/deny/deny-wins/case-insensitive/
fail-closed), _collect_actors de-dup & ordering, config parsing, fetch()
metadata emission incl. the enrichment-failure path, and registry wiring.
Docs updated.

Co-authored-by: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com>

* web/chat: lazy "new chat" creation + per-chat draft persistence (ClickHouse#136)

The + button no longer POSTs a session up front. It mints a local
"virtual" chat (temp UUID, never sent to the backend) and defers the
POST /api/sessions to the first message, then adopts the server-minted
id for the turn — so a session is created only when you actually send,
and it becomes a real, selectable chat that survives switching away.

Unsent input is stored per session id, so switching chats shows an empty
composer and returning restores the unfinished message (fixing the
single shared textarea that leaked text across sessions). A pencil glyph
marks non-active chats that hold a draft, the virtual chat pins to the
top of the sidebar, and switching to any chat focuses the composer.

Also scope view-mutating WS events (stream/panels/interaction) to the
active session, so a reconnect that rebinds the socket to another session
can't hijack the current view with a phantom "Thinking..." badge or a
disabled composer.

POST /api/sessions returns a partial row (no updated_at), so the promoted
session fills the fields the sidebar needs locally, and getDateGroup
tolerates a missing date instead of throwing.

Frontend-only; no API or schema changes.

* feat(chat): per-chat URL routing + browser tab title mirroring (ClickHouse#134)

* feat(chat): render sidebar sessions as <Link> with per-chat URL

Session rows used to be <div onClick={switchSession}>, so Vimium couldn't
hint them and there was no shareable URL per chat — the address bar always
read /chat.

- SessionSidebar: wrap each session row (conversations + system) in a
  react-router <Link to={`/chat/${id}`}>. Menu buttons inside the row now
  preventDefault on click so opening the kebab doesn't navigate.
- ChatPage: drop the onSelect prop; add a navigate() effect that mirrors
  activeSession into the URL (replace), so createSession / WS-driven
  switches / deleteSession auto-pick all update the address bar too.

* fix(chat): stop URL ↔ activeSession ping-pong loop on session click

The previous "mirror activeSession into the URL" effect ran on every
click: the <Link> set sessionId=A instantly, but activeSession was still
B until switchSession() resolved a tick later. The effect saw the
mismatch and yanked the URL back to /chat/B, which then re-triggered
switchSession, which re-triggered the mirror — infinite flip.

Guard the mirror: only push activeSession into the URL when the URL
can't be the source of truth (no sessionId, or sessionId points to a
session that's no longer in the list — i.e. just deleted). On a normal
click the URL is valid, so the effect stays out of the way and lets
switchSession catch up naturally.

* fix(chat): cmd+click opens the correct chat in a new tab

A fresh tab opened with /chat/A would briefly bounce to a different chat
because two effects raced before loadSessions() resolved:
- the URL-mirror useEffect ran with sessions=[], decided "URL points to
  unknown session", and yanked URL → /chat/<store-default>
- the WS session_switched handler also ran on connect with activeSession=""
  and switched to the server-side default, which then propagated to URL

- ChatPage: drop the mirror effect entirely; instead navigate explicitly
  from handleCreateSession and handleDeleteSession (the two places that
  change activeSession without a URL change). useCallback for stability.
- App.tsx: global new-chat shortcut awaits createSession() and navigates
  to /chat/<new-id> with replace so the URL reflects the just-made chat.
- sessionHandlers: handleSessionSwitched no longer overrides when the
  URL already names a specific chat — ChatPage's useEffect[sessionId]
  will switch to it. Prevents the new-tab flash through the server
  default.

* feat(chat): mirror active session title into the browser tab

Sets document.title to the cleaned session title (same strip rules as
the sidebar: leading '#' and 'Implement:' removed). Falls back to plain
"Nerve" when there is no active session or when ChatPage unmounts, so
other pages don't inherit a stale chat title.

* Cache notification messages for reaction context (#1) (ClickHouse#132)

Notifications sent via NotificationService._deliver_telegram() bypassed
TelegramChannel.send() and went directly through bot.send_message(),
so they were never stored in _message_cache. When a user reacted to a
notification, the reaction handler couldn't find the original text and
only showed "[Reaction: 👎]" without context.

Now _deliver_telegram caches sent messages via channel._cache_message()
so reactions on notifications include the message text.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Preserve actionable URLs through email preprocessing and condensation (ClickHouse#131)

Gmail preprocessing was stripping all standalone URLs indiscriminately.
Now only removes boilerplate (unsubscribe, social media, tracking pixels)
and keeps actionable links (booking, messaging, payment, reply threads).
Condense prompts updated to explicitly preserve actionable URLs.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Skip context-1m beta header for excluded models (ClickHouse#130)

Some Claude OAuth subscriptions reject the context-1m-2025-08-07 beta
for specific models (e.g. claude-sonnet-4-6 returns 400 "long context
beta not yet available for this subscription"), which broke any session
running on those models even with context_1m=True.

Add AgentConfig.context_1m_excluded_models (case-insensitive substring
match on the resolved model name) and a context_1m_enabled_for(model)
helper. The helper drives both the beta-header decision and the
max_context value used for context-bar usage metadata, so excluded
models report the correct 200k ceiling in the UI.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix manual cron session rotation (ClickHouse#126)

* feat(web): sidebar pills + keyboard shortcuts (claude.ai parity) (ClickHouse#133)

* feat(web): keyboard shortcuts (claude.ai parity)

Adds a small shortcut system around a single document-level keydown
listener plus a help modal. Mirrors the bindings claude.ai/chat exposes,
skipping ones that don't map (artifacts, force-send, settings).

Global: ⌘⇧O new chat, ⌘K focus search, ⌘/ shortcuts modal,
Esc cascades to modal → search → stop generation.

Chat: ⌘⇧S sidebar, ⌘⇧; focus input, ⌘⇧C copy last response,
⌘⇧⌫ delete current (confirmed), ⌘\ side panel (already existed).

Mac/Linux labels and Backspace/Delete aliases render automatically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(chat): auto-focus input on session change & let mod/Esc shortcuts fire while typing

- ChatInput: focus textarea whenever activeSession changes (new chat or session switch)
- useKeyboardShortcuts: default to allowing Cmd/Ctrl combos and Escape even when focus
  is inside an input/textarea/contentEditable; printable keys still need explicit opt-in
- keyboard.ts: add isSafeInInputCombo() helper; clarify allowInInput semantics

* feat(sidebar): replace header with Search/New chat pills

- Remove 'Conversations' header label
- Convert search field into a pill button labeled 'Search sessions';
  the input mounts on hover (or focus / when a query exists) with a
  200ms fade and unmounts on leave so it never lingers in the DOM
- Add a 'New chat' pill on the right; the search input overlays it
  while open so the create button is hidden behind the field
- Both controls now share rounded-full styling with a subtle border
  to read clearly as buttons

* fix(chat): restore Cmd+K — mount sidebar search before focusing

The sidebar Search pill unmounts its input unless hovered/focused/searched,
so document.getElementById('nerve-sidebar-search') was returning null and
the shortcut silently did nothing.

- chatStore: add searchFocusNonce + requestSearchFocus() trigger
- SessionSidebar: subscribe to the nonce; pin → mount → fade in → focus
- App.tsx: Cmd+K now calls requestSearchFocus() instead of poking the DOM

* fix(chat): Cmd+K race — focus on mount, release pin only after onFocus confirms

Previous attempt dropped `searchPinned` synchronously right after `.focus()`,
which can produce a render where pinned=false AND focused=false (onFocus
still in the React event queue) — that collapses `shouldShowSearch` and
starts the 200ms fade-out before focused=true commits.

- SessionSidebar: focus as soon as the input is mounted (don't wait for the
  fade-in); release the pin only once searchFocused becomes true, so the
  input is always kept visible while the focus event is in flight.
- App.tsx: skip setTimeout(0) when already on /chat — only defer when we
  just kicked off a navigate.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: pufit <pufit.dev@gmail.com>

* Respect configured cron timezone (ClickHouse#127)

Co-authored-by: pufit <pufit.dev@gmail.com>

* Add CI: backend tests + frontend build (ClickHouse#144)

* Add CI workflow: backend tests + frontend build

GitHub Actions running pytest (Python 3.13) and the Vite frontend build
on push/PR to main. Declares a [test] extra in pyproject for pytest +
pytest-asyncio.

* Track Codex rollout test fixtures (un-ignore from *.jsonl)

The blanket *.jsonl gitignore rule excluded tests/fixtures/codex/rollouts/
fixtures, so the codex source tests passed locally but failed on a clean
checkout (CI) with FileNotFoundError. Add a .gitignore negation so the
fixtures are tracked. Fixtures are fully synthetic (placeholder UUIDs/paths).

* CI: bump actions to Node24 runtime + key uv cache on pyproject.toml

Silences the Node 20 deprecation warnings (checkout v5, setup-uv v6,
setup-node v5) and gives the uv cache a real invalidation key.

* Add local Ollama model selection to the web composer (ClickHouse#147)

Expose locally-installed Ollama models as selectable chat models in the
composer's model picker. The Claude Agent SDK only speaks the Anthropic
Messages API, so Ollama (OpenAI-compatible) is reached through the bundled
CLIProxyAPI proxy, registered as an openai-compatibility upstream — this
requires proxy.enabled.

Backend:
- OllamaConfig + ollama_routable gate (Ollama enabled AND proxy enabled)
- nerve/ollama.py: best-effort model discovery via Ollama GET /api/tags
- proxy/service.py: register discovered models as a proxy upstream
- GET /api/models route for the picker
- engine: thread per-session model, recreate the SDK client on a
  mid-session model switch, and suppress Anthropic-only knobs (extended
  thinking, effort, context-1m beta) for non-Claude models
- server: pass the WS per-message model through to run()
- startup warning when ollama.enabled but proxy.enabled is false

Frontend:
- api.getModels() + optional model arg on ws.sendMessage
- chatStore holds available/selected/default model (persisted to localStorage)
- ChatInput renders a model picker, shown only when more than one model
  is offered

config.example.yaml: document the proxy and ollama blocks.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Fix crypto.randomUUID crash in non-secure (plain-HTTP) contexts (ClickHouse#149)

crypto.randomUUID() is only available in secure contexts (HTTPS or
localhost), so accessing the UI over plain HTTP via a LAN host throws
"TypeError: crypto.randomUUID is not a function". Add a randomUUID()
helper that falls back to a v4 UUID built from crypto.getRandomValues()
(available in non-secure contexts), then Math.random() as a last resort.
Use it in chatStore and ChatInput.

* Give background sub-agents the same tool permissions as foreground (ClickHouse#150)

Background sub-agents (the Agent tool with run_in_background) had their
nested Write/Edit/Bash denied: a detached, non-blocking task can't surface
an approval prompt, so the CLI denies tools needing one and can_use_tool is
never invoked for them.

Add a catch-all PreToolUse hook that pre-approves all non-interactive tools
(a hook fires for nested background-subagent calls where can_use_tool can't
reach). Interactive tools and Read still defer to can_use_tool / the image
validator, so web pause-for-input and image validation are unchanged. Gated
by agent.background_agent_permissions (default true).

* Keep background sub-agent output in the side panel, not the main chat (ClickHouse#151)

Background sub-agents (the Agent tool with run_in_background) spilled all
their tools and thoughts into the main chat instead of their side-panel.

Root cause: the live streaming handlers routed a sub-agent's child events
(those carrying parent_tool_use_id) to a panel only when a panel with that
id was still status === 'running'. A background sub-agent's Agent tool
returns immediately with a task id, so the immediate tool_result and the
backend's subagent_complete marked the panel complete (and scheduled
auto-close) right away. The sub-agent's real activity streams afterward, by
which point no panel is 'running', so every child event fell through into
the main chat. Reload looked correct only because the replay path enforces
a stronger invariant (any parent_tool_use_id event belongs to its panel,
never the main chat) — but new live events kept hitting the buggy path.

Fix, mirroring the replay invariant and the existing Workflow pattern:
- Route any parent_tool_use_id event (thinking/token/tool_use/tool_result)
  to its panel by id regardless of status, and never into the main chat.
- Flag run_in_background sub-agent panels (background). Treat the immediate
  Agent result like a Workflow launch: record it on the inline card but keep
  the panel open and running. Ignore the premature subagent_complete and
  skip these panels in finalizeRunningPanels so the launching turn's done
  doesn't close them.
- Settle background panels when no background task is still running
  (handleBackgroundTasksUpdate) — the natural completion signal, since there
  is no per-tool_use_id done event for a detached sub-agent. An explicit
  /stop settles them too.
- Carry the background flag through buffer replay for reconnect parity.

* Fix navigator.clipboard crash over plain HTTP (non-secure context) (ClickHouse#154)

navigator.clipboard is only exposed in secure contexts (HTTPS or
http://localhost). When the UI is accessed over plain HTTP via a LAN
hostname/IP, or behind a proxy without a trusted cert, the entire
clipboard object is undefined and any call throws
'TypeError: Cannot read properties of undefined (reading writeText)'.

Two affected call sites:
- web/src/components/Chat/CodeBlock.tsx — the per-code-block Copy button
- web/src/pages/ChatPage.tsx — Cmd+Shift+C 'copy last response' shortcut

Both silently failed without surfacing any UI feedback.

Add a shared copyToClipboard helper that prefers navigator.clipboard
when available and falls back to a hidden off-screen <textarea> +
document.execCommand('copy'). execCommand is deprecated but still
implemented in every browser we care about.

Mirrors the same non-secure-context fallback pattern already used in
utils/uuid.ts (introduced by ClickHouse#149).

* Fix file-upload 404 on a new chat by materializing the session first (ClickHouse#155)

The "+" button mints a client-only "virtual" session that isn't
persisted in the DB until the first message is sent. Attaching a file
to such a chat uploaded against that temp id, so POST /api/files/upload
hit the handler's `if not session: 404 "Session not found"` guard — a
404 that looked like a missing route but was application-level.

Extract a shared `ensureRealSession()` store action that materializes
the virtual session (POST /api/sessions, adopt the server id, carry the
draft across) and call it before uploading, so the upload targets a real
session row. sendMessage now reuses the same action instead of its own
inline materialization.

* cron: align rotate_at with upstream's self.timezone basis

Per merge-policy: where the fork reinvents what upstream already ships,
prefer upstream's version to minimize future merge conflicts.

The fork previously computed today's rotate_at boundary in the system
local timezone (`datetime.now(local_tz).replace(hour=...)`). Upstream
uses `now.astimezone(self.timezone).replace(hour=...)` — i.e. the
cron-configured timezone from `config.timezone`. In production both
yield the same instant because `config.timezone` = system local, so
this is a behavior no-op for the deployment but eliminates a recurring
merge conflict.

The fork's original feature — `last_rotated_at`-based rotation that
survives restarts past the boundary — is preserved untouched.

Test fixture adjusted to compute the boundary in UTC (matching the
default `_make_cron_service()` timezone) so tests stop depending on
the host's local timezone (CI may run UTC, dev box CEST).

---------

Co-authored-by: pufit <pufit@clickhouse.com>
Co-authored-by: polyglotAI-bot <polyglotai@clickhouse.com>
Co-authored-by: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com>
Co-authored-by: Pervakov Grigorii <pervakov.grigory@gmail.com>
Co-authored-by: Minh Vu <vuhoangminh97@gmail.com>
Co-authored-by: pufit <pufit.dev@gmail.com>
Co-authored-by: Lino Uruñuela <wachynaky@gmail.com>
constkolesnyak added a commit to constkolesnyak/nerve that referenced this pull request Jul 2, 2026
* Add dynamic-workflow progress UI (Claude Code Workflow tool) (ClickHouse#143)

* Add dynamic-workflow progress UI (Claude Code Workflow tool)

Render Claude Code dynamic-workflow runs in a dedicated side-panel tab:
a live phase -> agent tree with per-agent state/model/tokens/tool, plus a
compact Workflow card in the chat that opens the panel.

The CLI already streams the full progress tree on task_progress system
messages (data['workflow_progress']); the engine parsed it for the
background-task chip but discarded the tree. Now it normalizes the tree,
broadcasts a workflow_progress event, and folds the final snapshot onto
the Workflow tool_call block (merge_workflow_into_call) so the panel
reconstructs after reload — even though workflows settle in the
background past the launching turn.

Backend: streaming.broadcast_workflow_progress, engine workflow registry
+ _emit_workflow_progress/_build_workflow_snapshot/_workflow_status, and
db.merge_workflow_into_call. Frontend: WorkflowSnapshot types, a
workflow PanelTab, handleWorkflowProgress, buffer-replay, WorkflowPanel +
WorkflowToolBlock, and Workflow tool routing. Tests in
test_workflow_progress.py.

* Refine workflow detection from live-capture findings

Validated the data path against a real headless workflow run. Refinements:
- Detect workflow tasks by task_type containing 'workflow' too (CLI reports
  task_type='local_workflow'), not only the captured tool_use_id /
  workflow_progress — robust if the launching tool_use was missed.
- Use the CLI's authoritative workflow_name (on task_started) for the panel
  label instead of guessing from the tool input (fixes inline-script naming).
- Frontend: derive phase count/title from agent phaseIndex when the CLI omits
  workflow_phase rows (single-phase runs emit only agent entries).
- Add _handle_system_message workflow integration test (task_started ->
  task_progress -> terminal task_updated) using real captured payload shapes.

* Persist workflow snapshot for runs that settle within their turn

E2E testing surfaced a timing gap: a workflow that completes inside its
launching turn (fast runs) settles before that turn's message row exists,
so merge_workflow_into_call finds nothing to patch and the tree is lost on
reload (live view is unaffected). Fold the cached snapshot onto the
Workflow tool_call block at _finalize_turn so it persists regardless of
timing; longer workflows that settle post-finalize still use the merge.
Adds _fold_workflow_snapshots + unit tests.

* cron: load drop-in gate plugins from ~/.nerve/cron/gates/ (ClickHouse#142)

Custom cron run-gates previously required editing GATE_REGISTRY in
nerve/cron/gates.py — an additive but still upstream edit that diverges
on every pull. This adds a one-time, generic, upstreamable plugin loader:
drop a .py file defining a CronGate subclass into the gate-plugins
directory and Nerve auto-discovers and registers it at startup, so
jobs.yaml can reference it via run_if: [{type: <name>}] exactly like a
built-in. No edit to gates.py's registry → custom gates never conflict
on an upstream pull.

- nerve/cron/gate_plugins.py (new): load_gate_plugins(dir) scans the
  directory, imports each file via importlib (no pip/package), and
  registers every CronGate subclass with a non-empty type. Fail-safe —
  a file that fails to import (syntax error, module-level raise, even a
  stray sys.exit()), defines an abstract gate, or collides on type is
  logged and skipped; it can never crash daemon startup. Built-in wins
  on a type collision; among plugins the first by filename wins.
- nerve/cron/service.py: load plugins at the top of CronService.start(),
  before jobs are parsed (CronJob builds its gates at construction).
- nerve/config.py: new cron.gate_plugins_dir key (default
  ~/.nerve/cron/gates).
- docs/cron.md: "Custom gate plugins (drop-in)" section, including the
  DB-only-context note and a trust note (files here execute at startup —
  same trust model as config.yaml / MCP servers / cron prompts).
- nerve/cron/gates.py: one-line docstring pointer to the mechanism; the
  GATE_REGISTRY itself is untouched.
- tests/test_cron_gate_plugins.py (new, 21 tests).

A gate still receives GateContext{job_id, db} (DB-only) — enough for an
age- or count-based gate. A liveness/session-registry based gate would
need the context widened and is out of scope for this loader.

Co-authored-by: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com>

* agent/engine: keep cron/hook client alive for live background tasks (ClickHouse#141)

One-shot runs (run_cron / run_hook) discarded the SDK client in their
finally the instant the agent yielded — even with a Bash(run_in_background)
task still running. Discarding kills the subprocess and the idle-stream
watcher that delivers the task's completion turn, so the background task is
orphaned and the run never resumes to finish its work (push, update state,
notify). This stranded isolated cron workers.

Route the three one-shot teardowns through a new _teardown_oneshot_client()
that skips the discard while _has_live_background_tasks() is true — the same
predicate run_idle_client_sweep already uses to keep such clients alive
(ClickHouse#125). The watcher then delivers the completion turn and the idle sweep
reaps the client once the task settles, exactly as for a web/interactive
session.

run_persistent_cron is excluded (keepalive_if_bg=False): its session_id is
stable and reused across runs, so parking the client would let the next run
collide with the still-in-flight task on the same conversation. Keep-alive is
only safe for the unique-per-run isolated paths.

Tests: cron/hook keep the client alive on a live bg task; persistent-cron
still discards; plus an end-to-end test driving the real idle-stream watcher
to resume a parked session when its background task completes.

Co-authored-by: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* cron: fix day-of-week off-by-one in crontab schedules (ClickHouse#138)

APScheduler's numeric day_of_week is 0=Mon..6=Sun, while Unix crontab
is 0=Sun..6=Sat (7 also means Sun). CronTrigger.from_crontab passes the
number straight through without remapping, so every numeric day-of-week
schedule fired one weekday late: "0 13 * * 1" (Monday) actually ran on
Tuesday, and "0 3 * * 0" (Sunday) ran on Monday.

Add _crontab_to_trigger, a from_crontab replacement that translates the
day-of-week field to APScheduler's day-name aliases (preserving *,
ranges like 1-5, lists like 1,4, and step suffixes) and leaves the
other four fields and the timezone handling unchanged. Route the three
trigger-construction sites (job scheduling, source scheduling, and the
overdue check) through it.

Tests cover the day-of-week mapping, parity with from_crontab for
non-DOW schedules, the interval-string fallback, and a weekly
_is_overdue case that is due after exactly one week.

After a restart, every numeric day-of-week cron shifts to the day its
schedule already specifies; that is the intended correction.

* Add a GitHub actor allowlist/denylist guardrail to the notifications source (ClickHouse#137)

The github notifications source already fetched every login involved in a
notification (issue/PR author, assignees, comment & review authors) during
enrichment but only rendered them into content. Surface them as an `actors`
metadata key and add `allow_actors`/`deny_actors` to GitHubSyncConfig, wired
as a second InboxFilter rule in build_source_runners alongside the existing
repo guardrail.

This lets a deployment deterministically restrict *who* can put a GitHub
notification in front of the worker, dropping drive-by @mentions from
untrusted accounts at ingest — before the agent (and the prompt-injection
surface) ever sees them. Matching is list-valued (kept if any involved login
matches), deny-wins, and a non-empty allow_actors is fail-closed. Empty
(the default) preserves existing behavior.

Tests: actor FieldRule semantics (allow/deny/deny-wins/case-insensitive/
fail-closed), _collect_actors de-dup & ordering, config parsing, fetch()
metadata emission incl. the enrichment-failure path, and registry wiring.
Docs updated.

Co-authored-by: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com>

* web/chat: lazy "new chat" creation + per-chat draft persistence (ClickHouse#136)

The + button no longer POSTs a session up front. It mints a local
"virtual" chat (temp UUID, never sent to the backend) and defers the
POST /api/sessions to the first message, then adopts the server-minted
id for the turn — so a session is created only when you actually send,
and it becomes a real, selectable chat that survives switching away.

Unsent input is stored per session id, so switching chats shows an empty
composer and returning restores the unfinished message (fixing the
single shared textarea that leaked text across sessions). A pencil glyph
marks non-active chats that hold a draft, the virtual chat pins to the
top of the sidebar, and switching to any chat focuses the composer.

Also scope view-mutating WS events (stream/panels/interaction) to the
active session, so a reconnect that rebinds the socket to another session
can't hijack the current view with a phantom "Thinking..." badge or a
disabled composer.

POST /api/sessions returns a partial row (no updated_at), so the promoted
session fills the fields the sidebar needs locally, and getDateGroup
tolerates a missing date instead of throwing.

Frontend-only; no API or schema changes.

* feat(chat): per-chat URL routing + browser tab title mirroring (ClickHouse#134)

* feat(chat): render sidebar sessions as <Link> with per-chat URL

Session rows used to be <div onClick={switchSession}>, so Vimium couldn't
hint them and there was no shareable URL per chat — the address bar always
read /chat.

- SessionSidebar: wrap each session row (conversations + system) in a
  react-router <Link to={`/chat/${id}`}>. Menu buttons inside the row now
  preventDefault on click so opening the kebab doesn't navigate.
- ChatPage: drop the onSelect prop; add a navigate() effect that mirrors
  activeSession into the URL (replace), so createSession / WS-driven
  switches / deleteSession auto-pick all update the address bar too.

* fix(chat): stop URL ↔ activeSession ping-pong loop on session click

The previous "mirror activeSession into the URL" effect ran on every
click: the <Link> set sessionId=A instantly, but activeSession was still
B until switchSession() resolved a tick later. The effect saw the
mismatch and yanked the URL back to /chat/B, which then re-triggered
switchSession, which re-triggered the mirror — infinite flip.

Guard the mirror: only push activeSession into the URL when the URL
can't be the source of truth (no sessionId, or sessionId points to a
session that's no longer in the list — i.e. just deleted). On a normal
click the URL is valid, so the effect stays out of the way and lets
switchSession catch up naturally.

* fix(chat): cmd+click opens the correct chat in a new tab

A fresh tab opened with /chat/A would briefly bounce to a different chat
because two effects raced before loadSessions() resolved:
- the URL-mirror useEffect ran with sessions=[], decided "URL points to
  unknown session", and yanked URL → /chat/<store-default>
- the WS session_switched handler also ran on connect with activeSession=""
  and switched to the server-side default, which then propagated to URL

- ChatPage: drop the mirror effect entirely; instead navigate explicitly
  from handleCreateSession and handleDeleteSession (the two places that
  change activeSession without a URL change). useCallback for stability.
- App.tsx: global new-chat shortcut awaits createSession() and navigates
  to /chat/<new-id> with replace so the URL reflects the just-made chat.
- sessionHandlers: handleSessionSwitched no longer overrides when the
  URL already names a specific chat — ChatPage's useEffect[sessionId]
  will switch to it. Prevents the new-tab flash through the server
  default.

* feat(chat): mirror active session title into the browser tab

Sets document.title to the cleaned session title (same strip rules as
the sidebar: leading '#' and 'Implement:' removed). Falls back to plain
"Nerve" when there is no active session or when ChatPage unmounts, so
other pages don't inherit a stale chat title.

* Cache notification messages for reaction context (#1) (ClickHouse#132)

Notifications sent via NotificationService._deliver_telegram() bypassed
TelegramChannel.send() and went directly through bot.send_message(),
so they were never stored in _message_cache. When a user reacted to a
notification, the reaction handler couldn't find the original text and
only showed "[Reaction: 👎]" without context.

Now _deliver_telegram caches sent messages via channel._cache_message()
so reactions on notifications include the message text.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Preserve actionable URLs through email preprocessing and condensation (ClickHouse#131)

Gmail preprocessing was stripping all standalone URLs indiscriminately.
Now only removes boilerplate (unsubscribe, social media, tracking pixels)
and keeps actionable links (booking, messaging, payment, reply threads).
Condense prompts updated to explicitly preserve actionable URLs.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* Skip context-1m beta header for excluded models (ClickHouse#130)

Some Claude OAuth subscriptions reject the context-1m-2025-08-07 beta
for specific models (e.g. claude-sonnet-4-6 returns 400 "long context
beta not yet available for this subscription"), which broke any session
running on those models even with context_1m=True.

Add AgentConfig.context_1m_excluded_models (case-insensitive substring
match on the resolved model name) and a context_1m_enabled_for(model)
helper. The helper drives both the beta-header decision and the
max_context value used for context-bar usage metadata, so excluded
models report the correct 200k ceiling in the UI.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix manual cron session rotation (ClickHouse#126)

* feat(web): sidebar pills + keyboard shortcuts (claude.ai parity) (ClickHouse#133)

* feat(web): keyboard shortcuts (claude.ai parity)

Adds a small shortcut system around a single document-level keydown
listener plus a help modal. Mirrors the bindings claude.ai/chat exposes,
skipping ones that don't map (artifacts, force-send, settings).

Global: ⌘⇧O new chat, ⌘K focus search, ⌘/ shortcuts modal,
Esc cascades to modal → search → stop generation.

Chat: ⌘⇧S sidebar, ⌘⇧; focus input, ⌘⇧C copy last response,
⌘⇧⌫ delete current (confirmed), ⌘\ side panel (already existed).

Mac/Linux labels and Backspace/Delete aliases render automatically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(chat): auto-focus input on session change & let mod/Esc shortcuts fire while typing

- ChatInput: focus textarea whenever activeSession changes (new chat or session switch)
- useKeyboardShortcuts: default to allowing Cmd/Ctrl combos and Escape even when focus
  is inside an input/textarea/contentEditable; printable keys still need explicit opt-in
- keyboard.ts: add isSafeInInputCombo() helper; clarify allowInInput semantics

* feat(sidebar): replace header with Search/New chat pills

- Remove 'Conversations' header label
- Convert search field into a pill button labeled 'Search sessions';
  the input mounts on hover (or focus / when a query exists) with a
  200ms fade and unmounts on leave so it never lingers in the DOM
- Add a 'New chat' pill on the right; the search input overlays it
  while open so the create button is hidden behind the field
- Both controls now share rounded-full styling with a subtle border
  to read clearly as buttons

* fix(chat): restore Cmd+K — mount sidebar search before focusing

The sidebar Search pill unmounts its input unless hovered/focused/searched,
so document.getElementById('nerve-sidebar-search') was returning null and
the shortcut silently did nothing.

- chatStore: add searchFocusNonce + requestSearchFocus() trigger
- SessionSidebar: subscribe to the nonce; pin → mount → fade in → focus
- App.tsx: Cmd+K now calls requestSearchFocus() instead of poking the DOM

* fix(chat): Cmd+K race — focus on mount, release pin only after onFocus confirms

Previous attempt dropped `searchPinned` synchronously right after `.focus()`,
which can produce a render where pinned=false AND focused=false (onFocus
still in the React event queue) — that collapses `shouldShowSearch` and
starts the 200ms fade-out before focused=true commits.

- SessionSidebar: focus as soon as the input is mounted (don't wait for the
  fade-in); release the pin only once searchFocused becomes true, so the
  input is always kept visible while the focus event is in flight.
- App.tsx: skip setTimeout(0) when already on /chat — only defer when we
  just kicked off a navigate.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: pufit <pufit.dev@gmail.com>

* Respect configured cron timezone (ClickHouse#127)

Co-authored-by: pufit <pufit.dev@gmail.com>

* Add CI: backend tests + frontend build (ClickHouse#144)

* Add CI workflow: backend tests + frontend build

GitHub Actions running pytest (Python 3.13) and the Vite frontend build
on push/PR to main. Declares a [test] extra in pyproject for pytest +
pytest-asyncio.

* Track Codex rollout test fixtures (un-ignore from *.jsonl)

The blanket *.jsonl gitignore rule excluded tests/fixtures/codex/rollouts/
fixtures, so the codex source tests passed locally but failed on a clean
checkout (CI) with FileNotFoundError. Add a .gitignore negation so the
fixtures are tracked. Fixtures are fully synthetic (placeholder UUIDs/paths).

* CI: bump actions to Node24 runtime + key uv cache on pyproject.toml

Silences the Node 20 deprecation warnings (checkout v5, setup-uv v6,
setup-node v5) and gives the uv cache a real invalidation key.

* Add local Ollama model selection to the web composer (ClickHouse#147)

Expose locally-installed Ollama models as selectable chat models in the
composer's model picker. The Claude Agent SDK only speaks the Anthropic
Messages API, so Ollama (OpenAI-compatible) is reached through the bundled
CLIProxyAPI proxy, registered as an openai-compatibility upstream — this
requires proxy.enabled.

Backend:
- OllamaConfig + ollama_routable gate (Ollama enabled AND proxy enabled)
- nerve/ollama.py: best-effort model discovery via Ollama GET /api/tags
- proxy/service.py: register discovered models as a proxy upstream
- GET /api/models route for the picker
- engine: thread per-session model, recreate the SDK client on a
  mid-session model switch, and suppress Anthropic-only knobs (extended
  thinking, effort, context-1m beta) for non-Claude models
- server: pass the WS per-message model through to run()
- startup warning when ollama.enabled but proxy.enabled is false

Frontend:
- api.getModels() + optional model arg on ws.sendMessage
- chatStore holds available/selected/default model (persisted to localStorage)
- ChatInput renders a model picker, shown only when more than one model
  is offered

config.example.yaml: document the proxy and ollama blocks.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Fix crypto.randomUUID crash in non-secure (plain-HTTP) contexts (ClickHouse#149)

crypto.randomUUID() is only available in secure contexts (HTTPS or
localhost), so accessing the UI over plain HTTP via a LAN host throws
"TypeError: crypto.randomUUID is not a function". Add a randomUUID()
helper that falls back to a v4 UUID built from crypto.getRandomValues()
(available in non-secure contexts), then Math.random() as a last resort.
Use it in chatStore and ChatInput.

* Give background sub-agents the same tool permissions as foreground (ClickHouse#150)

Background sub-agents (the Agent tool with run_in_background) had their
nested Write/Edit/Bash denied: a detached, non-blocking task can't surface
an approval prompt, so the CLI denies tools needing one and can_use_tool is
never invoked for them.

Add a catch-all PreToolUse hook that pre-approves all non-interactive tools
(a hook fires for nested background-subagent calls where can_use_tool can't
reach). Interactive tools and Read still defer to can_use_tool / the image
validator, so web pause-for-input and image validation are unchanged. Gated
by agent.background_agent_permissions (default true).

* Keep background sub-agent output in the side panel, not the main chat (ClickHouse#151)

Background sub-agents (the Agent tool with run_in_background) spilled all
their tools and thoughts into the main chat instead of their side-panel.

Root cause: the live streaming handlers routed a sub-agent's child events
(those carrying parent_tool_use_id) to a panel only when a panel with that
id was still status === 'running'. A background sub-agent's Agent tool
returns immediately with a task id, so the immediate tool_result and the
backend's subagent_complete marked the panel complete (and scheduled
auto-close) right away. The sub-agent's real activity streams afterward, by
which point no panel is 'running', so every child event fell through into
the main chat. Reload looked correct only because the replay path enforces
a stronger invariant (any parent_tool_use_id event belongs to its panel,
never the main chat) — but new live events kept hitting the buggy path.

Fix, mirroring the replay invariant and the existing Workflow pattern:
- Route any parent_tool_use_id event (thinking/token/tool_use/tool_result)
  to its panel by id regardless of status, and never into the main chat.
- Flag run_in_background sub-agent panels (background). Treat the immediate
  Agent result like a Workflow launch: record it on the inline card but keep
  the panel open and running. Ignore the premature subagent_complete and
  skip these panels in finalizeRunningPanels so the launching turn's done
  doesn't close them.
- Settle background panels when no background task is still running
  (handleBackgroundTasksUpdate) — the natural completion signal, since there
  is no per-tool_use_id done event for a detached sub-agent. An explicit
  /stop settles them too.
- Carry the background flag through buffer replay for reconnect parity.

* Fix navigator.clipboard crash over plain HTTP (non-secure context) (ClickHouse#154)

navigator.clipboard is only exposed in secure contexts (HTTPS or
http://localhost). When the UI is accessed over plain HTTP via a LAN
hostname/IP, or behind a proxy without a trusted cert, the entire
clipboard object is undefined and any call throws
'TypeError: Cannot read properties of undefined (reading writeText)'.

Two affected call sites:
- web/src/components/Chat/CodeBlock.tsx — the per-code-block Copy button
- web/src/pages/ChatPage.tsx — Cmd+Shift+C 'copy last response' shortcut

Both silently failed without surfacing any UI feedback.

Add a shared copyToClipboard helper that prefers navigator.clipboard
when available and falls back to a hidden off-screen <textarea> +
document.execCommand('copy'). execCommand is deprecated but still
implemented in every browser we care about.

Mirrors the same non-secure-context fallback pattern already used in
utils/uuid.ts (introduced by ClickHouse#149).

* Fix file-upload 404 on a new chat by materializing the session first (ClickHouse#155)

The "+" button mints a client-only "virtual" session that isn't
persisted in the DB until the first message is sent. Attaching a file
to such a chat uploaded against that temp id, so POST /api/files/upload
hit the handler's `if not session: 404 "Session not found"` guard — a
404 that looked like a missing route but was application-level.

Extract a shared `ensureRealSession()` store action that materializes
the virtual session (POST /api/sessions, adopt the server id, carry the
draft across) and call it before uploading, so the upload targets a real
session row. sendMessage now reuses the same action instead of its own
inline materialization.

* Fix source-message updates silently dropped when the updated row is the max rowid (ClickHouse#158)

insert_source_messages re-surfaces an updated mutable-source record (e.g. a
GitHub notification thread that gained a new comment) by deleting the old row
and re-inserting it, relying on the re-insert getting a higher rowid so
consumer cursors that poll rowid > cursor_seq re-deliver it.

source_messages has PRIMARY KEY (source, id) and no AUTOINCREMENT, so the
implicit rowid of a re-INSERT is MAX(rowid)+1. When the replaced row is itself
the current MAX, deleting it first lowers the max and the re-insert reuses the
same rowid, which is <= a consumer cursor already parked there. The update is
then never re-delivered.

Capture MAX(rowid)+1 before the delete (while the old row still counts toward
the max) and re-insert at that explicit rowid, guaranteeing it is strictly
above every prior rowid and every consumer cursor. The unchanged-record fast
path and the fresh-insert path are untouched.

Add tests/test_source_resurface.py covering the max-rowid case (the regression),
the non-max case, idempotent no-op re-ingest, and repeated successive updates.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Make the conversation column and session list drag-resizable (ClickHouse#157)

* Make the chat conversation column drag-resizable

The conversation reading column was capped at a fixed 768px (max-w-3xl)
and centered, so widening the window only grew the side margins. This
adds a drag handle at the column's right edge to set its width.

- New ChatWidthHandle component, mirroring the side-panel resize
  interaction in SidePanel.tsx. The column is centered, so it grows
  symmetrically (2*dx), which keeps it centered and makes the dragged
  edge track the cursor 1:1.
- The chosen width drives a --chat-width CSS variable consumed by the
  message rows, todo panel, and composer via max-w-[var(--chat-width)],
  so they stay aligned. Defaults to 48rem (768px), so nothing changes
  visually until you drag.
- Persisted to localStorage (nerve_chat_width), clamped 480..2000.
  Hidden below the md breakpoint, where the column already fills the
  viewport.

* Make the chat width handle visible at rest

The handle was transparent until hover, so the resize affordance was
easy to miss. Add a small persistent grip at the column's right edge
(vertical center) that brightens and grows on hover and drag, plus a
faint full-height guide line on hover to show the resize axis. Widen
the grab area to 16px.

* Make the session list drag-resizable

The session sidebar was a fixed 240px (w-60). Add a drag handle on its
right edge so the conversation list can be widened or narrowed. The
width persists (localStorage nerve_sidebar_width, clamped 180..480) and
the width transition is disabled while dragging so it stays responsive.
This mirrors the side-panel and conversation-column resize.

* Surface serving-model change events (API downgrades) in chat (ClickHouse#161)

* Reset-aware per-turn cost accounting + v036 backfill of swallowed turns (ClickHouse#162)

The SDK cost counter is cumulative per CLI client process; the
max(delta, 0) clamp recorded $0 for the first turn after every client
recycle (idle sweep, oneshot cron teardown, restart, model switch) —
for persistent crons that meant every run. compute_turn_cost now
detects counter resets, attributes the new cumulative to the turn, and
backstops zero-with-traffic turns with the token-based estimate. New
clients also zero the persisted baseline explicitly. v036 backfills
the affected rows from token counts and adds the recovered cost to
session totals (additive, not re-summed — telemetry pruning deletes
old usage rows, so a blanket re-sum would erase legitimate history).

Also: FTS5 task-search queries are now sanitized with a word-character
allowlist — a $ (or +, =, &, %) in a search string previously raised
fts5 syntax errors in task_create's duplicate check.

* fixup: land migration renumber (v036→v037) properly

The renumber landed in the working tree but never got staged into
the merge commit, so CI saw the original v036_backfill_zero_cost_turns
name and blew up on the ImportError in test_cost_accounting.

---------

Co-authored-by: pufit <pufit@clickhouse.com>
Co-authored-by: polyglotAI-bot <polyglotai@clickhouse.com>
Co-authored-by: Polyglot AI <293096396+polyglotAI-bot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Alex Fedotyev <61838744+alex-fedotyev@users.noreply.github.com>
Co-authored-by: Pervakov Grigorii <pervakov.grigory@gmail.com>
Co-authored-by: Minh Vu <vuhoangminh97@gmail.com>
Co-authored-by: pufit <pufit.dev@gmail.com>
Co-authored-by: Lino Uruñuela <wachynaky@gmail.com>
Co-authored-by: Oranje AI <293843428+oranjeai@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant