diff --git a/VERSION b/VERSION index 6633391..a881cf7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.18.0 +0.20.0 \ No newline at end of file diff --git a/cmd/nomos/agent.go b/cmd/nomos/agent.go index 929dc6e..359f6e7 100644 --- a/cmd/nomos/agent.go +++ b/cmd/nomos/agent.go @@ -181,6 +181,11 @@ type agentEvent struct { Data any `json:"data,omitempty"` SessionID string `json:"session_id,omitempty"` Iteration int `json:"iteration,omitempty"` + // IsThinking marks text/text_delta events that carry the model's internal + // reasoning (text produced before tool calls in the same iteration), as + // distinct from the final response text. The frontend renders these as + // collapsible thinking blocks separated from the response. + IsThinking bool `json:"is_thinking,omitempty"` } func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) { @@ -465,7 +470,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s // led to each step. Emitting it lets the persist layer accumulate // per-iteration reasoning into the row's text field. if strings.TrimSpace(msg.Content) != "" { - emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID}) + emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID, IsThinking: true}) } slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID) diff --git a/cmd/nomos/continue.go b/cmd/nomos/continue.go index 53c058f..bbaa16c 100644 --- a/cmd/nomos/continue.go +++ b/cmd/nomos/continue.go @@ -242,6 +242,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool var toolCalls []map[string]any var finalText, errText string + var finalThinking string persist := func() { if msgID == uuid.Nil { @@ -254,6 +255,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool body, _ := json.Marshal(map[string]any{ "role": "assistant", "text": text, + "thinking": finalThinking, "tool_calls": toolCalls, "auto": true, // marks this as an autonomous continuation, not an operator turn }) @@ -289,10 +291,14 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool } } toolCalls, finalText, errText = nil, "", "" + finalThinking = "" // P3: accumulate per-iteration reasoning instead of overwriting // (same fix as main.go's chat handler). Without this, a resumed // turn's intermediate thinking is lost on reload. + // (same fix as main.go's chat handler). Without this, a resumed + // turn's intermediate thinking is lost on reload. var textParts []string + var thinkingParts []string emit := func(ev agentEvent) { if ev.Type == "tool_use" || ev.Type == "tool_result" { if m, ok := ev.Data.(map[string]any); ok { @@ -319,8 +325,13 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool } if ev.Type == "text" { if t, ok := ev.Data.(string); ok && t != "" { - textParts = append(textParts, t) - finalText = strings.Join(textParts, "\n\n") + if ev.IsThinking { + thinkingParts = append(thinkingParts, t) + finalThinking = strings.Join(thinkingParts, "\n\n") + } else { + textParts = append(textParts, t) + finalText = strings.Join(textParts, "\n\n") + } persist() } } diff --git a/cmd/nomos/main.go b/cmd/nomos/main.go index 4a31a54..f382c1c 100644 --- a/cmd/nomos/main.go +++ b/cmd/nomos/main.go @@ -180,7 +180,9 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string // P3: accumulate per-iteration reasoning instead of overwriting with the // final `text` event (see the original inline comment in handleChat). var textParts []string + var thinkingParts []string var finalText string + var finalThinking string placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""}) msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder) @@ -194,6 +196,7 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string body, _ := json.Marshal(map[string]any{ "role": "assistant", "text": finalText, + "thinking": finalThinking, "tool_calls": toolCalls, }) a.store.updateMessage(pctx, msgID, body) @@ -223,8 +226,13 @@ func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string } if ev.Type == "text" { if t, ok := ev.Data.(string); ok && t != "" { - textParts = append(textParts, t) - finalText = strings.Join(textParts, "\n\n") + if ev.IsThinking { + thinkingParts = append(thinkingParts, t) + finalThinking = strings.Join(thinkingParts, "\n\n") + } else { + textParts = append(textParts, t) + finalText = strings.Join(textParts, "\n\n") + } persist() } } diff --git a/plans/done/2026-08-04-chat-window-overhaul.md b/plans/done/2026-08-04-chat-window-overhaul.md new file mode 100644 index 0000000..bff3d9f --- /dev/null +++ b/plans/done/2026-08-04-chat-window-overhaul.md @@ -0,0 +1,191 @@ +# 2026-08-04 — Chat interaction overhaul: inline progressive stream (Claude Code style) + +**Status:** Planned — not started. (Refocused from the earlier feature-heavy +draft; backend features deferred — see "Deferred".) + +## Goal + +Streamline agent interactions — thinking, plan, tool usage, responses — into +**one linear progressive inline stream per turn** (the Claude Code / Cline / +Roo pattern), instead of the current split where the transcript shows a +collapsed trace and the real live activity lives in a separate rail timeline. +The right rail becomes **graph-only** (and auto-zooms to fit all entities). + +## Locked decisions (operator interview) + +| Decision | Choice | +|---|---| +| Live activity layout | **Inline stream (Claude Code)** — one progressive column per turn; rail keeps ONLY the Scope graph; Activity timeline tab removed | +| Tool-call detail | **Per-tool progressive lines** — each tool its own compact live line (spinner → one-line result summary), expandable to raw | +| Feature phases | **Defer** — edit/resubmit, @mentions, attachments are later phases; this plan is interaction-focused + graph auto-zoom | + +## Diagnosis (grounded in current code) + +- The transcript (`ChatThread` → `AgentTrace`) collapses a whole turn's tool + calls into one line ("Proposed plan" / "N tool calls"), raw-JSON detail on + expand. Not progressive; you can't see what's happening without expanding. +- The actual live plan + tool timeline lives in the **right rail** + (`TaskContextPanel` → `UnifiedTimeline`): newest-first backbone + tool stubs. + So "what is the agent doing" is in a **second place** — a cognitive split. +- `UnifiedTimeline` is imported **only** by `TaskContextPanel` (grep confirms), + so removing the Activity pane is self-contained. +- The `activityLog` **store** stays required: it feeds inline labels + (`toolActivityLabel`), live `run` output (`toolsWithLive`), and the mascot + (`mascot/stimuli.ts`). Only the timeline *view* is removed. +- Tool events already arrive separately (`tool_use` then `tool_result` in + `chat.ts`), and the activity log already carries humanized labels + per-tool + `stepSeq` attribution. So progressive per-tool lines + step grouping are a + **presentation** change, not a data/model change. +- `run` results are free-form text (e.g. `"run on lxc:caddy: ERROR exit status + 1"`) → one-line result summaries are best-effort text parsing, no backend. + +## Design + +### D1 — One progressive inline stream per turn +Replace `AgentTrace` (one collapsed blob per turn) with a new +**`TurnTrace.svelte`** rendered inline for each assistant turn, top-to-bottom: +1. **Live plan checklist** (only on the most-recent/running turn — see D3). +2. **Tool lines grouped by plan step** (D2), then orphan tools (no step). +3. **Streamed text answer** (existing `markdown-body prose-chat`), with the + blinking cursor while streaming (existing). +4. A compact **"Thinking" line** while `working` and before any output: reuses + the existing `indicatorLabel` (running step → tool → "Agent is thinking…"). + Fades once text/tools arrive; reappears between steps. + +### D2 — Per-tool progressive lines (the Claude-Code signature) +One `ToolLine.svelte` per tool call (replaces `ToolCallCard`'s row style): +- Left: state icon — spinner while `tool_use`-only, ✓ on result, ✗ on error. +- Label: existing `toolActivityLabel(tool)` (humanized action). +- **One-line result summary** on completion — new `toolResultSummary(tool)` + in `activity.ts` (see plumbing). E.g.: + - `run` → `exit 0 · ` (parse "exit status N" / "ERROR") + - `get_entity` → `host:hubris (healthy)`; `get_health_summary` → `healthy X · degraded Y · down Z` + - `list_entities`/`list_lxcs` → `N entities`; `get_relations` → `N relations` + - `search_knowledge` → `N results`; `upsert_knowledge` → `recorded document:…` + - `update_plan_step` → `step `; `propose_plan` → `N steps` + - default → first non-empty line of stringified result (≤80ch); `done` if empty +- Live `run` output: while streaming, the line auto-expands a pinned-tail mini + pane (reuse the `liveOutput` path from `toolsWithLive`). +- Click → expand raw args/result (border-driven `
`, cyberspace-square).
+- Border-driven, no rounded/shadow (per `border_driven_language`).
+
+### D3 — Live plan checklist (TodoWrite-style)
+On the **running/last** turn, render the current-generation `planSteps`
+(already generation-aware via `workspace.ts`) as a checklist: pending = hollow,
+running = spinner + highlight, done = ✓, failed = ✗, blocked = pause. Steps
+check off live as `plan.step.*` events land. This is the unified timeline's
+plan view, moved inline and scoped to the active turn. Past turns render only
+their tool lines + text (the plan is session-level; the running turn carries
+its current state, mirroring how TodoWrite re-displays state each turn). On a
+terminal task state (`done`/`failed`), the checklist collapses to one line:
+`Plan complete — N steps` / `Plan failed — step K`.
+
+### D4 — Rail → graph only
+`TaskContextPanel`: remove the Activity pane and the `UnifiedTimeline` import;
+the panel becomes the Scope graph full-height (keep the collapsible "Scope"
+header + the `nowTouching` strip). The graph is now the rail's entire job, so
+auto-fit (D6) matters more. `activityLog*` stores remain imported only where
+the inline stream/mascot need them.
+
+### D5 — Cyberspace cohesion of the stream
+Apply alongside the rewrite so the new inline view is on-system from day one:
+- Transcript → **terminal log rows** (square, full-width, `YOU`/`NOMOS`
+  role-tags, hairline `divide-y` separators; no bubbles, no soft shadow).
+  Delete `.user-msg { box-shadow }`.
+- Tool lines + expanded `
`: border-driven, square, opaque.
+- Composer: opaque `bg-background`, square (remove `rounded-2xl`/`bg-card/50`).
+- Rewrite the stale "Art Nouveau" `
diff --git a/web/src/lib/components/ChatThread.svelte b/web/src/lib/components/ChatThread.svelte
index 909ca2a..28f344d 100644
--- a/web/src/lib/components/ChatThread.svelte
+++ b/web/src/lib/components/ChatThread.svelte
@@ -6,20 +6,26 @@
   // instead of being copy-pasted between the two.
   import { Pane, Splitpanes } from 'svelte-splitpanes'
   import { activityLog, type ActivityEntry } from '$lib/stores/activity'
+  import { resumeSession } from '$lib/api'
   import type { Readable } from 'svelte/store'
   import { Button } from '$lib/components/ui/button'
   import { Textarea } from '$lib/components/ui/textarea'
-  import AgentTrace from './AgentTrace.svelte'
+  import TurnTrace from './TurnTrace.svelte'
+  import ThinkingBlock from './ThinkingBlock.svelte'
   import OperatorQuestion from './OperatorQuestion.svelte'
+  import GlyphIndicator from './GlyphIndicator.svelte'
   import Spinner from './Spinner.svelte'
   import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
   import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
   import SquareIcon from '@lucide/svelte/icons/square'
+  import CopyIcon from '@lucide/svelte/icons/copy'
+  import CheckIcon from '@lucide/svelte/icons/check'
+  import ArrowDownToLineIcon from '@lucide/svelte/icons/arrow-down-to-line'
   import { marked } from 'marked'
   import DOMPurify from 'dompurify'
   import type { ChatMessage } from '$lib/stores/chat'
   import type { ToolCallResult } from '$lib/types'
-  import type { SessionQuestion } from '$lib/api'
+  import type { PlanStep, SessionQuestion } from '$lib/api'
 
   let {
     messages,
@@ -36,7 +42,10 @@
     activityLog: activityLogProp = activityLog,
     sessionId = null,
     question = null,
-    initialDraft = ''
+    initialDraft = '',
+    planSteps = [],
+    taskStatus,
+    lastActiveAt
   }: {
     messages: ChatMessage[]
     streaming: boolean
@@ -64,6 +73,16 @@
      *  than making them retype it. Left editable on purpose — it is a starting
      *  point, not a command. */
     initialDraft?: string
+    /** Current-generation plan steps for this session — rendered as a live
+     *  checklist on the running turn (TodoWrite-style). Empty for a new/plan-less
+     *  task and for the new-task launcher. */
+    planSteps?: PlanStep[]
+    /** Session status (active/planning/executing/…/done/failed). Drives the
+     *  plan checklist's collapse-to-summary at a terminal state. */
+    taskStatus?: string
+    /** Session's last_active_at timestamp — used to detect a stuck turn
+     *  (working but no activity for >5 min) and show elapsed time. */
+    lastActiveAt?: string
   } = $props()
 
   let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
@@ -103,6 +122,63 @@
     return 'Agent is thinking…'
   })
 
