diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index 6cc7e8f43..f8933442a 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -36,7 +36,13 @@ import type { ProviderConditionSnapshot } from '@shared/types/providerConditions export type SessionKind = 'claude' | 'codex' | 'terminal' -export type ManagerEvents = { +// WHY private: this is the EventEmitter event map for SessionManager. +// It's used internally to type `on()`/`off()`/`emit()` overloads (see +// the `SessionManager` interface below), but no external file imports +// the bare type. Keeping it module-private documents the API surface: +// callers consume the manager through the `SessionManager` interface, +// not via direct event-map type access. +type ManagerEvents = { started: [{ sessionId: string; kind: SessionKind; projectDir?: string }] 'pty-data': [{ sessionId: string; data: string }] /** Raw PTY bytes for an attached agent inline terminal. Emitted @@ -87,7 +93,12 @@ export type ManagerEvents = { exit: [{ sessionId: string; exitCode: number; signal?: number }] } -export type SpawnOptions = { +// WHY private: SpawnOptions is the argument shape for the spawn() +// method on this class. No external file types its own spawn() wrapper +// in terms of this — IPC handlers convert from their JSON-RPC payload +// shapes inline. Privatizing prevents accidental cross-module coupling +// to a type that will keep evolving alongside spawn() internals. +type SpawnOptions = { /** Which kind of session to spawn. Defaults to 'claude' so the * pre-existing call sites keep working without a kind arg. */ kind?: SessionKind @@ -115,7 +126,10 @@ export type SpawnOptions = { recoverTmuxName?: string } -export type SpawnResult = { +// WHY private: same reasoning as SpawnOptions — the IPC handler that +// returns this to the renderer flattens it into a JSON payload at the +// boundary, so external code never sees the named type. +type SpawnResult = { sessionId: string /** Set only when a tmux-backed terminal was spawned (or recovered). * Renderer must persist this so a subsequent launch can recover diff --git a/src/main/window/mainWindow.ts b/src/main/window/mainWindow.ts index c8cb65dab..510316446 100644 --- a/src/main/window/mainWindow.ts +++ b/src/main/window/mainWindow.ts @@ -47,10 +47,6 @@ function pushTrafficLightInset(): void { } } -export function getMainWindow(): BrowserWindow | null { - return mainWindow -} - export function focusMainWindow(): void { if (!mainWindow || mainWindow.isDestroyed()) return if (mainWindow.isMinimized()) mainWindow.restore() diff --git a/src/providers/claude/runtime/claudeSession.ts b/src/providers/claude/runtime/claudeSession.ts index b30642d79..8ed00effe 100644 --- a/src/providers/claude/runtime/claudeSession.ts +++ b/src/providers/claude/runtime/claudeSession.ts @@ -8,7 +8,6 @@ import { ClaudeCodeHeadless, createProxyServer, spawnClaudeWithProxy, - terminalToMarkdown, type CompactionState, type ClaudeConditionSnapshot, type JsonlEntry, @@ -19,8 +18,13 @@ import { type TrustDialogState, } from 'claude-code-headless' -// Re-export terminalToMarkdown so existing callers keep working. -export { terminalToMarkdown } +// HISTORICAL: this file used to `export { terminalToMarkdown }` as a +// compat shim left over from when the function was inlined here before +// being moved into the headless package. The shim has zero callers in +// the repo today — consumers either import directly from +// claude-code-headless or from src/shared/runtime/ptyScreen — so the +// re-export is gone. If a future caller needs it, import it where it +// actually lives. export type ClaudeSessionOptions = { cwd?: string diff --git a/src/providers/codex/renderer/rows/CodexRows.tsx b/src/providers/codex/renderer/rows/CodexRows.tsx index 20c9f26ab..b231759c2 100644 --- a/src/providers/codex/renderer/rows/CodexRows.tsx +++ b/src/providers/codex/renderer/rows/CodexRows.tsx @@ -8,10 +8,15 @@ import { CodeRenderContext } from '@renderer/features/feed/ui/Feed' import { MarkerRow } from '@renderer/features/feed/ui/MarkerRow' import { formatToolFilePath } from '@shared/paths/displayPath' import type { ToolResultBlock, ToolUseBlock } from '@shared/types/transcript' - -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' ? value as Record : null -} +import { asRecord } from '@shared/lib/asRecord' +// WHY the import switch matters here: the local copy this replaced +// did NOT exclude arrays — it returned `value as Record<...>` for +// any non-null object including arrays. The shared helper rejects +// arrays. Every call site below was using `.raw` / `.input` style +// property reads on what should always be a plain object, so the +// stricter check is a free safety improvement, not a regression. +// If a future caller wants array-as-record they should use a +// different helper or do the cast inline. function textFromContent(content: unknown): string { if (typeof content === 'string') return content diff --git a/src/providers/codex/types/codexTranscript.ts b/src/providers/codex/types/codexTranscript.ts index ccaca6b74..74ce3a686 100644 --- a/src/providers/codex/types/codexTranscript.ts +++ b/src/providers/codex/types/codexTranscript.ts @@ -13,12 +13,24 @@ // turn_context — per-turn metadata (token counts, timing) // event_msg — UI/lifecycle events (user_message, etc.) // -// We type these loosely (same philosophy as Claude's transcript.ts) -// because the on-disk format is large and we only render a subset. -// Use the type guards at runtime; don't trust the discriminator alone. +// HISTORICAL: this file used to export named payload types +// (CodexSessionMeta, CodexResponseItem, CodexEventMsg) and a guard +// (isCodexConversationEntry). They had zero importers in the renderer +// or main — code that touches a rollout line either narrows the +// `payload: unknown` inline at the call site or treats it as +// `Record` and probes for the fields it needs. The +// named types went stale relative to those call sites, so we deleted +// them rather than keep two parallel shapes that drifted apart. If a +// caller ever needs strongly-typed payloads again, derive them at the +// consumer. +// +// We type the envelope loosely (same philosophy as Claude's +// transcript.ts) because the on-disk format is large and we only +// render a subset. Use the type guards at runtime; don't trust the +// discriminator alone. // // Lives under src/core/types/ so main, renderer, and testbench can all -// import. Pure types + guards — no runtime, no Node, no DOM. +// import. Pure types — no runtime, no Node, no DOM. /** * One line of a codex rollout JSONL file. The envelope carries a @@ -29,73 +41,3 @@ export type CodexRolloutLine = { type: string payload: unknown } - -/** - * The session_meta payload — first line of every rollout. Carries - * the session UUID, originating cwd, model provider, git info, etc. - */ -export type CodexSessionMeta = { - id: string // UUID (ThreadId in Rust) - timestamp: string - cwd: string - originator: string // "codex" - cli_version: string - source: string // "cli" | "app_server" | etc. - model_provider?: string - agent_path?: string - agent_nickname?: string - agent_role?: string - base_instructions?: string - forked_from_id?: string - memory_mode?: string -} - -/** - * A response_item payload — an OpenAI ResponseItem carrying a message, - * function call, or function call output. The `type` field inside the - * payload (distinct from the envelope's type) discriminates further. - * - * Key shapes we render: - * { type: "message", role: "user", content: [{ type: "input_text", text: "..." }] } - * { type: "message", role: "assistant", content: [{ type: "output_text", text: "..." }] } - * { type: "function_call", name: "...", arguments: "...", call_id: "..." } - * { type: "function_call_output", call_id: "...", output: "..." } - */ -export type CodexResponseItem = { - type: string - id?: string - role?: string // "user" | "assistant" | "system" - name?: string // function name for function_call - call_id?: string // links function_call ↔ function_call_output - arguments?: string // JSON string for function_call - output?: string // result for function_call_output - content?: Array<{ - type: string // "input_text" | "output_text" | "refusal" | etc. - text?: string - annotations?: unknown[] - }> - status?: string // "completed" | "in_progress" | "incomplete" | etc. -} - -/** - * An event_msg payload — UI/lifecycle events emitted by codex's - * event system. The `type` inside payload discriminates: - * user_message, agent_message, tool_call, tool_output, error, etc. - */ -export type CodexEventMsg = { - type: string // "user_message" | "agent_message" | etc. - message?: string - kind?: string // "plain" | etc. - text_elements?: unknown[] - local_images?: unknown[] -} - -/** - * Type guard: is this rollout line a conversation entry we should - * render in the feed? Returns true for response_item and event_msg - * which carry the actual conversation content; false for session_meta, - * compacted, and turn_context which are metadata. - */ -export function isCodexConversationEntry(line: CodexRolloutLine): boolean { - return line.type === 'response_item' || line.type === 'event_msg' -} diff --git a/src/renderer/src/features/path-picker/ui/PathPickerModal.tsx b/src/renderer/src/features/path-picker/ui/PathPickerModal.tsx index bc1e03bf5..6345a9d8c 100644 --- a/src/renderer/src/features/path-picker/ui/PathPickerModal.tsx +++ b/src/renderer/src/features/path-picker/ui/PathPickerModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { PathInput } from '@renderer/ui/PathInput' +import { relativeTime } from '@renderer/lib/relativeTime' // Duplicate of the SessionInfo shape preload exposes. Inlined here so // the renderer's tsconfig doesn't need to reach across into src/preload @@ -391,26 +392,3 @@ function ResumeRow({ ) } -/** - * Render a timestamp as a short relative string: `3m`, `2h`, `4d`, - * `3w`. Matches the vibe of git log / GitHub timestamps. Used in the - * resume picker where exact dates would cost space and not add info. - */ -function relativeTime(ms: number): string { - const delta = Date.now() - ms - if (delta < 0) return 'now' - const s = Math.floor(delta / 1000) - if (s < 60) return `${s}s ago` - const m = Math.floor(s / 60) - if (m < 60) return `${m}m ago` - const h = Math.floor(m / 60) - if (h < 24) return `${h}h ago` - const d = Math.floor(h / 24) - if (d < 7) return `${d}d ago` - const w = Math.floor(d / 7) - if (w < 5) return `${w}w ago` - const mo = Math.floor(d / 30) - if (mo < 12) return `${mo}mo ago` - const y = Math.floor(d / 365) - return `${y}y ago` -} diff --git a/src/renderer/src/features/system-perf/formatBytes.ts b/src/renderer/src/features/system-perf/formatBytes.ts new file mode 100644 index 000000000..fd53fe8c1 --- /dev/null +++ b/src/renderer/src/features/system-perf/formatBytes.ts @@ -0,0 +1,22 @@ +// System-perf byte formatter. +// +// WHY SI units (1e9 / 1e6) and not binary (GiB / MiB / KiB): every +// caller in the renderer compares its readout against macOS Activity +// Monitor, which is SI. Off by ~7% from binary, but in line with what +// the user actually sees in the OS-level monitor. Don't switch to +// binary without changing every caller's surrounding label first. +// +// WHY a tunable `fractionDigits`: the badge in the header is +// space-constrained (single line, fixed width) so it prefers integer +// MB/KB readings. The popover has room for more precision on the +// GB axis. The split used to live in two separate copies of this +// function (badge had no arg, popover had a `fractionDigits = 2` +// default) — folded here with the same `2` default so badge callers +// just pass nothing. + +export function formatBytes(bytes: number, fractionDigits = 2): string { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(fractionDigits)} GB` + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB` + if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB` + return `${bytes} B` +} diff --git a/src/renderer/src/features/system-perf/ui/SystemPerfBadge.tsx b/src/renderer/src/features/system-perf/ui/SystemPerfBadge.tsx index 9d2fbe3a2..b52ee771a 100644 --- a/src/renderer/src/features/system-perf/ui/SystemPerfBadge.tsx +++ b/src/renderer/src/features/system-perf/ui/SystemPerfBadge.tsx @@ -1,4 +1,5 @@ import type { SystemPerformanceStats } from '@shared/performance/types' +import { formatBytes } from '@renderer/features/system-perf/formatBytes' // Color zones for the heap badge. // @@ -66,17 +67,6 @@ function strokeFor(zone: Zone): string { } } -// "1.23 GB" / "456 MB". We always use SI multiples (1e9 / 1e6) to -// match Activity Monitor's display rather than Chrome's binary -// (GiB) units. Off by ~7% from GiB but in line with what users -// see in the OS-level monitor. -function formatBytes(bytes: number): string { - if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB` - if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB` - if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB` - return `${bytes} B` -} - type Props = { current: SystemPerformanceStats buffer: SystemPerformanceStats[] diff --git a/src/renderer/src/features/system-perf/ui/SystemPerfPopover.tsx b/src/renderer/src/features/system-perf/ui/SystemPerfPopover.tsx index 71093d637..106d37f94 100644 --- a/src/renderer/src/features/system-perf/ui/SystemPerfPopover.tsx +++ b/src/renderer/src/features/system-perf/ui/SystemPerfPopover.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import type { SystemPerformanceStats } from '@shared/performance/types' +import { formatBytes } from '@renderer/features/system-perf/formatBytes' // Popover dimensions — mirror PerformancePanel's outer chrome so // the two diagnostic surfaces look like siblings. @@ -22,18 +23,6 @@ const CHART_HEIGHT = 140 const CHART_PAD_TOP = 8 const CHART_PAD_BOTTOM = 16 -// "1.23 GB" / "456 MB" — duplicated from SystemPerfBadge so the -// popover doesn't import from a UI sibling. Keeping the helper -// local also means we can change one's formatting without -// accidentally shifting the other (the badge is space-constrained -// in the header; the popover has room for more precision). -function formatBytes(bytes: number, fractionDigits = 2): string { - if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(fractionDigits)} GB` - if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB` - if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB` - return `${bytes} B` -} - // Growth rate: bytes/sec over the last `seconds` window. Returns // positive for growth, negative for shrinkage, null if there's // not enough buffer to span the window. diff --git a/src/renderer/src/features/workspace/lib/sessionDisplay.ts b/src/renderer/src/features/workspace/lib/sessionDisplay.ts new file mode 100644 index 000000000..bd301fdf4 --- /dev/null +++ b/src/renderer/src/features/workspace/lib/sessionDisplay.ts @@ -0,0 +1,31 @@ +// Small display helpers shared by the workspace's search and +// activity modals (PromptSearchModal, AgentActivityModal). +// +// WHY this is scoped to those modal surfaces and not a generic +// "providerLabel" module: tile-tree pane headers use slightly +// different wording (full provider names, "Claude Code" vs "Claude") +// and a different glyph set, and pulling them through one shared +// formatter risks unifying surfaces that should diverge. Keep this +// strictly for list/row UIs that show a one-character provider +// marker and a path basename. + +export function cwdBasename(cwd: string): string { + if (!cwd) return '' + // Trim trailing slashes so `/foo/bar/` doesn't return an empty + // basename. Then split on '/' and take the last non-empty segment. + const trimmed = cwd.replace(/\/+$/, '') + const parts = trimmed.split('/').filter(Boolean) + return parts[parts.length - 1] ?? trimmed +} + +// WHY this accepts 'terminal' even though most callers only pass +// claude/codex: AgentActivityModal renders terminal sessions in the +// same list, so widening the signature means the modal doesn't have +// to special-case its row renderer. Callers that only deal with +// agent providers (PromptSearchModal) can still pass the narrower +// 'claude' | 'codex' subset — TypeScript will accept it. +export function providerGlyph(kind: 'claude' | 'codex' | 'terminal'): string { + if (kind === 'claude') return '⏺' + if (kind === 'codex') return '›' + return '$' +} diff --git a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx index 25e7a8964..3d2a5b568 100644 --- a/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx +++ b/src/renderer/src/features/workspace/ui/AgentActivityModal.tsx @@ -5,6 +5,8 @@ import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' import type { SessionId, Tab } from '@renderer/workspace/types' import type { Workspace } from '@renderer/workspace/workspaceStore' import type { Entry } from '@shared/types/transcript' +import { relativeTime } from '@renderer/lib/relativeTime' +import { cwdBasename, providerGlyph } from '@renderer/features/workspace/lib/sessionDisplay' // AgentActivityModal — overview of every visible pane grouped by tab // with a last-activity indicator per row. @@ -59,29 +61,6 @@ type TabGroup = { rows: Array<{ row: Row; idx: number }> } -// Relative-time renderer copy-pasted from PromptSearchModal.tsx. We -// didn't extract to a shared util because the other component is the -// only other caller right now — when a third surface wants it, hoist. -function relativeTime(ts: number): string { - const diffMs = Date.now() - ts - if (diffMs < 0) return 'in the future' - const secs = Math.floor(diffMs / 1000) - if (secs < 5) return 'just now' - if (secs < 60) return `${secs}s ago` - const mins = Math.floor(secs / 60) - if (mins < 60) return `${mins}m ago` - const hrs = Math.floor(mins / 60) - if (hrs < 24) return `${hrs}h ago` - const days = Math.floor(hrs / 24) - if (days < 7) return `${days}d ago` - const weeks = Math.floor(days / 7) - if (weeks < 5) return `${weeks}w ago` - const months = Math.floor(days / 30) - if (months < 12) return `${months}mo ago` - const years = Math.floor(days / 365) - return `${years}y ago` -} - function absoluteTime(ts: number): string { const d = new Date(ts) if (Number.isNaN(d.getTime())) return '' @@ -108,18 +87,6 @@ function extractLatestEntryTs(entries: Entry[]): number | null { return null } -function cwdBasename(cwd: string): string { - if (!cwd) return '' - const parts = cwd.split('/').filter(Boolean) - return parts[parts.length - 1] ?? cwd -} - -function providerGlyph(kind: 'claude' | 'codex' | 'terminal'): string { - if (kind === 'claude') return '⏺' - if (kind === 'codex') return '›' - return '$' -} - export function AgentActivityModal({ open, workspace, onClose }: Props) { const [selectedIdx, setSelectedIdx] = useState(0) const [sortMode, setSortMode] = useState('activity') diff --git a/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx b/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx index 12546c993..030ecea91 100644 --- a/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx +++ b/src/renderer/src/features/workspace/ui/PromptSearchModal.tsx @@ -3,6 +3,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { SessionIndexEntry, SessionIndexPrompt } from '@preload/index' import { commandTargetSessionId } from '@renderer/workspace/hook/selectors/commandTargetSessionId' import type { Workspace } from '@renderer/workspace/workspaceStore' +import { relativeTime } from '@renderer/lib/relativeTime' +import { cwdBasename, providerGlyph } from '@renderer/features/workspace/lib/sessionDisplay' // PromptSearchModal — cross-session prompt search. // @@ -52,43 +54,12 @@ const SEARCH_DEBOUNCE_MS = 150 // see the full prompt. const PROMPT_PREVIEW_MAX_CHARS = 180 -function relativeTime(ts: number): string { - const diffMs = Date.now() - ts - if (diffMs < 0) return 'in the future' - const secs = Math.floor(diffMs / 1000) - if (secs < 60) return `${secs}s ago` - const mins = Math.floor(secs / 60) - if (mins < 60) return `${mins}m ago` - const hrs = Math.floor(mins / 60) - if (hrs < 24) return `${hrs}h ago` - const days = Math.floor(hrs / 24) - if (days < 7) return `${days}d ago` - const weeks = Math.floor(days / 7) - if (weeks < 5) return `${weeks}w ago` - const months = Math.floor(days / 30) - if (months < 12) return `${months}mo ago` - const years = Math.floor(days / 365) - return `${years}y ago` -} - function truncatePreview(text: string): string { const firstLine = text.split('\n').find(l => l.trim()) ?? text if (firstLine.length <= PROMPT_PREVIEW_MAX_CHARS) return firstLine return firstLine.slice(0, PROMPT_PREVIEW_MAX_CHARS) + '…' } -function providerGlyph(kind: 'claude' | 'codex'): string { - // Match the feed's existing markers (⏺ for Claude, › for Codex). - return kind === 'claude' ? '⏺' : '›' -} - -function cwdBasename(cwd: string): string { - if (!cwd) return '' - const trimmed = cwd.replace(/\/+$/, '') - const idx = trimmed.lastIndexOf('/') - return idx >= 0 ? trimmed.slice(idx + 1) : trimmed -} - export function PromptSearchModal({ open, workspace, onClose }: Props) { const [query, setQuery] = useState('') const [sessions, setSessions] = useState([]) diff --git a/src/renderer/src/features/worktrees/ui/WorktreesBar.tsx b/src/renderer/src/features/worktrees/ui/WorktreesBar.tsx index 0ca17e198..b7d76bd5a 100644 --- a/src/renderer/src/features/worktrees/ui/WorktreesBar.tsx +++ b/src/renderer/src/features/worktrees/ui/WorktreesBar.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { formatWorktreeDump, labelFor, providerLabel } from '@renderer/features/worktrees/lib/formatWorktreeDump' +import { relativeTime } from '@renderer/lib/relativeTime' import { loadWorktreeDump, type WorktreeDump, @@ -183,21 +184,3 @@ function shortenPath(path: string): string { return `…/${parts.slice(-3).join('/')}` } -function relativeTime(ts: number): string { - const diffMs = Date.now() - ts - if (diffMs < 0) return 'in the future' - const secs = Math.floor(diffMs / 1000) - if (secs < 60) return `${secs}s ago` - const mins = Math.floor(secs / 60) - if (mins < 60) return `${mins}m ago` - const hrs = Math.floor(mins / 60) - if (hrs < 24) return `${hrs}h ago` - const days = Math.floor(hrs / 24) - if (days < 7) return `${days}d ago` - const weeks = Math.floor(days / 7) - if (weeks < 5) return `${weeks}w ago` - const months = Math.floor(days / 30) - if (months < 12) return `${months}mo ago` - const years = Math.floor(days / 365) - return `${years}y ago` -} diff --git a/src/renderer/src/lib/relativeTime.ts b/src/renderer/src/lib/relativeTime.ts new file mode 100644 index 000000000..178b9ec36 --- /dev/null +++ b/src/renderer/src/lib/relativeTime.ts @@ -0,0 +1,37 @@ +// Compact relative timestamps for status surfaces. +// +// Why this lives in src/renderer/src/lib (and not under +// features/worktrees or features/workspace): four different modals +// and bars need the same formatter — the path-picker session-age +// label, prompt-search results, agent-activity rows, and the +// worktrees bar — and they cross feature boundaries. Keeping the +// helper in a feature folder forced one of them to reach across +// (which would always feel wrong) so we hoist it to the shared +// renderer-lib level instead. +// +// The 'just now' threshold (sub-5s) exists so the agent-activity +// modal does not flicker between '0s ago', '1s ago', '2s ago' on +// freshly-touched sessions; older surfaces (where 'just now' would +// be inappropriate) can keep using the sub-minute seconds tick by +// querying with timestamps that are already > 5s old, but in +// practice everyone has been happy with 'just now'. + +export function relativeTime(ts: number): string { + const diffMs = Date.now() - ts + if (diffMs < 0) return 'in the future' + const secs = Math.floor(diffMs / 1000) + if (secs < 5) return 'just now' + if (secs < 60) return `${secs}s ago` + const mins = Math.floor(secs / 60) + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.floor(hrs / 24) + if (days < 7) return `${days}d ago` + const weeks = Math.floor(days / 7) + if (weeks < 5) return `${weeks}w ago` + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo ago` + const years = Math.floor(days / 365) + return `${years}y ago` +} diff --git a/src/renderer/src/workspace/codex/eventCursor.ts b/src/renderer/src/workspace/codex/eventCursor.ts new file mode 100644 index 000000000..efbb10215 --- /dev/null +++ b/src/renderer/src/workspace/codex/eventCursor.ts @@ -0,0 +1,34 @@ +// Codex rollout turn-id reconstruction helpers. +// +// WHY these exist as named extractors and not as `payload?.turn_id` +// expressions inline at each call site: +// +// Codex's `turn_context` lines are the clean source of truth for +// which turn a rollout entry belongs to, but pagination chunks and +// live IPC bursts can start *after* the `turn_context` that defined +// the active turn. Payload-level `turn_id` markers on individual +// rollout entries are therefore load-bearing fallback evidence — the +// only way to tie an `event_msg` to its parent turn when the +// surrounding `turn_context` is out of frame. +// +// Three different consumers do the same `payload?.turn_id` / +// `payload?.type` extraction: initial history, ipc subscriptions, +// and the history-action reducer. Keeping the extractors shared +// here prevents the three from drifting apart on field naming / +// fallback logic / null-handling, which would silently mis-route +// events into the wrong turn (and the user would see ordering bugs +// in the feed for codex resumes / scrollback loads). + +export function codexTurnIdFromEventPayload( + raw: Record, +): string | null { + const payload = raw.payload as Record | undefined + return typeof payload?.turn_id === 'string' ? payload.turn_id : null +} + +export function codexEventType( + raw: Record, +): string | null { + const payload = raw.payload as Record | undefined + return typeof payload?.type === 'string' ? payload.type : null +} diff --git a/src/renderer/src/workspace/codex/rollout.ts b/src/renderer/src/workspace/codex/rollout.ts index c2c7e5581..92b138c57 100644 --- a/src/renderer/src/workspace/codex/rollout.ts +++ b/src/renderer/src/workspace/codex/rollout.ts @@ -9,6 +9,7 @@ import { parseCodexJson, stripCodexExecWrapper, } from '@renderer/workspace/codex/entries' +import { asRecord } from '@shared/lib/asRecord' // Codex rollout → feed entry mapping. // @@ -514,12 +515,6 @@ export function mapCodexRolloutToFeedEntries(entry: Record): En return [] } -function asRecord(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null -} - /** Build the history marker for a Codex rollout entry. The format is * `:` — mirrors the * uuid used by mapCodexRolloutToFeedEntries so the older-history diff --git a/src/renderer/src/workspace/hook/actions/history.ts b/src/renderer/src/workspace/hook/actions/history.ts index 4f3135a09..8c4afdc39 100644 --- a/src/renderer/src/workspace/hook/actions/history.ts +++ b/src/renderer/src/workspace/hook/actions/history.ts @@ -26,16 +26,10 @@ import { import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import * as perf from '@renderer/performance/client' - -function codexTurnIdFromEventPayload(raw: Record): string | null { - const payload = raw.payload as Record | undefined - return typeof payload?.turn_id === 'string' ? payload.turn_id : null -} - -function codexEventType(raw: Record): string | null { - const payload = raw.payload as Record | undefined - return typeof payload?.type === 'string' ? payload.type : null -} +import { + codexEventType, + codexTurnIdFromEventPayload, +} from '@renderer/workspace/codex/eventCursor' // Older history loader — called by Feed's scroll handler when the // user scrolls near the top. diff --git a/src/renderer/src/workspace/hook/actions/initialHistory.ts b/src/renderer/src/workspace/hook/actions/initialHistory.ts index 3756175ef..a38df5c76 100644 --- a/src/renderer/src/workspace/hook/actions/initialHistory.ts +++ b/src/renderer/src/workspace/hook/actions/initialHistory.ts @@ -31,16 +31,10 @@ import { import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import * as perf from '@renderer/performance/client' - -function codexTurnIdFromEventPayload(raw: Record): string | null { - const payload = raw.payload as Record | undefined - return typeof payload?.turn_id === 'string' ? payload.turn_id : null -} - -function codexEventType(raw: Record): string | null { - const payload = raw.payload as Record | undefined - return typeof payload?.type === 'string' ? payload.type : null -} +import { + codexEventType, + codexTurnIdFromEventPayload, +} from '@renderer/workspace/codex/eventCursor' function seedSeenFromRuntime(runtime: SessionRuntime, seen: Set): void { for (const entry of runtime.entries) { diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index f90ba89e9..06036bbed 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -29,6 +29,10 @@ import { mapCodexRolloutToFeedEntries, stampCodexTurnId, } from '@renderer/workspace/codex/rollout' +import { + codexEventType, + codexTurnIdFromEventPayload, +} from '@renderer/workspace/codex/eventCursor' import { claudeHistoryMarker, extractEmbeddedClaudeProgressEntry, @@ -76,16 +80,6 @@ import * as perf from '@renderer/performance/client' // inherit the previous task's turn id. const codexCurrentTurnIdBySession = new Map() -function codexTurnIdFromEventPayload(raw: Record): string | null { - const payload = raw.payload as Record | undefined - return typeof payload?.turn_id === 'string' ? payload.turn_id : null -} - -function codexEventType(raw: Record): string | null { - const payload = raw.payload as Record | undefined - return typeof payload?.type === 'string' ? payload.type : null -} - type WorktreeCacheEntry = { worktrees: WorktreeIdentity[] refreshedAt: number diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/claudeImages.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/claudeImages.ts index c405a938c..6451c2881 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/claudeImages.ts +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/claudeImages.ts @@ -17,9 +17,17 @@ import type { ClaudeDraftImage } from '@renderer/workspace/workspaceState' // All three return the same `ClaudeDraftImage[]` so the caller can // concat + dedupe uniformly. -export const MAX_CLAUDE_IMAGE_BYTES = 5 * 1024 * 1024 +// WHY only SUPPORTED_CLAUDE_IMAGE_FORMATS_TEXT, the two predicates, +// and the public ingress helpers below are exported: every other +// constant and helper in this file (MAX_CLAUDE_IMAGE_BYTES, the +// media-type Set, fileToDataUrl, parseDataUrl, estimateBase64...) is +// an internal implementation detail used only inside this module's +// predicates and ingress functions. The user-facing format-text +// string is exported because the composer toast needs it; the two +// predicates are exported because the paste hook gates on them. +const MAX_CLAUDE_IMAGE_BYTES = 5 * 1024 * 1024 -export const SUPPORTED_CLAUDE_IMAGE_MEDIA_TYPES = new Set([ +const SUPPORTED_CLAUDE_IMAGE_MEDIA_TYPES = new Set([ 'image/png', 'image/jpeg', 'image/gif', @@ -36,7 +44,7 @@ export function exceedsClaudeImageSizeLimit(image: ClaudeDraftImage): boolean { return estimateBase64DecodedBytes(image.base64Data) > MAX_CLAUDE_IMAGE_BYTES } -export function fileToDataUrl(file: File): Promise { +function fileToDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onerror = () => reject(reader.error ?? new Error('failed to read file')) @@ -48,7 +56,7 @@ export function fileToDataUrl(file: File): Promise { }) } -export function parseDataUrl(dataUrl: string): { mediaType: string; base64Data: string } { +function parseDataUrl(dataUrl: string): { mediaType: string; base64Data: string } { const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl) if (!match) { throw new Error('unsupported image data url') @@ -63,7 +71,7 @@ export function parseDataUrl(dataUrl: string): { mediaType: string; base64Data: // 1 byte per `=` pad char. We only use this for the size-limit gate, // so approximate is fine — a 5 MB cap enforced at ±16 bytes is still // effectively a 5 MB cap. -export function estimateBase64DecodedBytes(base64Data: string): number { +function estimateBase64DecodedBytes(base64Data: string): number { const normalized = base64Data.trim() if (normalized.length === 0) return 0 const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0 diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/useClaudeImagePaste.ts b/src/renderer/src/workspace/tile-tree/TileLeaf/useClaudeImagePaste.ts index 53cb4b193..7e3ee96d2 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/useClaudeImagePaste.ts +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/useClaudeImagePaste.ts @@ -29,9 +29,10 @@ import { // // We also gate on media type (Claude supports PNG/JPEG/GIF/WebP) // and a 5 MB size cap — both enforced with a toast on violation -// and a short-circuit return. The caller owns `appendDraftImages` -// (usually the draft-images hook); we hand the validated payload -// over at the end. +// and a short-circuit return. Validated images are appended into +// the draft-images store via setDraftImages; the hook does the +// append internally so callers only need to consume handlePaste +// and removeDraftImage (paste-only ingress). // // `e.preventDefault()` is called whenever we handle the paste so // the default "paste as text" behavior doesn't leak data URL blobs @@ -118,5 +119,5 @@ export function useClaudeImagePaste({ [appendDraftImages, provider, showToast], ) - return { handlePaste, removeDraftImage, appendDraftImages } + return { handlePaste, removeDraftImage } } diff --git a/src/shared/lib/asRecord.ts b/src/shared/lib/asRecord.ts new file mode 100644 index 000000000..f88eaea9e --- /dev/null +++ b/src/shared/lib/asRecord.ts @@ -0,0 +1,18 @@ +// Shared unknown-JSON narrowing helper. +// +// WHY this exists as a named helper instead of inline assertions: +// most transcript and provider payloads enter agent-code as +// `unknown` (from JSONL reads, IPC payloads, proxy events, ...). +// Two parallel hand-written copies of this exact predicate had +// drifted in subtle ways — one accepted `null`, one didn't; one +// excluded arrays, one didn't — and the resulting `as Record<...>` +// casts at the call sites quietly differed too. Centralizing makes +// the "object but not array, not null" invariant explicit at the +// boundary and gives consumers a single place to evolve the rule +// (e.g. if we ever want to reject typed-array views). + +export function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +}