History

feat(renderer): simplify thread UI and reduce work-log noise

- 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 filtering

Theo Browne committed 9 months ago

Commit de5df261 parent 1bd823c6 files · +235 −136Browse files

Unified · Split

6 files
  1. TODO.md +4 −7
  2. apps/renderer/src/components/ChatView.tsx +6 −56
  3. apps/renderer/src/components/Sidebar.tsx +63 −39
  4. apps/renderer/src/session-logic.test.ts +125 −0
  5. apps/renderer/src/session-logic.ts +36 −33
  6. apps/renderer/src/store.ts +1 −1
TODO.md+4−7
@@ -1,9 +1,6 @@
11 ## TODO
22
3−- [x] Fix sort order for threads (newest first)
4−- [x] Indicate in thread list which threads are currently working
5−- [x] Logs should be per-thread, not a single events.txt
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
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"
3+- [x] Remove model picker from new project menu
4+- [x] CMD+Shift+O to create new thread in current project
5+- [x] Remove project title pill from input box and topnav
6+- [x] Remove connection status from topnav
apps/renderer/src/components/ChatView.tsx+6−56
@@ -32,13 +32,6 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { · 31 unmodified lines
3232 return `${formatTimestamp(createdAt)} • ${duration}`;
3333 }
3434
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−
4235 function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
4336 if (tone === "error") return "text-rose-300/50";
4437 if (tone === "tool") return "text-[#8a8a8a]";
@@ -69,13 +62,11 @@ export default function ChatView() { · 24 unmodified lines
6962 );
7063 const phase = derivePhase(activeThread?.session ?? null);
7164 const isWorking = phase === "running" || isSending || isConnecting;
72− const workLogTurnId =
73− activeThread?.latestTurnId ?? activeThread?.session?.activeTurnId;
7465 const nowIso = new Date(nowTick).toISOString();
7566 const modelOptions = MODEL_OPTIONS;
7667 const workLogEntries = useMemo(
77− () => deriveWorkLogEntries(activeThread?.events ?? [], workLogTurnId),
78− [activeThread?.events, workLogTurnId],
68+ () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
69+ [activeThread?.events],
7970 );
8071 const assistantCompletionByItemId = useMemo(() => {
8172 const map = new Map<string, string>();
@@ -320,47 +311,8 @@ export default function ChatView() { · 238 unmodified lines
320311 <h2 className="text-sm font-medium text-[#e0e0e0]">
321312 {activeThread.title}
322313 </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− )}
328314 </div>
329315 <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>
364316 {/* Diff toggle */}
365317 <button
366318 type="button"
@@ -418,7 +370,10 @@ export default function ChatView() { · 51 unmodified lines
418370 {timelineEntry.entry.detail ? (
419371 <>
420372 {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+ >
422377 {timelineEntry.entry.detail}
423378 </span>
424379 </>
@@ -634,11 +589,6 @@ export default function ChatView() { · 209 unmodified lines
634589
635590 {/* Right side: send / stop button */}
636591 <div className="flex items-center gap-2">
637− {activeProject && (
638− <span className="text-[11px] text-[#a0a0a0]/25">
639− {activeProject.name}
640− </span>
641− )}
642592 {phase === "running" ? (
643593 <button
644594 type="button"
apps/renderer/src/components/Sidebar.tsx+63−39
@@ -1,5 +1,5 @@
1−import { useMemo, useState } from "react";
2−import { DEFAULT_MODEL, MODEL_OPTIONS, resolveModelSlug } from "../model-logic";
1+import { useCallback, useEffect, useMemo, useState } from "react";
2+import { DEFAULT_MODEL } from "../model-logic";
33 import { readNativeApi } from "../session-logic";
44 import { useStore } from "../store";
55 import type { Project } from "../types";
@@ -27,45 +27,45 @@ export default function Sidebar() { · 21 unmodified lines
2727 const api = useMemo(() => readNativeApi(), []);
2828 const [addingProject, setAddingProject] = useState(false);
2929 const [newCwd, setNewCwd] = useState("");
30− const [newModel, setNewModel] = useState(DEFAULT_MODEL);
3130 const [isPickingFolder, setIsPickingFolder] = useState(false);
3231
3332 const handleAddProject = () => {
3433 const cwd = newCwd.trim();
3534 if (!cwd) return;
3635 const name = cwd.split("/").filter(Boolean).pop() ?? "project";
37− const normalizedModel = resolveModelSlug(newModel);
3836 const project: Project = {
3937 id: crypto.randomUUID(),
4038 name,
4139 cwd,
42− model: normalizedModel,
40+ model: DEFAULT_MODEL,
4341 expanded: true,
4442 };
4543 dispatch({ type: "ADD_PROJECT", project });
4644 setNewCwd("");
47− setNewModel(DEFAULT_MODEL);
4845 setAddingProject(false);
4946 };
5047
51− const handleNewThread = (projectId: string) => {
52− dispatch({
53− type: "ADD_THREAD",
54− thread: {
55− id: crypto.randomUUID(),
56− projectId,
57− title: "New thread",
58− model:
59− state.projects.find((p) => p.id === projectId)?.model ??
60− DEFAULT_MODEL,
61− session: null,
62− messages: [],
63− events: [],
64− error: null,
65− createdAt: new Date().toISOString(),
66− },
67− });
68− };
48+ const handleNewThread = useCallback(
49+ (projectId: string) => {
50+ dispatch({
51+ type: "ADD_THREAD",
52+ thread: {
53+ id: crypto.randomUUID(),
54+ projectId,
55+ title: "New thread",
56+ model:
57+ state.projects.find((p) => p.id === projectId)?.model ??
58+ DEFAULT_MODEL,
59+ session: null,
60+ messages: [],
61+ events: [],
62+ error: null,
63+ createdAt: new Date().toISOString(),
64+ },
65+ });
66+ },
67+ [dispatch, state.projects],
68+ );
6969
7070 const handlePickFolder = async () => {
7171 if (!api || isPickingFolder) return;
@@ -79,6 +79,45 @@ export default function Sidebar() { · 7 unmodified lines
7979 }
8080 };
8181
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+
82121 return (
83122 <aside className="sidebar flex h-full w-[260px] shrink-0 flex-col border-r border-white/[0.08] bg-[#141414]">
84123 {/* Drag region / traffic light space */}
@@ -238,21 +277,6 @@ export default function Sidebar() { · 153 unmodified lines
238277 {isPickingFolder ? "Picking folder..." : "Browse for folder"}
239278 </button>
240279 )}
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>
256280 <div className="flex gap-2">
257281 <button
258282 type="button"
apps/renderer/src/session-logic.test.ts+125−0
@@ -161,6 +161,131 @@ describe("deriveWorkLogEntries", () => { · 160 unmodified lines
161161
162162 expect(entries[0]?.label).toBe("Turn complete in 10s");
163163 });
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+ });
164289 });
165290
166291 describe("evolveSession", () => {
apps/renderer/src/session-logic.ts+36−33
@@ -86,14 +86,20 @@ export type TimelineEntry = · 85 unmodified lines
8686 entry: WorkLogEntry;
8787 };
8888
89−function truncateLine(value: string, limit = 140): string {
90− if (value.length <= limit) return value;
91− return `${value.slice(0, limit - 1)}…`;
89+function normalizeDetail(value: string | undefined): string | undefined {
90+ if (!value) return undefined;
91+ const trimmed = value.trim();
92+ return trimmed.length > 0 ? trimmed : undefined;
9293 }
9394
9495 function normalizeItemType(raw: string | undefined): string {
9596 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();
97103 }
98104
99105 function shouldDropItemType(type: string): boolean {
@@ -108,23 +114,30 @@ function shouldDropItemType(type: string): boolean { · 8 unmodified lines
108114 return type === "work" || type.startsWith("work ");
109115 }
110116
117+function shouldShowItemLifecycle(type: string): boolean {
118+ return (
119+ type.includes("tool") ||
120+ type.includes("command") ||
121+ type.includes("file change")
122+ );
123+}
124+
111125 function shouldDropMethod(method: string): boolean {
112− return /(^|\/)(preamble|work)(\/|$)/i.test(method);
126+ return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method);
113127 }
114128
115129 function itemTypeMeta(type: string): {
116130 label: string;
117131 tone: WorkLogEntry["tone"];
118132 } {
119− if (
120− type.includes("tool") ||
121− type.includes("command") ||
122− type.includes("file change")
123− ) {
124− return { label: "Tool call", tone: "tool" };
133+ if (type.includes("command")) {
134+ return { label: "Command run", tone: "tool" };
135+ }
136+ if (type.includes("file change")) {
137+ return { label: "File change", tone: "tool" };
125138 }
126− if (type.includes("agent message")) {
127− return { label: "Drafting response", tone: "thinking" };
139+ if (type.includes("tool")) {
140+ return { label: "Tool call", tone: "tool" };
128141 }
129142 return { label: "Work item", tone: "info" };
130143 }
@@ -144,9 +157,8 @@ function extractDetail( · 13 unmodified lines
144157 ];
145158
146159 for (const candidate of candidates) {
147− if (candidate && candidate.trim().length > 0) {
148− return truncateLine(candidate.trim());
149− }
160+ const detail = normalizeDetail(candidate);
161+ if (detail) return detail;
150162 }
151163 return undefined;
152164 }
@@ -158,6 +170,9 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { · 5 unmodified lines
158170 if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) {
159171 return null;
160172 }
173+ if (!shouldShowItemLifecycle(normalizedType)) {
174+ return null;
175+ }
161176
162177 const meta = itemTypeMeta(normalizedType);
163178 const isStarted = event.method === "item/started";
@@ -234,6 +249,7 @@ function entryFromNotification( · 70 unmodified lines
234249 const status = asString(turn?.status);
235250 const turnError = asObject(turn?.error);
236251 const turnErrorMessage = asString(turnError?.message);
252+ const turnErrorDetail = normalizeDetail(turnErrorMessage);
237253 const eventAt = Date.parse(event.createdAt);
238254 const durationMs =
239255 Number.isNaN(turnStartedAt) ||
@@ -251,7 +267,7 @@ function entryFromNotification( · 11 unmodified lines
251267 : durationMs !== undefined
252268 ? `Turn complete in ${formatDuration(durationMs)}`
253269 : "Turn complete",
254− ...(turnErrorMessage ? { detail: truncateLine(turnErrorMessage) } : {}),
270+ ...(turnErrorDetail ? { detail: turnErrorDetail } : {}),
255271 tone: status === "failed" ? "error" : "info",
256272 };
257273 }
@@ -259,21 +275,7 @@ function entryFromNotification( · 1 unmodified line
259275 const lifecycleEntry = entryFromItemLifecycle(event);
260276 if (lifecycleEntry) return lifecycleEntry;
261277
262− if (event.method.startsWith("item/")) {
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− }
278+ if (event.method.startsWith("item/")) return null;
277279
278280 return null;
279281 }
@@ -281,12 +283,13 @@ function entryFromNotification( · 1 unmodified line
281283 function entryFromError(event: ProviderEvent): WorkLogEntry | null {
282284 if (event.kind !== "error") return null;
283285 if (shouldDropMethod(event.method)) return null;
286+ const detail = normalizeDetail(event.message);
284287
285288 return {
286289 id: event.id,
287290 createdAt: event.createdAt,
288291 label: "Runtime error",
289− ...(event.message ? { detail: truncateLine(event.message) } : {}),
292+ ...(detail ? { detail } : {}),
290293 tone: "error",
291294 };
292295 }
apps/renderer/src/store.ts+1−1
@@ -217,7 +217,7 @@ function reducer(state: AppState, action: Action): AppState { · 216 unmodified lines
217217 event,
218218 activeAssistantItemRef,
219219 ),
220− events: [event, ...t.events].slice(0, 200),
220+ events: [event, ...t.events],
221221 error:
222222 event.kind === "error" && event.message ? event.message : t.error,
223223 ...updateTurnFields(t, event),