authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-08 14:55:43-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-08 14:55:43-08:00
log87483c3a95e1c0a6c86e636671eeea442216d771
tree5386e0ed048a665dc92eb5d2da4f6e09f0a6efbe
parent54bc785f36ef35c884e23697b8d782fe8bcd8abb
signature Commit is signed but in an unrecognized format.

more render changes, more plans


14 files changed, 611 insertions(+), 60 deletions(-)

.plans/01-shared-model-normalization.md created+42
......@@ -0,0 +1,42 @@
1# Plan: Centralize Model Normalization in Contracts
2
3## Summary
4Move model alias/default normalization into `packages/contracts` so desktop and renderer use one shared source of truth.
5
6## Motivation
7- Removes duplicated logic between:
8 - `apps/desktop/src/codexAppServerManager.ts`
9 - `apps/renderer/src/model-logic.ts`
10- Prevents behavior drift when model aliases/defaults are updated.
11
12## Scope
13- Add shared model utilities to contracts.
14- Update desktop and renderer to consume shared utilities.
15- Keep renderer-specific display options in renderer.
16
17## Proposed Changes
181. Add `packages/contracts/src/model.ts` with:
19 - Canonical model list
20 - Alias map
21 - `normalizeModelSlug`
22 - `resolveModelSlug`
23 - `DEFAULT_MODEL`
242. Export model utilities from `packages/contracts/src/index.ts`.
253. Update `apps/desktop/src/codexAppServerManager.ts` to replace local alias map/helper.
264. Update `apps/renderer/src/model-logic.ts` to wrap or re-export shared functions.
275. Update tests:
28 - Move/duplicate normalization tests to contracts.
29 - Keep renderer tests focused on renderer-only behavior.
30
31## Risks
32- Desktop/renderer may currently rely on slightly different fallback behavior.
33- Import graph must avoid bundling issues for Electron main/preload.
34
35## Validation
36- `bun run test`
37- `bun run typecheck`
38- Manual check that model selection and session start still send expected model slug.
39
40## Done Criteria
41- No duplicated alias/default map in desktop and renderer.
42- Shared model utilities are contract-tested.
.plans/02-typed-ipc-boundaries.md created+37
......@@ -0,0 +1,37 @@
1# Plan: Strengthen Typed IPC Boundaries in Main Process
2
3## Summary
4Replace loose payload casting in IPC handlers with strict schema parsing and typed helper wrappers.
5
6## Motivation
7- `apps/desktop/src/main.ts` currently uses casts like `payload as Parameters<...>`.
8- Casts can hide contract breakages until runtime.
9
10## Scope
11- Desktop main process IPC registration.
12- Optional shared helper for handler registration.
13
14## Proposed Changes
151. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to:
16 - Parse payload(s) with Zod schemas
17 - Standardize typed handler signatures
182. Refactor provider IPC handlers in `apps/desktop/src/main.ts` to use:
19 - `providerSessionStartInputSchema.parse`
20 - `providerSendTurnInputSchema.parse`
21 - `providerInterruptTurnInputSchema.parse`
22 - `providerStopSessionInputSchema.parse`
233. Apply same pattern to agent/terminal handlers where possible.
244. Add tests for handler parsing failure paths (invalid payloads).
25
26## Risks
27- Refactor can subtly change IPC error shape/messages.
28- Helper abstraction should stay simple and not obscure control flow.
29
30## Validation
31- `bun run test`
32- `bun run typecheck`
33- Manual invalid payload check from renderer/devtools to confirm fast failure.
34
35## Done Criteria
36- No provider handler uses `payload as Parameters<...>`.
37- All IPC entrypoints parse unknown payloads at boundary.
.plans/03-split-codex-app-server-manager.md created+41
......@@ -0,0 +1,41 @@
1# Plan: Decompose CodexAppServerManager
2
3## Summary
4Split `CodexAppServerManager` into smaller modules with clear responsibilities.
5
6## Motivation
7- `apps/desktop/src/codexAppServerManager.ts` is large and mixes:
8 - Process lifecycle
9 - JSON-RPC parsing/routing
10 - Session state transitions
11 - Event emission
12- This increases regression risk and slows changes.
13
14## Scope
15- Desktop provider internals only.
16- Keep external behavior/API stable.
17
18## Proposed Changes
191. Extract modules:
20 - `codex/processLifecycle.ts`
21 - `codex/jsonrpcRouter.ts`
22 - `codex/sessionState.ts`
23 - `codex/parsing.ts`
242. Keep `CodexAppServerManager` as thin orchestrator/facade.
253. Move pure helpers (`classifyCodexStderrLine`, route parsing) into unit-testable files.
264. Add targeted unit tests for:
27 - Message classification
28 - Request/notification/response routing
29 - Session state transitions
30
31## Risks
32- Reordering event handling can change behavior.
33- Must preserve pending request timeout/cancellation semantics.
34
35## Validation
36- Existing tests pass.
37- Add module-level tests for parsing and transition logic.
38
39## Done Criteria
40- Main manager file materially smaller and orchestration-focused.
41- Core protocol/state logic covered by focused tests.
.plans/04-split-chatview-component.md created+40
......@@ -0,0 +1,40 @@
1# Plan: Split ChatView into Smaller UI/Logic Units
2
3## Summary
4Refactor `ChatView.tsx` into composable pieces with isolated responsibilities.
5
6## Motivation
7- `apps/renderer/src/components/ChatView.tsx` is large and handles:
8 - Session orchestration
9 - Send/interrupt actions
10 - Timeline rendering
11 - Header/status UI
12 - Composer UI
13- Hard to test and maintain as one component.
14
15## Scope
16- Renderer component boundaries and hooks.
17- Keep visual behavior unchanged.
18
19## Proposed Changes
201. Create hook: `apps/renderer/src/hooks/useChatSession.ts`
21 - `ensureSession`
22 - `sendTurn`
23 - `interruptTurn`
242. Split presentational components:
25 - `components/chat/ThreadHeader.tsx`
26 - `components/chat/MessageTimeline.tsx`
27 - `components/chat/ComposerBar.tsx`
283. Keep `ChatView.tsx` as container wiring store + hook + child components.
294. Add focused tests for hook behavior (error handling, session reuse).
30
31## Risks
32- Refactor can break subtle UI interactions (auto-scroll, menu close, keyboard send).
33
34## Validation
35- `bun run test`
36- Manual smoke: send, stream, interrupt, model switch.
37
38## Done Criteria
39- `ChatView.tsx` significantly reduced and easier to scan.
40- Session logic isolated from rendering.
.plans/05-zod-persisted-state-validation.md created+34
......@@ -0,0 +1,34 @@
1# Plan: Move Renderer Persisted-State Validation to Zod
2
3## Summary
4Use explicit Zod schemas for localStorage state parsing and migration.
5
6## Motivation
7- `apps/renderer/src/store.ts` has large manual sanitize functions.
8- Manual type guards are verbose and easier to get wrong during schema evolution.
9
10## Scope
11- Renderer state hydration/persistence path.
12- No backend/protocol changes.
13
14## Proposed Changes
151. Add schema module: `apps/renderer/src/persistenceSchema.ts`
16 - Persisted payload versions (`v1`, `v2`)
17 - Thread/message/project schemas
182. Replace `sanitizeProjects/sanitizeThreads/sanitizeMessages` with schema parsing + transforms.
193. Keep migration logic explicit (legacy model migration and key migration).
204. Add tests for:
21 - Invalid payload fallback to initial state
22 - Legacy payload migration
23 - Unknown thread/project references filtered
24
25## Risks
26- Overly strict schemas could drop valid historical data unexpectedly.
27
28## Validation
29- Unit tests for migration/hydration.
30- Manual reload test with existing localStorage data.
31
32## Done Criteria
33- Store hydration logic is schema-driven.
34- Migration behavior is tested and documented.
.plans/06-provider-logstream-lifecycle.md created+31
......@@ -0,0 +1,31 @@
1# Plan: Add Provider Log Stream Lifecycle Management
2
3## Summary
4Ensure `ProviderManager` logging stream is initialized, rotated/structured, and closed safely.
5
6## Motivation
7- `apps/desktop/src/providerManager.ts` opens a write stream in constructor.
8- Stream lifecycle is not explicit on shutdown.
9
10## Scope
11- Desktop provider logging behavior.
12- App shutdown integration.
13
14## Proposed Changes
151. Add explicit `dispose()` on `ProviderManager`:
16 - Remove event listeners
17 - End/close log stream
182. Call `providerManager.dispose()` from app shutdown path in `apps/desktop/src/main.ts`.
193. Optional: change log format to JSON lines with stable fields.
204. Optional: per-session log files under `.logs/providers/`.
21
22## Risks
23- Improper close sequencing may lose final log lines.
24
25## Validation
26- Manual run/quit cycle to ensure no open handle warnings.
27- Confirm logs flush on quit and file descriptors are not leaked.
28
29## Done Criteria
30- ProviderManager owns complete log stream lifecycle.
31- Shutdown path explicitly disposes provider resources.
.plans/07-ci-quality-gates.md created+34
......@@ -0,0 +1,34 @@
1# Plan: Add CI Workflow for Core Quality Gates
2
3## Summary
4Add GitHub Actions workflow to run lint/typecheck/test (and optionally smoke-test) on pushes and PRs.
5
6## Motivation
7- Repository currently has no CI workflow files.
8- Quality checks are only local/manual.
9
10## Scope
11- `.github/workflows/ci.yml`
12- Bun + Turbo setup in CI.
13
14## Proposed Changes
151. Add `ci.yml` with jobs:
16 - Setup Bun and Node environment
17 - Install deps
18 - `bun run lint`
19 - `bun run typecheck`
20 - `bun run test`
212. Add separate optional job for `bun run smoke-test` (desktop/Electron).
223. Configure caching for Bun/Turbo as appropriate.
23
24## Risks
25- Smoke test may be flaky in headless CI environments.
26- CI runtime can grow if caching is misconfigured.
27
28## Validation
29- Verify workflow runs on a branch PR.
30- Ensure failures surface clearly by job name.
31
32## Done Criteria
33- CI blocks regressions in lint/typecheck/test.
34- Workflow docs added to README.
.plans/08-precommit-format-and-lint.md created+32
......@@ -0,0 +1,32 @@
1# Plan: Add Pre-Commit Formatting/Lint Hooks
2
3## Summary
4Introduce pre-commit automation so formatting and basic lint checks happen before commits.
5
6## Motivation
7- Current lint failures include formatting-only issues.
8- Shift-left feedback reduces noisy CI failures and cleanup churn.
9
10## Scope
11- Root tooling config and package scripts.
12- No runtime code changes.
13
14## Proposed Changes
151. Add hook tooling (e.g. Husky + lint-staged or Lefthook).
162. Configure staged-file tasks:
17 - `biome format --write`
18 - `biome check`
193. Add setup docs in README.
204. Keep checks fast to avoid developer friction.
21
22## Risks
23- Slow hooks can frustrate contributors and be bypassed.
24- Need to ensure compatibility with Bun workspace setup.
25
26## Validation
27- Create sample staged changes and verify hook behavior.
28- Confirm formatting fixes are applied automatically.
29
30## Done Criteria
31- Pre-commit hook installed and documented.
32- Formatting-only lint failures drop significantly.
.plans/09-event-state-test-expansion.md created+35
......@@ -0,0 +1,35 @@
1# Plan: Expand Event/State Transition Test Coverage
2
3## Summary
4Add focused tests for renderer event handling and session evolution logic.
5
6## Motivation
7- Core behavior is event-driven and stateful.
8- Existing renderer tests cover only a subset of timeline/model behavior.
9
10## Scope
11- `apps/renderer/src/session-logic.test.ts`
12- Optional reducer tests for `apps/renderer/src/store.ts`.
13
14## Proposed Changes
151. Add tests for `evolveSession`:
16 - `thread/started`
17 - `turn/started`
18 - `turn/completed` success/failure
19 - error/session closed events
202. Add tests for `applyEventToMessages`:
21 - start/delta/completed flow
22 - out-of-order event cases
23 - turn completion clearing streaming flags
243. Add reducer integration tests for `APPLY_EVENT`.
25
26## Risks
27- Tests may be brittle if event payload fixtures are too coupled to implementation details.
28
29## Validation
30- `bun run test`
31- Ensure new tests remain deterministic and fast.
32
33## Done Criteria
34- High-risk event transitions are covered by unit tests.
35- Regressions in stream assembly/session status are caught quickly.
.plans/10-unify-process-session-abstraction.md created+35
......@@ -0,0 +1,35 @@
1# Plan: Unify Process and PTY Session Abstractions in ProcessManager
2
3## Summary
4Refactor `ProcessManager` to use a single runtime-session interface for child-process and PTY modes.
5
6## Motivation
7- `apps/desktop/src/processManager.ts` maintains parallel maps and branch-heavy logic.
8- New execution backends/providers will multiply complexity.
9
10## Scope
11- Desktop process execution internals.
12- Preserve public `ProcessManager` API.
13
14## Proposed Changes
151. Introduce internal interface (e.g. `RuntimeSession`):
16 - `write(data)`
17 - `kill()`
18 - lifecycle/output event hooks
192. Implement:
20 - `ChildProcessSession`
21 - `PtySession`
223. Replace dual maps with one `Map<string, RuntimeSession>`.
234. Keep output/exit event contract unchanged.
245. Add tests for both implementations.
25
26## Risks
27- PTY behavior differs by platform; abstraction must not hide required differences.
28
29## Validation
30- Existing `processManager.test.ts` passes.
31- Add PTY-path tests where feasible.
32
33## Done Criteria
34- Manager no longer branches per backend in `write/kill/killAll`.
35- Session backends are independently testable.
.plans/README.md created+12
......@@ -0,0 +1,12 @@
1# Maintainability Plans
2
31. `01-shared-model-normalization.md`
42. `02-typed-ipc-boundaries.md`
53. `03-split-codex-app-server-manager.md`
64. `04-split-chatview-component.md`
75. `05-zod-persisted-state-validation.md`
86. `06-provider-logstream-lifecycle.md`
97. `07-ci-quality-gates.md`
108. `08-precommit-format-and-lint.md`
119. `09-event-state-test-expansion.md`
1210. `10-unify-process-session-abstraction.md`
apps/renderer/src/components/ChatView.tsx+80-60
......@@ -16,6 +16,7 @@ import {
1616} from "../model-logic";
1717import {
1818 derivePhase,
19 deriveTimelineEntries,
1920 deriveWorkLogEntries,
2021 formatElapsed,
2122 formatTimestamp,
......@@ -83,11 +84,18 @@ export default function ChatView() {
8384 }
8485 return map;
8586 }, [activeThread?.events]);
86 const recentWorkLogEntries = workLogEntries.slice(-12);
87 const timelineEntries = useMemo(
88 () =>
89 deriveTimelineEntries(
90 activeThread?.messages ?? [],
91 isWorking ? workLogEntries : [],
92 ),
93 [activeThread?.messages, isWorking, workLogEntries],
94 );
8795
8896 // Auto-scroll on new messages
8997 const messageCount = activeThread?.messages.length ?? 0;
90 const workLogCount = recentWorkLogEntries.length;
98 const workLogCount = workLogEntries.length;
9199 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change
92100 useEffect(() => {
93101 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
......@@ -341,73 +349,85 @@ export default function ChatView() {
341349 </div>
342350 ) : (
343351 <div className="mx-auto max-w-3xl space-y-4">
344 {activeThread.messages.map((msg) => (
345 <div key={msg.id}>
346 {msg.role === "user" ? (
347 <div className="flex justify-end">
348 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-white/[0.08] bg-white/[0.05] px-4 py-3">
349 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-[#e0e0e0]">
350 {msg.text}
351 </pre>
352 <p className="mt-1.5 text-right text-[10px] text-[#a0a0a0]/30">
353 {formatTimestamp(msg.createdAt)}
354 </p>
355 </div>
356 </div>
357 ) : (
358 <div className="border-l-2 border-white/[0.15] pl-4">
359 <ChatMarkdown
360 text={
361 msg.text || (msg.streaming ? "" : "(empty response)")
362 }
363 />
364 {msg.streaming && (
365 <div className="pt-1.5">
366 <span className="inline-flex items-center gap-2 rounded-full border border-sky-400/25 bg-sky-500/[0.08] px-2 py-0.5 text-[10px] text-sky-100/90">
367 <span className="inline-flex gap-1">
368 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80" />
369 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:150ms]" />
370 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:300ms]" />
371 </span>
372 <span>Thinking</span>
373 </span>
374 </div>
375 )}
376 <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30">
377 {formatMessageMeta(
378 msg.createdAt,
379 msg.streaming
380 ? formatElapsed(msg.createdAt, nowIso)
381 : formatElapsed(
382 msg.createdAt,
383 assistantCompletionByItemId.get(msg.id),
384 ),
385 )}
386 </p>
387 </div>
388 )}
389 </div>
390 ))}
391 {/* Inline activity feed */}
392 {isWorking && (
393 <div className="border-l-2 border-white/[0.05] pl-4 py-1">
394 {recentWorkLogEntries.map((entry) => (
352 {timelineEntries.map((timelineEntry) =>
353 timelineEntry.kind === "work" ? (
354 <div
355 key={timelineEntry.id}
356 className="border-l-2 border-white/[0.05] pl-4 py-1"
357 >
395358 <p
396 key={entry.id}
397 className={`py-[2px] text-[12px] leading-relaxed ${workToneClass(entry.tone)}`}
359 className={`py-[2px] text-[12px] leading-relaxed ${workToneClass(timelineEntry.entry.tone)}`}
398360 >
399 {entry.detail ? (
361 {timelineEntry.entry.detail ? (
400362 <>
401 {entry.label}
363 {timelineEntry.entry.label}
402364 <span className="ml-1.5 font-mono text-[11px] opacity-60">
403 {entry.detail}
365 {timelineEntry.entry.detail}
404366 </span>
405367 </>
406368 ) : (
407 entry.label
369 timelineEntry.entry.label
408370 )}
409371 </p>
410 ))}
372 </div>
373 ) : (
374 <div key={timelineEntry.id}>
375 {timelineEntry.message.role === "user" ? (
376 <div className="flex justify-end">
377 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-white/[0.08] bg-white/[0.05] px-4 py-3">
378 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-[#e0e0e0]">
379 {timelineEntry.message.text}
380 </pre>
381 <p className="mt-1.5 text-right text-[10px] text-[#a0a0a0]/30">
382 {formatTimestamp(timelineEntry.message.createdAt)}
383 </p>
384 </div>
385 </div>
386 ) : (
387 <div className="border-l-2 border-white/[0.15] pl-4">
388 <ChatMarkdown
389 text={
390 timelineEntry.message.text ||
391 (timelineEntry.message.streaming
392 ? ""
393 : "(empty response)")
394 }
395 />
396 {timelineEntry.message.streaming && (
397 <div className="pt-1.5">
398 <span className="inline-flex items-center gap-2 rounded-full border border-sky-400/25 bg-sky-500/[0.08] px-2 py-0.5 text-[10px] text-sky-100/90">
399 <span className="inline-flex gap-1">
400 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80" />
401 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:150ms]" />
402 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:300ms]" />
403 </span>
404 <span>Thinking</span>
405 </span>
406 </div>
407 )}
408 <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30">
409 {formatMessageMeta(
410 timelineEntry.message.createdAt,
411 timelineEntry.message.streaming
412 ? formatElapsed(
413 timelineEntry.message.createdAt,
414 nowIso,
415 )
416 : formatElapsed(
417 timelineEntry.message.createdAt,
418 assistantCompletionByItemId.get(
419 timelineEntry.message.id,
420 ),
421 ),
422 )}
423 </p>
424 </div>
425 )}
426 </div>
427 ),
428 )}
429 {isWorking && (
430 <div className="border-l-2 border-white/[0.05] pl-4 py-1">
411431 <div className="flex items-center pt-1">
412432 <span className="inline-flex items-center gap-[3px]">
413433 <span className="h-1 w-1 rounded-full bg-white/20 animate-pulse" />
apps/renderer/src/session-logic.test.ts created+75
......@@ -0,0 +1,75 @@
1import { describe, expect, it } from "vitest";
2import type { WorkLogEntry } from "./session-logic";
3import { deriveTimelineEntries } from "./session-logic";
4import type { ChatMessage } from "./types";
5
6describe("deriveTimelineEntries", () => {
7 it("interleaves messages and work entries by timestamp", () => {
8 const messages: ChatMessage[] = [
9 {
10 id: "m-user",
11 role: "user",
12 text: "Hi",
13 createdAt: "2026-02-08T10:00:00.000Z",
14 streaming: false,
15 },
16 {
17 id: "m-assistant",
18 role: "assistant",
19 text: "Hello",
20 createdAt: "2026-02-08T10:05:00.000Z",
21 streaming: false,
22 },
23 ];
24 const workEntries: WorkLogEntry[] = [
25 {
26 id: "w-1",
27 label: "Tool call",
28 createdAt: "2026-02-08T10:02:00.000Z",
29 tone: "tool",
30 },
31 {
32 id: "w-2",
33 label: "Preamble",
34 createdAt: "2026-02-08T10:03:00.000Z",
35 tone: "thinking",
36 },
37 ];
38
39 const timeline = deriveTimelineEntries(messages, workEntries);
40
41 expect(timeline.map((entry) => entry.id)).toEqual([
42 "message:m-user",
43 "work:w-1",
44 "work:w-2",
45 "message:m-assistant",
46 ]);
47 });
48
49 it("prefers work entries when timestamps are equal", () => {
50 const messages: ChatMessage[] = [
51 {
52 id: "m-1",
53 role: "assistant",
54 text: "Done",
55 createdAt: "2026-02-08T10:00:00.000Z",
56 streaming: false,
57 },
58 ];
59 const workEntries: WorkLogEntry[] = [
60 {
61 id: "w-1",
62 label: "Tool call",
63 createdAt: "2026-02-08T10:00:00.000Z",
64 tone: "tool",
65 },
66 ];
67
68 const timeline = deriveTimelineEntries(messages, workEntries);
69
70 expect(timeline.map((entry) => entry.id)).toEqual([
71 "work:w-1",
72 "message:m-1",
73 ]);
74 });
75});
apps/renderer/src/session-logic.ts+83
......@@ -72,6 +72,20 @@ export interface WorkLogEntry {
7272 tone: "thinking" | "tool" | "info" | "error";
7373}
7474
75export type TimelineEntry =
76 | {
77 id: string;
78 kind: "message";
79 createdAt: string;
80 message: ChatMessage;
81 }
82 | {
83 id: string;
84 kind: "work";
85 createdAt: string;
86 entry: WorkLogEntry;
87 };
88
7589function truncateLine(value: string, limit = 140): string {
7690 if (value.length <= limit) return value;
7791 return `${value.slice(0, limit - 1)}…`;
......@@ -277,6 +291,75 @@ export function deriveWorkLogEntries(
277291 return entries;
278292}
279293
294function toTimestamp(isoDate: string): number {
295 const parsed = Date.parse(isoDate);
296 return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed;
297}
298
299export function deriveTimelineEntries(
300 messages: ChatMessage[],
301 workEntries: WorkLogEntry[],
302): TimelineEntry[] {
303 const timeline: TimelineEntry[] = [];
304 let messageIndex = 0;
305 let workIndex = 0;
306
307 while (messageIndex < messages.length || workIndex < workEntries.length) {
308 const message = messages[messageIndex];
309 const workEntry = workEntries[workIndex];
310
311 if (!message && workEntry) {
312 timeline.push({
313 id: `work:${workEntry.id}`,
314 kind: "work",
315 createdAt: workEntry.createdAt,
316 entry: workEntry,
317 });
318 workIndex += 1;
319 continue;
320 }
321
322 if (!workEntry && message) {
323 timeline.push({
324 id: `message:${message.id}`,
325 kind: "message",
326 createdAt: message.createdAt,
327 message,
328 });
329 messageIndex += 1;
330 continue;
331 }
332
333 if (!message || !workEntry) {
334 break;
335 }
336
337 const messageAt = toTimestamp(message.createdAt);
338 const workAt = toTimestamp(workEntry.createdAt);
339
340 if (workAt <= messageAt) {
341 timeline.push({
342 id: `work:${workEntry.id}`,
343 kind: "work",
344 createdAt: workEntry.createdAt,
345 entry: workEntry,
346 });
347 workIndex += 1;
348 continue;
349 }
350
351 timeline.push({
352 id: `message:${message.id}`,
353 kind: "message",
354 createdAt: message.createdAt,
355 message,
356 });
357 messageIndex += 1;
358 }
359
360 return timeline;
361}
362
280363export function derivePhase(session: ProviderSession | null): SessionPhase {
281364 if (!session || session.status === "closed") return "disconnected";
282365 if (session.status === "connecting") return "connecting";