| author | |
| committer | |
| log | de5df2690de58406a480071eb2c7ba7e2fd4e3bd |
| tree | f5c044df92808fd2001ea381dcc4f0b686967ab3 |
| parent | 1bd823cd209fc67ef0bf6f31185e267fbdf8099a |
| signature | Commit is signed but in an unrecognized format. |
- remove model picker when creating projects and default to the project model
- add Cmd+Shift+O shortcut to open a new thread in the active project
- remove project/status pills from chat chrome for a cleaner layout
- only surface actionable item lifecycle events (tool/command/file change)
- add tests covering reasoning and agent-message filtering6 files changed, 235 insertions(+), 136 deletions(-)
TODO.md+4-7| ... | @@ -1,9 +1,6 @@ | ... | @@ -1,9 +1,6 @@ |
| 1 | ## TODO | 1 | ## TODO |
| 2 | 2 | ||
| 3 | - [x] Fix sort order for threads (newest first) | 3 | - [x] Remove model picker from new project menu |
| 4 | - [x] Indicate in thread list which threads are currently working | 4 | - [x] CMD+Shift+O to create new thread in current project |
| 5 | - [x] Logs should be per-thread, not a single events.txt | 5 | - [x] Remove project title pill from input box and topnav |
| 6 | - [x] Audit the logs to identify which events are (and aren't) worth showing to the user, i.e. drop "preamble" and "work" events | 6 | - [x] Remove connection status from topnav |
| 7 | - [x] Make the "end message" stand out from the work done prior (maybe just a bar between the two that says "worked for 10 minutes 31 seconds" or however long it was) | ||
| 8 | - [x] Better logging of "time to complete" (I want this more than the timestamps tbh) | ||
| 9 | - [x] Remove the left bars on messages to make UI feel "flatter" |
apps/renderer/src/components/ChatView.tsx+6-56| ... | @@ -32,13 +32,6 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { | ... | @@ -32,13 +32,6 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 32 | return `${formatTimestamp(createdAt)} • ${duration}`; | 32 | return `${formatTimestamp(createdAt)} • ${duration}`; |
| 33 | } | 33 | } |
| 34 | 34 | ||
| 35 | function statusLabel(phase: string): string { | ||
| 36 | if (phase === "running") return "Thinking / working"; | ||
| 37 | if (phase === "connecting") return "Connecting"; | ||
| 38 | if (phase === "ready") return "Ready"; | ||
| 39 | return "Disconnected"; | ||
| 40 | } | ||
| 41 | |||
| 42 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 35 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 43 | if (tone === "error") return "text-rose-300/50"; | 36 | if (tone === "error") return "text-rose-300/50"; |
| 44 | if (tone === "tool") return "text-[#8a8a8a]"; | 37 | if (tone === "tool") return "text-[#8a8a8a]"; |
| ... | @@ -69,13 +62,11 @@ export default function ChatView() { | ... | @@ -69,13 +62,11 @@ export default function ChatView() { |
| 69 | ); | 62 | ); |
| 70 | const phase = derivePhase(activeThread?.session ?? null); | 63 | const phase = derivePhase(activeThread?.session ?? null); |
| 71 | const isWorking = phase === "running" || isSending || isConnecting; | 64 | const isWorking = phase === "running" || isSending || isConnecting; |
| 72 | const workLogTurnId = | ||
| 73 | activeThread?.latestTurnId ?? activeThread?.session?.activeTurnId; | ||
| 74 | const nowIso = new Date(nowTick).toISOString(); | 65 | const nowIso = new Date(nowTick).toISOString(); |
| 75 | const modelOptions = MODEL_OPTIONS; | 66 | const modelOptions = MODEL_OPTIONS; |
| 76 | const workLogEntries = useMemo( | 67 | const workLogEntries = useMemo( |
| 77 | () => deriveWorkLogEntries(activeThread?.events ?? [], workLogTurnId), | 68 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), |
| 78 | [activeThread?.events, workLogTurnId], | 69 | [activeThread?.events], |
| 79 | ); | 70 | ); |
| 80 | const assistantCompletionByItemId = useMemo(() => { | 71 | const assistantCompletionByItemId = useMemo(() => { |
| 81 | const map = new Map<string, string>(); | 72 | const map = new Map<string, string>(); |
| ... | @@ -320,47 +311,8 @@ export default function ChatView() { | ... | @@ -320,47 +311,8 @@ export default function ChatView() { |
| 320 | <h2 className="text-sm font-medium text-[#e0e0e0]"> | 311 | <h2 className="text-sm font-medium text-[#e0e0e0]"> |
| 321 | {activeThread.title} | 312 | {activeThread.title} |
| 322 | </h2> | 313 | </h2> |
| 323 | {activeProject && ( | ||
| 324 | <span className="rounded-full bg-white/[0.06] px-2 py-0.5 text-[10px] text-[#a0a0a0]/50"> | ||
| 325 | {activeProject.name} | ||
| 326 | </span> | ||
| 327 | )} | ||
| 328 | </div> | 314 | </div> |
| 329 | <div className="flex items-center gap-3"> | 315 | <div className="flex items-center gap-3"> |
| 330 | {/* Status indicator */} | ||
| 331 | <div | ||
| 332 | className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] ${ | ||
| 333 | phase === "running" | ||
| 334 | ? "border-sky-400/35 bg-sky-500/[0.08] text-sky-100" | ||
| 335 | : phase === "connecting" | ||
| 336 | ? "border-amber-400/35 bg-amber-500/[0.08] text-amber-100" | ||
| 337 | : phase === "ready" | ||
| 338 | ? "border-emerald-400/35 bg-emerald-500/[0.08] text-emerald-100" | ||
| 339 | : "border-white/[0.08] bg-white/[0.04] text-[#a0a0a0]/70" | ||
| 340 | }`} | ||
| 341 | > | ||
| 342 | <span className="relative inline-flex h-2.5 w-2.5"> | ||
| 343 | {(phase === "running" || phase === "connecting") && ( | ||
| 344 | <span | ||
| 345 | className={`absolute inline-flex h-full w-full animate-ping rounded-full ${ | ||
| 346 | phase === "running" ? "bg-sky-300/70" : "bg-amber-300/70" | ||
| 347 | }`} | ||
| 348 | /> | ||
| 349 | )} | ||
| 350 | <span | ||
| 351 | className={`relative inline-flex h-2.5 w-2.5 rounded-full ${ | ||
| 352 | phase === "running" | ||
| 353 | ? "bg-sky-200" | ||
| 354 | : phase === "connecting" | ||
| 355 | ? "bg-amber-200" | ||
| 356 | : phase === "ready" | ||
| 357 | ? "bg-emerald-200" | ||
| 358 | : "bg-[#a0a0a0]/40" | ||
| 359 | }`} | ||
| 360 | /> | ||
| 361 | </span> | ||
| 362 | <span>{statusLabel(phase)}</span> | ||
| 363 | </div> | ||
| 364 | {/* Diff toggle */} | 316 | {/* Diff toggle */} |
| 365 | <button | 317 | <button |
| 366 | type="button" | 318 | type="button" |
| ... | @@ -418,7 +370,10 @@ export default function ChatView() { | ... | @@ -418,7 +370,10 @@ export default function ChatView() { |
| 418 | {timelineEntry.entry.detail ? ( | 370 | {timelineEntry.entry.detail ? ( |
| 419 | <> | 371 | <> |
| 420 | {timelineEntry.entry.label} | 372 | {timelineEntry.entry.label} |
| 421 | <span className="ml-1.5 font-mono text-[11px] opacity-60"> | 373 | <span |
| 374 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | ||
| 375 | title={timelineEntry.entry.detail} | ||
| 376 | > | ||
| 422 | {timelineEntry.entry.detail} | 377 | {timelineEntry.entry.detail} |
| 423 | </span> | 378 | </span> |
| 424 | </> | 379 | </> |
| ... | @@ -634,11 +589,6 @@ export default function ChatView() { | ... | @@ -634,11 +589,6 @@ export default function ChatView() { |
| 634 | 589 | ||
| 635 | {/* Right side: send / stop button */} | 590 | {/* Right side: send / stop button */} |
| 636 | <div className="flex items-center gap-2"> | 591 | <div className="flex items-center gap-2"> |
| 637 | {activeProject && ( | ||
| 638 | <span className="text-[11px] text-[#a0a0a0]/25"> | ||
| 639 | {activeProject.name} | ||
| 640 | </span> | ||
| 641 | )} | ||
| 642 | {phase === "running" ? ( | 592 | {phase === "running" ? ( |
| 643 | <button | 593 | <button |
| 644 | type="button" | 594 | type="button" |
apps/renderer/src/components/Sidebar.tsx+63-39| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { useMemo, useState } from "react"; | 1 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 2 | import { DEFAULT_MODEL, MODEL_OPTIONS, resolveModelSlug } from "../model-logic"; | 2 | import { DEFAULT_MODEL } from "../model-logic"; |
| 3 | import { readNativeApi } from "../session-logic"; | 3 | import { readNativeApi } from "../session-logic"; |
| 4 | import { useStore } from "../store"; | 4 | import { useStore } from "../store"; |
| 5 | import type { Project } from "../types"; | 5 | import type { Project } from "../types"; |
| ... | @@ -27,45 +27,45 @@ export default function Sidebar() { | ... | @@ -27,45 +27,45 @@ export default function Sidebar() { |
| 27 | const api = useMemo(() => readNativeApi(), []); | 27 | const api = useMemo(() => readNativeApi(), []); |
| 28 | const [addingProject, setAddingProject] = useState(false); | 28 | const [addingProject, setAddingProject] = useState(false); |
| 29 | const [newCwd, setNewCwd] = useState(""); | 29 | const [newCwd, setNewCwd] = useState(""); |
| 30 | const [newModel, setNewModel] = useState(DEFAULT_MODEL); | ||
| 31 | const [isPickingFolder, setIsPickingFolder] = useState(false); | 30 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| 32 | 31 | ||
| 33 | const handleAddProject = () => { | 32 | const handleAddProject = () => { |
| 34 | const cwd = newCwd.trim(); | 33 | const cwd = newCwd.trim(); |
| 35 | if (!cwd) return; | 34 | if (!cwd) return; |
| 36 | const name = cwd.split("/").filter(Boolean).pop() ?? "project"; | 35 | const name = cwd.split("/").filter(Boolean).pop() ?? "project"; |
| 37 | const normalizedModel = resolveModelSlug(newModel); | ||
| 38 | const project: Project = { | 36 | const project: Project = { |
| 39 | id: crypto.randomUUID(), | 37 | id: crypto.randomUUID(), |
| 40 | name, | 38 | name, |
| 41 | cwd, | 39 | cwd, |
| 42 | model: normalizedModel, | 40 | model: DEFAULT_MODEL, |
| 43 | expanded: true, | 41 | expanded: true, |
| 44 | }; | 42 | }; |
| 45 | dispatch({ type: "ADD_PROJECT", project }); | 43 | dispatch({ type: "ADD_PROJECT", project }); |
| 46 | setNewCwd(""); | 44 | setNewCwd(""); |
| 47 | setNewModel(DEFAULT_MODEL); | ||
| 48 | setAddingProject(false); | 45 | setAddingProject(false); |
| 49 | }; | 46 | }; |
| 50 | 47 | ||
| 51 | const handleNewThread = (projectId: string) => { | 48 | const handleNewThread = useCallback( |
| 52 | dispatch({ | 49 | (projectId: string) => { |
| 53 | type: "ADD_THREAD", | 50 | dispatch({ |
| 54 | thread: { | 51 | type: "ADD_THREAD", |
| 55 | id: crypto.randomUUID(), | 52 | thread: { |
| 56 | projectId, | 53 | id: crypto.randomUUID(), |
| 57 | title: "New thread", | 54 | projectId, |
| 58 | model: | 55 | title: "New thread", |
| 59 | state.projects.find((p) => p.id === projectId)?.model ?? | 56 | model: |
| 60 | DEFAULT_MODEL, | 57 | state.projects.find((p) => p.id === projectId)?.model ?? |
| 61 | session: null, | 58 | DEFAULT_MODEL, |
| 62 | messages: [], | 59 | session: null, |
| 63 | events: [], | 60 | messages: [], |
| 64 | error: null, | 61 | events: [], |
| 65 | createdAt: new Date().toISOString(), | 62 | error: null, |
| 66 | }, | 63 | createdAt: new Date().toISOString(), |
| 67 | }); | 64 | }, |
| 68 | }; | 65 | }); |
| 66 | }, | ||
| 67 | [dispatch, state.projects], | ||
| 68 | ); | ||
| 69 | 69 | ||
| 70 | const handlePickFolder = async () => { | 70 | const handlePickFolder = async () => { |
| 71 | if (!api || isPickingFolder) return; | 71 | if (!api || isPickingFolder) return; |
| ... | @@ -79,6 +79,45 @@ export default function Sidebar() { | ... | @@ -79,6 +79,45 @@ export default function Sidebar() { |
| 79 | } | 79 | } |
| 80 | }; | 80 | }; |
| 81 | 81 | ||
| 82 | useEffect(() => { | ||
| 83 | const onWindowKeyDown = (event: KeyboardEvent) => { | ||
| 84 | const isNewThreadShortcut = | ||
| 85 | event.metaKey && | ||
| 86 | event.shiftKey && | ||
| 87 | !event.ctrlKey && | ||
| 88 | !event.altKey && | ||
| 89 | event.key.toLowerCase() === "o"; | ||
| 90 | if (!isNewThreadShortcut) return; | ||
| 91 | |||
| 92 | const target = event.target; | ||
| 93 | if (target instanceof HTMLElement) { | ||
| 94 | const tagName = target.tagName; | ||
| 95 | if ( | ||
| 96 | target.isContentEditable || | ||
| 97 | tagName === "INPUT" || | ||
| 98 | tagName === "TEXTAREA" || | ||
| 99 | tagName === "SELECT" | ||
| 100 | ) { | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | const activeThread = state.threads.find( | ||
| 106 | (t) => t.id === state.activeThreadId, | ||
| 107 | ); | ||
| 108 | const projectId = activeThread?.projectId ?? state.projects[0]?.id; | ||
| 109 | if (!projectId) return; | ||
| 110 | |||
| 111 | event.preventDefault(); | ||
| 112 | handleNewThread(projectId); | ||
| 113 | }; | ||
| 114 | |||
| 115 | window.addEventListener("keydown", onWindowKeyDown); | ||
| 116 | return () => { | ||
| 117 | window.removeEventListener("keydown", onWindowKeyDown); | ||
| 118 | }; | ||
| 119 | }, [handleNewThread, state.activeThreadId, state.projects, state.threads]); | ||
| 120 | |||
| 82 | return ( | 121 | return ( |
| 83 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-white/[0.08] bg-[#141414]"> | 122 | <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-white/[0.08] bg-[#141414]"> |
| 84 | {/* Drag region / traffic light space */} | 123 | {/* Drag region / traffic light space */} |
| ... | @@ -238,21 +277,6 @@ export default function Sidebar() { | ... | @@ -238,21 +277,6 @@ export default function Sidebar() { |
| 238 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} | 277 | {isPickingFolder ? "Picking folder..." : "Browse for folder"} |
| 239 | </button> | 278 | </button> |
| 240 | )} | 279 | )} |
| 241 | <select | ||
| 242 | className="mb-2 w-full rounded-md border border-white/[0.1] bg-white/[0.04] px-2 py-1.5 font-mono text-xs text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/30 focus:outline-none" | ||
| 243 | value={newModel} | ||
| 244 | onChange={(e) => setNewModel(e.target.value)} | ||
| 245 | onKeyDown={(e) => { | ||
| 246 | if (e.key === "Enter") handleAddProject(); | ||
| 247 | if (e.key === "Escape") setAddingProject(false); | ||
| 248 | }} | ||
| 249 | > | ||
| 250 | {MODEL_OPTIONS.map((model) => ( | ||
| 251 | <option key={model} value={model} className="bg-[#141414]"> | ||
| 252 | {model} | ||
| 253 | </option> | ||
| 254 | ))} | ||
| 255 | </select> | ||
| 256 | <div className="flex gap-2"> | 280 | <div className="flex gap-2"> |
| 257 | <button | 281 | <button |
| 258 | type="button" | 282 | type="button" |
apps/renderer/src/session-logic.test.ts+125| ... | @@ -161,6 +161,131 @@ describe("deriveWorkLogEntries", () => { | ... | @@ -161,6 +161,131 @@ describe("deriveWorkLogEntries", () => { |
| 161 | 161 | ||
| 162 | expect(entries[0]?.label).toBe("Turn complete in 10s"); | 162 | expect(entries[0]?.label).toBe("Turn complete in 10s"); |
| 163 | }); | 163 | }); |
| 164 | |||
| 165 | it("hides reasoning and agent-message noise from the visible work log", () => { | ||
| 166 | const entries = deriveWorkLogEntries( | ||
| 167 | [ | ||
| 168 | makeEvent({ | ||
| 169 | id: "evt-turn-start", | ||
| 170 | method: "turn/started", | ||
| 171 | turnId: "turn-1", | ||
| 172 | createdAt: "2026-02-08T10:00:00.000Z", | ||
| 173 | }), | ||
| 174 | makeEvent({ | ||
| 175 | id: "evt-summary-part", | ||
| 176 | method: "item/reasoning/summaryPartAdded", | ||
| 177 | turnId: "turn-1", | ||
| 178 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 179 | }), | ||
| 180 | makeEvent({ | ||
| 181 | id: "evt-summary-delta", | ||
| 182 | method: "item/reasoning/summaryTextDelta", | ||
| 183 | turnId: "turn-1", | ||
| 184 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 185 | }), | ||
| 186 | makeEvent({ | ||
| 187 | id: "evt-agent-start", | ||
| 188 | method: "item/started", | ||
| 189 | turnId: "turn-1", | ||
| 190 | createdAt: "2026-02-08T10:00:03.000Z", | ||
| 191 | payload: { | ||
| 192 | item: { id: "item-msg", type: "agentMessage", text: "Working..." }, | ||
| 193 | }, | ||
| 194 | }), | ||
| 195 | makeEvent({ | ||
| 196 | id: "evt-agent-complete", | ||
| 197 | method: "item/completed", | ||
| 198 | turnId: "turn-1", | ||
| 199 | createdAt: "2026-02-08T10:00:04.000Z", | ||
| 200 | payload: { | ||
| 201 | item: { | ||
| 202 | id: "item-msg", | ||
| 203 | type: "agentMessage", | ||
| 204 | text: "Done response", | ||
| 205 | }, | ||
| 206 | }, | ||
| 207 | }), | ||
| 208 | makeEvent({ | ||
| 209 | id: "evt-tool-start", | ||
| 210 | method: "item/started", | ||
| 211 | turnId: "turn-1", | ||
| 212 | createdAt: "2026-02-08T10:00:05.000Z", | ||
| 213 | payload: { | ||
| 214 | item: { id: "item-tool", type: "tool_call", command: "ls -la" }, | ||
| 215 | }, | ||
| 216 | }), | ||
| 217 | makeEvent({ | ||
| 218 | id: "evt-tool-complete", | ||
| 219 | method: "item/completed", | ||
| 220 | turnId: "turn-1", | ||
| 221 | createdAt: "2026-02-08T10:00:06.000Z", | ||
| 222 | payload: { | ||
| 223 | item: { id: "item-tool", type: "tool_call", command: "ls -la" }, | ||
| 224 | }, | ||
| 225 | }), | ||
| 226 | makeEvent({ | ||
| 227 | id: "evt-turn-complete", | ||
| 228 | method: "turn/completed", | ||
| 229 | turnId: "turn-1", | ||
| 230 | createdAt: "2026-02-08T10:00:08.000Z", | ||
| 231 | payload: { turn: { id: "turn-1", status: "completed" } }, | ||
| 232 | }), | ||
| 233 | ], | ||
| 234 | "turn-1", | ||
| 235 | ); | ||
| 236 | |||
| 237 | expect(entries.map((entry) => entry.label)).toEqual([ | ||
| 238 | "Turn complete in 8.0s", | ||
| 239 | "Tool call complete", | ||
| 240 | "Tool call", | ||
| 241 | ]); | ||
| 242 | }); | ||
| 243 | |||
| 244 | it("preserves full tool-call detail text without data truncation", () => { | ||
| 245 | const longCommand = | ||
| 246 | 'node ./scripts/sync.js --project ct-round-5 --mode dry-run --include "very long argument with lots of detail and metadata to verify there is no hard truncation in session logic"'; | ||
| 247 | const entries = deriveWorkLogEntries( | ||
| 248 | [ | ||
| 249 | makeEvent({ | ||
| 250 | id: "evt-tool-start", | ||
| 251 | method: "item/started", | ||
| 252 | turnId: "turn-1", | ||
| 253 | createdAt: "2026-02-08T10:00:05.000Z", | ||
| 254 | payload: { | ||
| 255 | item: { id: "item-tool", type: "tool_call", command: longCommand }, | ||
| 256 | }, | ||
| 257 | }), | ||
| 258 | ], | ||
| 259 | "turn-1", | ||
| 260 | ); | ||
| 261 | |||
| 262 | expect(entries[0]?.detail).toBe(longCommand); | ||
| 263 | }); | ||
| 264 | |||
| 265 | it("can derive work-log entries across all turns when not scoped", () => { | ||
| 266 | const entries = deriveWorkLogEntries( | ||
| 267 | [ | ||
| 268 | makeEvent({ | ||
| 269 | id: "evt-turn-1-tool", | ||
| 270 | method: "item/started", | ||
| 271 | turnId: "turn-1", | ||
| 272 | createdAt: "2026-02-08T10:00:01.000Z", | ||
| 273 | payload: { item: { type: "tool_call", command: "ls -la" } }, | ||
| 274 | }), | ||
| 275 | makeEvent({ | ||
| 276 | id: "evt-turn-2-tool", | ||
| 277 | method: "item/started", | ||
| 278 | turnId: "turn-2", | ||
| 279 | createdAt: "2026-02-08T10:00:02.000Z", | ||
| 280 | payload: { item: { type: "tool_call", command: "pwd" } }, | ||
| 281 | }), | ||
| 282 | ], | ||
| 283 | undefined, | ||
| 284 | ); | ||
| 285 | |||
| 286 | expect(entries).toHaveLength(2); | ||
| 287 | expect(entries.map((entry) => entry.detail)).toEqual(["pwd", "ls -la"]); | ||
| 288 | }); | ||
| 164 | }); | 289 | }); |
| 165 | 290 | ||
| 166 | describe("evolveSession", () => { | 291 | describe("evolveSession", () => { |
apps/renderer/src/session-logic.ts+36-33| ... | @@ -86,14 +86,20 @@ export type TimelineEntry = | ... | @@ -86,14 +86,20 @@ export type TimelineEntry = |
| 86 | entry: WorkLogEntry; | 86 | entry: WorkLogEntry; |
| 87 | }; | 87 | }; |
| 88 | 88 | ||
| 89 | function truncateLine(value: string, limit = 140): string { | 89 | function normalizeDetail(value: string | undefined): string | undefined { |
| 90 | if (value.length <= limit) return value; | 90 | if (!value) return undefined; |
| 91 | return `${value.slice(0, limit - 1)}…`; | 91 | const trimmed = value.trim(); |
| 92 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 92 | } | 93 | } |
| 93 | 94 | ||
| 94 | function normalizeItemType(raw: string | undefined): string { | 95 | function normalizeItemType(raw: string | undefined): string { |
| 95 | if (!raw) return "item"; | 96 | if (!raw) return "item"; |
| 96 | return raw.replace(/[_-]/g, " ").replace(/\s+/g, " ").trim().toLowerCase(); | 97 | return raw |
| 98 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | ||
| 99 | .replace(/[._/-]/g, " ") | ||
| 100 | .replace(/\s+/g, " ") | ||
| 101 | .trim() | ||
| 102 | .toLowerCase(); | ||
| 97 | } | 103 | } |
| 98 | 104 | ||
| 99 | function shouldDropItemType(type: string): boolean { | 105 | function shouldDropItemType(type: string): boolean { |
| ... | @@ -108,23 +114,30 @@ function shouldDropItemType(type: string): boolean { | ... | @@ -108,23 +114,30 @@ function shouldDropItemType(type: string): boolean { |
| 108 | return type === "work" || type.startsWith("work "); | 114 | return type === "work" || type.startsWith("work "); |
| 109 | } | 115 | } |
| 110 | 116 | ||
| 117 | function shouldShowItemLifecycle(type: string): boolean { | ||
| 118 | return ( | ||
| 119 | type.includes("tool") || | ||
| 120 | type.includes("command") || | ||
| 121 | type.includes("file change") | ||
| 122 | ); | ||
| 123 | } | ||
| 124 | |||
| 111 | function shouldDropMethod(method: string): boolean { | 125 | function shouldDropMethod(method: string): boolean { |
| 112 | return /(^|\/)(preamble|work)(\/|$)/i.test(method); | 126 | return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method); |
| 113 | } | 127 | } |
| 114 | 128 | ||
| 115 | function itemTypeMeta(type: string): { | 129 | function itemTypeMeta(type: string): { |
| 116 | label: string; | 130 | label: string; |
| 117 | tone: WorkLogEntry["tone"]; | 131 | tone: WorkLogEntry["tone"]; |
| 118 | } { | 132 | } { |
| 119 | if ( | 133 | if (type.includes("command")) { |
| 120 | type.includes("tool") || | 134 | return { label: "Command run", tone: "tool" }; |
| 121 | type.includes("command") || | 135 | } |
| 122 | type.includes("file change") | 136 | if (type.includes("file change")) { |
| 123 | ) { | 137 | return { label: "File change", tone: "tool" }; |
| 124 | return { label: "Tool call", tone: "tool" }; | ||
| 125 | } | 138 | } |
| 126 | if (type.includes("agent message")) { | 139 | if (type.includes("tool")) { |
| 127 | return { label: "Drafting response", tone: "thinking" }; | 140 | return { label: "Tool call", tone: "tool" }; |
| 128 | } | 141 | } |
| 129 | return { label: "Work item", tone: "info" }; | 142 | return { label: "Work item", tone: "info" }; |
| 130 | } | 143 | } |
| ... | @@ -144,9 +157,8 @@ function extractDetail( | ... | @@ -144,9 +157,8 @@ function extractDetail( |
| 144 | ]; | 157 | ]; |
| 145 | 158 | ||
| 146 | for (const candidate of candidates) { | 159 | for (const candidate of candidates) { |
| 147 | if (candidate && candidate.trim().length > 0) { | 160 | const detail = normalizeDetail(candidate); |
| 148 | return truncateLine(candidate.trim()); | 161 | if (detail) return detail; |
| 149 | } | ||
| 150 | } | 162 | } |
| 151 | return undefined; | 163 | return undefined; |
| 152 | } | 164 | } |
| ... | @@ -158,6 +170,9 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { | ... | @@ -158,6 +170,9 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { |
| 158 | if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) { | 170 | if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) { |
| 159 | return null; | 171 | return null; |
| 160 | } | 172 | } |
| 173 | if (!shouldShowItemLifecycle(normalizedType)) { | ||
| 174 | return null; | ||
| 175 | } | ||
| 161 | 176 | ||
| 162 | const meta = itemTypeMeta(normalizedType); | 177 | const meta = itemTypeMeta(normalizedType); |
| 163 | const isStarted = event.method === "item/started"; | 178 | const isStarted = event.method === "item/started"; |
| ... | @@ -234,6 +249,7 @@ function entryFromNotification( | ... | @@ -234,6 +249,7 @@ function entryFromNotification( |
| 234 | const status = asString(turn?.status); | 249 | const status = asString(turn?.status); |
| 235 | const turnError = asObject(turn?.error); | 250 | const turnError = asObject(turn?.error); |
| 236 | const turnErrorMessage = asString(turnError?.message); | 251 | const turnErrorMessage = asString(turnError?.message); |
| 252 | const turnErrorDetail = normalizeDetail(turnErrorMessage); | ||
| 237 | const eventAt = Date.parse(event.createdAt); | 253 | const eventAt = Date.parse(event.createdAt); |
| 238 | const durationMs = | 254 | const durationMs = |
| 239 | Number.isNaN(turnStartedAt) || | 255 | Number.isNaN(turnStartedAt) || |
| ... | @@ -251,7 +267,7 @@ function entryFromNotification( | ... | @@ -251,7 +267,7 @@ function entryFromNotification( |
| 251 | : durationMs !== undefined | 267 | : durationMs !== undefined |
| 252 | ? `Turn complete in ${formatDuration(durationMs)}` | 268 | ? `Turn complete in ${formatDuration(durationMs)}` |
| 253 | : "Turn complete", | 269 | : "Turn complete", |
| 254 | ...(turnErrorMessage ? { detail: truncateLine(turnErrorMessage) } : {}), | 270 | ...(turnErrorDetail ? { detail: turnErrorDetail } : {}), |
| 255 | tone: status === "failed" ? "error" : "info", | 271 | tone: status === "failed" ? "error" : "info", |
| 256 | }; | 272 | }; |
| 257 | } | 273 | } |
| ... | @@ -259,21 +275,7 @@ function entryFromNotification( | ... | @@ -259,21 +275,7 @@ function entryFromNotification( |
| 259 | const lifecycleEntry = entryFromItemLifecycle(event); | 275 | const lifecycleEntry = entryFromItemLifecycle(event); |
| 260 | if (lifecycleEntry) return lifecycleEntry; | 276 | if (lifecycleEntry) return lifecycleEntry; |
| 261 | 277 | ||
| 262 | if (event.method.startsWith("item/")) { | 278 | if (event.method.startsWith("item/")) return null; |
| 263 | const payload = asObject(event.payload); | ||
| 264 | const item = asObject(payload?.item); | ||
| 265 | const normalizedType = normalizeItemType(asString(item?.type)); | ||
| 266 | if (shouldDropItemType(normalizedType)) { | ||
| 267 | return null; | ||
| 268 | } | ||
| 269 | |||
| 270 | return { | ||
| 271 | id: event.id, | ||
| 272 | createdAt: event.createdAt, | ||
| 273 | label: `Event: ${event.method.replace(/^item\//, "")}`, | ||
| 274 | tone: "info", | ||
| 275 | }; | ||
| 276 | } | ||
| 277 | 279 | ||
| 278 | return null; | 280 | return null; |
| 279 | } | 281 | } |
| ... | @@ -281,12 +283,13 @@ function entryFromNotification( | ... | @@ -281,12 +283,13 @@ function entryFromNotification( |
| 281 | function entryFromError(event: ProviderEvent): WorkLogEntry | null { | 283 | function entryFromError(event: ProviderEvent): WorkLogEntry | null { |
| 282 | if (event.kind !== "error") return null; | 284 | if (event.kind !== "error") return null; |
| 283 | if (shouldDropMethod(event.method)) return null; | 285 | if (shouldDropMethod(event.method)) return null; |
| 286 | const detail = normalizeDetail(event.message); | ||
| 284 | 287 | ||
| 285 | return { | 288 | return { |
| 286 | id: event.id, | 289 | id: event.id, |
| 287 | createdAt: event.createdAt, | 290 | createdAt: event.createdAt, |
| 288 | label: "Runtime error", | 291 | label: "Runtime error", |
| 289 | ...(event.message ? { detail: truncateLine(event.message) } : {}), | 292 | ...(detail ? { detail } : {}), |
| 290 | tone: "error", | 293 | tone: "error", |
| 291 | }; | 294 | }; |
| 292 | } | 295 | } |
apps/renderer/src/store.ts+1-1| ... | @@ -217,7 +217,7 @@ function reducer(state: AppState, action: Action): AppState { | ... | @@ -217,7 +217,7 @@ function reducer(state: AppState, action: Action): AppState { |
| 217 | event, | 217 | event, |
| 218 | activeAssistantItemRef, | 218 | activeAssistantItemRef, |
| 219 | ), | 219 | ), |
| 220 | events: [event, ...t.events].slice(0, 200), | 220 | events: [event, ...t.events], |
| 221 | error: | 221 | error: |
| 222 | event.kind === "error" && event.message ? event.message : t.error, | 222 | event.kind === "error" && event.message ? event.message : t.error, |
| 223 | ...updateTurnFields(t, event), | 223 | ...updateTurnFields(t, event), |