+  // ── stuck detection + elapsed time ─────────────────────────────────────
+  // A turn is "stuck" when the server says working (planning/executing) but
+  // last_active_at is >5 min old — the agent's turn ended without updating
+  // the session status (crash, timeout, or a zombie gate). Show a distinct
+  // stuck indicator with a Resume button instead of a misleading "working…".
+  let resuming = $state(false)
+  let now = $state(Date.now())
+
+  $effect(() => {
+    if (!working) return
+    const id = setInterval(() => {
+      now = Date.now()
+    }, 1000)
+    return () => clearInterval(id)
+  })
+
+  const elapsedSeconds = $derived(
+    working && lastActiveAt
+      ? Math.max(0, Math.floor((now - new Date(lastActiveAt).getTime()) / 1000))
+      : 0
+  )
+  const isStuck = $derived(working && !streaming && elapsedSeconds > 300)
+
+  function formatElapsed(s: number): string {
+    if (s < 60) return `${s}s`
+    if (s < 3600) return `${Math.floor(s / 60)}m`
+    return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
+  }
+
+  async function handleResume() {
+    if (!sessionId || resuming) return
+    resuming = true
+    try {
+      await resumeSession(sessionId)
+    } finally {
+      resuming = false
+    }
+  }
+
+  // ── glyph backdrop ─────────────────────────────────────────────────────
+  // The agent's live semantic state, rendered as a faint procedural glyph
+  // behind the transcript. Computed from the same signals as the sidebar
+  // (status / working / streaming / connection / stuck) so the backdrop
+  // breathes with the agent without any store imports (prop-driven).
+  const agentSprite = $derived.by(() => {
+    if (connectionState !== 'connected') return 'status.offline'
+    if (taskStatus === 'failed') return 'status.error'
+    if (taskStatus === 'abandoned') return 'status.cancelled'
+    if (taskStatus === 'done') return 'status.success'
+    if (taskStatus === 'awaiting_input') return 'ai.listening'
+    if (isStuck) return 'status.warning'
+    if (streaming) return 'ai.speaking'
+    if (working) return 'ai.still-working'
+    if (taskStatus === 'planning') return 'ai.thinking'
+    return 'ai.idle'
+  })
+
   // Resizable input area — drag the splitter above it to grow the textarea,
   // capped so it can't swallow the whole thread. Both the minimum and the
   // default are exactly one line: measured from the textarea's own
