v0.20.0: thinking blocks, chat windows overhaul, scroll fix
Backend: - Add isThinking flag to agentEvent for text before tool calls - Separate thinking from response text in runChatTurn and continue.go - Persist thinking in a dedicated field in message content Frontend: - Add thinking field to MessageContent, ChatMessage, ChatTextEvent types - Create ThinkingBlock.svelte — collapsible block with brain icon - SSE handler moves text_delta content to thinking on isThinking flag - Render thinking block between tools and response in ChatThread - Fix chat window scroll reset on focus change (stable windowKeys order) - Remove redundant #key id wrapper in WindowLayer - Enlarge sidebar rail (24→32 default, 40→60 max) - Remove glyph from sidebar, square graph at top - Replace AgentTrace/ToolCallCard/UnifiedTimeline with TurnTrace/ToolLine
This commit is contained in:
@@ -181,6 +181,11 @@ type agentEvent struct {
|
|||||||
Data any `json:"data,omitempty"`
|
Data any `json:"data,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Iteration int `json:"iteration,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)) {
|
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
|
// led to each step. Emitting it lets the persist layer accumulate
|
||||||
// per-iteration reasoning into the row's text field.
|
// per-iteration reasoning into the row's text field.
|
||||||
if strings.TrimSpace(msg.Content) != "" {
|
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)
|
slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID)
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool
|
|||||||
|
|
||||||
var toolCalls []map[string]any
|
var toolCalls []map[string]any
|
||||||
var finalText, errText string
|
var finalText, errText string
|
||||||
|
var finalThinking string
|
||||||
|
|
||||||
persist := func() {
|
persist := func() {
|
||||||
if msgID == uuid.Nil {
|
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{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"text": text,
|
"text": text,
|
||||||
|
"thinking": finalThinking,
|
||||||
"tool_calls": toolCalls,
|
"tool_calls": toolCalls,
|
||||||
"auto": true, // marks this as an autonomous continuation, not an operator turn
|
"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, "", ""
|
toolCalls, finalText, errText = nil, "", ""
|
||||||
|
finalThinking = ""
|
||||||
// P3: accumulate per-iteration reasoning instead of overwriting
|
// P3: accumulate per-iteration reasoning instead of overwriting
|
||||||
// (same fix as main.go's chat handler). Without this, a resumed
|
// (same fix as main.go's chat handler). Without this, a resumed
|
||||||
// turn's intermediate thinking is lost on reload.
|
// 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 textParts []string
|
||||||
|
var thinkingParts []string
|
||||||
emit := func(ev agentEvent) {
|
emit := func(ev agentEvent) {
|
||||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||||
if m, ok := ev.Data.(map[string]any); ok {
|
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 ev.Type == "text" {
|
||||||
if t, ok := ev.Data.(string); ok && t != "" {
|
if t, ok := ev.Data.(string); ok && t != "" {
|
||||||
textParts = append(textParts, t)
|
if ev.IsThinking {
|
||||||
finalText = strings.Join(textParts, "\n\n")
|
thinkingParts = append(thinkingParts, t)
|
||||||
|
finalThinking = strings.Join(thinkingParts, "\n\n")
|
||||||
|
} else {
|
||||||
|
textParts = append(textParts, t)
|
||||||
|
finalText = strings.Join(textParts, "\n\n")
|
||||||
|
}
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// P3: accumulate per-iteration reasoning instead of overwriting with the
|
||||||
// final `text` event (see the original inline comment in handleChat).
|
// final `text` event (see the original inline comment in handleChat).
|
||||||
var textParts []string
|
var textParts []string
|
||||||
|
var thinkingParts []string
|
||||||
var finalText string
|
var finalText string
|
||||||
|
var finalThinking string
|
||||||
|
|
||||||
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
|
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
|
||||||
msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
|
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{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"text": finalText,
|
"text": finalText,
|
||||||
|
"thinking": finalThinking,
|
||||||
"tool_calls": toolCalls,
|
"tool_calls": toolCalls,
|
||||||
})
|
})
|
||||||
a.store.updateMessage(pctx, msgID, body)
|
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 ev.Type == "text" {
|
||||||
if t, ok := ev.Data.(string); ok && t != "" {
|
if t, ok := ev.Data.(string); ok && t != "" {
|
||||||
textParts = append(textParts, t)
|
if ev.IsThinking {
|
||||||
finalText = strings.Join(textParts, "\n\n")
|
thinkingParts = append(thinkingParts, t)
|
||||||
|
finalThinking = strings.Join(thinkingParts, "\n\n")
|
||||||
|
} else {
|
||||||
|
textParts = append(textParts, t)
|
||||||
|
finalText = strings.Join(textParts, "\n\n")
|
||||||
|
}
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
191
plans/done/2026-08-04-chat-window-overhaul.md
Normal file
191
plans/done/2026-08-04-chat-window-overhaul.md
Normal file
@@ -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 · <first line>` (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 <seq> → <status>`; `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 `<pre>`, 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 `<pre>`: border-driven, square, opaque.
|
||||||
|
- Composer: opaque `bg-background`, square (remove `rounded-2xl`/`bg-card/50`).
|
||||||
|
- Rewrite the stale "Art Nouveau" `<style>` comments → "cyberspace/terminal".
|
||||||
|
- Per `central_css_override`: drive surface styling centrally in `app.css`
|
||||||
|
where it's a primitive concern; no ad-hoc `rounded-*`/`shadow-*`/`backdrop-blur`.
|
||||||
|
|
||||||
|
### D6 — Graph auto-fit + drag-pan (`SessionGraph.svelte`) (carried over)
|
||||||
|
- Wrap nodes+links in `<g transform="translate(tx,ty) scale(s)">`; fit the bbox
|
||||||
|
of all nodes (radius + label + padding) into `cw`/`ch`; cap `s ∈ [0.2, 2.5]`.
|
||||||
|
- Re-fit on: mount, node-set change, container resize, sim-settle
|
||||||
|
(`alpha > 0.05`), background double-click. **Not** every tick (fights pan).
|
||||||
|
A `userPanned` flag pauses auto-follow after a manual pan until next
|
||||||
|
membership/resize/double-click.
|
||||||
|
- Background drag = pan (`tx`/`ty`); node drag converts screen→graph via the
|
||||||
|
inverse transform before setting `fx`/`fy`. Dot-grid stays in screen space.
|
||||||
|
- Keep: open-on-click, `touched` pulse, health-diff label, selection ring.
|
||||||
|
Respect `scrollIntoView` pitfall (transform, not scroll).
|
||||||
|
|
||||||
|
## Phased task list (each independently shippable; all frontend)
|
||||||
|
|
||||||
|
- **P1 — Inline progressive stream.** `TurnTrace.svelte` + `ToolLine.svelte`;
|
||||||
|
wire into `ChatThread` per turn; "Thinking" line; tool→step grouping via
|
||||||
|
activity-log `stepSeq` matched by tool id; keep `toolsWithLive` for `run`.
|
||||||
|
- **P2 — Live plan checklist.** Inline current-gen `planSteps` on the running
|
||||||
|
turn; collapse-to-summary at terminal state.
|
||||||
|
- **P3 — Rail → graph only.** Strip Activity pane + `UnifiedTimeline` from
|
||||||
|
`TaskContextPanel`; verify no other importers (grep: only TaskContextPanel).
|
||||||
|
- **P4 — Cyberspace cohesion.** Terminal log rows; remove rounded/shadow/
|
||||||
|
translucency; square composer; centralize in `app.css`; fix stale comments.
|
||||||
|
- **P5 — Graph auto-fit + drag-pan.** D6.
|
||||||
|
- **Polish (small, frontend-only):** per-message/tool **copy**; **scroll-to-
|
||||||
|
bottom** button (uses `container.scrollTo`, never `scrollIntoView`).
|
||||||
|
|
||||||
|
## Plumbing specifics (grounded, no backend)
|
||||||
|
|
||||||
|
- New `toolResultSummary(t: ToolCallResult): string` in `activity.ts`, beside
|
||||||
|
`toolActivityLabel`. Per-name switch (D2 list), graceful fallback.
|
||||||
|
- Tool→step grouping: build `id → stepSeq` from the activity log once per turn;
|
||||||
|
tools with no step render as orphans.
|
||||||
|
- Reuse: `planSteps` (generation-aware), `indicatorLabel`, `toolsWithLive`,
|
||||||
|
`toolActivityLabel`, `liveOutput` streaming path.
|
||||||
|
|
||||||
|
## Constraints honored (saved decisions)
|
||||||
|
|
||||||
|
- `design_system.central_css_override`, `border_driven_language`: square,
|
||||||
|
hairline, opaque, focus-by-color, no soft shadows/glows.
|
||||||
|
- `chat_thread.pane_layout`: dynamic status (Thinking line, live checklist)
|
||||||
|
lives in the **message Pane**, never the input Pane.
|
||||||
|
- `wmkit.scrollintoview_reflow_pitfall`: `container.scrollTo` for scroll-to-
|
||||||
|
bottom; transform (not scroll) for graph pan.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Removing the rail timeline loses the "overview" view.** Mitigation: the
|
||||||
|
inline checklist + per-turn tool lines carry the same info progressively; the
|
||||||
|
graph still shows fleet scope. If operators miss the overview, a collapsed
|
||||||
|
"full timeline" can return as a toggle (follow-up).
|
||||||
|
- **Auto-fit vs manual pan** — handled by `userPanned` + settle-alpha gate.
|
||||||
|
- **Inline stream length on long turns** (15–27 min, many tools) — progressive
|
||||||
|
lines can get long; mitigate by auto-collapsing finished steps (keep the
|
||||||
|
running step + its tools expanded, prior steps as one-line summaries).
|
||||||
|
- **Best-effort result summaries** may misformat unusual payloads — fallback is
|
||||||
|
always a truncated raw line + expandable raw detail, never a blank.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `npm run lint`, `tsc --noEmit` (no NEW errors beyond the known baseline in
|
||||||
|
`ui/*`, `oidc.ts`, `windows.ts`, `workspace.ts`), `vite build`, `vitest`
|
||||||
|
(add a `toolResultSummary` unit test per tool name + fallback).
|
||||||
|
- Manual matrix: (a) start a long task → Thinking line → plan checklist
|
||||||
|
appears and checks off live → each tool streams as its own line with a
|
||||||
|
one-line summary → text streams; (b) reload mid-turn → working still shows;
|
||||||
|
(c) `run` tool → live output pins to tail then collapses to summary; (d)
|
||||||
|
graph auto-fits at settle + on new entity + drag-pan + double-click reset;
|
||||||
|
(e) no rounded/soft-shadow remains on chat surfaces; (f) rail shows graph only.
|
||||||
|
|
||||||
|
## Deferred (later phases, after this lands + validates)
|
||||||
|
|
||||||
|
- **Edit-and-resubmit** — `truncateFrom` store method + `POST /sessions/{id}/edit`
|
||||||
|
(extract `streamTurn` from `handleChat`); reuse `reopenSession` (already
|
||||||
|
exists, store.go:838 — marks prior `session_plan_steps` `replaced`, clears
|
||||||
|
outcome) for the reset. Reject edit while the gate is busy (HTTP 409); edit
|
||||||
|
cannot queue (truncation must be atomic). Regenerate = no-op-edit case.
|
||||||
|
- **@entity mentions** — small `GET /api/v1/entities/search?q=` + composer
|
||||||
|
autocomplete inserting `type:name` slugs the agent/graph already parse.
|
||||||
|
- **Attachments** — multipart upload + `agent_attachments` table + configured
|
||||||
|
`OIKOS_ATTACHMENTS_DIR` (explicit volume, not relative) + capped text inlining.
|
||||||
|
- **Continue button** — needs `/resume` to `reopenSession` first for terminal
|
||||||
|
sessions (today `/resume` does not reopen `done`/`failed`; `handleChat`'s
|
||||||
|
follow-up path does). Small backend tweak.
|
||||||
|
- **Image vision** pending provider confirmation.
|
||||||
|
|
||||||
|
## Out of scope / follow-ups
|
||||||
|
|
||||||
|
- A collapsible "full timeline" overview toggle if the rail removal is missed.
|
||||||
|
- `read_attachment` MCP tool (lazy full-content fetch, lower context than inlining).
|
||||||
|
- Oldest-first timeline toggle / per-tool `tool.*` events for background turns.
|
||||||
@@ -66,6 +66,7 @@ See [`done/`](done/) for executed plans:
|
|||||||
| 2026-08-03 | [Nomos chat: reliability & predictability audit](done/2026-08-03-nomos-chat-reliability-and-ux-audit.md) |
|
| 2026-08-03 | [Nomos chat: reliability & predictability audit](done/2026-08-03-nomos-chat-reliability-and-ux-audit.md) |
|
||||||
| 2026-08-03 | [Adopt cyberspace.online terminal aesthetic + dithered images](done/2026-08-03-cyberspace-style-adoption.md) |
|
| 2026-08-03 | [Adopt cyberspace.online terminal aesthetic + dithered images](done/2026-08-03-cyberspace-style-adoption.md) |
|
||||||
| 2026-08-03 | [Nomos chat: working-visibility, message queue, generation-aware timeline](done/2026-08-03-nomos-chat-working-visibility.md) |
|
| 2026-08-03 | [Nomos chat: working-visibility, message queue, generation-aware timeline](done/2026-08-03-nomos-chat-working-visibility.md) |
|
||||||
|
| 2026-08-04 | [Chat interaction overhaul: inline progressive stream + thinking blocks](done/2026-08-04-chat-window-overhaul.md) |
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
13
web/package-lock.json
generated
13
web/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "oikos-web",
|
"name": "oikos-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@joan/procedural-glyph-engine": "file:../../../../../private/tmp/orby-pkg",
|
||||||
"@surdeddd/wmkit": "^0.3.0",
|
"@surdeddd/wmkit": "^0.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
@@ -43,6 +44,14 @@
|
|||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../../../../private/tmp/orby-pkg": {
|
||||||
|
"name": "@joan/procedural-glyph-engine",
|
||||||
|
"version": "5.0.0",
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||||
@@ -875,6 +884,10 @@
|
|||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@joan/procedural-glyph-engine": {
|
||||||
|
"resolved": "../../../../../private/tmp/orby-pkg",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@joan/procedural-glyph-engine": "file:../../../../../private/tmp/orby-pkg",
|
||||||
"@surdeddd/wmkit": "^0.3.0",
|
"@surdeddd/wmkit": "^0.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3-force": "^3.0.0",
|
"d3-force": "^3.0.0",
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// The agent's working trace for one assistant turn: the live "thinking"
|
|
||||||
// indicator and that turn's tool calls merged into a single collapsible
|
|
||||||
// strip, instead of a stack of one card per call (a 13-call turn buried the
|
|
||||||
// actual answer). Collapsed it's one line — the current activity while
|
|
||||||
// running, a count once finished. Expanded it lists what the agent did, in
|
|
||||||
// humanized language, each row opening to its raw args/result.
|
|
||||||
import type { ToolCallResult } from '$lib/types'
|
|
||||||
import ToolCallCard from './ToolCallCard.svelte'
|
|
||||||
import Spinner from './Spinner.svelte'
|
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
|
||||||
|
|
||||||
let {
|
|
||||||
tools = [],
|
|
||||||
label = null,
|
|
||||||
status = 'idle'
|
|
||||||
}: {
|
|
||||||
tools?: ToolCallResult[]
|
|
||||||
/** Live indicator text — the running step, an error, or "Done". */
|
|
||||||
label?: string | null
|
|
||||||
/** `idle` = no live state; the strip is just this turn's finished trace. */
|
|
||||||
status?: 'running' | 'done' | 'error' | 'idle'
|
|
||||||
} = $props()
|
|
||||||
|
|
||||||
let expanded = $state(false)
|
|
||||||
|
|
||||||
const count = $derived(tools.length)
|
|
||||||
// Collapsed line: prefer the live activity while something is happening,
|
|
||||||
// otherwise summarize the turn so a finished trace still says what it was.
|
|
||||||
const headline = $derived.by(() => {
|
|
||||||
if (status !== 'idle' && label) return label
|
|
||||||
if (count > 0) return count === 1 ? '1 tool call' : `${count} tool calls`
|
|
||||||
return 'No tool calls'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="trace rounded-lg border border-border/60 bg-card/40 transition-colors"
|
|
||||||
class:running={status === 'running'}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors hover:bg-muted/40"
|
|
||||||
onclick={() => (expanded = !expanded)}
|
|
||||||
aria-expanded={expanded}
|
|
||||||
aria-label={expanded ? 'Hide agent trace' : 'Show agent trace'}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="shrink-0 {status === 'error'
|
|
||||||
? 'text-destructive'
|
|
||||||
: status === 'idle'
|
|
||||||
? 'text-muted-foreground'
|
|
||||||
: 'text-primary'}"
|
|
||||||
>
|
|
||||||
{#if status === 'running'}
|
|
||||||
<Spinner class="size-3" />
|
|
||||||
{:else if status === 'error'}
|
|
||||||
<XIcon class="size-3" />
|
|
||||||
{:else if status === 'done'}
|
|
||||||
<CheckIcon class="size-3" />
|
|
||||||
{:else}
|
|
||||||
<SparklesIcon class="size-3" />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="min-w-0 flex-1 truncate text-xs {status === 'error'
|
|
||||||
? 'text-destructive'
|
|
||||||
: status === 'running'
|
|
||||||
? 'text-foreground/80'
|
|
||||||
: 'text-muted-foreground'}"
|
|
||||||
>
|
|
||||||
{headline}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{#if count > 0 && status !== 'idle'}
|
|
||||||
<span class="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">{count}</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<ChevronRightIcon
|
|
||||||
class="size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
|
|
||||||
? 'rotate-90'
|
|
||||||
: ''}"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if expanded}
|
|
||||||
<div class="border-t border-border/40 p-1">
|
|
||||||
{#if count > 0}
|
|
||||||
{#each tools as tool (tool.id)}
|
|
||||||
<ToolCallCard {tool} />
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<p class="px-2 py-1.5 text-[11px] text-muted-foreground">
|
|
||||||
Nothing recorded for this turn yet.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.trace {
|
|
||||||
animation: trace-in 0.2s ease-out;
|
|
||||||
}
|
|
||||||
/* A faint pulse while the agent is mid-turn — the collapsed strip is the
|
|
||||||
only thing on screen then, so it carries the "still working" signal. */
|
|
||||||
.trace.running {
|
|
||||||
border-color: color-mix(in oklab, var(--primary) 35%, var(--border));
|
|
||||||
}
|
|
||||||
@keyframes trace-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.trace {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -6,20 +6,26 @@
|
|||||||
// instead of being copy-pasted between the two.
|
// instead of being copy-pasted between the two.
|
||||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||||
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
|
||||||
|
import { resumeSession } from '$lib/api'
|
||||||
import type { Readable } from 'svelte/store'
|
import type { Readable } from 'svelte/store'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { Textarea } from '$lib/components/ui/textarea'
|
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 OperatorQuestion from './OperatorQuestion.svelte'
|
||||||
|
import GlyphIndicator from './GlyphIndicator.svelte'
|
||||||
import Spinner from './Spinner.svelte'
|
import Spinner from './Spinner.svelte'
|
||||||
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
import CornerDownLeftIcon from '@lucide/svelte/icons/corner-down-left'
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw'
|
||||||
import SquareIcon from '@lucide/svelte/icons/square'
|
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 { marked } from 'marked'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import type { ChatMessage } from '$lib/stores/chat'
|
import type { ChatMessage } from '$lib/stores/chat'
|
||||||
import type { ToolCallResult } from '$lib/types'
|
import type { ToolCallResult } from '$lib/types'
|
||||||
import type { SessionQuestion } from '$lib/api'
|
import type { PlanStep, SessionQuestion } from '$lib/api'
|
||||||
|
|
||||||
let {
|
let {
|
||||||
messages,
|
messages,
|
||||||
@@ -36,7 +42,10 @@
|
|||||||
activityLog: activityLogProp = activityLog,
|
activityLog: activityLogProp = activityLog,
|
||||||
sessionId = null,
|
sessionId = null,
|
||||||
question = null,
|
question = null,
|
||||||
initialDraft = ''
|
initialDraft = '',
|
||||||
|
planSteps = [],
|
||||||
|
taskStatus,
|
||||||
|
lastActiveAt
|
||||||
}: {
|
}: {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
streaming: boolean
|
streaming: boolean
|
||||||
@@ -64,6 +73,16 @@
|
|||||||
* than making them retype it. Left editable on purpose — it is a starting
|
* than making them retype it. Left editable on purpose — it is a starting
|
||||||
* point, not a command. */
|
* point, not a command. */
|
||||||
initialDraft?: string
|
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()
|
} = $props()
|
||||||
|
|
||||||
let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
|
let input = $state(typeof initialDraft === 'string' ? initialDraft : '')
|
||||||
@@ -103,6 +122,63 @@
|
|||||||
return 'Agent is thinking…'
|
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,
|
// 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
|
// 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
|
// 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
|
// unless user scrolled up to read. Sets scrollTop on the messages container
|
||||||
// directly instead of `scrollIntoView`, which walks ancestors and forces a
|
// directly instead of `scrollIntoView`, which walks ancestors and forces a
|
||||||
// reflow that can momentarily perturb the window titlebar height.
|
// 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(() => {
|
$effect(() => {
|
||||||
void messages
|
void messages
|
||||||
void question
|
void question
|
||||||
if (streaming || !scrolledUp) {
|
if (streaming || !scrolledUp) {
|
||||||
setTimeout(
|
const behavior = streaming ? ('auto' as const) : ('smooth' as const)
|
||||||
() => container?.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }),
|
requestAnimationFrame(() => {
|
||||||
50
|
container?.scrollTo({ top: container.scrollHeight, behavior })
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -216,25 +299,51 @@
|
|||||||
onSend(q)
|
onSend(q)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge live streaming output (from the activity log's `run` entry) onto the
|
// Per-message copy affordance (border-driven icon button on each row).
|
||||||
// in-flight turn's tool calls so the inline tool card shows command output as
|
let copiedId = $state<string | null>(null)
|
||||||
// it arrives — the place the operator naturally "checks the tool". Only the
|
async function copyMessage(msg: ChatMessage) {
|
||||||
// last assistant message can be streaming, so only it gets enriched; history
|
try {
|
||||||
// is untouched (and has no live output anyway). (F4)
|
await navigator.clipboard.writeText(msg.text)
|
||||||
function toolsWithLive(
|
copiedId = msg.id
|
||||||
tools: ToolCallResult[],
|
setTimeout(() => {
|
||||||
entries: ActivityEntry[],
|
if (copiedId === msg.id) copiedId = null
|
||||||
isLiveTurn: boolean
|
}, 1400)
|
||||||
): ToolCallResult[] {
|
} catch {
|
||||||
if (!isLiveTurn) return tools
|
/* clipboard unavailable — silently no-op */
|
||||||
const liveById = new Map<string, string>()
|
|
||||||
for (const e of entries) {
|
|
||||||
if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
|
|
||||||
}
|
}
|
||||||
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<string, { liveOutput?: string; stepSeq?: number }>()
|
||||||
|
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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -247,107 +356,117 @@
|
|||||||
on:resize={() => (userResizedInput = true)}
|
on:resize={() => (userResizedInput = true)}
|
||||||
>
|
>
|
||||||
<Pane class="flex flex-col">
|
<Pane class="flex flex-col">
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
<div class="relative min-h-0 flex-1">
|
||||||
<div class="mx-auto flex min-h-full max-w-3xl flex-col gap-5 p-4">
|
<!-- Glyph backdrop — the agent's live semantic state as a faint
|
||||||
{#if messages.length === 0}
|
procedural watermark behind the transcript. Fixed (doesn't scroll
|
||||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 text-center">
|
with the messages), pointer-events none, behind the content. -->
|
||||||
<div>
|
<div class="glyph-backdrop" aria-hidden="true">
|
||||||
<h2 class="text-xl font-semibold">Nomos</h2>
|
<GlyphIndicator sprite={agentSprite} seed={sessionId ?? 'oikos'} size={640} opacity={0.07} />
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
|
||||||
Your resident operator. Ask about the fleet, or tell it to act.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{#if suggestions.length}
|
|
||||||
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
|
||||||
{#each suggestions as q}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
|
|
||||||
onclick={() => ask(q)}
|
|
||||||
>
|
|
||||||
{q}
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#each messages as msg, idx (msg.id)}
|
|
||||||
<div class="flex flex-col gap-1.5 {msg.role === 'user' ? 'items-end' : 'items-start'}">
|
|
||||||
{#if msg.role === 'user'}
|
|
||||||
<div class="flex items-baseline gap-2 px-1">
|
|
||||||
<span class="text-[10px] font-medium text-muted-foreground/70">You</span>
|
|
||||||
{#if msg.created_at}
|
|
||||||
<span class="text-[9px] text-muted-foreground/50"
|
|
||||||
>{formatTime(msg.created_at)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-foreground whitespace-pre-wrap user-msg"
|
|
||||||
>
|
|
||||||
{msg.text}
|
|
||||||
</div>
|
|
||||||
{#if idx === messages.length - 1 && working && !streaming}
|
|
||||||
<!-- The last message is this user bubble and the agent is
|
|
||||||
working but not live-streaming → the message was queued
|
|
||||||
behind an in-flight turn (plan 2026-08-03 F2). It'll run
|
|
||||||
when the current step finishes. -->
|
|
||||||
<span class="px-1 text-[10px] text-muted-foreground"
|
|
||||||
>Queued — Nomos will run this when it finishes the current step.</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
{@const isLast = idx === messages.length - 1}
|
|
||||||
{@const traceStatus = !isLast
|
|
||||||
? 'idle'
|
|
||||||
: error
|
|
||||||
? 'error'
|
|
||||||
: working
|
|
||||||
? 'running'
|
|
||||||
: indicatorDone
|
|
||||||
? 'done'
|
|
||||||
: 'idle'}
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
<div class="flex items-baseline gap-2 px-1">
|
|
||||||
<span class="text-[10px] font-medium text-muted-foreground/70">Nomos</span>
|
|
||||||
{#if msg.created_at}
|
|
||||||
<span class="text-[9px] text-muted-foreground/50"
|
|
||||||
>{formatTime(msg.created_at)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<!-- The working trace sits above the answer: it's what happened
|
|
||||||
first, and collapsed it keeps a long tool run from burying
|
|
||||||
the text below it. -->
|
|
||||||
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
|
|
||||||
<AgentTrace
|
|
||||||
tools={toolsWithLive(msg.tools, $activityLogProp, isLast && traceStatus !== 'idle')}
|
|
||||||
status={traceStatus}
|
|
||||||
label={traceStatus === 'idle' ? null : indicatorLabel}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if msg.text}
|
|
||||||
<div
|
|
||||||
class="markdown-body prose-chat max-w-none text-sm leading-relaxed assistant-msg"
|
|
||||||
>
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
|
||||||
{@html render(msg.text)}
|
|
||||||
{#if isLast && streaming}
|
|
||||||
<span class="stream-cursor" aria-hidden="true"></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{#if question}
|
|
||||||
<OperatorQuestion {sessionId} {question} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="relative z-[1] h-full overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
||||||
|
<div class="mx-auto flex min-h-full max-w-3xl flex-col divide-y divide-border px-4">
|
||||||
|
{#if messages.length === 0}
|
||||||
|
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8 text-center">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">Nomos</h2>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Your resident operator. Ask about the fleet, or tell it to act.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if suggestions.length}
|
||||||
|
<div class="grid w-full max-w-md grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{#each suggestions as q}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-auto justify-start whitespace-normal py-2 text-left text-xs"
|
||||||
|
onclick={() => ask(q)}
|
||||||
|
>
|
||||||
|
{q}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each messages as msg, idx (msg.id)}
|
||||||
|
{@const isLast = idx === messages.length - 1}
|
||||||
|
<div class="msg-row relative flex gap-3 py-3">
|
||||||
|
<div class="msg-role" aria-hidden="true">{msg.role === 'user' ? 'YOU' : 'NOMOS'}</div>
|
||||||
|
<div class="msg-body min-w-0 flex-1">
|
||||||
|
{#if msg.role === 'user'}
|
||||||
|
<div class="user-text whitespace-pre-wrap text-sm leading-relaxed">{msg.text}</div>
|
||||||
|
{#if msg.created_at}
|
||||||
|
<div class="msg-time">{formatTime(msg.created_at)}</div>
|
||||||
|
{/if}
|
||||||
|
{#if isLast && working && !streaming}
|
||||||
|
<!-- The last message is this user row and the agent is working but not
|
||||||
|
live-streaming → the message was queued behind an in-flight turn
|
||||||
|
(plan 2026-08-03 F2). It'll run when the current step finishes. -->
|
||||||
|
<div class="queued-hint">Queued — runs when Nomos finishes the current step.</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{@const traceStatus = !isLast
|
||||||
|
? 'idle'
|
||||||
|
: error
|
||||||
|
? 'error'
|
||||||
|
: working
|
||||||
|
? 'running'
|
||||||
|
: indicatorDone
|
||||||
|
? 'done'
|
||||||
|
: 'idle'}
|
||||||
|
<!-- Inline progressive trace: live plan checklist (last turn) +
|
||||||
|
thinking line + per-tool lines, then the streamed answer. -->
|
||||||
|
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
|
||||||
|
<TurnTrace
|
||||||
|
tools={enrichTools(msg.tools, $activityLogProp)}
|
||||||
|
status={traceStatus}
|
||||||
|
label={traceStatus === 'idle' ? null : indicatorLabel}
|
||||||
|
{isLast}
|
||||||
|
planSteps={isLast ? planSteps : []}
|
||||||
|
{taskStatus}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if msg.thinking}
|
||||||
|
<ThinkingBlock thinking={msg.thinking} />
|
||||||
|
{/if}
|
||||||
|
{#if msg.text}
|
||||||
|
<div
|
||||||
|
class="markdown-body prose-chat max-w-none text-sm leading-relaxed"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->
|
||||||
|
{@html render(msg.text)}
|
||||||
|
{#if isLast && streaming}
|
||||||
|
<span class="stream-cursor" aria-hidden="true"></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if msg.created_at}
|
||||||
|
<div class="msg-time">{formatTime(msg.created_at)}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="msg-action"
|
||||||
|
title="Copy message"
|
||||||
|
aria-label="Copy message"
|
||||||
|
onclick={() => copyMessage(msg)}
|
||||||
|
>
|
||||||
|
{#if copiedId === msg.id}<CheckIcon class="size-3.5" />{:else}<CopyIcon class="size-3.5" />{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if question}
|
||||||
|
<div class="py-3"><OperatorQuestion {sessionId} {question} /></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if scrolledUp && messages.length > 0}
|
||||||
|
<button class="jump-bottom" onclick={jumpToBottom} aria-label="Jump to latest">
|
||||||
|
<ArrowDownToLineIcon class="size-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if connectionState === 'disconnected'}
|
{#if connectionState === 'disconnected'}
|
||||||
@@ -420,20 +539,36 @@
|
|||||||
spinner + fg label + primary-tinted hairline border, aligned to the
|
spinner + fg label + primary-tinted hairline border, aligned to the
|
||||||
textarea column. -->
|
textarea column. -->
|
||||||
<div class="mx-auto w-full max-w-3xl px-4">
|
<div class="mx-auto w-full max-w-3xl px-4">
|
||||||
<div class="composer-status mb-2">
|
{#if isStuck}
|
||||||
<Spinner class="size-3 shrink-0 text-primary" />
|
<div class="composer-status composer-status-stuck mb-2">
|
||||||
<span class="composer-status-label">Working</span>
|
<span class="composer-status-label stuck-label">Stuck</span>
|
||||||
<span class="composer-status-text"
|
<span class="composer-status-text"
|
||||||
>message will queue — runs when Nomos is free</span
|
>no activity for {formatElapsed(elapsedSeconds)}</span
|
||||||
>
|
>
|
||||||
</div>
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
class="h-6 text-[11px]"
|
||||||
|
onclick={handleResume}
|
||||||
|
disabled={resuming}
|
||||||
|
>
|
||||||
|
{resuming ? 'Resuming…' : 'Resume'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="composer-status mb-2">
|
||||||
|
<Spinner class="size-3 shrink-0 text-primary" />
|
||||||
|
<span class="composer-status-label">Working</span>
|
||||||
|
<span class="composer-status-text">{formatElapsed(elapsedSeconds)}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Pane>
|
</Pane>
|
||||||
|
|
||||||
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
|
<Pane bind:size={inputSize} minSize={inputMinSize} maxSize={45} class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
class="flex h-full min-h-0 flex-col border-t bg-card/50 p-3 input-ornament relative"
|
class="flex h-full min-h-0 flex-col border-t bg-background p-3 input-ornament relative"
|
||||||
bind:this={inputWrapperRef}
|
bind:this={inputWrapperRef}
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
@@ -448,7 +583,7 @@
|
|||||||
bind:value={input}
|
bind:value={input}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
placeholder="Ask Nomos anything…"
|
placeholder="Ask Nomos anything…"
|
||||||
class="h-full max-h-none min-h-0 resize-none rounded-2xl px-4 py-3 pr-12 field-sizing-fixed"
|
class="h-full max-h-none min-h-0 resize-none px-4 py-3 pr-12 field-sizing-fixed"
|
||||||
disabled={streaming}
|
disabled={streaming}
|
||||||
/>
|
/>
|
||||||
{#if streaming}
|
{#if streaming}
|
||||||
@@ -456,7 +591,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="absolute right-2 bottom-2 rounded-lg"
|
class="absolute right-2 bottom-2"
|
||||||
onclick={onCancel}
|
onclick={onCancel}
|
||||||
aria-label="Stop"
|
aria-label="Stop"
|
||||||
>
|
>
|
||||||
@@ -467,7 +602,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="absolute right-2 bottom-2 rounded-lg"
|
class="absolute right-2 bottom-2"
|
||||||
disabled={!input.trim()}
|
disabled={!input.trim()}
|
||||||
aria-label="Send"
|
aria-label="Send"
|
||||||
>
|
>
|
||||||
@@ -481,34 +616,110 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Art Nouveau chat styling ── */
|
/* ── Cyberspace / terminal chat styling ──
|
||||||
|
Messages are full-width terminal log rows (left role-tag column +
|
||||||
|
content), separated by hairline divide-y. Border-driven, square, no soft
|
||||||
|
shadows — same language as the rest of the app. Prose deltas below sit on
|
||||||
|
top of the shared .markdown-body base (app.css); a two-class selector
|
||||||
|
(`.markdown-body.prose-chat`) wins on specificity over app.css's single
|
||||||
|
`.markdown-body` rules deterministically, regardless of <style> injection
|
||||||
|
order. */
|
||||||
|
|
||||||
/* Assistant message wrapper */
|
/* Message rows */
|
||||||
.assistant-msg {
|
.msg-row {
|
||||||
position: relative;
|
/* role column + body; the copy action is absolutely positioned top-right */
|
||||||
}
|
}
|
||||||
|
.msg-role {
|
||||||
/* User message — soft terracotta bubble, gentle lift */
|
flex-shrink: 0;
|
||||||
.user-msg {
|
width: 3.25rem;
|
||||||
box-shadow: 0 1px 8px -4px var(--primary);
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.msg-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
.user-text {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.msg-time {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.queued-hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.msg-action {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
right: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 0.12s,
|
||||||
|
color 0.12s,
|
||||||
|
border-color 0.12s;
|
||||||
|
}
|
||||||
|
.msg-row:hover .msg-action,
|
||||||
|
.msg-action:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.msg-action:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* Prose overrides — deltas on top of the shared .markdown-body base
|
/* Glyph backdrop — fills the message pane, centers the glyph, stays put
|
||||||
(app.css) only. The template applies both classes together
|
while the transcript scrolls over it. pointer-events none so it never
|
||||||
(class="markdown-body prose-chat ..."); everything below either adds a
|
intercepts scroll/click. */
|
||||||
look .markdown-body doesn't have (li::marker, the pre/blockquote
|
.glyph-backdrop {
|
||||||
::before ornaments, hr, strong, the table-wrapper, the code-copy
|
position: absolute;
|
||||||
button) or overrides a .markdown-body value that this "Art Nouveau"
|
inset: 0;
|
||||||
chat treatment wants different (code/pre padding, heading size, th/td
|
z-index: 0;
|
||||||
padding, blockquote border color, link underline style). Anywhere a
|
display: flex;
|
||||||
value is actually overridden, the selector is
|
align-items: center;
|
||||||
`.markdown-body.prose-chat` rather than `.prose-chat` alone —
|
justify-content: center;
|
||||||
:global() selectors from two different <style> blocks land in the same
|
pointer-events: none;
|
||||||
stylesheet with no scoping to arbitrate between them, so equal
|
overflow: hidden;
|
||||||
specificity would leave the winner to injection order (unreliable
|
}
|
||||||
across dev/build). The two-class selector's higher specificity wins
|
|
||||||
deterministically regardless. */
|
/* Jump-to-latest button — border-driven square, sits over the transcript */
|
||||||
|
.jump-bottom {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
box-shadow: 2px 2px 0 0 var(--border);
|
||||||
|
}
|
||||||
|
.jump-bottom:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
border-color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prose deltas (markdown-body base lives in app.css). */
|
||||||
.prose-chat :global(li) {
|
.prose-chat :global(li) {
|
||||||
padding-left: 0.25rem;
|
padding-left: 0.25rem;
|
||||||
}
|
}
|
||||||
@@ -541,8 +752,8 @@
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section headings — serif (Inknut) with a short accent rule. Extra top
|
/* Headings — short accent rule under each; first heading in a message
|
||||||
margin separates sections; the first heading in a message doesn't. */
|
doesn't get extra top margin. */
|
||||||
.markdown-body.prose-chat :global(h1) {
|
.markdown-body.prose-chat :global(h1) {
|
||||||
font-size: 1.15em;
|
font-size: 1.15em;
|
||||||
margin: 1.15rem 0 0.4rem;
|
margin: 1.15rem 0 0.4rem;
|
||||||
@@ -577,7 +788,6 @@
|
|||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
border-radius: 1px;
|
|
||||||
background: linear-gradient(to right, var(--primary), transparent);
|
background: linear-gradient(to right, var(--primary), transparent);
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
@@ -604,7 +814,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.prose-chat :global(blockquote)::before {
|
.prose-chat :global(blockquote)::before {
|
||||||
content: '“';
|
content: '"';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -0.15rem;
|
left: -0.15rem;
|
||||||
top: -0.35rem;
|
top: -0.35rem;
|
||||||
@@ -628,8 +838,6 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bold is emphasis, not color — dark weight reads cleanly and lets the
|
|
||||||
terracotta accent stay meaningful (code, headings, links). */
|
|
||||||
.prose-chat :global(strong) {
|
.prose-chat :global(strong) {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -641,7 +849,7 @@
|
|||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Input area ornament */
|
/* Input area hairline ornament */
|
||||||
.input-ornament::before {
|
.input-ornament::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -654,9 +862,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Composer "working/queued" status strip — terminal status-bar idiom: a
|
/* Composer "working/queued" status strip — terminal status-bar idiom: a
|
||||||
hairline primary-tinted border (matching .trace.running), square corners,
|
hairline primary-tinted border, square corners, a spinner + an uppercase
|
||||||
a spinner + an uppercase fg label + muted detail. Border-driven, no
|
fg label + muted detail. Border-driven, no shadow. */
|
||||||
shadow — same language as the rest of the cyberspace surfaces. */
|
|
||||||
.composer-status {
|
.composer-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -676,8 +883,16 @@
|
|||||||
.composer-status-text {
|
.composer-status-text {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
.composer-status-stuck {
|
||||||
|
border-color: color-mix(in oklab, var(--warning) 50%, var(--border));
|
||||||
|
background: color-mix(in oklab, var(--warning) 8%, var(--card));
|
||||||
|
}
|
||||||
|
.stuck-label {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
/* Code copy button — global: injected via render() into {html} blocks */
|
/* Code copy button — global: injected via render() into {html} blocks.
|
||||||
|
Square (cyberspace), not rounded. */
|
||||||
.prose-chat :global(.code-block-wrapper) {
|
.prose-chat :global(.code-block-wrapper) {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -690,7 +905,6 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
border-radius: 0.375rem;
|
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition:
|
transition:
|
||||||
@@ -715,7 +929,6 @@
|
|||||||
height: 1.1em;
|
height: 1.1em;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
border-radius: 1px;
|
|
||||||
margin-left: 1px;
|
margin-left: 1px;
|
||||||
vertical-align: text-bottom;
|
vertical-align: text-bottom;
|
||||||
animation: cursor-blink 0.9s ease-in-out infinite;
|
animation: cursor-blink 0.9s ease-in-out infinite;
|
||||||
@@ -730,4 +943,12 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.stream-cursor,
|
||||||
|
.msg-action {
|
||||||
|
animation: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
89
web/src/lib/components/GlyphIndicator.svelte
Normal file
89
web/src/lib/components/GlyphIndicator.svelte
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
import {
|
||||||
|
createGlyph,
|
||||||
|
type JoanGlyphEngine,
|
||||||
|
type SpriteName
|
||||||
|
} from '@joan/procedural-glyph-engine'
|
||||||
|
import { getTheme } from '$lib/stores/theme.svelte'
|
||||||
|
|
||||||
|
let {
|
||||||
|
sprite,
|
||||||
|
seed = 'oikos',
|
||||||
|
size = 96,
|
||||||
|
opacity = 1
|
||||||
|
}: {
|
||||||
|
sprite: string
|
||||||
|
seed?: string
|
||||||
|
/** Display max-width in px (the engine's internal grid stays 96; CSS
|
||||||
|
* upscales pixelated for larger backdrops). */
|
||||||
|
size?: number
|
||||||
|
/** Canvas opacity — <1 for a faint watermark backdrop. */
|
||||||
|
opacity?: number
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
let canvas = $state<HTMLCanvasElement | null>(null)
|
||||||
|
let glyph = $state<JoanGlyphEngine | null>(null)
|
||||||
|
|
||||||
|
function palette(t: 'light' | 'dark') {
|
||||||
|
return {
|
||||||
|
background: 'transparent',
|
||||||
|
off: t === 'dark' ? '#1a1a1a' : '#e6dcc0',
|
||||||
|
ink: t === 'dark' ? '#efe5c0' : '#000000',
|
||||||
|
accent: t === 'dark' ? '#a89984' : '#3a3a3a',
|
||||||
|
glow: 'transparent'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const g = createGlyph(canvas!, {
|
||||||
|
sprite: sprite as SpriteName,
|
||||||
|
seed,
|
||||||
|
gridSize: 96,
|
||||||
|
palette: palette(getTheme()),
|
||||||
|
background: false,
|
||||||
|
orbBackgroundColor: 'transparent',
|
||||||
|
orbBackgroundMode: 'none'
|
||||||
|
})
|
||||||
|
glyph = g
|
||||||
|
|
||||||
|
const obs = new MutationObserver(() => {
|
||||||
|
g.configure({ palette: palette(getTheme()) })
|
||||||
|
})
|
||||||
|
obs.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class']
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => obs.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
glyph?.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (glyph && sprite) {
|
||||||
|
glyph.transitionTo(sprite as SpriteName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
bind:this={canvas}
|
||||||
|
width="96"
|
||||||
|
height="96"
|
||||||
|
class="glyph"
|
||||||
|
style="max-width:{size}px;opacity:{opacity}"
|
||||||
|
aria-hidden="true"
|
||||||
|
></canvas>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.glyph {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
margin: 0 auto;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
chatErrors
|
chatErrors
|
||||||
} from '$lib/stores/chat'
|
} from '$lib/stores/chat'
|
||||||
import { activityLogFor } from '$lib/stores/activity'
|
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 ChatThread from '$lib/components/ChatThread.svelte'
|
||||||
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||||
|
|
||||||
@@ -42,6 +42,9 @@
|
|||||||
// context rail mounts.
|
// context rail mounts.
|
||||||
// eslint-disable-next-line svelte/valid-compile
|
// eslint-disable-next-line svelte/valid-compile
|
||||||
const workspace = workspaceFor(sessionId)
|
const workspace = workspaceFor(sessionId)
|
||||||
|
const planSteps = workspace.planSteps
|
||||||
|
// eslint-disable-next-line svelte/valid-compile
|
||||||
|
const chatTask = taskFor(sessionId)
|
||||||
const openQuestion = workspace.openQuestion
|
const openQuestion = workspace.openQuestion
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
||||||
@@ -73,7 +76,7 @@
|
|||||||
|
|
||||||
// Resizable right rail — sized smaller by default since task windows open
|
// Resizable right rail — sized smaller by default since task windows open
|
||||||
// narrower than the full page.
|
// narrower than the full page.
|
||||||
let railSize = $state(24)
|
let railSize = $state(32)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full min-h-0">
|
<div class="flex h-full min-h-0">
|
||||||
@@ -99,13 +102,16 @@
|
|||||||
activityLog={sessionActivityLog}
|
activityLog={sessionActivityLog}
|
||||||
{sessionId}
|
{sessionId}
|
||||||
question={$openQuestion}
|
question={$openQuestion}
|
||||||
|
planSteps={$planSteps}
|
||||||
|
taskStatus={$chatTask?.status}
|
||||||
|
lastActiveAt={$chatTask?.last_active_at}
|
||||||
onSend={(text) => sendSessionMessage(sessionId, text)}
|
onSend={(text) => sendSessionMessage(sessionId, text)}
|
||||||
onCancel={() => cancelSessionStream(sessionId)}
|
onCancel={() => cancelSessionStream(sessionId)}
|
||||||
onReconnect={() => loadSessionChat(sessionId)}
|
onReconnect={() => loadSessionChat(sessionId)}
|
||||||
onDismissError={dismissError}
|
onDismissError={dismissError}
|
||||||
/>
|
/>
|
||||||
</Pane>
|
</Pane>
|
||||||
<Pane bind:size={railSize} minSize={18} maxSize={40}>
|
<Pane bind:size={railSize} minSize={24} maxSize={60}>
|
||||||
<TaskContextPanel {sessionId} />
|
<TaskContextPanel {sessionId} />
|
||||||
</Pane>
|
</Pane>
|
||||||
</Splitpanes>
|
</Splitpanes>
|
||||||
|
|||||||
@@ -75,6 +75,18 @@
|
|||||||
let cw = $state(300)
|
let cw = $state(300)
|
||||||
let ch = $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<string>) {
|
function collectSlugs(value: unknown, out: Set<string>) {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const m = value.match(SLUG_RE)
|
const m = value.match(SLUG_RE)
|
||||||
@@ -206,6 +218,7 @@
|
|||||||
.alphaDecay(0.045)
|
.alphaDecay(0.045)
|
||||||
.on('tick', () => {
|
.on('tick', () => {
|
||||||
nodes = [...nodes]
|
nodes = [...nodes]
|
||||||
|
if (!userPanned) fitView()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +274,49 @@
|
|||||||
return slug.split(':').pop() ?? slug
|
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
|
// 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
|
// during render. Kept as plain objects (not Maps) since Svelte 5 runes track
|
||||||
// object identity fine and this is small (≤12 touched, ≤8 diffs).
|
// object identity fine and this is small (≤12 touched, ≤8 diffs).
|
||||||
@@ -283,16 +339,22 @@
|
|||||||
return typeof end === 'object' ? end.slug : end
|
return typeof end === 'object' ? end.slug : end
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── drag / select ───────────────────────────────────────────────────
|
// ─── drag / select / pan ─────────────────────────────────────────────
|
||||||
// A click (pointerdown+up with no movement in between) opens the entity
|
// A click (pointerdown+up with no movement in between) opens the entity
|
||||||
// straight in its own floating window (WindowLayer) instead of a
|
// straight in its own floating window (WindowLayer); `selected` only drives
|
||||||
// click-through mini-panel — `selected` now only drives the highlight/dim
|
// the highlight/dim styling. Node drag pins the node in GRAPH coords
|
||||||
// styling below, so you can see at a glance which node you last opened.
|
// (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 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()
|
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) {
|
function onNodeDown(e: PointerEvent, node: Node) {
|
||||||
@@ -301,26 +363,44 @@
|
|||||||
dragState = { node, moved: false }
|
dragState = { node, moved: false }
|
||||||
sim?.alphaTarget(0.2).restart()
|
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) {
|
function onMove(e: PointerEvent) {
|
||||||
if (!dragState) return
|
if (dragState) {
|
||||||
const p = toLocal(e.clientX, e.clientY)
|
const p = toGraph(e.clientX, e.clientY)
|
||||||
dragState.node.fx = p.x
|
dragState.node.fx = p.x
|
||||||
dragState.node.fy = p.y
|
dragState.node.fy = p.y
|
||||||
dragState.moved = true
|
dragState.moved = true
|
||||||
nodes = [...nodes]
|
nodes = [...nodes]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (panState) {
|
||||||
|
tx = e.clientX - panState.x
|
||||||
|
ty = e.clientY - panState.y
|
||||||
|
userPanned = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function selectAndOpen(node: Node) {
|
function selectAndOpen(node: Node) {
|
||||||
selected = node
|
selected = node
|
||||||
openEntityWindow(node.slug)
|
openEntityWindow(node.slug)
|
||||||
}
|
}
|
||||||
function onUp() {
|
function onUp() {
|
||||||
if (!dragState) return
|
if (dragState) {
|
||||||
const { node, moved } = dragState
|
const { node, moved } = dragState
|
||||||
node.fx = null
|
node.fx = null
|
||||||
node.fy = null
|
node.fy = null
|
||||||
sim?.alphaTarget(0)
|
sim?.alphaTarget(0)
|
||||||
dragState = null
|
dragState = null
|
||||||
if (!moved) selectAndOpen(node)
|
if (!moved) selectAndOpen(node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
panState = null
|
||||||
|
}
|
||||||
|
function refit() {
|
||||||
|
userPanned = false
|
||||||
|
fitView()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedRelations = $derived(
|
const selectedRelations = $derived(
|
||||||
@@ -342,7 +422,7 @@
|
|||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside class="flex h-full min-h-0 flex-col bg-card/40">
|
<aside class="flex h-full min-h-0 flex-col bg-card">
|
||||||
{#if nowTouching}
|
{#if nowTouching}
|
||||||
<div
|
<div
|
||||||
class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary"
|
class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary"
|
||||||
@@ -447,9 +527,11 @@
|
|||||||
class="h-full w-full touch-none select-none"
|
class="h-full w-full touch-none select-none"
|
||||||
role="application"
|
role="application"
|
||||||
aria-label="Session entity graph"
|
aria-label="Session entity graph"
|
||||||
|
onpointerdown={onBgDown}
|
||||||
onpointermove={onMove}
|
onpointermove={onMove}
|
||||||
onpointerup={onUp}
|
onpointerup={onUp}
|
||||||
onpointercancel={onUp}
|
onpointercancel={onUp}
|
||||||
|
ondblclick={refit}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
|
<pattern id={dotGridId} width="12" height="12" patternUnits="userSpaceOnUse">
|
||||||
@@ -457,8 +539,9 @@
|
|||||||
</pattern>
|
</pattern>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
|
<rect width={cw} height={ch} fill="url(#{dotGridId})" />
|
||||||
<g>
|
<g transform={viewTransform}>
|
||||||
{#each links as link}
|
<g>
|
||||||
|
{#each links as link}
|
||||||
{@const s = endpoint(link.source)}
|
{@const s = endpoint(link.source)}
|
||||||
{@const t = endpoint(link.target)}
|
{@const t = endpoint(link.target)}
|
||||||
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
|
{#if s?.x != null && t?.x != null && s?.y != null && t?.y != null}
|
||||||
@@ -560,6 +643,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</g>
|
</g>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,172 +1,65 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
|
||||||
import {
|
import {
|
||||||
startWorkspace,
|
startWorkspace,
|
||||||
planSteps,
|
|
||||||
currentTask,
|
|
||||||
currentWorking,
|
|
||||||
touched,
|
touched,
|
||||||
healthDiffs,
|
healthDiffs,
|
||||||
workspaceFor,
|
workspaceFor,
|
||||||
taskFor,
|
taskFor,
|
||||||
taskWorking
|
taskWorking,
|
||||||
|
currentWorking,
|
||||||
|
currentTask
|
||||||
} from '$lib/stores/workspace'
|
} from '$lib/stores/workspace'
|
||||||
import { messages, chatFor } from '$lib/stores/chat'
|
import { messages, chatFor, streaming, connectionState } from '$lib/stores/chat'
|
||||||
import { activityLog, activityLogFor } from '$lib/stores/activity'
|
|
||||||
import SessionGraph from './SessionGraph.svelte'
|
import SessionGraph from './SessionGraph.svelte'
|
||||||
import UnifiedTimeline from './UnifiedTimeline.svelte'
|
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
||||||
import Spinner from './Spinner.svelte'
|
|
||||||
|
|
||||||
// Omitted (main Chat page): tracks the global "current session" — one
|
|
||||||
// shared view, same as always. Passed (a floating task window's
|
|
||||||
// SessionChatWindow): this panel switches entirely to that session's own
|
|
||||||
// store bundle (workspaceFor/chatFor/activityLogFor), so several windows'
|
|
||||||
// panels can be open and live at once instead of all showing whatever
|
|
||||||
// happens to be the single global "current session". Per-session workspace
|
|
||||||
// tracking (startSessionWorkspace) is started by SessionChatWindow itself,
|
|
||||||
// not here — it has to run even while this panel stays unmounted (see its
|
|
||||||
// hasContext gate), so only the global fallback path starts its own here.
|
|
||||||
let { sessionId = null }: { sessionId?: string | null } = $props()
|
let { sessionId = null }: { sessionId?: string | null } = $props()
|
||||||
|
|
||||||
onMount(() => (sessionId ? undefined : startWorkspace()))
|
onMount(() => (sessionId ? undefined : startWorkspace()))
|
||||||
|
|
||||||
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
|
const ws = $derived(sessionId ? workspaceFor(sessionId) : null)
|
||||||
const planStepsStore = $derived(ws ? ws.planSteps : planSteps)
|
|
||||||
const touchedStore = $derived(ws ? ws.touched : touched)
|
const touchedStore = $derived(ws ? ws.touched : touched)
|
||||||
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
|
const healthDiffsStore = $derived(ws ? ws.healthDiffs : healthDiffs)
|
||||||
const taskStore = $derived(sessionId ? taskFor(sessionId) : currentTask)
|
|
||||||
const chat = $derived(sessionId ? chatFor(sessionId) : null)
|
const chat = $derived(sessionId ? chatFor(sessionId) : null)
|
||||||
const workingStore = $derived(sessionId ? taskWorking(sessionId) : currentWorking)
|
|
||||||
const messagesStore = $derived(chat ? chat.messages : messages)
|
const messagesStore = $derived(chat ? chat.messages : messages)
|
||||||
const activityLogStore = $derived(sessionId ? activityLogFor(sessionId) : activityLog)
|
|
||||||
|
|
||||||
let scopeOpen = $state(true)
|
// eslint-disable-next-line svelte/valid-compile
|
||||||
let activityOpen = $state(true)
|
const taskWorkingStore = sessionId ? taskWorking(sessionId) : currentWorking
|
||||||
|
// eslint-disable-next-line svelte/valid-compile
|
||||||
|
const taskStreamingStore = sessionId ? chatFor(sessionId).streaming : streaming
|
||||||
|
// eslint-disable-next-line svelte/valid-compile
|
||||||
|
const taskConnStore = sessionId ? chatFor(sessionId).connectionState : connectionState
|
||||||
|
// eslint-disable-next-line svelte/valid-compile
|
||||||
|
const taskStatusStore = sessionId ? taskFor(sessionId) : currentTask
|
||||||
|
|
||||||
// Resize: each section is a Pane in one vertical Splitpanes. Sizes are
|
// ── stuck detection (mirrors ChatThread) ───────────────────────────────
|
||||||
// percentages of the panel's height; undefined means "share the space
|
let now = $state(Date.now())
|
||||||
// evenly with the other auto sections". Collapsing a section pins it to
|
$effect(() => {
|
||||||
// COLLAPSED_SIZE (roughly a header's worth of height) and remembers its
|
if (!$taskWorkingStore) return
|
||||||
// last size so reopening restores it.
|
const id = setInterval(() => {
|
||||||
const COLLAPSED_SIZE = 6
|
now = Date.now()
|
||||||
const OPEN_MIN_SIZE = 12
|
}, 1000)
|
||||||
let sizes = $state<(number | undefined)[]>([30, 70])
|
return () => clearInterval(id)
|
||||||
// Reopening must restore a concrete number, never `undefined` — the pane
|
})
|
||||||
// only re-triggers the library's resize/equalize pass when `size` changes
|
const lastActiveAt = $derived($taskStatusStore?.last_active_at)
|
||||||
// to a different *number*, so setting it back to `undefined` silently
|
const isStuck = $derived(
|
||||||
// no-ops and leaves the section stuck at its collapsed height.
|
$taskWorkingStore &&
|
||||||
let savedSizes: number[] = [30, 70]
|
!$taskStreamingStore &&
|
||||||
|
lastActiveAt &&
|
||||||
|
now - new Date(lastActiveAt).getTime() > 300_000
|
||||||
|
)
|
||||||
|
|
||||||
function toggleSection(i: number, isOpen: boolean) {
|
|
||||||
if (isOpen) {
|
|
||||||
savedSizes[i] = sizes[i] ?? savedSizes[i]
|
|
||||||
sizes[i] = COLLAPSED_SIZE
|
|
||||||
} else {
|
|
||||||
sizes[i] = savedSizes[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plan collapsed status
|
|
||||||
const planDone = $derived($planStepsStore.filter((s) => s.status === 'done').length)
|
|
||||||
const planTotal = $derived($planStepsStore.length)
|
|
||||||
|
|
||||||
// Activity collapsed status
|
|
||||||
const activityRunning = $derived($activityLogStore.filter((e) => e.status === 'running').length)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full min-h-0 flex-col">
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
<Splitpanes horizontal theme="oikos-theme" dblClickSplitter={false} class="min-h-0 flex-1">
|
<div class="shrink-0" style="aspect-ratio: 1; width: 100%;">
|
||||||
<!-- Scope -->
|
<SessionGraph
|
||||||
<Pane
|
messages={$messagesStore}
|
||||||
bind:size={sizes[0]}
|
touched={$touchedStore}
|
||||||
minSize={scopeOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE}
|
healthDiffs={$healthDiffsStore}
|
||||||
maxSize={scopeOpen ? 100 : COLLAPSED_SIZE}
|
/>
|
||||||
class="flex flex-col"
|
</div>
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
|
||||||
onclick={() => {
|
|
||||||
toggleSection(0, scopeOpen)
|
|
||||||
scopeOpen = !scopeOpen
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if scopeOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
|
||||||
class="size-3"
|
|
||||||
/>{/if}
|
|
||||||
<span>Scope</span>
|
|
||||||
{#if !scopeOpen}
|
|
||||||
<span class="ml-auto font-normal normal-case"
|
|
||||||
>{$touchedStore.length
|
|
||||||
? `${$touchedStore.length} entit${$touchedStore.length === 1 ? 'y' : 'ies'}`
|
|
||||||
: 'Graph'}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#if scopeOpen}
|
|
||||||
<div class="min-h-0 flex-1">
|
|
||||||
<SessionGraph
|
|
||||||
messages={$messagesStore}
|
|
||||||
touched={$touchedStore}
|
|
||||||
healthDiffs={$healthDiffsStore}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Pane>
|
|
||||||
|
|
||||||
<!-- Activity (merged plan + event log) -->
|
|
||||||
<Pane
|
|
||||||
bind:size={sizes[1]}
|
|
||||||
minSize={activityOpen ? OPEN_MIN_SIZE : COLLAPSED_SIZE}
|
|
||||||
maxSize={activityOpen ? 100 : COLLAPSED_SIZE}
|
|
||||||
class="flex flex-col"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground"
|
|
||||||
onclick={() => {
|
|
||||||
toggleSection(1, activityOpen)
|
|
||||||
activityOpen = !activityOpen
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if activityOpen}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
|
||||||
class="size-3"
|
|
||||||
/>{/if}
|
|
||||||
<span>Activity</span>
|
|
||||||
{#if $workingStore && activityRunning > 0}
|
|
||||||
<Spinner class="size-3 text-primary" />
|
|
||||||
{/if}
|
|
||||||
{#if planTotal > 0}
|
|
||||||
<span
|
|
||||||
class="font-normal normal-case tabular-nums {planDone === planTotal
|
|
||||||
? 'text-muted-foreground'
|
|
||||||
: 'text-primary'}">{planDone}/{planTotal}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if !activityOpen && planTotal === 0}
|
|
||||||
{#if $taskStore?.goal}
|
|
||||||
<span class="ml-auto max-w-[120px] truncate font-normal normal-case"
|
|
||||||
>{$taskStore.goal}</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span class="ml-auto font-normal normal-case text-muted-foreground"
|
|
||||||
>No activity yet</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#if activityOpen}
|
|
||||||
<div class="min-h-0 flex-1 overflow-hidden">
|
|
||||||
<UnifiedTimeline
|
|
||||||
entries={$activityLogStore}
|
|
||||||
planSteps={$planStepsStore}
|
|
||||||
streaming={$workingStore}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</Pane>
|
|
||||||
</Splitpanes>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
106
web/src/lib/components/ThinkingBlock.svelte
Normal file
106
web/src/lib/components/ThinkingBlock.svelte
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Brain, ChevronRight } from '@lucide/svelte'
|
||||||
|
let { thinking }: { thinking: string } = $props()
|
||||||
|
let expanded = $state(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="thinking-block">
|
||||||
|
<button
|
||||||
|
class="row"
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
<Brain class="icon size-3" />
|
||||||
|
<span class="text min-w-0 flex-1">
|
||||||
|
<span class="label">Thought{thinking.includes('\n') ? 's' : ''}</span>
|
||||||
|
</span>
|
||||||
|
<span class="summary">{thinking.slice(0, 60).replace(/\n/g, ' ')}{thinking.length > 60 ? '…' : ''}</span>
|
||||||
|
<ChevronRight class="chev size-3 {expanded ? 'open' : ''}" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
<div class="detail"><pre>{thinking}</pre></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.thinking-block {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
animation: thinking-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes thinking-in {
|
||||||
|
from { opacity: 0; transform: translateY(-2px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.thinking-block { animation: none; }
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.row:hover .label {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 45%;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.chev {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
.chev.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.detail {
|
||||||
|
padding: 0.25rem 0 0.4rem 1.25rem;
|
||||||
|
}
|
||||||
|
.thinking-block :global(pre) {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.chev { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// One tool call inside AgentTrace's expanded list. Renders as a borderless
|
|
||||||
// row (the trace supplies the container/border) whose own click reveals the
|
|
||||||
// raw args/result — so the trace stays a readable thinking log by default
|
|
||||||
// and the JSON is one more click away, not stacked inline.
|
|
||||||
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
|
|
||||||
import type { ToolCallResult } from '$lib/types'
|
|
||||||
import { toolActivityLabel } from '$lib/stores/activity'
|
|
||||||
|
|
||||||
let { tool }: { tool: ToolCallResult } = $props()
|
|
||||||
let expanded = $state(false)
|
|
||||||
let liveEl = $state<HTMLPreElement | null>(null)
|
|
||||||
|
|
||||||
const status = $derived.by(() => {
|
|
||||||
if (tool.type === 'tool_use') return 'running'
|
|
||||||
if (tool.error) return 'error'
|
|
||||||
return 'done'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auto-open while a command is streaming its output, so the operator sees it
|
|
||||||
// without an extra click — mirrors UnifiedTimeline. Once the tool_result
|
|
||||||
// lands (status flips off running) liveOutput clears and the card respects
|
|
||||||
// the manual toggle again. (F4)
|
|
||||||
const open = $derived(expanded || !!tool.liveOutput)
|
|
||||||
$effect(() => {
|
|
||||||
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
|
|
||||||
})
|
|
||||||
|
|
||||||
const label = $derived(toolActivityLabel(tool))
|
|
||||||
|
|
||||||
const argsSummary = $derived.by(() => {
|
|
||||||
if (!tool.args) return ''
|
|
||||||
const entries = Object.entries(tool.args)
|
|
||||||
if (entries.length === 0) return ''
|
|
||||||
const first = entries[0]
|
|
||||||
const val = typeof first[1] === 'string' ? first[1] : JSON.stringify(first[1])
|
|
||||||
return `${first[0]}: ${val.length > 60 ? val.slice(0, 60) + '…' : val}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasDetail = $derived(
|
|
||||||
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="tool-row">
|
|
||||||
<button
|
|
||||||
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
|
|
||||||
onclick={() => (expanded = !expanded)}
|
|
||||||
aria-expanded={open}
|
|
||||||
disabled={!hasDetail && !tool.liveOutput}
|
|
||||||
>
|
|
||||||
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
|
|
||||||
{#if status === 'running'}
|
|
||||||
<Loader2 class="size-3 animate-spin" />
|
|
||||||
{:else if status === 'error'}
|
|
||||||
<X class="size-3" />
|
|
||||||
{:else}
|
|
||||||
<Check class="size-3" />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span class="min-w-0 flex-1">
|
|
||||||
<span class="block truncate text-xs text-foreground/90">{label}</span>
|
|
||||||
{#if argsSummary}
|
|
||||||
<span class="block truncate font-mono text-[10px] text-muted-foreground/60"
|
|
||||||
>{argsSummary}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
|
|
||||||
{#if hasDetail || tool.liveOutput}
|
|
||||||
<ChevronRight
|
|
||||||
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {open
|
|
||||||
? 'rotate-90'
|
|
||||||
: ''}"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if open}
|
|
||||||
<div class="space-y-2 px-2 pb-2 pl-7">
|
|
||||||
{#if tool.liveOutput}
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-primary"
|
|
||||||
>
|
|
||||||
<Loader2 class="size-2.5 animate-spin" />
|
|
||||||
Live output
|
|
||||||
</div>
|
|
||||||
<pre
|
|
||||||
bind:this={liveEl}
|
|
||||||
class="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/60 p-2 font-mono text-[11px] text-foreground/90">{tool.liveOutput}</pre>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if tool.args}
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
||||||
>
|
|
||||||
Args
|
|
||||||
</div>
|
|
||||||
<pre
|
|
||||||
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
|
|
||||||
tool.args,
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}</pre>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if tool.result !== undefined && tool.result !== null}
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"
|
|
||||||
>
|
|
||||||
Result
|
|
||||||
</div>
|
|
||||||
<pre
|
|
||||||
class="max-h-48 overflow-x-auto rounded-md bg-muted/60 p-2 text-[11px]">{JSON.stringify(
|
|
||||||
tool.result,
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}</pre>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if tool.error}
|
|
||||||
<div>
|
|
||||||
<div class="mb-1 text-[10px] font-semibold uppercase tracking-wider text-destructive">
|
|
||||||
Error
|
|
||||||
</div>
|
|
||||||
<pre
|
|
||||||
class="max-h-48 overflow-x-auto rounded-md border border-destructive/20 bg-destructive/5 p-2 text-[11px] text-destructive">{tool.error}</pre>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
258
web/src/lib/components/ToolLine.svelte
Normal file
258
web/src/lib/components/ToolLine.svelte
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
// One tool call rendered as a compact, progressive line — the Claude-Code
|
||||||
|
// signature for the inline trace. Collapsed: state icon + humanized label +
|
||||||
|
// a one-line RESULT summary on completion (or a "live" tag while a `run`
|
||||||
|
// streams). Expanded (click): raw args/result/error in opaque <pre> blocks.
|
||||||
|
// Border-driven, square, no rounded/shadow (cyberspace system).
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Check, ChevronRight, Loader2, X } from '@lucide/svelte'
|
||||||
|
import type { ToolCallResult } from '$lib/types'
|
||||||
|
import { toolActivityLabel, toolResultSummary } from '$lib/stores/activity'
|
||||||
|
let { tool }: { tool: ToolCallResult } = $props()
|
||||||
|
let expanded = $state(false)
|
||||||
|
let liveEl = $state<HTMLPreElement | null>(null)
|
||||||
|
|
||||||
|
const status = $derived(tool.type === 'tool_use' ? 'running' : tool.error ? 'error' : 'done')
|
||||||
|
const label = $derived(toolActivityLabel(tool))
|
||||||
|
const summary = $derived(toolResultSummary(tool))
|
||||||
|
// Tool calls start COLLAPSED — the operator expands them on demand. The
|
||||||
|
// live `run` output is shown in a separate pinned-tail mini pane below the
|
||||||
|
// collapsed row (not by auto-opening the whole detail), so the line stays
|
||||||
|
// compact while the command streams. Previously `open` auto-expanded on
|
||||||
|
// liveOutput and then collapsed when it cleared — the "start open, then
|
||||||
|
// collapse" behavior the operator found confusing.
|
||||||
|
const open = $derived(expanded)
|
||||||
|
$effect(() => {
|
||||||
|
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasDetail = $derived(
|
||||||
|
!!tool.args || (tool.result !== undefined && tool.result !== null) || !!tool.error
|
||||||
|
)
|
||||||
|
function pretty(v: unknown): string {
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(v), null, 2)
|
||||||
|
} catch {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(v, null, 2)
|
||||||
|
} catch {
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="tool-line">
|
||||||
|
<button
|
||||||
|
class="row"
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={!hasDetail && !tool.liveOutput}
|
||||||
|
>
|
||||||
|
<span class="icon {status}" aria-hidden="true">
|
||||||
|
{#if status === 'running'}
|
||||||
|
<Loader2 class="size-3 animate-spin" />
|
||||||
|
{:else if status === 'error'}
|
||||||
|
<X class="size-3" />
|
||||||
|
{:else}
|
||||||
|
<Check class="size-3" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="text min-w-0 flex-1">
|
||||||
|
<span class="label {status === 'done' ? 'done-text' : ''}">{label}</span>
|
||||||
|
</span>
|
||||||
|
{#if status === 'running' && tool.liveOutput}
|
||||||
|
<span class="live-tag"><Loader2 class="size-2.5 animate-spin" /> live</span>
|
||||||
|
{:else if status === 'done' && summary}
|
||||||
|
<span class="summary">{summary}</span>
|
||||||
|
{:else if status === 'error'}
|
||||||
|
<span class="summary err">error</span>
|
||||||
|
{/if}
|
||||||
|
{#if hasDetail || tool.liveOutput}
|
||||||
|
<ChevronRight class="chev size-3 {open ? 'open' : ''}" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if tool.liveOutput}
|
||||||
|
<!-- Live `run` output — pinned-tail mini pane, always visible while the
|
||||||
|
command streams. Separate from the expand/collapse state so the tool
|
||||||
|
line itself stays collapsed. -->
|
||||||
|
<div class="live-output">
|
||||||
|
<pre bind:this={liveEl} class="live">{tool.liveOutput}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="detail">
|
||||||
|
{#if tool.args}
|
||||||
|
<div class="khead">Args</div>
|
||||||
|
<pre>{pretty(tool.args)}</pre>
|
||||||
|
{/if}
|
||||||
|
{#if tool.result !== undefined && tool.result !== null}
|
||||||
|
<div class="khead">Result</div>
|
||||||
|
<pre class={status === 'error' ? 'err' : ''}>{pretty(tool.result)}</pre>
|
||||||
|
{/if}
|
||||||
|
{#if tool.error}
|
||||||
|
<div class="khead err">Error</div>
|
||||||
|
<pre class="err">{tool.error}</pre>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tool-line {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
animation: tool-line-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes tool-line-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.tool-line {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.row:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.row:not(:disabled):hover .label {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 0.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.icon.running {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.icon.error {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
.icon.done {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.label.done-text {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 45%;
|
||||||
|
}
|
||||||
|
.summary.err {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
.live-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.chev {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
.chev.open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem 0 0.4rem 1.25rem;
|
||||||
|
}
|
||||||
|
.live-output {
|
||||||
|
padding: 0.1rem 0 0.3rem 1.25rem;
|
||||||
|
}
|
||||||
|
.live-output :global(pre.live) {
|
||||||
|
max-height: 8rem;
|
||||||
|
}
|
||||||
|
.khead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.khead.err {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
.tool-line :global(pre) {
|
||||||
|
margin: 0;
|
||||||
|
max-height: 12rem;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.tool-line :global(pre.err) {
|
||||||
|
color: var(--destructive);
|
||||||
|
border-color: color-mix(in oklab, var(--destructive) 40%, var(--border));
|
||||||
|
background: color-mix(in oklab, var(--destructive) 6%, var(--muted));
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.chev {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
325
web/src/lib/components/TurnTrace.svelte
Normal file
325
web/src/lib/components/TurnTrace.svelte
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
// The agent's working trace for ONE assistant turn, rendered inline as a
|
||||||
|
// progressive Claude-Code-style stream instead of a collapsed blob (replaces
|
||||||
|
// AgentTrace). Top to bottom: live plan checklist (running turn only), a
|
||||||
|
// "Thinking…" line while the model reasons (before the first tool / between
|
||||||
|
// steps), then each tool call as its own compact line grouped under its plan
|
||||||
|
// step. The streamed text answer is rendered by ChatThread after this.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ToolCallResult } from '$lib/types'
|
||||||
|
import type { PlanStep } from '$lib/api'
|
||||||
|
import ToolLine from './ToolLine.svelte'
|
||||||
|
import Spinner from './Spinner.svelte'
|
||||||
|
import CheckIcon from '@lucide/svelte/icons/check'
|
||||||
|
import XIcon from '@lucide/svelte/icons/x'
|
||||||
|
import PauseIcon from '@lucide/svelte/icons/pause'
|
||||||
|
import SlashIcon from '@lucide/svelte/icons/slash'
|
||||||
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||||
|
|
||||||
|
let {
|
||||||
|
tools = [],
|
||||||
|
status = 'idle',
|
||||||
|
label = null,
|
||||||
|
isLast = false,
|
||||||
|
planSteps = [],
|
||||||
|
taskStatus
|
||||||
|
}: {
|
||||||
|
tools?: ToolCallResult[]
|
||||||
|
/** `idle` = this turn has no live state (a finished historical turn). */
|
||||||
|
status?: 'running' | 'done' | 'error' | 'idle'
|
||||||
|
/** Live indicator text while thinking (the running step / tool / "thinking…"). */
|
||||||
|
label?: string | null
|
||||||
|
isLast?: boolean
|
||||||
|
planSteps?: PlanStep[]
|
||||||
|
taskStatus?: string
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
const TERMINAL = new Set(['done', 'failed', 'abandoned'])
|
||||||
|
|
||||||
|
// seq → step title (current-gen only) so tool groups can label themselves.
|
||||||
|
const stepTitle = $derived(new Map<number, string>(planSteps.map((s) => [s.seq, s.title])))
|
||||||
|
|
||||||
|
// Group consecutive tools by their plan step (when attributed). Plan-less /
|
||||||
|
// meta tools (propose_plan, set_goal, …) have no stepSeq and form orphan
|
||||||
|
// groups rendered without a header.
|
||||||
|
interface Group {
|
||||||
|
step: { seq: number; title: string } | null
|
||||||
|
tools: ToolCallResult[]
|
||||||
|
}
|
||||||
|
const groups = $derived.by<Group[]>(() => {
|
||||||
|
const out: Group[] = []
|
||||||
|
let cur: Group | null = null
|
||||||
|
for (const t of tools) {
|
||||||
|
const seq = t.stepSeq
|
||||||
|
if (!cur || (cur.step?.seq ?? null) !== (seq ?? null)) {
|
||||||
|
cur = {
|
||||||
|
step: seq != null && stepTitle.has(seq) ? { seq, title: stepTitle.get(seq)! } : null,
|
||||||
|
tools: []
|
||||||
|
}
|
||||||
|
out.push(cur)
|
||||||
|
}
|
||||||
|
cur.tools.push(t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
// Thinking line: visible while the turn is running and the model is reasoning
|
||||||
|
// — before the first tool, or after a tool finishes but before the next one
|
||||||
|
// starts. Hidden while a tool is mid-flight (its own spinner carries the
|
||||||
|
// liveness) and on idle/finished turns.
|
||||||
|
const lastToolRunning = $derived(
|
||||||
|
tools.length > 0 && tools[tools.length - 1].type === 'tool_use'
|
||||||
|
)
|
||||||
|
const showThinking = $derived(status === 'running' && !lastToolRunning)
|
||||||
|
|
||||||
|
// Plan checklist: only on the running/last turn, and only if a plan exists.
|
||||||
|
const showPlan = $derived(isLast && planSteps.length > 0)
|
||||||
|
const planTerminal = $derived(!!taskStatus && TERMINAL.has(taskStatus))
|
||||||
|
let planExpanded = $state(false)
|
||||||
|
const planDone = $derived(planSteps.filter((s) => s.status === 'done').length)
|
||||||
|
const planFailedStep = $derived(planSteps.find((s) => s.status === 'failed'))
|
||||||
|
|
||||||
|
// Elapsed time on the running step — ticks every second while a step is
|
||||||
|
// running so the operator can see how long it's been going (and spot a
|
||||||
|
// stuck step).
|
||||||
|
let now = $state(Date.now())
|
||||||
|
$effect(() => {
|
||||||
|
const running = planSteps.some((s) => s.status === 'running')
|
||||||
|
if (!running) return
|
||||||
|
const id = setInterval(() => {
|
||||||
|
now = Date.now()
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
})
|
||||||
|
function stepElapsed(s: PlanStep): string {
|
||||||
|
if (s.status !== 'running' || !s.started_at) return ''
|
||||||
|
const sec = Math.max(0, Math.floor((now - new Date(s.started_at).getTime()) / 1000))
|
||||||
|
if (sec < 60) return `${sec}s`
|
||||||
|
if (sec < 3600) return `${Math.floor(sec / 60)}m`
|
||||||
|
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showPlan}
|
||||||
|
<div class="plan {planTerminal && !planExpanded ? 'plan-collapsed' : ''}">
|
||||||
|
{#if planTerminal && !planExpanded}
|
||||||
|
<button class="plan-summary" onclick={() => (planExpanded = true)}>
|
||||||
|
{#if planFailedStep}
|
||||||
|
<XIcon class="size-3 text-destructive" />
|
||||||
|
<span class="plan-summary-text">Plan failed — step {planFailedStep.seq}</span>
|
||||||
|
{:else}
|
||||||
|
<CheckIcon class="size-3 text-primary" />
|
||||||
|
<span class="plan-summary-text">Plan complete — {planDone}/{planSteps.length} steps</span>
|
||||||
|
{/if}
|
||||||
|
<ChevronRightIcon class="size-3 text-muted-foreground/60" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="plan-head">
|
||||||
|
<span class="plan-head-label">Plan</span>
|
||||||
|
<span class="plan-head-count">{planDone}/{planSteps.length}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="plan-list">
|
||||||
|
{#each planSteps as s (s.id)}
|
||||||
|
<li class="plan-step {s.status === 'running' ? 'running' : ''}">
|
||||||
|
<span class="plan-node {s.status}" aria-hidden="true">
|
||||||
|
{#if s.status === 'running'}<Spinner class="size-3 text-primary" />
|
||||||
|
{:else if s.status === 'done'}<CheckIcon class="size-2.5" strokeWidth={3.5} />
|
||||||
|
{:else if s.status === 'failed'}<XIcon class="size-2.5" strokeWidth={3.5} />
|
||||||
|
{:else if s.status === 'blocked'}<PauseIcon class="size-2" strokeWidth={3} />
|
||||||
|
{:else if s.status === 'skipped' || s.status === 'replaced'}<SlashIcon class="size-2" strokeWidth={3} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="plan-title" title={s.title}>{s.title}</span>
|
||||||
|
{#if s.status === 'running'}
|
||||||
|
<span class="plan-elapsed">{stepElapsed(s)}</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Thinking line: always rendered (fixed height) so appearing/disappearing
|
||||||
|
doesn't shift the layout — it just fades in/out. Shows a STABLE label
|
||||||
|
("Working…") rather than the current operation, which would rewrite
|
||||||
|
itself on every step/tool transition and read as text appearing and
|
||||||
|
disappearing. The current operation is already visible in the plan
|
||||||
|
checklist (running step) and the tool lines below. -->
|
||||||
|
<div class="thinking {showThinking ? '' : 'thinking-hidden'}" aria-hidden={!showThinking}>
|
||||||
|
<Spinner class="size-3 shrink-0 text-primary" />
|
||||||
|
<span class="thinking-text">Working…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if groups.length > 0}
|
||||||
|
<div class="tools">
|
||||||
|
{#each groups as g, gi (gi)}
|
||||||
|
{#if g.step}
|
||||||
|
<div class="step-head">Step {g.step.seq} · {g.step.title}</div>
|
||||||
|
{/if}
|
||||||
|
{#each g.tools as tool (tool.id ?? `${gi}-${tool.name}`)}
|
||||||
|
<ToolLine {tool} />
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if status === 'idle' && tools.length === 0}
|
||||||
|
<!-- finished turn with no tools: nothing to render -->
|
||||||
|
{:else if status === 'error'}
|
||||||
|
<div class="thinking err">
|
||||||
|
<XIcon class="size-3 shrink-0 text-destructive" />
|
||||||
|
<span class="thinking-text">{label || 'Turn ended with an error'}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.plan {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: color-mix(in oklab, var(--primary) 3%, var(--card));
|
||||||
|
padding: 0.35rem 0.55rem 0.4rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
animation: plan-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes plan-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.plan {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.plan-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.plan-summary-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--foreground);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.plan-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.plan-head-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.plan-head-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.plan-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
.plan-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.1rem 0;
|
||||||
|
}
|
||||||
|
.plan-step.running {
|
||||||
|
background: color-mix(in oklab, var(--primary) 8%, transparent);
|
||||||
|
margin: 0 -0.3rem;
|
||||||
|
padding-left: 0.3rem;
|
||||||
|
padding-right: 0.3rem;
|
||||||
|
}
|
||||||
|
.plan-node {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.plan-node.done {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.plan-node.failed {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
.plan-node.running {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.plan-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.plan-step.running .plan-title {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.plan-elapsed {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--primary);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
height: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.thinking-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.thinking.err {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
.thinking-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.step-head {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
padding: 0.35rem 0 0.1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { untrack } from 'svelte'
|
|
||||||
import { slide } from 'svelte/transition'
|
|
||||||
import type { ActivityEntry } from '$lib/stores/activity'
|
|
||||||
import type { PlanStep } from '$lib/api'
|
|
||||||
import Spinner from './Spinner.svelte'
|
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check'
|
|
||||||
import XIcon from '@lucide/svelte/icons/x'
|
|
||||||
import PauseIcon from '@lucide/svelte/icons/pause'
|
|
||||||
import SlashIcon from '@lucide/svelte/icons/slash'
|
|
||||||
import WrenchIcon from '@lucide/svelte/icons/wrench'
|
|
||||||
import MilestoneIcon from '@lucide/svelte/icons/milestone'
|
|
||||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
|
||||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
|
|
||||||
import FlagIcon from '@lucide/svelte/icons/flag'
|
|
||||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
|
|
||||||
import { openEntityWindow } from '$lib/stores/windows'
|
|
||||||
|
|
||||||
// Merged plan + activity timeline, designed for the narrow rail:
|
|
||||||
// - ordered newest-first: what the agent is doing right now is at the top,
|
|
||||||
// history flows downward, and the goal sits at the bottom where the task
|
|
||||||
// began (see the sort in `items`)
|
|
||||||
// - one continuous vertical "backbone"; every item owns a segment of it,
|
|
||||||
// colored by state (done = filled primary, running = faint primary,
|
|
||||||
// pending/future = muted) so the line visibly fills in as work completes
|
|
||||||
// - plan steps are filled status nodes ON the backbone; their tool calls
|
|
||||||
// branch off with horizontal stubs
|
|
||||||
// - flat entries (goal/knowledge/complete/orphan tools) are milestone
|
|
||||||
// markers on the same backbone
|
|
||||||
// - the running step auto-expands and the view auto-scrolls to keep the
|
|
||||||
// current step visible while the agent works (follow mode disengages if
|
|
||||||
// the operator scrolls down into history, re-engages when streaming
|
|
||||||
// starts again)
|
|
||||||
let {
|
|
||||||
entries,
|
|
||||||
planSteps: steps,
|
|
||||||
streaming = false
|
|
||||||
}: {
|
|
||||||
entries: ActivityEntry[]
|
|
||||||
planSteps: PlanStep[]
|
|
||||||
streaming?: boolean
|
|
||||||
} = $props()
|
|
||||||
|
|
||||||
// Explicit user toggles only — default open state derives from step status
|
|
||||||
// (running = expanded, everything else = collapsed) so a step collapses
|
|
||||||
// itself the moment it finishes unless the operator pinned it open.
|
|
||||||
let stepToggles = $state(new Map<string, boolean>())
|
|
||||||
let expandedTools = $state(new Set<string>())
|
|
||||||
|
|
||||||
function stepOpen(step: PlanStep): boolean {
|
|
||||||
return stepToggles.get(step.id) ?? step.status === 'running'
|
|
||||||
}
|
|
||||||
function toggleStep(step: PlanStep) {
|
|
||||||
stepToggles.set(step.id, !stepOpen(step))
|
|
||||||
stepToggles = new Map(stepToggles)
|
|
||||||
}
|
|
||||||
// Pin each streaming output pane to its tail as chunks arrive. Keyed by
|
|
||||||
// tool id because several run entries can be on screen, though only the
|
|
||||||
// newest one is ever actually streaming.
|
|
||||||
let liveOutputEls = $state<Record<string, HTMLPreElement | null>>({})
|
|
||||||
$effect(() => {
|
|
||||||
// Depend on entries only. liveOutputEls is written by bind:this, so
|
|
||||||
// tracking it here would let a re-render re-trigger this effect.
|
|
||||||
const current = entries
|
|
||||||
untrack(() => {
|
|
||||||
for (const e of current) {
|
|
||||||
if (!e.liveOutput) continue
|
|
||||||
const el = liveOutputEls[e.id]
|
|
||||||
if (el) el.scrollTop = el.scrollHeight
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleTool(id: string) {
|
|
||||||
if (expandedTools.has(id)) expandedTools.delete(id)
|
|
||||||
else expandedTools.add(id)
|
|
||||||
expandedTools = new Set(expandedTools)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Timeline model ────────────────────────────────────────────────────────
|
|
||||||
type TLItem =
|
|
||||||
| { kind: 'step'; step: PlanStep; tools: ActivityEntry[]; ts: number }
|
|
||||||
| { kind: 'entry'; entry: ActivityEntry; ts: number }
|
|
||||||
|
|
||||||
const items = $derived.by<TLItem[]>(() => {
|
|
||||||
const stepIds = new Set(steps.map((s) => s.id))
|
|
||||||
const out: TLItem[] = []
|
|
||||||
|
|
||||||
for (const s of steps) {
|
|
||||||
if (s.status === 'pending' && !entries.some((e) => e.stepSeq === s.seq)) {
|
|
||||||
// Pending steps with no activity yet still show on the timeline so
|
|
||||||
// the operator sees what's coming — but only if a plan exists. ts 0
|
|
||||||
// parks them at the tail of the newest-first sort below (see there).
|
|
||||||
if (steps.length > 0) {
|
|
||||||
out.push({ kind: 'step', step: s, tools: [], ts: 0 })
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const tools = entries.filter(
|
|
||||||
(e) =>
|
|
||||||
e.stepSeq === s.seq &&
|
|
||||||
(e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error')
|
|
||||||
)
|
|
||||||
const stepEntry = entries.find((e) => e.id === s.id)
|
|
||||||
// Timed from the step's own entry, else its earliest tool — so a step
|
|
||||||
// is placed by when it started, not by its latest activity.
|
|
||||||
const ts = stepEntry?.timestamp ?? tools[0]?.timestamp ?? Date.now()
|
|
||||||
// Tools inside a step run newest-first too, matching the outer order.
|
|
||||||
out.push({ kind: 'step', step: s, tools: [...tools].reverse(), ts })
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const e of entries) {
|
|
||||||
const isTool = e.type === 'tool_running' || e.type === 'tool_done' || e.type === 'tool_error'
|
|
||||||
if (isTool && e.stepSeq != null) continue // nested under its step
|
|
||||||
if (!isTool && stepIds.has(e.id)) continue // rendered as step node
|
|
||||||
out.push({ kind: 'entry', entry: e, ts: e.timestamp })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Newest first: whatever the agent is doing right now sits at the top of
|
|
||||||
// the rail, with history flowing downward. The two ts-0 groups fall to
|
|
||||||
// the bottom for free, which is where both belong in this order: the goal
|
|
||||||
// (timestamp 0 — where the task started) and not-yet-run plan steps.
|
|
||||||
// Sorting the latter by their future position would put them *above* the
|
|
||||||
// running step and push it off the top, which is exactly what this
|
|
||||||
// ordering exists to prevent. Array.sort is stable, so each group keeps
|
|
||||||
// its insertion order (plan steps in seq order).
|
|
||||||
out.sort((a, b) => b.ts - a.ts)
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Current activity + auto-scroll ────────────────────────────────────────
|
|
||||||
const currentId = $derived.by<string | null>(() => {
|
|
||||||
const runningTool = entries.find((e) => e.type === 'tool_running' && e.status === 'running')
|
|
||||||
if (runningTool) return runningTool.id
|
|
||||||
const runningStep = steps.find((s) => s.status === 'running')
|
|
||||||
if (runningStep) return runningStep.id
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
let container = $state<HTMLDivElement | null>(null)
|
|
||||||
let follow = $state(true)
|
|
||||||
|
|
||||||
// Newest-first, so "following the agent" means being parked at the top —
|
|
||||||
// the mirror of the bottom-anchored follow this had when it ran oldest-first.
|
|
||||||
function onScroll() {
|
|
||||||
if (!container) return
|
|
||||||
follow = container.scrollTop < 80
|
|
||||||
}
|
|
||||||
|
|
||||||
// A new turn re-engages follow mode even if the operator had scrolled up.
|
|
||||||
let wasStreaming = $state(false)
|
|
||||||
$effect(() => {
|
|
||||||
if (streaming && !wasStreaming) follow = true
|
|
||||||
wasStreaming = streaming
|
|
||||||
})
|
|
||||||
|
|
||||||
// Scroll to the current step/tool whenever it changes (smooth) or when new
|
|
||||||
// entries land while following (instant, to avoid scroll-queue jank).
|
|
||||||
$effect(() => {
|
|
||||||
if (!currentId || !follow || !container) return
|
|
||||||
container
|
|
||||||
.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
|
||||||
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
||||||
})
|
|
||||||
let lastEntryCount = 0
|
|
||||||
$effect(() => {
|
|
||||||
const n = entries.length
|
|
||||||
if (n === lastEntryCount) return
|
|
||||||
lastEntryCount = n
|
|
||||||
if (!follow || !container) return
|
|
||||||
const target = currentId
|
|
||||||
? container.querySelector(`[data-tl-id="${CSS.escape(currentId)}"]`)
|
|
||||||
: null
|
|
||||||
if (target) target.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
|
||||||
else container.scrollTop = 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Presentation helpers ──────────────────────────────────────────────────
|
|
||||||
// Segment geometry: the backbone's center runs at x=17.5px (node center:
|
|
||||||
// px-3 = 11.25px at the app's 15px root font-size + half of the 13px node),
|
|
||||||
// so the 1px line sits at left-17px. Each item's segment spans its full
|
|
||||||
// height so tools inside an expanded step stay on the line; first/last
|
|
||||||
// items clip theirs to their node/tool centers so the line never dangles
|
|
||||||
// past the timeline's ends.
|
|
||||||
function segClass(
|
|
||||||
status: string,
|
|
||||||
isFirst: boolean,
|
|
||||||
isLast: boolean,
|
|
||||||
expandedWithTools: boolean
|
|
||||||
): string {
|
|
||||||
let color = 'bg-border'
|
|
||||||
if (status === 'done') color = 'bg-primary/60'
|
|
||||||
else if (status === 'running') color = 'bg-primary/40'
|
|
||||||
else if (status === 'failed') color = 'bg-destructive/40'
|
|
||||||
|
|
||||||
if (isFirst && isLast) return `${color} top-[13px] h-0`
|
|
||||||
if (isFirst) return `${color} top-[13px] bottom-0`
|
|
||||||
if (isLast && expandedWithTools) return `${color} top-0 bottom-[11px]`
|
|
||||||
if (isLast) return `${color} top-0 bottom-[calc(100%-13px)]`
|
|
||||||
return `${color} top-0 bottom-0`
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryIcon(entry: ActivityEntry) {
|
|
||||||
switch (entry.type) {
|
|
||||||
case 'goal':
|
|
||||||
return MilestoneIcon
|
|
||||||
case 'knowledge':
|
|
||||||
return SparklesIcon
|
|
||||||
case 'complete':
|
|
||||||
return FlagIcon
|
|
||||||
case 'question':
|
|
||||||
return HelpCircleIcon
|
|
||||||
default:
|
|
||||||
return WrenchIcon
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function hhmm(ts: number): string {
|
|
||||||
if (!ts || ts > Number.MAX_SAFE_INTEGER - 1000) return ''
|
|
||||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
function hhmmss(ts: number): string {
|
|
||||||
if (!ts) return ''
|
|
||||||
return new Date(ts).toLocaleTimeString([], {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
function prettyPrint(raw: string): string {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
|
||||||
} catch {
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
|
||||||
<div class="flex-1 overflow-y-auto" bind:this={container} onscroll={onScroll}>
|
|
||||||
{#if items.length === 0}
|
|
||||||
<div class="flex flex-col items-center gap-2 px-3 py-6 text-center">
|
|
||||||
<svg viewBox="0 0 64 110" class="h-14 w-auto text-muted-foreground/40" fill="none">
|
|
||||||
<line
|
|
||||||
x1="32"
|
|
||||||
y1="8"
|
|
||||||
x2="32"
|
|
||||||
y2="102"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1"
|
|
||||||
stroke-dasharray="2.5 4"
|
|
||||||
opacity="0.35"
|
|
||||||
/>
|
|
||||||
<circle cx="32" cy="22" r="4" fill="currentColor">
|
|
||||||
<animate
|
|
||||||
attributeName="opacity"
|
|
||||||
values="0.25;0.9;0.25"
|
|
||||||
dur="2.4s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</circle>
|
|
||||||
<circle cx="32" cy="55" r="4" fill="currentColor">
|
|
||||||
<animate
|
|
||||||
attributeName="opacity"
|
|
||||||
values="0.25;0.9;0.25"
|
|
||||||
dur="2.4s"
|
|
||||||
begin="0.6s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</circle>
|
|
||||||
<circle cx="32" cy="55" r="4" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<animate
|
|
||||||
attributeName="r"
|
|
||||||
values="4;11;4"
|
|
||||||
dur="2.4s"
|
|
||||||
begin="0.6s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
<animate
|
|
||||||
attributeName="opacity"
|
|
||||||
values="0.6;0;0.6"
|
|
||||||
dur="2.4s"
|
|
||||||
begin="0.6s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</circle>
|
|
||||||
<circle cx="32" cy="88" r="4" fill="currentColor">
|
|
||||||
<animate
|
|
||||||
attributeName="opacity"
|
|
||||||
values="0.25;0.9;0.25"
|
|
||||||
dur="2.4s"
|
|
||||||
begin="1.2s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
</circle>
|
|
||||||
</svg>
|
|
||||||
<p class="text-[11px] leading-relaxed text-muted-foreground">Waiting for activity…</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col py-1">
|
|
||||||
{#each items as item, i (item.kind === 'step' ? item.step.id : item.entry.id)}
|
|
||||||
{@const isFirst = i === 0}
|
|
||||||
{@const isLast = i === items.length - 1}
|
|
||||||
{#if item.kind === 'step'}
|
|
||||||
{@const st = item.step.status}
|
|
||||||
{@const open = stepOpen(item.step)}
|
|
||||||
{@const hasDetail = !!item.step.detail?.trim()}
|
|
||||||
{@const expandable = item.tools.length > 0 || hasDetail}
|
|
||||||
{@const expandedWithTools = open && item.tools.length > 0}
|
|
||||||
<!-- Step node on the backbone -->
|
|
||||||
<div class="relative" data-tl-id={item.step.id}>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute left-[17px] w-px {segClass(
|
|
||||||
st,
|
|
||||||
isFirst,
|
|
||||||
isLast,
|
|
||||||
expandedWithTools
|
|
||||||
)}"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="relative flex w-full items-center gap-2 rounded px-3 py-1.5 text-left text-xs {expandable
|
|
||||||
? 'cursor-pointer hover:bg-muted/30'
|
|
||||||
: 'cursor-default'} {st === 'running' ? 'bg-primary/5' : ''}"
|
|
||||||
onclick={() => expandable && toggleStep(item.step)}
|
|
||||||
aria-expanded={open}
|
|
||||||
disabled={!expandable}
|
|
||||||
>
|
|
||||||
<!-- Filled status node -->
|
|
||||||
<span
|
|
||||||
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full
|
|
||||||
{st === 'done'
|
|
||||||
? 'bg-primary'
|
|
||||||
: st === 'running'
|
|
||||||
? 'bg-background'
|
|
||||||
: st === 'failed'
|
|
||||||
? 'bg-destructive'
|
|
||||||
: st === 'blocked'
|
|
||||||
? 'bg-warning/25 border border-warning'
|
|
||||||
: st === 'skipped' || st === 'replaced'
|
|
||||||
? 'bg-muted'
|
|
||||||
: 'bg-background border border-muted-foreground/40'}"
|
|
||||||
>
|
|
||||||
{#if st === 'running'}
|
|
||||||
<span class="absolute -inset-0.5 animate-ping rounded-full bg-primary/30"
|
|
||||||
></span>
|
|
||||||
<Spinner class="relative size-3.5 text-primary" />
|
|
||||||
{:else if st === 'done'}
|
|
||||||
<CheckIcon class="size-2.5 text-primary-foreground" strokeWidth={3.5} />
|
|
||||||
{:else if st === 'failed'}
|
|
||||||
<XIcon class="size-2.5 text-destructive-foreground" strokeWidth={3.5} />
|
|
||||||
{:else if st === 'blocked'}
|
|
||||||
<PauseIcon class="size-2 text-warning" strokeWidth={3} />
|
|
||||||
{:else if st === 'skipped' || st === 'replaced'}
|
|
||||||
<SlashIcon class="size-2 text-muted-foreground" strokeWidth={3} />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
title={item.step.title}
|
|
||||||
class="min-w-0 flex-1 leading-snug {open
|
|
||||||
? 'whitespace-normal'
|
|
||||||
: 'truncate'} {st === 'done'
|
|
||||||
? 'text-muted-foreground'
|
|
||||||
: st === 'running'
|
|
||||||
? 'font-medium text-foreground'
|
|
||||||
: 'text-muted-foreground'}"
|
|
||||||
>
|
|
||||||
{item.step.title}
|
|
||||||
</span>
|
|
||||||
{#if hhmm(item.ts)}
|
|
||||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
|
||||||
>{hhmm(item.ts)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if item.tools.length > 0}
|
|
||||||
<span class="shrink-0 text-muted-foreground/60">
|
|
||||||
{#if open}<ChevronDownIcon class="size-3" />{:else}<ChevronRightIcon
|
|
||||||
class="size-3"
|
|
||||||
/>{/if}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if expandedWithTools}
|
|
||||||
<div transition:slide={{ duration: 150 }} class="flex flex-col">
|
|
||||||
{#each item.tools as tool (tool.id)}
|
|
||||||
{@const tOpen = expandedTools.has(tool.id) || !!tool.liveOutput}
|
|
||||||
<div class="relative" data-tl-id={tool.id}>
|
|
||||||
<!-- Branch stub: backbone → tool -->
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute left-[17px] top-[9.5px] h-px w-[17px] {tool.status ===
|
|
||||||
'failed'
|
|
||||||
? 'bg-destructive/40'
|
|
||||||
: 'bg-border'}"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center gap-1.5 py-1 pl-9 pr-3 text-left text-[11px] {tool.args ||
|
|
||||||
tool.detail ||
|
|
||||||
tool.liveOutput
|
|
||||||
? 'cursor-pointer hover:bg-muted/20'
|
|
||||||
: 'cursor-default'}"
|
|
||||||
onclick={() =>
|
|
||||||
(tool.args || tool.detail || tool.liveOutput) && toggleTool(tool.id)}
|
|
||||||
>
|
|
||||||
<span class="flex size-3 shrink-0 items-center justify-center">
|
|
||||||
{#if tool.status === 'running'}
|
|
||||||
<Spinner class="size-2.5 text-primary" />
|
|
||||||
{:else if tool.status === 'failed'}
|
|
||||||
<XIcon class="size-2.5 text-destructive" strokeWidth={3.5} />
|
|
||||||
{:else}
|
|
||||||
<CheckIcon class="size-2.5 text-primary/70" strokeWidth={3.5} />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
title={tool.description}
|
|
||||||
class="min-w-0 flex-1 truncate leading-snug {tool.status === 'done'
|
|
||||||
? 'text-muted-foreground'
|
|
||||||
: tool.status === 'failed'
|
|
||||||
? 'text-destructive'
|
|
||||||
: 'text-foreground/80'}"
|
|
||||||
>
|
|
||||||
{tool.description}
|
|
||||||
</span>
|
|
||||||
{#if !tool.link}
|
|
||||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
|
|
||||||
>{hhmm(tool.timestamp)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#if tool.link}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
|
||||||
title="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
|
||||||
aria-label="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
|
||||||
onclick={() => openEntityWindow(tool.link!.slug)}
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon class="size-3" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if tOpen}
|
|
||||||
<div
|
|
||||||
transition:slide={{ duration: 120 }}
|
|
||||||
class="flex flex-col gap-1 pb-1.5 pl-[52px] pr-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70"
|
|
||||||
>
|
|
||||||
<span class="capitalize">{tool.status}</span>
|
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<span>{hhmmss(tool.timestamp)}</span>
|
|
||||||
{#if tool.toolName}<span aria-hidden="true">·</span><code
|
|
||||||
class="font-mono">{tool.toolName}</code
|
|
||||||
>{/if}
|
|
||||||
</div>
|
|
||||||
{#if tool.args}
|
|
||||||
<pre
|
|
||||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
|
|
||||||
tool.args
|
|
||||||
)}</pre>
|
|
||||||
{/if}
|
|
||||||
{#if tool.liveOutput}
|
|
||||||
<!-- Streaming while the command runs. Bound so it
|
|
||||||
can be pinned to the tail as chunks arrive. -->
|
|
||||||
<pre
|
|
||||||
bind:this={liveOutputEls[tool.id]}
|
|
||||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{tool.liveOutput}</pre>
|
|
||||||
{/if}
|
|
||||||
{#if tool.detail}
|
|
||||||
<pre
|
|
||||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {tool.status ===
|
|
||||||
'failed'
|
|
||||||
? 'text-destructive'
|
|
||||||
: 'text-muted-foreground'}">{prettyPrint(tool.detail)}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Flat entry: milestone marker on the backbone -->
|
|
||||||
{@const e = item.entry}
|
|
||||||
{@const Icon = entryIcon(e)}
|
|
||||||
{@const eOpen = expandedTools.has(e.id)}
|
|
||||||
<div class="relative" data-tl-id={e.id}>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute left-[17px] w-px {segClass(
|
|
||||||
e.status,
|
|
||||||
isFirst,
|
|
||||||
isLast,
|
|
||||||
false
|
|
||||||
)}"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] {e.args ||
|
|
||||||
e.detail
|
|
||||||
? 'cursor-pointer hover:bg-muted/30'
|
|
||||||
: 'cursor-default'}"
|
|
||||||
onclick={() => (e.args || e.detail) && toggleTool(e.id)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="relative z-10 flex size-3.5 shrink-0 items-center justify-center rounded-full border bg-background
|
|
||||||
{e.status === 'failed'
|
|
||||||
? 'border-destructive text-destructive'
|
|
||||||
: e.status === 'running'
|
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-border text-primary'}"
|
|
||||||
>
|
|
||||||
{#if e.status === 'running'}
|
|
||||||
<Spinner class="size-2.5" />
|
|
||||||
{:else if e.status === 'failed'}
|
|
||||||
<XIcon class="size-2" strokeWidth={3.5} />
|
|
||||||
{:else}
|
|
||||||
<Icon class="size-2" strokeWidth={2.5} />
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
title={e.description}
|
|
||||||
class="min-w-0 flex-1 truncate leading-snug {e.status === 'done'
|
|
||||||
? 'text-muted-foreground'
|
|
||||||
: 'text-foreground/80'}"
|
|
||||||
>
|
|
||||||
{e.description}
|
|
||||||
</span>
|
|
||||||
{#if !e.link}
|
|
||||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
|
||||||
>{hhmm(e.timestamp)}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#if e.link}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
|
|
||||||
title="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
|
||||||
aria-label="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
|
|
||||||
onclick={() => openEntityWindow(e.link!.slug)}
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon class="size-3" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if eOpen}
|
|
||||||
<div
|
|
||||||
transition:slide={{ duration: 120 }}
|
|
||||||
class="flex flex-col gap-1 pb-1.5 pl-9 pr-3"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1.5 text-[9px] text-muted-foreground/70">
|
|
||||||
<span class="capitalize">{e.status}</span>
|
|
||||||
<span aria-hidden="true">·</span>
|
|
||||||
<span>{hhmmss(e.timestamp)}</span>
|
|
||||||
{#if e.toolName}<span aria-hidden="true">·</span><code class="font-mono"
|
|
||||||
>{e.toolName}</code
|
|
||||||
>{/if}
|
|
||||||
</div>
|
|
||||||
{#if e.args}
|
|
||||||
<pre
|
|
||||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed text-muted-foreground">{prettyPrint(
|
|
||||||
e.args
|
|
||||||
)}</pre>
|
|
||||||
{/if}
|
|
||||||
{#if e.detail}
|
|
||||||
<pre
|
|
||||||
class="max-h-36 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-1.5 font-mono text-[9px] leading-relaxed {e.status ===
|
|
||||||
'failed'
|
|
||||||
? 'text-destructive'
|
|
||||||
: 'text-muted-foreground'}">{prettyPrint(e.detail)}</pre>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
wm,
|
wm,
|
||||||
dk,
|
dk,
|
||||||
wmState,
|
wmState,
|
||||||
|
windowKeys,
|
||||||
openEntityWindow,
|
openEntityWindow,
|
||||||
NEW_TASK_WINDOW_ID,
|
NEW_TASK_WINDOW_ID,
|
||||||
SESSION_PREFIX,
|
SESSION_PREFIX,
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
|
<div use:dk.desktop class="absolute inset-0 z-40 pointer-events-none">
|
||||||
{#each $wmState.order as id (id)}
|
{#each $windowKeys as id (id)}
|
||||||
{@const win = $wmState.windows[id]}
|
{@const win = $wmState.windows[id]}
|
||||||
{@const appId = appIdFromWindowId(id)}
|
{@const appId = appIdFromWindowId(id)}
|
||||||
{@const app = appId ? $appById.get(appId) : undefined}
|
{@const app = appId ? $appById.get(appId) : undefined}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock('./execstream', () => ({
|
|||||||
liveExecutionOutputFor: vi.fn(() => writable(null))
|
liveExecutionOutputFor: vi.fn(() => writable(null))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { computeActivityLog } from './activity'
|
import { computeActivityLog, toolResultSummary } from './activity'
|
||||||
import type { ChatMessage } from './chat'
|
import type { ChatMessage } from './chat'
|
||||||
import type { PlanStep, Session } from '$lib/api'
|
import type { PlanStep, Session } from '$lib/api'
|
||||||
|
|
||||||
@@ -172,3 +172,76 @@ describe('computeActivityLog generation awareness (F4)', () => {
|
|||||||
expect(entries.find((e) => e.id === 't1')!.stepSeq).toBeUndefined()
|
expect(entries.find((e) => e.id === 't1')!.stepSeq).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// toolResultSummary (chat interaction overhaul): a one-line, humanized outcome
|
||||||
|
// per tool so each inline tool line reads as a result instead of raw JSON.
|
||||||
|
describe('toolResultSummary', () => {
|
||||||
|
type TR = NonNullable<ChatMessage['tools']>[number]
|
||||||
|
const done = (name: string, result: unknown, args?: Record<string, unknown>): TR => ({
|
||||||
|
type: 'tool_result',
|
||||||
|
name,
|
||||||
|
id: name,
|
||||||
|
result,
|
||||||
|
args
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is empty for a still-running call and for an errored one', () => {
|
||||||
|
expect(toolResultSummary({ type: 'tool_use', name: 'run', id: 'r' })).toBe('')
|
||||||
|
expect(
|
||||||
|
toolResultSummary({ type: 'tool_result', name: 'run', id: 'r', error: 'boom' })
|
||||||
|
).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses run exit status', () => {
|
||||||
|
expect(
|
||||||
|
toolResultSummary(done('run', 'run on lxc:caddy: ERROR exit status 1'))
|
||||||
|
).toContain('exit 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('summarizes a clean run with its first line', () => {
|
||||||
|
const s = toolResultSummary(done('run', 'Active: active (running)'))
|
||||||
|
expect(s.startsWith('ok')).toBe(true)
|
||||||
|
expect(s).toContain('active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats get_entity as slug (health)', () => {
|
||||||
|
expect(
|
||||||
|
toolResultSummary(done('get_entity', { slug: 'host:hubris', health: 'healthy' }))
|
||||||
|
).toBe('host:hubris (healthy)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts list results', () => {
|
||||||
|
expect(
|
||||||
|
toolResultSummary(done('list_entities', { entities: Array(10).fill({}) }))
|
||||||
|
).toBe('10 entities')
|
||||||
|
expect(toolResultSummary(done('list_lxcs', { containers: [1, 2] }))).toBe('2 containers')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats fleet health counts', () => {
|
||||||
|
expect(
|
||||||
|
toolResultSummary(
|
||||||
|
done('get_health_summary', { health: { healthy: 5, degraded: 1, down: 0, unknown: 2 } })
|
||||||
|
)
|
||||||
|
).toBe('healthy 5 · degraded 1 · down 0 · unknown 2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts the knowledge slug from upsert_knowledge', () => {
|
||||||
|
expect(
|
||||||
|
toolResultSummary(done('upsert_knowledge', 'Saved document:nomos/foo-bar to the DB'))
|
||||||
|
).toBe('recorded document:nomos/foo-bar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats update_plan_step from args', () => {
|
||||||
|
expect(
|
||||||
|
toolResultSummary(done('update_plan_step', 'ok', { seq: 2, status: 'done' }))
|
||||||
|
).toBe('step 2 → done')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts proposed plan steps', () => {
|
||||||
|
expect(toolResultSummary(done('propose_plan', { steps: [{}, {}, {}] }))).toBe('3 steps')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the first line for unmapped tools', () => {
|
||||||
|
expect(toolResultSummary(done('some_new_tool', 'first line\nsecond line'))).toBe('first line')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -433,3 +433,148 @@ export function toolActivityLabel(t: ToolCallResult): string {
|
|||||||
return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
|
return t.name.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── toolResultSummary ────────────────────────────────────────────────────
|
||||||
|
// A one-line, humanized summary of a tool's RESULT (the Claude-Code-style
|
||||||
|
// "exit 0 · <line>" / "host:hubris (healthy)" affordance) so each tool line
|
||||||
|
// in the inline trace reads as an outcome instead of a raw JSON blob. Empty
|
||||||
|
// for a still-running call (no result yet) or an errored one (the error is
|
||||||
|
// surfaced separately). Best-effort by tool name; the fallback is the first
|
||||||
|
// non-empty line of the stringified result, truncated — never blank (the
|
||||||
|
// expandable raw detail is always one click away).
|
||||||
|
function resultAsString(r: unknown): string {
|
||||||
|
if (r == null) return ''
|
||||||
|
if (typeof r === 'string') return r
|
||||||
|
try {
|
||||||
|
return JSON.stringify(r)
|
||||||
|
} catch {
|
||||||
|
return String(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function firstLine(s: string, max = 80): string {
|
||||||
|
const line = s
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.find((l) => l.length > 0) ?? ''
|
||||||
|
return line.length > max ? `${line.slice(0, max - 1)}…` : line
|
||||||
|
}
|
||||||
|
function resultArray(r: unknown): unknown[] | null {
|
||||||
|
if (Array.isArray(r)) return r
|
||||||
|
if (r && typeof r === 'object') {
|
||||||
|
const o = r as Record<string, unknown>
|
||||||
|
for (const k of [
|
||||||
|
'entities',
|
||||||
|
'results',
|
||||||
|
'relations',
|
||||||
|
'steps',
|
||||||
|
'items',
|
||||||
|
'containers',
|
||||||
|
'docs',
|
||||||
|
'questions',
|
||||||
|
'signals',
|
||||||
|
'events',
|
||||||
|
'patterns',
|
||||||
|
'skills'
|
||||||
|
]) {
|
||||||
|
if (Array.isArray(o[k])) return o[k] as unknown[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
function num(v: unknown): number | null {
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) ? v : null
|
||||||
|
}
|
||||||
|
function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
|
||||||
|
return `${n} ${n === 1 ? singular : pluralForm}`
|
||||||
|
}
|
||||||
|
export function toolResultSummary(t: ToolCallResult): string {
|
||||||
|
if (t.type === 'tool_use') return '' // still running
|
||||||
|
if (t.error) return '' // error surfaced separately
|
||||||
|
const args = t.args ?? {}
|
||||||
|
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||||||
|
const obj = (r: unknown): Record<string, unknown> | null =>
|
||||||
|
r && typeof r === 'object' && !Array.isArray(r) ? (r as Record<string, unknown>) : null
|
||||||
|
switch (t.name) {
|
||||||
|
case 'run': {
|
||||||
|
const s = resultAsString(t.result)
|
||||||
|
const m = s.match(/exit (?:status )?(\d+)/i)
|
||||||
|
const tag = m ? `exit ${m[1]}` : /error/i.test(s) ? 'error' : 'ok'
|
||||||
|
const rest = firstLine(s.replace(/[\s\S]*exit (?:status )?\d+/i, ''), 60)
|
||||||
|
return rest ? `${tag} · ${rest}` : tag
|
||||||
|
}
|
||||||
|
case 'get_entity': {
|
||||||
|
const o = obj(t.result)
|
||||||
|
const slug = str(o?.slug) || str(args.slug_or_id)
|
||||||
|
const health = str(o?.health) || str(o?.state)
|
||||||
|
return [slug, health && `(${health})`].filter(Boolean).join(' ') || 'found'
|
||||||
|
}
|
||||||
|
case 'get_relations': {
|
||||||
|
const a = resultArray(t.result)
|
||||||
|
return a ? plural(a.length, 'relation') : 'done'
|
||||||
|
}
|
||||||
|
case 'list_entities':
|
||||||
|
case 'list_lxcs': {
|
||||||
|
const a = resultArray(t.result)
|
||||||
|
if (!a) return 'done'
|
||||||
|
return t.name === 'list_lxcs'
|
||||||
|
? plural(a.length, 'container')
|
||||||
|
: plural(a.length, 'entity', 'entities')
|
||||||
|
}
|
||||||
|
case 'get_health_summary': {
|
||||||
|
const o = obj(t.result)
|
||||||
|
const h = (o?.health && obj(o.health)) || o
|
||||||
|
if (h) {
|
||||||
|
const parts = ['healthy', 'degraded', 'down', 'unknown']
|
||||||
|
.map((k) => {
|
||||||
|
const n = num((h as Record<string, unknown>)[k])
|
||||||
|
return n != null ? `${k} ${n}` : null
|
||||||
|
})
|
||||||
|
.filter((p): p is string => p != null)
|
||||||
|
if (parts.length) return parts.join(' · ')
|
||||||
|
}
|
||||||
|
return 'done'
|
||||||
|
}
|
||||||
|
case 'get_state_snapshot': {
|
||||||
|
const o = obj(t.result)
|
||||||
|
const drift = num(o?.drift ?? o?.drift_count)
|
||||||
|
return drift != null ? plural(drift, 'drift') : 'done'
|
||||||
|
}
|
||||||
|
case 'search_knowledge':
|
||||||
|
case 'get_entity_knowledge':
|
||||||
|
case 'get_patterns':
|
||||||
|
case 'get_skills': {
|
||||||
|
const a = resultArray(t.result)
|
||||||
|
return a ? plural(a.length, 'result') : firstLine(resultAsString(t.result)) || 'done'
|
||||||
|
}
|
||||||
|
case 'upsert_knowledge': {
|
||||||
|
const m = resultAsString(t.result).match(/[a-z]+:nomos\/[a-z0-9-]+/)
|
||||||
|
return m ? `recorded ${m[0]}` : 'recorded'
|
||||||
|
}
|
||||||
|
case 'update_plan_step': {
|
||||||
|
const seq = num(args.seq)
|
||||||
|
const status = str(args.status)
|
||||||
|
if (seq != null && status) return `step ${seq} → ${status}`
|
||||||
|
return status || 'updated'
|
||||||
|
}
|
||||||
|
case 'propose_plan': {
|
||||||
|
const a = resultArray(t.result) ?? resultArray(args.steps)
|
||||||
|
return a ? plural(a.length, 'step') : 'planned'
|
||||||
|
}
|
||||||
|
case 'set_goal':
|
||||||
|
return 'goal set'
|
||||||
|
case 'complete_task':
|
||||||
|
return str(args.outcome) || 'complete'
|
||||||
|
case 'ask_operator':
|
||||||
|
return 'asked'
|
||||||
|
case 'ping_service': {
|
||||||
|
const s = resultAsString(t.result).toLowerCase()
|
||||||
|
return /ok|reachable|up|healthy/.test(s) ? 'reachable' : firstLine(s, 40) || 'done'
|
||||||
|
}
|
||||||
|
case 'get_execution_status': {
|
||||||
|
const o = obj(t.result)
|
||||||
|
return str(o?.state) || firstLine(resultAsString(t.result), 40) || 'done'
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return firstLine(resultAsString(t.result)) || 'done'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface ChatMessage {
|
|||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
text: string
|
text: string
|
||||||
|
thinking?: string
|
||||||
tools: ToolCallResult[]
|
tools: ToolCallResult[]
|
||||||
pendingApprovals: PendingApproval[]
|
pendingApprovals: PendingApproval[]
|
||||||
created_at?: string
|
created_at?: string
|
||||||
@@ -223,6 +224,7 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
role: m.role as 'user' | 'assistant',
|
role: m.role as 'user' | 'assistant',
|
||||||
text: content?.text ?? '',
|
text: content?.text ?? '',
|
||||||
|
thinking: content?.thinking ?? undefined,
|
||||||
tools,
|
tools,
|
||||||
pendingApprovals: extractApprovals(tools),
|
pendingApprovals: extractApprovals(tools),
|
||||||
created_at: m.created_at
|
created_at: m.created_at
|
||||||
@@ -230,6 +232,16 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chatMessagesChanged(a: ChatMessage[], b: ChatMessage[]): boolean {
|
||||||
|
if (a.length !== b.length) return true
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i].id !== b[i].id || a[i].role !== b[i].role || a[i].text !== b[i].text || a[i].tools.length !== b[i].tools.length) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadSessionMessages(sessionId: string) {
|
export async function loadSessionMessages(sessionId: string) {
|
||||||
currentSession.set(sessionId)
|
currentSession.set(sessionId)
|
||||||
// This is a fresh view of sessionId's current (REST-loaded) state — reset
|
// This is a fresh view of sessionId's current (REST-loaded) state — reset
|
||||||
@@ -270,15 +282,11 @@ function startPolling(sessionId: string) {
|
|||||||
if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return
|
if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return
|
||||||
const msgs = await fetchMessages(sessionId)
|
const msgs = await fetchMessages(sessionId)
|
||||||
if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time
|
if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time
|
||||||
// No cheap "anything new?" check: the auto-continuation worker updates a
|
const incoming = toChatMessages(msgs)
|
||||||
// placeholder message IN PLACE as each tool call lands (see
|
if (chatMessagesChanged(get(messages), incoming)) {
|
||||||
// cmd/nomos/continue.go), so the message COUNT stays the same while the
|
sessionMessages.set(msgs)
|
||||||
// content changes — a length-only diff (the previous version of this
|
messages.set(incoming)
|
||||||
// code) never detected those updates and progress looked frozen even
|
}
|
||||||
// though the backend was actively working. Just re-set every tick;
|
|
||||||
// Svelte's own diffing keeps the actual re-render cheap.
|
|
||||||
sessionMessages.set(msgs)
|
|
||||||
messages.set(toChatMessages(msgs))
|
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +409,15 @@ export function sendMessage(text: string) {
|
|||||||
messages.update((ms) => {
|
messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
const last = ms[ms.length - 1]
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
if (ev.is_thinking) {
|
||||||
|
ms[ms.length - 1] = {
|
||||||
|
...last,
|
||||||
|
thinking: (last.thinking || '') + ev.data,
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [...ms]
|
return [...ms]
|
||||||
})
|
})
|
||||||
@@ -596,7 +612,10 @@ function startSessionPolling(sessionId: string) {
|
|||||||
if (get(chat.streaming) && get(chat.connectionState) === 'connected') return
|
if (get(chat.streaming) && get(chat.connectionState) === 'connected') return
|
||||||
const msgs = await fetchMessages(sessionId)
|
const msgs = await fetchMessages(sessionId)
|
||||||
if (get(chat.streaming)) return // re-check: the fetch itself takes time
|
if (get(chat.streaming)) return // re-check: the fetch itself takes time
|
||||||
chat.messages.set(toChatMessages(msgs))
|
const incoming = toChatMessages(msgs)
|
||||||
|
if (chatMessagesChanged(get(chat.messages), incoming)) {
|
||||||
|
chat.messages.set(incoming)
|
||||||
|
}
|
||||||
// F3 safety net: if we're recovering from a dropped SSE but the
|
// F3 safety net: if we're recovering from a dropped SSE but the
|
||||||
// session's task has already reached a turn-ended status, clear the
|
// session's task has already reached a turn-ended status, clear the
|
||||||
// stuck disconnected/streaming flags. Catches the edge where the
|
// stuck disconnected/streaming flags. Catches the edge where the
|
||||||
@@ -728,7 +747,15 @@ export function sendSessionMessage(sessionId: string, text: string) {
|
|||||||
chat.messages.update((ms) => {
|
chat.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
const last = ms[ms.length - 1]
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
if (ev.is_thinking) {
|
||||||
|
ms[ms.length - 1] = {
|
||||||
|
...last,
|
||||||
|
thinking: (last.thinking || '') + ev.data,
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [...ms]
|
return [...ms]
|
||||||
})
|
})
|
||||||
@@ -869,7 +896,15 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
|
|||||||
c.messages.update((ms) => {
|
c.messages.update((ms) => {
|
||||||
const last = ms[ms.length - 1]
|
const last = ms[ms.length - 1]
|
||||||
if (last && last.role === 'assistant') {
|
if (last && last.role === 'assistant') {
|
||||||
ms[ms.length - 1] = { ...last, text: ev.data }
|
if (ev.is_thinking) {
|
||||||
|
ms[ms.length - 1] = {
|
||||||
|
...last,
|
||||||
|
thinking: (last.thinking || '') + ev.data,
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ms[ms.length - 1] = { ...last, text: ev.data }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return [...ms]
|
return [...ms]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ export const dk = createDesktop(wm, {
|
|||||||
})
|
})
|
||||||
export const wmState = wmStore(wm)
|
export const wmState = wmStore(wm)
|
||||||
|
|
||||||
|
// Stable insertion-order window IDs — unlike $wmState.order (which reorders on
|
||||||
|
// focus/raise), this only changes when a window is opened or closed. Used by
|
||||||
|
// WindowLayer's {#each} so the DOM order stays stable; wmkit handles visual
|
||||||
|
// stacking via z-index in syncAll(). Without this, every focus change moves
|
||||||
|
// <section> elements in the DOM, which resets scroll positions of scrollable
|
||||||
|
// children in Chrome.
|
||||||
|
let _lastKeys: string[] = []
|
||||||
|
export const windowKeys = derived(wmState, ($s) => {
|
||||||
|
const keys = Object.keys($s.windows)
|
||||||
|
if (keys.length === _lastKeys.length && keys.every((k, i) => k === _lastKeys[i])) {
|
||||||
|
return _lastKeys
|
||||||
|
}
|
||||||
|
_lastKeys = keys
|
||||||
|
return keys
|
||||||
|
})
|
||||||
|
|
||||||
// The session id backing whichever task/chat window currently has focus, or
|
// The session id backing whichever task/chat window currently has focus, or
|
||||||
// null when no task window is focused (Tasks app, an entity window, or
|
// null when no task window is focused (Tasks app, an entity window, or
|
||||||
// nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so
|
// nothing at all). The desktop mascot's stimuli (stimuli.ts) key off this so
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface ChatTextDeltaEvent {
|
|||||||
export interface ChatTextEvent {
|
export interface ChatTextEvent {
|
||||||
type: 'text'
|
type: 'text'
|
||||||
data: string
|
data: string
|
||||||
|
is_thinking?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatDoneEvent {
|
export interface ChatDoneEvent {
|
||||||
@@ -71,12 +72,18 @@ export interface ToolCallResult {
|
|||||||
// running, so the tool card can show output as it arrives instead of all at
|
// running, so the tool card can show output as it arrives instead of all at
|
||||||
// once when the tool_result lands. Not present on persisted/historical calls.
|
// once when the tool_result lands. Not present on persisted/historical calls.
|
||||||
liveOutput?: string
|
liveOutput?: string
|
||||||
|
// Plan step this call belongs to (current generation only). Attached by
|
||||||
|
// ChatThread from the activity log so the inline trace can group a turn's
|
||||||
|
// tool calls under their step. Undefined for orphan calls (no plan / older
|
||||||
|
// generation / plan-less Q&A).
|
||||||
|
stepSeq?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Message content (persisted messages from /agent/sessions/:id) ----
|
// ---- Message content (persisted messages from /agent/sessions/:id) ----
|
||||||
|
|
||||||
export interface MessageContent {
|
export interface MessageContent {
|
||||||
text?: string
|
text?: string
|
||||||
|
thinking?: string
|
||||||
tool_calls?: ToolCallResult[]
|
tool_calls?: ToolCallResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user