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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 22 additions & 14 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,21 +560,18 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
})
}

func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
eng := s.activeEngine()
if eng == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"})
return
}
// statusSnapshot assembles the per-engine status payload (running state,
// provider/model/mode, live token snapshot) shared by GET /api/status and the
// one-shot resume reply (POST /api/sessions), so the two can never drift.
func (s *Server) statusSnapshot(eng *Engine) map[string]any {
full := eng.tokenUsage.GetFull()
provider, mdl, modeStr := eng.modelSnapshot()
writeJSON(w, http.StatusOK, map[string]any{
"running": eng.running.Load(),
"ws_clients": s.wsBroker.ClientCount(),
"pwd": eng.pwd,
"provider": provider,
"model": mdl,
"mode": modeStr,
return map[string]any{
"running": eng.running.Load(),
"pwd": eng.pwd,
"provider": provider,
"model": mdl,
"mode": modeStr,
// Live token snapshot so a client reconnecting between turns can render
// the context bar + cache hit rate without waiting for the next
// token_update WS event. total_tokens = current context occupancy.
Expand All @@ -590,7 +587,18 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
"cache_supported": eng.tokenUsage.CacheObserved(),
"model_context_limit": s.currentModelContextLimit(eng),
},
})
}
}

func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
eng := s.activeEngine()
if eng == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "no active task"})
return
}
payload := s.statusSnapshot(eng)
payload["ws_clients"] = s.wsBroker.ClientCount()
writeJSON(w, http.StatusOK, payload)
}

// currentModelContextLimit resolves the context window of the given engine's
Expand Down
46 changes: 43 additions & 3 deletions internal/web/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,34 @@ func (s *Server) handleTruncateHistory(w http.ResponseWriter, r *http.Request) {
})
}

// writeResumeReply answers a resume request (POST /api/sessions with a
// session_id) with everything the client needs to repaint the conversation in
// ONE round trip: the raw JSONL entries it replays into its timeline, plus the
// server-truth state it would otherwise fetch with four more serial requests
// (status / goal / todos). entries is omitted when the session file could not
// be read; the client then falls back to GET /api/sessions/{id}. The server
// already loads the entries to reconstruct the engine state, so this reuses
// that read instead of doing a second one for the client.
func (s *Server) writeResumeReply(w http.ResponseWriter, eng *Engine, entries []session.Entry) {
resp := s.statusSnapshot(eng)
resp["status"] = "ok"
resp["session_id"] = eng.taskID
if entries != nil {
resp["entries"] = entries
}
if eng.env != nil && eng.env.GoalStore != nil {
resp["goal"] = eng.env.GoalStore.Get()
} else {
resp["goal"] = nil
}
if eng.todoStore != nil {
resp["todos"] = eng.todoStore.Items()
} else {
resp["todos"] = []any{}
}
writeJSON(w, http.StatusOK, resp)
}

func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) {
// Parse optional resume session ID + project. Creating a task no longer
// blocks on "is the agent running" — tasks run concurrently.
Expand All @@ -364,7 +392,13 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) {
if req.SessionID != "" {
if eng := s.resolveEngine(req.SessionID); eng != nil {
s.setActiveEngine(eng)
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID})
// Best-effort: a read failure only drops the embedded entries —
// the client falls back to GET /api/sessions/{id}.
entries, err := session.LoadSession(req.SessionID)
if err != nil {
config.Logger().Printf("[web] resume: embedded entries unavailable for %s (client falls back to GET): %v", req.SessionID, err)
}
s.writeResumeReply(w, eng, entries)
return
}
}
Expand All @@ -389,8 +423,10 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) {
}