@@ -160,14 +236,21 @@
   // unless user scrolled up to read. Sets scrollTop on the messages container
   // directly instead of `scrollIntoView`, which walks ancestors and forces a
   // reflow that can momentarily perturb the window titlebar height.
+  //
+  // During streaming, scroll INSTANTLY (behavior: 'auto') — the content is
+  // growing continuously, so a smooth animation constantly chases a moving
+  // target and produces the jerky "jumping" the operator sees. For
+  // non-streaming updates (a completed message, a question), a smooth scroll
+  // is fine. Uses requestAnimationFrame so the scroll lands after the DOM
+  // update, not 50ms later.
   $effect(() => {
     void messages
     void question
     if (streaming || !scrolledUp) {
-      setTimeout(
-        () => container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }),
-        50
-      )
+      const behavior = streaming ? ('auto' as const) : ('smooth' as const)
+      requestAnimationFrame(() => {
+        container?.scrollTo({ top: container.scrollHeight, behavior })
+      })
     }
   })
 
@@ -216,25 +299,51 @@
     onSend(q)
   }
 
-  // Merge live streaming output (from the activity log's `run` entry) onto the
-  // in-flight turn's tool calls so the inline tool card shows command output as
-  // it arrives — the place the operator naturally "checks the tool". Only the
-  // last assistant message can be streaming, so only it gets enriched; history
-  // is untouched (and has no live output anyway). (F4)
-  function toolsWithLive(
-    tools: ToolCallResult[],
-    entries: ActivityEntry[],
-    isLiveTurn: boolean
-  ): ToolCallResult[] {
-    if (!isLiveTurn) return tools
-    const liveById = new Map()
-    for (const e of entries) {
-      if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
+  // Per-message copy affordance (border-driven icon button on each row).
+  let copiedId = $state(null)
+  async function copyMessage(msg: ChatMessage) {
+    try {
+      await navigator.clipboard.writeText(msg.text)
+      copiedId = msg.id
+      setTimeout(() => {
+        if (copiedId === msg.id) copiedId = null
+      }, 1400)
+    } catch {
+      /* clipboard unavailable — silently no-op */
     }
-    if (liveById.size === 0) return tools
-    return tools.map((t) =>
-      t.id && liveById.has(t.id) ? { ...t, liveOutput: liveById.get(t.id) } : t
-    )
+  }
+
+  // Scroll-to-bottom: uses container.scrollTo (never scrollIntoView, which
+  // reflows ancestor wmkit panes — see wmkit.scrollintoview_reflow_pitfall).
+  function jumpToBottom() {
+    container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
+    scrolledUp = false
+  }
+
+  // Enrich a turn's tool calls with live `run` output AND plan-step
+  // attribution pulled from the activity log (keyed by tool id), so the inline
+  // TurnTrace can pin streaming output to its tool and group calls under their
+  // step. Run for every turn (not just the live one) so historical turns group
+  // correctly too; unmapped tools pass through unchanged.
+  function enrichTools(tools: ToolCallResult[], entries: ActivityEntry[]): ToolCallResult[] {
+    const byId = new Map()
+    for (const e of entries) {
+      if (!e.id) continue
+      const cur = byId.get(e.id) ?? {}
+      if (e.liveOutput) cur.liveOutput = e.liveOutput
+      if (e.stepSeq != null) cur.stepSeq = e.stepSeq
+      byId.set(e.id, cur)
+    }
+    if (byId.size === 0) return tools
+    return tools.map((t) => {
+      if (!t.id) return t
+      const e = byId.get(t.id)
+      if (!e) return t
+      const next: ToolCallResult = { ...t }
+      if (e.liveOutput) next.liveOutput = e.liveOutput
+      if (e.stepSeq != null) next.stepSeq = e.stepSeq
+      return next
+    })
   }
 
 
@@ -247,107 +356,117 @@
     on:resize={() => (userResizedInput = true)}
   >
     
-      
-
- {#if messages.length === 0} -
-
-

Nomos

-

- Your resident operator. Ask about the fleet, or tell it to act. -

-
- {#if suggestions.length} -
- {#each suggestions as q} - - {/each} -
- {/if} -
- {/if} - - {#each messages as msg, idx (msg.id)} -
- {#if msg.role === 'user'} -
- You - {#if msg.created_at} - {formatTime(msg.created_at)} - {/if} -
-
- {msg.text} -
- {#if idx === messages.length - 1 && working && !streaming} - - Queued — Nomos will run this when it finishes the current step. - {/if} - {:else} - {@const isLast = idx === messages.length - 1} - {@const traceStatus = !isLast - ? 'idle' - : error - ? 'error' - : working - ? 'running' - : indicatorDone - ? 'done' - : 'idle'} -
-
- Nomos - {#if msg.created_at} - {formatTime(msg.created_at)} - {/if} -
- - {#if msg.tools.length > 0 || traceStatus !== 'idle'} - - {/if} - {#if msg.text} -
- - {@html render(msg.text)} - {#if isLast && streaming} - - {/if} -
- {/if} -
- {/if} -
- {/each} - {#if question} - - {/if} +
+ + +
+
+ {#if messages.length === 0} +
+
+

Nomos

+

+ Your resident operator. Ask about the fleet, or tell it to act. +

+
+ {#if suggestions.length} +
+ {#each suggestions as q} + + {/each} +
+ {/if} +
+ {/if} + + {#each messages as msg, idx (msg.id)} + {@const isLast = idx === messages.length - 1} +
+ +
+ {#if msg.role === 'user'} +
{msg.text}
+ {#if msg.created_at} +
{formatTime(msg.created_at)}
+ {/if} + {#if isLast && working && !streaming} + +
Queued — runs when Nomos finishes the current step.
+ {/if} + {:else} + {@const traceStatus = !isLast + ? 'idle' + : error + ? 'error' + : working + ? 'running' + : indicatorDone + ? 'done' + : 'idle'} + + {#if msg.tools.length > 0 || traceStatus !== 'idle'} + + {/if} + {#if msg.thinking} + + {/if} + {#if msg.text} +
+ + {@html render(msg.text)} + {#if isLast && streaming} + + {/if} +
+ {#if msg.created_at} +
{formatTime(msg.created_at)}
+ {/if} + {/if} + {/if} +
+ +
+ {/each} + {#if question} +
+ {/if} +
+
+ {#if scrolledUp && messages.length > 0} + + {/if}
{#if connectionState === 'disconnected'} @@ -420,20 +539,36 @@ spinner + fg label + primary-tinted hairline border, aligned to the textarea column. -->
-
- - Working - message will queue — runs when Nomos is free -
+ {#if isStuck} +
+ Stuck + no activity for {formatElapsed(elapsedSeconds)} + +
+ {:else} +
+ + Working + {formatElapsed(elapsedSeconds)} +
+ {/if}
{/if}
{#if streaming} @@ -456,7 +591,7 @@ type="button" size="icon-sm" variant="secondary" - class="absolute right-2 bottom-2 rounded-lg" + class="absolute right-2 bottom-2" onclick={onCancel} aria-label="Stop" > @@ -467,7 +602,7 @@ type="submit" size="icon-sm" variant="secondary" - class="absolute right-2 bottom-2 rounded-lg" + class="absolute right-2 bottom-2" disabled={!input.trim()} aria-label="Send" > @@ -481,34 +616,110 @@
diff --git a/web/src/lib/components/GlyphIndicator.svelte b/web/src/lib/components/GlyphIndicator.svelte new file mode 100644 index 0000000..9b33d66 --- /dev/null +++ b/web/src/lib/components/GlyphIndicator.svelte @@ -0,0 +1,89 @@ + + + + + \ No newline at end of file diff --git a/web/src/lib/components/SessionChatWindow.svelte b/web/src/lib/components/SessionChatWindow.svelte index b20d271..0631c89 100644 --- a/web/src/lib/components/SessionChatWindow.svelte +++ b/web/src/lib/components/SessionChatWindow.svelte @@ -15,7 +15,7 @@ chatErrors } from '$lib/stores/chat' import { activityLogFor } from '$lib/stores/activity' - import { workspaceFor, startSessionWorkspace, taskWorking } from '$lib/stores/workspace' + import { workspaceFor, startSessionWorkspace, taskWorking, taskFor } from '$lib/stores/workspace' import ChatThread from '$lib/components/ChatThread.svelte' import TaskContextPanel from '$lib/components/TaskContextPanel.svelte' @@ -42,6 +42,9 @@ // context rail mounts. // eslint-disable-next-line svelte/valid-compile const workspace = workspaceFor(sessionId) + const planSteps = workspace.planSteps + // eslint-disable-next-line svelte/valid-compile + const chatTask = taskFor(sessionId) const openQuestion = workspace.openQuestion let loading = $state(true) @@ -73,7 +76,7 @@ // Resizable right rail — sized smaller by default since task windows open // narrower than the full page. - let railSize = $state(24) + let railSize = $state(32)
@@ -99,13 +102,16 @@ activityLog={sessionActivityLog} {sessionId} question={$openQuestion} + planSteps={$planSteps} + taskStatus={$chatTask?.status} + lastActiveAt={$chatTask?.last_active_at} onSend={(text) => sendSessionMessage(sessionId, text)} onCancel={() => cancelSessionStream(sessionId)} onReconnect={() => loadSessionChat(sessionId)} onDismissError={dismissError} /> - + diff --git a/web/src/lib/components/SessionGraph.svelte b/web/src/lib/components/SessionGraph.svelte index ff4e069..f4554ce 100644 --- a/web/src/lib/components/SessionGraph.svelte +++ b/web/src/lib/components/SessionGraph.svelte @@ -75,6 +75,18 @@ let cw = $state(300) let ch = $state(300) + // View transform (zoom-to-fit + drag-pan). The force simulation runs in its + // own graph coordinate space; this maps graph→screen so every entity stays + // visible regardless of how far the layout spreads or how narrow the panel + // is. tx/ty are screen px; scale is unitless. `userPanned` pauses auto-fit + // once the operator drags the background, until the entity set changes or + // they double-click to reset. + let tx = $state(0) + let ty = $state(0) + let scale = $state(1) + let userPanned = $state(false) + const viewTransform = $derived(`translate(${tx},${ty}) scale(${scale})`) + function collectSlugs(value: unknown, out: Set) { if (typeof value === 'string') { const m = value.match(SLUG_RE) @@ -206,6 +218,7 @@ .alphaDecay(0.045) .on('tick', () => { nodes = [...nodes] + if (!userPanned) fitView() }) } @@ -261,6 +274,49 @@ return slug.split(':').pop() ?? slug } + // Compute the view transform that fits every node (with label clearance) + // inside the panel, clamped so a single node doesn't fill it and a huge + // graph stays legible. No-op until the layout has positions / a size. + function fitView() { + if (!nodes.length || cw <= 1 || ch <= 1) return + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + for (const n of nodes) { + if (n.x == null || n.y == null) continue + const r = nodeRadius(n) + 12 // node + label clearance + minX = Math.min(minX, n.x - r) + minY = Math.min(minY, n.y - r) + maxX = Math.max(maxX, n.x + r) + maxY = Math.max(maxY, n.y + r) + } + if (!Number.isFinite(minX)) return + const pad = 16 + const w = Math.max(maxX - minX, 1) + const h = Math.max(maxY - minY, 1) + const s = Math.min((cw - pad * 2) / w, (ch - pad * 2) / h) + const clamped = Math.max(0.2, Math.min(2.5, Number.isFinite(s) ? s : 1)) + scale = clamped + tx = (cw - w * clamped) / 2 - minX * clamped + ty = (ch - h * clamped) / 2 - minY * clamped + } + + // When the entity SET changes (a new node added/removed), re-engage auto-fit + // so the new entity is brought into view. Same-slug re-renders (every sim + // tick) leave the signature unchanged and don't reset. + let lastMembership = '' + $effect(() => { + const sig = nodes + .map((n) => n.slug) + .sort() + .join('|') + if (sig !== lastMembership) { + lastMembership = sig + userPanned = false + } + }) + // Live touch/health-diff lookups, keyed by slug for O(1) per-node checks // during render. Kept as plain objects (not Maps) since Svelte 5 runes track // object identity fine and this is small (≤12 touched, ≤8 diffs). @@ -283,16 +339,22 @@ return typeof end === 'object' ? end.slug : end } - // ─── drag / select ─────────────────────────────────────────────────── + // ─── drag / select / pan ───────────────────────────────────────────── // A click (pointerdown+up with no movement in between) opens the entity - // straight in its own floating window (WindowLayer) instead of a - // click-through mini-panel — `selected` now only drives the highlight/dim - // styling below, so you can see at a glance which node you last opened. + // straight in its own floating window (WindowLayer); `selected` only drives + // the highlight/dim styling. Node drag pins the node in GRAPH coords + // (screen→graph via the inverse view transform). Background drag pans the + // view and sets userPanned so auto-fit pauses. Double-click background + // re-fits all entities. let dragState: { node: Node; moved: boolean } | null = null + let panState: { x: number; y: number } | null = null - function toLocal(clientX: number, clientY: number) { + function toGraph(clientX: number, clientY: number) { const rect = container!.getBoundingClientRect() - return { x: clientX - rect.left, y: clientY - rect.top } + return { + x: (clientX - rect.left - tx) / scale, + y: (clientY - rect.top - ty) / scale + } } function onNodeDown(e: PointerEvent, node: Node) { @@ -301,26 +363,44 @@ dragState = { node, moved: false } sim?.alphaTarget(0.2).restart() } + function onBgDown(e: PointerEvent) { + panState = { x: e.clientX - tx, y: e.clientY - ty } + ;(e.currentTarget as Element).setPointerCapture(e.pointerId) + } function onMove(e: PointerEvent) { - if (!dragState) return - const p = toLocal(e.clientX, e.clientY) - dragState.node.fx = p.x - dragState.node.fy = p.y - dragState.moved = true - nodes = [...nodes] + if (dragState) { + const p = toGraph(e.clientX, e.clientY) + dragState.node.fx = p.x + dragState.node.fy = p.y + dragState.moved = true + nodes = [...nodes] + return + } + if (panState) { + tx = e.clientX - panState.x + ty = e.clientY - panState.y + userPanned = true + } } function selectAndOpen(node: Node) { selected = node openEntityWindow(node.slug) } function onUp() { - if (!dragState) return - const { node, moved } = dragState - node.fx = null - node.fy = null - sim?.alphaTarget(0) - dragState = null - if (!moved) selectAndOpen(node) + if (dragState) { + const { node, moved } = dragState + node.fx = null + node.fy = null + sim?.alphaTarget(0) + dragState = null + if (!moved) selectAndOpen(node) + return + } + panState = null + } + function refit() { + userPanned = false + fitView() } const selectedRelations = $derived( @@ -342,7 +422,7 @@ ) -