| author | |
| committer | |
| log | 87483c3a95e1c0a6c86e636671eeea442216d771 |
| tree | 5386e0ed048a665dc92eb5d2da4f6e09f0a6efbe |
| parent | 54bc785f36ef35c884e23697b8d782fe8bcd8abb |
| signature | Commit is signed but in an unrecognized format. |
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 | ||
| 4 | Move 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 | ||
| 18 | 1. Add `packages/contracts/src/model.ts` with: | ||
| 19 | - Canonical model list | ||
| 20 | - Alias map | ||
| 21 | - `normalizeModelSlug` | ||
| 22 | - `resolveModelSlug` | ||
| 23 | - `DEFAULT_MODEL` | ||
| 24 | 2. Export model utilities from `packages/contracts/src/index.ts`. | ||
| 25 | 3. Update `apps/desktop/src/codexAppServerManager.ts` to replace local alias map/helper. | ||
| 26 | 4. Update `apps/renderer/src/model-logic.ts` to wrap or re-export shared functions. | ||
| 27 | 5. 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 | ||
| 4 | Replace 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 | ||
| 15 | 1. Add IPC helper utility (e.g. `apps/desktop/src/ipcHelpers.ts`) to: | ||
| 16 | - Parse payload(s) with Zod schemas | ||
| 17 | - Standardize typed handler signatures | ||
| 18 | 2. Refactor provider IPC handlers in `apps/desktop/src/main.ts` to use: | ||
| 19 | - `providerSessionStartInputSchema.parse` | ||
| 20 | - `providerSendTurnInputSchema.parse` | ||
| 21 | - `providerInterruptTurnInputSchema.parse` | ||
| 22 | - `providerStopSessionInputSchema.parse` | ||
| 23 | 3. Apply same pattern to agent/terminal handlers where possible. | ||
| 24 | 4. 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 | ||
| 4 | Split `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 | ||
| 19 | 1. Extract modules: | ||
| 20 | - `codex/processLifecycle.ts` | ||
| 21 | - `codex/jsonrpcRouter.ts` | ||
| 22 | - `codex/sessionState.ts` | ||
| 23 | - `codex/parsing.ts` | ||
| 24 | 2. Keep `CodexAppServerManager` as thin orchestrator/facade. | ||
| 25 | 3. Move pure helpers (`classifyCodexStderrLine`, route parsing) into unit-testable files. | ||
| 26 | 4. 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 | ||
| 4 | Refactor `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 | ||
| 20 | 1. Create hook: `apps/renderer/src/hooks/useChatSession.ts` | ||
| 21 | - `ensureSession` | ||
| 22 | - `sendTurn` | ||
| 23 | - `interruptTurn` | ||
| 24 | 2. Split presentational components: | ||
| 25 | - `components/chat/ThreadHeader.tsx` | ||
| 26 | - `components/chat/MessageTimeline.tsx` | ||
| 27 | - `components/chat/ComposerBar.tsx` | ||
| 28 | 3. Keep `ChatView.tsx` as container wiring store + hook + child components. | ||
| 29 | 4. 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 | ||
| 4 | Use 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 | ||
| 15 | 1. Add schema module: `apps/renderer/src/persistenceSchema.ts` | ||
| 16 | - Persisted payload versions (`v1`, `v2`) | ||
| 17 | - Thread/message/project schemas | ||
| 18 | 2. Replace `sanitizeProjects/sanitizeThreads/sanitizeMessages` with schema parsing + transforms. | ||
| 19 | 3. Keep migration logic explicit (legacy model migration and key migration). | ||
| 20 | 4. 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 | ||
| 4 | Ensure `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 | ||
| 15 | 1. Add explicit `dispose()` on `ProviderManager`: | ||
| 16 | - Remove event listeners | ||
| 17 | - End/close log stream | ||
| 18 | 2. Call `providerManager.dispose()` from app shutdown path in `apps/desktop/src/main.ts`. | ||
| 19 | 3. Optional: change log format to JSON lines with stable fields. | ||
| 20 | 4. 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 | ||
| 4 | Add 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 | ||
| 15 | 1. 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` | ||
| 21 | 2. Add separate optional job for `bun run smoke-test` (desktop/Electron). | ||
| 22 | 3. 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 | ||
| 4 | Introduce 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 | ||
| 15 | 1. Add hook tooling (e.g. Husky + lint-staged or Lefthook). | ||
| 16 | 2. Configure staged-file tasks: | ||
| 17 | - `biome format --write` | ||
| 18 | - `biome check` | ||
| 19 | 3. Add setup docs in README. | ||
| 20 | 4. 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 | ||
| 4 | Add 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 | ||
| 15 | 1. Add tests for `evolveSession`: | ||
| 16 | - `thread/started` | ||
| 17 | - `turn/started` | ||
| 18 | - `turn/completed` success/failure | ||
| 19 | - error/session closed events | ||
| 20 | 2. Add tests for `applyEventToMessages`: | ||
| 21 | - start/delta/completed flow | ||
| 22 | - out-of-order event cases | ||
| 23 | - turn completion clearing streaming flags | ||
| 24 | 3. 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 | ||
| 4 | Refactor `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 | ||
| 15 | 1. Introduce internal interface (e.g. `RuntimeSession`): | ||
| 16 | - `write(data)` | ||
| 17 | - `kill()` | ||
| 18 | - lifecycle/output event hooks | ||
| 19 | 2. Implement: | ||
| 20 | - `ChildProcessSession` | ||
| 21 | - `PtySession` | ||
| 22 | 3. Replace dual maps with one `Map<string, RuntimeSession>`. | ||
| 23 | 4. Keep output/exit event contract unchanged. | ||
| 24 | 5. 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 | |||
| 3 | 1. `01-shared-model-normalization.md` | ||
| 4 | 2. `02-typed-ipc-boundaries.md` | ||
| 5 | 3. `03-split-codex-app-server-manager.md` | ||
| 6 | 4. `04-split-chatview-component.md` | ||
| 7 | 5. `05-zod-persisted-state-validation.md` | ||
| 8 | 6. `06-provider-logstream-lifecycle.md` | ||
| 9 | 7. `07-ci-quality-gates.md` | ||
| 10 | 8. `08-precommit-format-and-lint.md` | ||
| 11 | 9. `09-event-state-test-expansion.md` | ||
| 12 | 10. `10-unify-process-session-abstraction.md` | ||
apps/renderer/src/components/ChatView.tsx+80-60| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | } from "../model-logic"; | 16 | } from "../model-logic"; |
| 17 | import { | 17 | import { |
| 18 | derivePhase, | 18 | derivePhase, |
| 19 | deriveTimelineEntries, | ||
| 19 | deriveWorkLogEntries, | 20 | deriveWorkLogEntries, |
| 20 | formatElapsed, | 21 | formatElapsed, |
| 21 | formatTimestamp, | 22 | formatTimestamp, |
| ... | @@ -83,11 +84,18 @@ export default function ChatView() { | ... | @@ -83,11 +84,18 @@ export default function ChatView() { |
| 83 | } | 84 | } |
| 84 | return map; | 85 | return map; |
| 85 | }, [activeThread?.events]); | 86 | }, [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 | ); | ||
| 87 | 95 | ||
| 88 | // Auto-scroll on new messages | 96 | // Auto-scroll on new messages |
| 89 | const messageCount = activeThread?.messages.length ?? 0; | 97 | const messageCount = activeThread?.messages.length ?? 0; |
| 90 | const workLogCount = recentWorkLogEntries.length; | 98 | const workLogCount = workLogEntries.length; |
| 91 | // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change | 99 | // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change |
| 92 | useEffect(() => { | 100 | useEffect(() => { |
| 93 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | 101 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
| ... | @@ -341,73 +349,85 @@ export default function ChatView() { | ... | @@ -341,73 +349,85 @@ export default function ChatView() { |
| 341 | </div> | 349 | </div> |
| 342 | ) : ( | 350 | ) : ( |
| 343 | <div className="mx-auto max-w-3xl space-y-4"> | 351 | <div className="mx-auto max-w-3xl space-y-4"> |
| 344 | {activeThread.messages.map((msg) => ( | 352 | {timelineEntries.map((timelineEntry) => |
| 345 | <div key={msg.id}> | 353 | timelineEntry.kind === "work" ? ( |
| 346 | {msg.role === "user" ? ( | 354 | <div |
| 347 | <div className="flex justify-end"> | 355 | key={timelineEntry.id} |
| 348 | <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-white/[0.08] bg-white/[0.05] px-4 py-3"> | 356 | className="border-l-2 border-white/[0.05] pl-4 py-1" |
| 349 | <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-[#e0e0e0]"> | 357 | > |
| 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) => ( | ||
| 395 | <p | 358 | <p |
| 396 | key={entry.id} | 359 | className={`py-[2px] text-[12px] leading-relaxed ${workToneClass(timelineEntry.entry.tone)}`} |
| 397 | className={`py-[2px] text-[12px] leading-relaxed ${workToneClass(entry.tone)}`} | ||
| 398 | > | 360 | > |
| 399 | {entry.detail ? ( | 361 | {timelineEntry.entry.detail ? ( |
| 400 | <> | 362 | <> |
| 401 | {entry.label} | 363 | {timelineEntry.entry.label} |
| 402 | <span className="ml-1.5 font-mono text-[11px] opacity-60"> | 364 | <span className="ml-1.5 font-mono text-[11px] opacity-60"> |
| 403 | {entry.detail} | 365 | {timelineEntry.entry.detail} |
| 404 | </span> | 366 | </span> |
| 405 | </> | 367 | </> |
| 406 | ) : ( | 368 | ) : ( |
| 407 | entry.label | 369 | timelineEntry.entry.label |
| 408 | )} | 370 | )} |
| 409 | </p> | 371 | </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"> | ||
| 411 | <div className="flex items-center pt-1"> | 431 | <div className="flex items-center pt-1"> |
| 412 | <span className="inline-flex items-center gap-[3px]"> | 432 | <span className="inline-flex items-center gap-[3px]"> |
| 413 | <span className="h-1 w-1 rounded-full bg-white/20 animate-pulse" /> | 433 | <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 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import type { WorkLogEntry } from "./session-logic"; | ||
| 3 | import { deriveTimelineEntries } from "./session-logic"; | ||
| 4 | import type { ChatMessage } from "./types"; | ||
| 5 | |||
| 6 | describe("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 { | ... | @@ -72,6 +72,20 @@ export interface WorkLogEntry { |
| 72 | tone: "thinking" | "tool" | "info" | "error"; | 72 | tone: "thinking" | "tool" | "info" | "error"; |
| 73 | } | 73 | } |
| 74 | 74 | ||
| 75 | export 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 | |||
| 75 | function truncateLine(value: string, limit = 140): string { | 89 | function truncateLine(value: string, limit = 140): string { |
| 76 | if (value.length <= limit) return value; | 90 | if (value.length <= limit) return value; |
| 77 | return `${value.slice(0, limit - 1)}…`; | 91 | return `${value.slice(0, limit - 1)}…`; |
| ... | @@ -277,6 +291,75 @@ export function deriveWorkLogEntries( | ... | @@ -277,6 +291,75 @@ export function deriveWorkLogEntries( |
| 277 | return entries; | 291 | return entries; |
| 278 | } | 292 | } |
| 279 | 293 | ||
| 294 | function toTimestamp(isoDate: string): number { | ||
| 295 | const parsed = Date.parse(isoDate); | ||
| 296 | return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; | ||
| 297 | } | ||
| 298 | |||
| 299 | export 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 | |||
| 280 | export function derivePhase(session: ProviderSession | null): SessionPhase { | 363 | export function derivePhase(session: ProviderSession | null): SessionPhase { |
| 281 | if (!session || session.status === "closed") return "disconnected"; | 364 | if (!session || session.status === "closed") return "disconnected"; |
| 282 | if (session.status === "connecting") return "connecting"; | 365 | if (session.status === "connecting") return "connecting"; |