// Resume: hydrate the fresh engine with the persisted conversation/todos/goal.
var entries []session.Entry
if req.SessionID != "" {
entries, lerr := session.LoadSession(req.SessionID)
var lerr error
entries, lerr = session.LoadSession(req.SessionID)
if lerr != nil {
// Stale/nonexistent session id: don't silently register a phantom empty
// engine under it — tear the just-built engine down and report not-found.
Expand Down Expand Up @@ -422,7 +458,11 @@ func (s *Server) handleNewSession(w http.ResponseWriter, r *http.Request) {
// Brand-new task: tell its view to start clean.
if req.SessionID == "" {
s.wsBroker.Broadcast(WSEvent{TaskID: eng.taskID, Type: "session_reset", Data: map[string]string{}})
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID})
return
}

writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "session_id": eng.taskID})
// Resume: one-shot reply (entries + goal + todos + status) so the client
// repaints without follow-up round trips.
s.writeResumeReply(w, eng, entries)
}
221 changes: 126 additions & 95 deletions web/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ function isNewerTs(candidate: string, current: string | undefined): boolean {
interface ChatState {
timeline: ThreadItem[]
isRunning: boolean
/** A session resume (replay) is in flight — the chat pane shows a skeleton
* so the switch feels instant instead of blank-until-ready. */
sessionLoading: boolean
tokenSnapshot: TokenSnapshot | null
goal: Goal | null
goalArmed: boolean
Expand All @@ -60,6 +63,7 @@ interface ChatState {
const initialChat: ChatState = {
timeline: [],
isRunning: false,
sessionLoading: false,
tokenSnapshot: null,
goal: null,
goalArmed: false,
Expand Down Expand Up @@ -91,6 +95,9 @@ const chatSlice = createSlice({
setRunning(s, a: { payload: boolean }) {
s.isRunning = a.payload
},
setSessionLoading(s, a: { payload: boolean }) {
s.sessionLoading = a.payload
},
addMessage(
s,
a: { payload: { role: Message['role']; content: string; source?: string; images?: Message['images']; level?: Message['level']; detail?: string; durationMs?: number } },
Expand Down Expand Up @@ -1045,117 +1052,141 @@ export const loadWorkspaceState = createAsyncThunk('app/loadWorkspaceState', asy
})

/**
* Load (replay) a session's history into the timeline. Ported from the Vue
* store's loadSession: fetches the JSONL entries, tells the backend to resume
* that session, clears the UI, then rebuilds the timeline by walking the entries
* (user/assistant → messages; tool_call+tool_result → resolved tool calls).
* Load (replay) a session's history into the timeline.
*
* Fast path: a SINGLE POST /api/sessions round trip both resumes the session
* server-side and returns everything the view needs to repaint — the raw
* JSONL entries (the server reuses its own reconstructing read; the file is
* not read twice) plus goal/todos/status. The pane swaps to a skeleton the
* instant the click lands, so perceived latency is ~0 and the old flow's
* four serial follow-up GETs (status, ask/approval pending, goal, todos)
* are gone. Legacy fallback (older server): fetch the entries via GET and
* the rest individually, in parallel, without gating the repaint.
*/
export const loadSession = createAsyncThunk(
'session/loadOne',
async (uuid: string, { dispatch, getState }) => {
// Fetch the session's history. A 404 means the session has no JSONL yet
// (fresh, never-used session) — return without rebuilding so the caller can
// fall back to a different session.
let entries: SessionEntry[]
// Immediate skeleton: the pane reacts to the click, not to the network.
dispatch(chatActions.setSessionLoading(true))
try {
entries = await api.session(uuid)
} catch {
return
}
// Tell the backend to switch to (resume) this session.
const resp = await api.newSession(uuid)
dispatch(sessionActions.setCurrentSession(resp.session_id || uuid))
const resp = await api.newSession(uuid)
dispatch(sessionActions.setCurrentSession(resp.session_id || uuid))

// Clear the UI before rebuilding.
dispatch(chatActions.clearChat())

// Rebuild the timeline from entries.
const timeline: ThreadItem[] = []
const pendingToolCalls = new Map<string, ToolCall>()
for (const e of entries) {
if (e.type === 'user' && (e.content || (e.images && e.images.length > 0))) {
timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('msg'), role: 'user', content: e.content || '', timestamp: ts(e.timestamp), images: e.images } })
} else if (e.type === 'assistant' && e.content) {
timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('asst'), role: 'assistant', content: e.content, timestamp: ts(e.timestamp) } })
} else if (e.type === 'tool_call' && e.name) {
const tc: ToolCall = {
id: genId('tc'),
toolCallID: e.tool_call_id,
name: e.name,
args: e.args || '',
status: 'running',
timestamp: ts(e.timestamp),
displayInfo: extractToolDisplayInfo(e.name, e.args || ''),
batchId: e.batch_id,
batchIndex: e.batch_index,
batchSize: e.batch_size,
startedAt: e.started_at,
// One-shot resume payload (entries + goal + todos + status). `provider`
// discriminates an older server without the one-shot payload at all;
// `entries` is omitted when the server could not read the session file
// — fall back to the dedicated endpoint then (a transient read failure
// must not blank a conversation that has history).
const oneShot = resp.provider !== undefined
let entries: SessionEntry[] | null | undefined = resp.entries
if (entries === undefined) {
// Older server (no one-shot payload) OR current server with an
// unreadable session file. A 404 means the session has no JSONL yet
// (fresh, never-used session) — return without rebuilding so the
// caller can fall back to a different session.
try {
entries = await api.session(uuid)
} catch {
return
}
timeline.push({ kind: 'tool', seq: nextSeq(), data: tc })
if (e.tool_call_id) pendingToolCalls.set(e.tool_call_id, tc)
} else if (e.type === 'tool_result') {
let resolved = false
if (e.tool_call_id) {
const tc = pendingToolCalls.get(e.tool_call_id)
if (tc) {
tc.output = e.output || ''
tc.error = e.error || ''
tc.status = e.error ? 'error' : 'done'
tc.denied = e.denied || undefined
if (e.duration_ms !== undefined && tc.meta?.duration_ms === undefined) {
tc.meta = { ...(tc.meta || {}), duration_ms: e.duration_ms }
}

// Clear the UI before rebuilding.
dispatch(chatActions.clearChat())

// Rebuild the timeline from entries.
const timeline: ThreadItem[] = []
const pendingToolCalls = new Map<string, ToolCall>()
for (const e of entries || []) {
if (e.type === 'user' && (e.content || (e.images && e.images.length > 0))) {
timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('msg'), role: 'user', content: e.content || '', timestamp: ts(e.timestamp), images: e.images } })
} else if (e.type === 'assistant' && e.content) {
timeline.push({ kind: 'message', seq: nextSeq(), data: { id: genId('asst'), role: 'assistant', content: e.content, timestamp: ts(e.timestamp) } })
} else if (e.type === 'tool_call' && e.name) {
const tc: ToolCall = {
id: genId('tc'),
toolCallID: e.tool_call_id,
name: e.name,
args: e.args || '',
status: 'running',
timestamp: ts(e.timestamp),
displayInfo: extractToolDisplayInfo(e.name, e.args || ''),
batchId: e.batch_id,
batchIndex: e.batch_index,
batchSize: e.batch_size,
startedAt: e.started_at,
}
timeline.push({ kind: 'tool', seq: nextSeq(), data: tc })
if (e.tool_call_id) pendingToolCalls.set(e.tool_call_id, tc)
} else if (e.type === 'tool_result') {
let resolved = false
if (e.tool_call_id) {
const tc = pendingToolCalls.get(e.tool_call_id)
if (tc) {
tc.output = e.output || ''
tc.error = e.error || ''
tc.status = e.error ? 'error' : 'done'
tc.denied = e.denied || undefined
if (e.duration_ms !== undefined && tc.meta?.duration_ms === undefined) {
tc.meta = { ...(tc.meta || {}), duration_ms: e.duration_ms }
}
pendingToolCalls.delete(e.tool_call_id)
resolved = true
}
pendingToolCalls.delete(e.tool_call_id)
resolved = true
}
}
if (!resolved && e.name) {
for (let i = timeline.length - 1; i >= 0; i--) {
const item = timeline[i]
if (item.kind === 'tool' && item.data.name === e.name && item.data.status === 'running') {
item.data.output = e.output || ''
item.data.error = e.error || ''
item.data.status = e.error ? 'error' : 'done'
item.data.denied = e.denied || undefined
if (e.duration_ms !== undefined && item.data.meta?.duration_ms === undefined) {
item.data.meta = { ...(item.data.meta || {}), duration_ms: e.duration_ms }
if (!resolved && e.name) {
for (let i = timeline.length - 1; i >= 0; i--) {
const item = timeline[i]
if (item.kind === 'tool' && item.data.name === e.name && item.data.status === 'running') {
item.data.output = e.output || ''
item.data.error = e.error || ''
item.data.status = e.error ? 'error' : 'done'
item.data.denied = e.denied || undefined
if (e.duration_ms !== undefined && item.data.meta?.duration_ms === undefined) {
item.data.meta = { ...(item.data.meta || {}), duration_ms: e.duration_ms }
}
break
}
break
}
}
}
}
}
// Any tool calls that never got a result (interrupted session) → done.
for (const tc of pendingToolCalls.values()) tc.status = 'done'
// Any tool calls that never got a result (interrupted session) → done.
for (const tc of pendingToolCalls.values()) tc.status = 'done'

dispatch(chatActions.setTimeline(timeline))

// Seed isRunning from the task list (a resumed task may still be running).
const state = getState() as RootState
const resumedId = resp.session_id || uuid
const running = !!state.session.tasks.find((t) => t.uuid === resumedId)?.running
dispatch(chatActions.setRunning(running))
dispatch(chatActions.setTimeline(timeline))

// Rehydrate server-truth state for the resumed session (token snapshot,
// provider/model, mode). clearChat nulled tokenSnapshot, and no
// token_update arrives until the session's next LLM call — without this
// the context ring stays hidden after switching conversations.
await dispatch(loadStatus())
await dispatch(reconcilePendingInteractions())

// Refresh goal + todos (the backend restored them; no WS push on switch).
try {
const goal = await api.goal()
dispatch(chatActions.setGoal(goal))
} catch {
// ignore
}
try {
const todos = await api.todos()
dispatch(chatActions.setTodos(todos))
} catch {
// ignore
if (oneShot) {
// Hydrate server-truth state from the SAME response — the old flow
// spent four extra serial round trips here (status, ask/approval
// pending, goal, todos). clearChat nulled tokenSnapshot, and no
// token_update arrives until the session's next LLM call — without
// this the context ring stays hidden after switching conversations.
dispatch(chatActions.setRunning(!!resp.running))
if (resp.pwd) dispatch(sessionActions.setProjectPath(resp.pwd))
dispatch(modelActions.setProvider(resp.provider || ''))
dispatch(modelActions.setModel(resp.model || ''))
dispatch(modelActions.setMode(normalizeMode(resp.mode || '')))
if (resp.token) dispatch(chatActions.setTokenSnapshot(resp.token))
dispatch(chatActions.setGoal(resp.goal ?? null))
dispatch(chatActions.setTodos(resp.todos ?? []))
} else {
// Older server: seed isRunning from the task list (a resumed task may
// still be running), then fetch the rest individually — in parallel,
// and none of it gates the timeline repaint.
const state = getState() as RootState
const resumedId = resp.session_id || uuid
const running = !!state.session.tasks.find((t) => t.uuid === resumedId)?.running
dispatch(chatActions.setRunning(running))
void dispatch(loadStatus())
void dispatch(loadGoal())
void dispatch(loadTodos())
}
// Pending approval/ask interactions only add interactive blocks — they
// never gate the repaint, so don't await them.
void dispatch(reconcilePendingInteractions())
} finally {
dispatch(chatActions.setSessionLoading(false))
}
},
)
Expand Down
Loading
Loading