diff --git a/AGENTS.md b/AGENTS.md index 9eba3d6dc..deb69993c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ If a tradeoff is required, choose correctness and robustness over short-term con ## Maintainability -Long term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem. +Long-term maintainability is a core priority. If you add new functionality, first check if there is shared logic that can be extracted to a separate module. Duplicate logic across multiple files is a code smell and should be avoided. Don't be afraid to change existing code. Don't take shortcuts by just adding local logic to solve a problem. ## Package Roles diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index d1b93a1ae..f53094181 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1230,7 +1230,7 @@ function registerIpcHandlers(): void { ipcMain.handle(LOG_LIST_CHANNEL, async () => { try { const entries = await FS.promises.readdir(LOG_DIR); - return entries.filter((f) => f.endsWith(".log")).sort(); + return entries.filter((f) => f.endsWith(".log")).toSorted(); } catch { return []; } diff --git a/apps/server/src/ampServerManager.ts b/apps/server/src/ampServerManager.ts index 8b86f7a8a..97bb4274d 100644 --- a/apps/server/src/ampServerManager.ts +++ b/apps/server/src/ampServerManager.ts @@ -355,6 +355,7 @@ export class AmpServerManager extends EventEmitter<{ } catch (err) { throw new Error( `Failed to write to AMP stdin for session ${input.threadId}: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, ); } diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 89785cf9a..e9a99f720 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -27,6 +27,7 @@ import { isCodexCliVersionSupported, parseCodexCliVersion, } from "./provider/codexCliVersion"; +import { createLogger } from "./logger"; type PendingRequestKey = string; @@ -514,6 +515,7 @@ export interface CodexAppServerManagerEvents { export class CodexAppServerManager extends EventEmitter { private readonly sessions = new Map(); + private readonly logger = createLogger("codex"); private runPromise: (effect: Effect.Effect) => Promise; constructor(services?: ServiceMap.ServiceMap) { @@ -585,21 +587,21 @@ export class CodexAppServerManager extends EventEmitter ThreadId.makeUnsafe(value); // Helpers to inspect spawned processes // --------------------------------------------------------------------------- -/** Minimal mock for ChildProcess returned by `spawn`. */ -function createMockChildProcess() { - const stdout = new EventEmitter(); - const stderr = new EventEmitter(); - const child = Object.assign(new EventEmitter(), { - stdout, - stderr, - stdin: { write: vi.fn() }, - kill: vi.fn(), - pid: 12345, - }); - return child; -} - -/** Capture spawn calls so we can inspect args & feed fake output. */ -function mockSpawn() { - const children: ReturnType[] = []; - const spawnMock = vi.fn((_cmd: string, _args: string[]) => { - const child = createMockChildProcess(); - children.push(child); - return child; - }); - vi.doMock("node:child_process", () => ({ spawn: spawnMock })); - return { spawnMock, children }; -} - -/** Feed a line of JSON to the child's stdout as if it were a readline event. */ -function feedStdoutLine( - child: ReturnType, - json: Record, -): void { - // Simulate readline "line" event by emitting data that includes a newline. - // Since we use readline.createInterface on stdout, we emit raw data. - child.stdout.emit("data", Buffer.from(JSON.stringify(json) + "\n")); -} - // --------------------------------------------------------------------------- // Unit tests — no real gemini process // --------------------------------------------------------------------------- @@ -234,7 +197,7 @@ describe("GeminiCliServerManager", () => { const sessions = manager.listSessions(); expect(sessions).toHaveLength(2); - expect(sessions.map((s) => s.threadId).sort()).toEqual(["thread-1", "thread-2"]); + expect(sessions.map((s) => s.threadId).toSorted()).toEqual(["thread-1", "thread-2"]); } finally { manager.stopAll(); } @@ -352,8 +315,8 @@ describe("GeminiCliServerManager JSON event mapping", () => { expect(events).toHaveLength(2); expect(events[0]?.type).toBe("content.delta"); expect(events[0]?.provider).toBe("geminiCli"); - expect((events[0]?.payload as { delta: string }).delta).toBe("Hello, "); - expect((events[0]?.payload as { streamKind: string }).streamKind).toBe("assistant_text"); + expect((events[0]!.payload as { delta: string }).delta).toBe("Hello, "); + expect((events[0]!.payload as { streamKind: string }).streamKind).toBe("assistant_text"); // Both deltas must share the same itemId for proper message aggregation. expect(events[1]?.type).toBe("content.delta"); @@ -660,7 +623,7 @@ describe.skipIf(!hasGemini || process.env.RUN_GEMINI_LIVE_TESTS !== "1")( // Turn should be completed successfully. const completed = events.find((e) => e.type === "turn.completed"); - expect((completed?.payload as { state: string }).state).toBe("completed"); + expect((completed!.payload as { state: string }).state).toBe("completed"); } finally { manager.stopAll(); } diff --git a/apps/server/src/kilo/serverLifecycle.ts b/apps/server/src/kilo/serverLifecycle.ts index 4297a7329..1aacd5459 100644 --- a/apps/server/src/kilo/serverLifecycle.ts +++ b/apps/server/src/kilo/serverLifecycle.ts @@ -58,14 +58,8 @@ export async function ensureServer( } const serverPromise = spawnOrConnect(options); - try { - const state = await serverPromise; - return { state, serverPromise }; - } catch (error) { - // Propagate the error — the caller's finally block clears serverPromise - // when this.server is still undefined, enabling retry on next call. - throw error; - } + const state = await serverPromise; + return { state, serverPromise }; } async function spawnOrConnect(options?: KiloProviderOptions): Promise { diff --git a/apps/server/src/kilo/types.ts b/apps/server/src/kilo/types.ts index 73796dd2b..01df88512 100644 --- a/apps/server/src/kilo/types.ts +++ b/apps/server/src/kilo/types.ts @@ -1,5 +1,4 @@ import type { - ProviderApprovalDecision, ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, diff --git a/apps/server/src/opencode/serverLifecycle.ts b/apps/server/src/opencode/serverLifecycle.ts index a8727963a..b0308a127 100644 --- a/apps/server/src/opencode/serverLifecycle.ts +++ b/apps/server/src/opencode/serverLifecycle.ts @@ -57,14 +57,8 @@ export async function ensureServer( } const serverPromise = spawnOrConnect(options); - try { - const state = await serverPromise; - return { state, serverPromise }; - } catch (error) { - // Propagate the error — the caller's finally block clears serverPromise - // when this.server is still undefined, enabling retry on next call. - throw error; - } + const state = await serverPromise; + return { state, serverPromise }; } async function spawnOrConnect(options?: OpenCodeProviderOptions): Promise { diff --git a/apps/server/src/opencode/types.ts b/apps/server/src/opencode/types.ts index 7bd13e97e..3ee20b1ad 100644 --- a/apps/server/src/opencode/types.ts +++ b/apps/server/src/opencode/types.ts @@ -1,5 +1,4 @@ import type { - ProviderApprovalDecision, ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 2df92b6a7..6e747b8e4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -17,7 +17,6 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; -import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -75,22 +74,6 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); -function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { - const error = Cause.squash(cause); - if (Schema.is(ProviderAdapterRequestError)(error)) { - const detail = error.detail.toLowerCase(); - return ( - detail.includes("unknown pending approval request") || - detail.includes("unknown pending permission request") - ); - } - const message = Cause.pretty(cause); - return ( - message.includes("unknown pending approval request") || - message.includes("unknown pending permission request") - ); -} - function isTemporaryWorktreeBranch(branch: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); } diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index c08ef590c..f1814069b 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -43,14 +43,14 @@ function updateThread( return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); } -function decodeForEvent( - schema: Schema.Schema, +function decodeForEvent( + schema: S, value: unknown, eventType: OrchestrationEvent["type"], field: string, -): Effect.Effect { +): Effect.Effect { return Effect.try({ - try: () => Schema.decodeUnknownSync(schema as any)(value), + try: () => Schema.decodeUnknownSync(schema)(value), catch: (error) => toProjectorDecodeError(`${eventType}:${field}`)(error as Schema.SchemaError), }); } diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index c7bcbda2c..68f3df635 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -4,7 +4,7 @@ * * @module SqliteClient */ -import { DatabaseSync, type StatementSync } from "node:sqlite"; +import { DatabaseSync, type SQLInputValue, type StatementSync } from "node:sqlite"; import * as Cache from "effect/Cache"; import * as Config from "effect/Config"; @@ -126,9 +126,9 @@ const makeWithDatabase = ( statement.setReadBigInts(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers))); try { if (hasRows(statement)) { - return Effect.succeed(statement.all(...(params as any))); + return Effect.succeed(statement.all(...(params as SQLInputValue[]))); } - const result = statement.run(...(params as any)); + const result = statement.run(...(params as SQLInputValue[])); return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); } catch (cause) { return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" })); @@ -147,11 +147,11 @@ const makeWithDatabase = ( if (hasRows(statement)) { statement.setReturnArrays(true); // Safe to cast to array after we've setReturnArrays(true) - return statement.all(...(params as any)) as unknown as ReadonlyArray< + return statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray< ReadonlyArray >; } - statement.run(...(params as any)); + statement.run(...(params as SQLInputValue[])); return []; }, catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts index 7eaf27626..f9b594e65 100644 --- a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts @@ -53,7 +53,10 @@ import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/Clau import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { toMessage } from "../toMessage.ts"; +import { createLogger } from "../../logger"; + const PROVIDER = "claudeCode" as const; +const logger = createLogger(PROVIDER); /** * Environment variables that must be stripped before spawning the Claude Code @@ -269,16 +272,16 @@ function diagnosticEnvKeys( ): ReadonlyArray { return Object.keys(env) .filter((key) => DESKTOP_DIAGNOSTIC_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) - .sort(); + .toSorted(); } function logDesktopClaudeDiagnostic(message: string, data?: Record): void { if (!isDesktopRuntime()) return; if (data) { - console.warn("[claudeCode][desktop]", message, data); + logger.warn(`[desktop] ${message}`, data); return; } - console.warn("[claudeCode][desktop]", message); + logger.warn(`[desktop] ${message}`); } function asRuntimeItemId(value: string): RuntimeItemId { @@ -848,9 +851,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { : {}), ...(errorMessage ? { errorMessage } : {}), }, - providerRefs: { - ...(fallbackTurnId ? { providerTurnId: String(fallbackTurnId) } : {}), - }, + providerRefs: fallbackTurnId ? { providerTurnId: String(fallbackTurnId) } : {}, }); const updatedAt = yield* nowIso; @@ -1762,7 +1763,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { stderr: (message: string) => { const trimmed = message.trimEnd(); if (trimmed.length > 0) { - console.warn("[claudeCode][stderr]", trimmed); + logger.warn(`[stderr] ${trimmed}`); } }, } @@ -1771,7 +1772,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) { }; logDesktopClaudeDiagnostic("starting Claude query", { - blockedEnvKeys: [...SPAWN_ENV_BLOCKLIST].sort(), + blockedEnvKeys: Array.from(SPAWN_ENV_BLOCKLIST).toSorted(), inheritedDiagnosticEnvKeys: diagnosticEnvKeys(process.env), forwardedDiagnosticEnvKeys: diagnosticEnvKeys(queryOptions.env ?? {}), model: input.model, diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index 9e5379b20..01926c063 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1588,9 +1588,9 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => const listSessions: CopilotAdapterShape["listSessions"] = () => Effect.sync(() => - Array.from(sessions.values()).map( - (record) => - ({ + Array.from(sessions.values()).map((record) => + Object.assign( + { provider: PROVIDER, status: record.currentTurnId ? "running" : "ready", runtimeMode: record.runtimeMode, @@ -1598,11 +1598,12 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => resumeCursor: record.session.sessionId, createdAt: record.createdAt, updatedAt: record.updatedAt, - ...(record.cwd ? { cwd: record.cwd } : {}), - ...(record.model ? { model: record.model } : {}), - ...(record.currentTurnId ? { activeTurnId: record.currentTurnId } : {}), - ...(record.lastError ? { lastError: record.lastError } : {}), - }) satisfies ProviderSession, + } as ProviderSession, + record.cwd ? { cwd: record.cwd } : undefined, + record.model ? { model: record.model } : undefined, + record.currentTurnId ? { activeTurnId: record.currentTurnId } : undefined, + record.lastError ? { lastError: record.lastError } : undefined, + ), ), ); @@ -1761,18 +1762,22 @@ export async function fetchCopilotUsage(): Promise<{ resetDate?: string; } >, - ).map(([key, snap]) => ({ - plan: key, - limit: Math.max(0, Math.trunc(snap.entitlementRequests)), - used: Math.max(0, Math.trunc(snap.usedRequests)), - remaining: Math.max( - 0, - Math.trunc(snap.entitlementRequests) - Math.trunc(snap.usedRequests), + ).map(([key, snap]) => + Object.assign( + { + plan: key, + limit: Math.max(0, Math.trunc(snap.entitlementRequests)), + used: Math.max(0, Math.trunc(snap.usedRequests)), + remaining: Math.max( + 0, + Math.trunc(snap.entitlementRequests) - Math.trunc(snap.usedRequests), + ), + percentageRemaining: snap.remainingPercentage, + overage: Math.max(0, Math.trunc(snap.overage)), + }, + snap.resetDate ? { resetDate: snap.resetDate } : undefined, ), - percentageRemaining: snap.remainingPercentage, - overage: Math.max(0, Math.trunc(snap.overage)), - ...(snap.resetDate ? { resetDate: snap.resetDate } : {}), - })); + ); return { provider: "copilot", quotas }; } finally { await client.stop().catch(() => {}); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts index be20099db..5322b45d5 100644 --- a/apps/server/src/workspaceEntries.ts +++ b/apps/server/src/workspaceEntries.ts @@ -37,11 +37,6 @@ interface SearchableWorkspaceEntry extends ProjectEntry { normalizedName: string; } -interface RankedWorkspaceEntry { - entry: SearchableWorkspaceEntry; - score: number; -} - const workspaceIndexCache = new Map(); const inFlightWorkspaceIndexBuilds = new Map>(); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a31..0c16ff671 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1686,9 +1686,9 @@ describe("WebSocket Server", () => { }; const status = vi.fn(() => Effect.succeed(statusResult)); - const runStackedAction = vi.fn(() => Effect.void as any); - const resolvePullRequest = vi.fn(() => Effect.void as any); - const preparePullRequestThread = vi.fn(() => Effect.void as any); + const runStackedAction = vi.fn(() => Effect.die("not implemented")); + const resolvePullRequest = vi.fn(() => Effect.die("not implemented")); + const preparePullRequestThread = vi.fn(() => Effect.die("not implemented")); const gitManager: GitManagerShape = { status, resolvePullRequest, @@ -1729,10 +1729,10 @@ describe("WebSocket Server", () => { }; const gitManager: GitManagerShape = { - status: vi.fn(() => Effect.void as any), + status: vi.fn(() => Effect.die("not implemented")), resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)), preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)), - runStackedAction: vi.fn(() => Effect.void as any), + runStackedAction: vi.fn(() => Effect.die("not implemented")), }; server = await createTestServer({ cwd: "/test", gitManager }); @@ -1777,9 +1777,9 @@ describe("WebSocket Server", () => { ), ); const gitManager: GitManagerShape = { - status: vi.fn(() => Effect.void as any), - resolvePullRequest: vi.fn(() => Effect.void as any), - preparePullRequestThread: vi.fn(() => Effect.void as any), + status: vi.fn(() => Effect.die("not implemented")), + resolvePullRequest: vi.fn(() => Effect.die("not implemented")), + preparePullRequestThread: vi.fn(() => Effect.die("not implemented")), runStackedAction, }; diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index f17e1b232..e50aea320 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -260,16 +260,9 @@ export function getSlashModelOptions( }); } -let listeners: Array<() => void> = []; let cachedRawSettings: string | null = null; let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; -function emitChange(): void { - for (const listener of listeners) { - listener(); - } -} - function migratePersistedAppSettings(value: unknown): unknown { if (!value || typeof value !== "object" || Array.isArray(value)) { return value; @@ -313,38 +306,6 @@ export function getAppSettingsSnapshot(): AppSettings { return cachedSnapshot; } -function persistSettings(next: AppSettings): void { - if (typeof window === "undefined") return; - - const raw = JSON.stringify(next); - try { - if (raw !== cachedRawSettings) { - window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); - } - } catch { - // Best-effort persistence only. - } - - cachedRawSettings = raw; - cachedSnapshot = next; -} - -function subscribe(listener: () => void): () => void { - listeners.push(listener); - - const onStorage = (event: StorageEvent) => { - if (event.key === APP_SETTINGS_STORAGE_KEY) { - emitChange(); - } - }; - - window.addEventListener("storage", onStorage); - return () => { - listeners = listeners.filter((entry) => entry !== listener); - window.removeEventListener("storage", onStorage); - }; -} - export function useAppSettings() { const [settings, setSettings] = useLocalStorage( APP_SETTINGS_STORAGE_KEY, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index e4a16bc91..3309f4eaa 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -36,7 +36,7 @@ import { useStore } from "../store"; import { type Project, type ProjectScript, type Thread } from "../types"; import { CommandDialog, CommandDialogPopup, CommandFooter } from "./ui/command"; import { ScrollArea } from "./ui/scroll-area"; -import { cn } from "~/lib/utils"; +import { cn, formatRelativeTime } from "~/lib/utils"; type PaletteGroupId = "actions" | "scripts" | "projects" | "threads"; @@ -84,16 +84,6 @@ const GROUP_LABELS: Record = { threads: "Threads", }; -function formatRelativeTime(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diff / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - function threadSubtitle(thread: Thread, projectName: string | undefined): string { const parts: string[] = []; if (projectName) parts.push(projectName); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7e355a66b..5aed608d6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -12,15 +12,7 @@ import { TriangleAlertIcon, XIcon, } from "lucide-react"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type DragEvent, - type MouseEvent, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; import { DndContext, type DragCancelEvent, @@ -53,10 +45,10 @@ import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { resolveThreadProvider } from "../lib/threadProvider"; -import { isMacPlatform, newCommandId, newProjectId, newThreadId } from "../lib/utils"; +import { formatRelativeTime, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; -import { isChatNewLocalShortcut, isChatNewShortcut, shortcutLabelForCommand } from "../keybindings"; -import { useProjectThreadNavigation } from "../hooks/useProjectThreadNavigation"; +import { shortcutLabelForCommand } from "../keybindings"; + import { type Thread } from "../types"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; @@ -122,16 +114,6 @@ function threadTitleMatchesSearch(thread: Thread, query: string): boolean { return thread.title.toLocaleLowerCase().includes(query); } -function formatRelativeTime(iso: string): string { - const diff = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diff / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - interface TerminalStatusIndicator { label: "Terminal process running"; colorClass: string; diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index beae4fbce..a2b92ae38 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -114,7 +114,7 @@ export function mergeDiscoveredModels( const discoveredBySlug = new Map(dedupedModels.map((m) => [m.slug, m])); const merged = (base[provider] ?? []).map((m) => { const disc = discoveredBySlug.get(m.slug); - return disc ? { ...m, ...disc } : m; + return disc ? Object.assign({}, m, disc) : m; }); // Append any discovered models that weren't already in the base list. const additions = dedupedModels.filter((m) => !existing.has(m.slug)); @@ -167,11 +167,11 @@ function groupModelsBySubProvider( const result: GroupedModelEntry[] = sorted.map((id) => { const group = groupMap.get(id)!; - const sortedModels = [...group.models].sort((a, b) => a.name.localeCompare(b.name)); + const sortedModels = group.models.toSorted((a, b) => a.name.localeCompare(b.name)); return { subProvider: group.displayName, models: sortedModels, connected: group.connected }; }); if (ungrouped.length > 0) { - const sortedUngrouped = [...ungrouped].sort((a, b) => a.name.localeCompare(b.name)); + const sortedUngrouped = ungrouped.toSorted((a, b) => a.name.localeCompare(b.name)); result.push({ subProvider: "Other", models: sortedUngrouped, connected: true }); } return result; diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 08a5de91e..f30436f57 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -30,3 +30,15 @@ export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUUID()); export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID()); export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID()); + +export function formatRelativeTime(iso: string): string { + const timestamp = new Date(iso).getTime(); + if (!Number.isFinite(timestamp)) return "just now"; + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 52f06b3db..e656252bb 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -265,10 +265,11 @@ function HighlightedLogContent({ content }: { content: string }) { const lines = content.split("\n"); return ( <> - {lines.map((line, i) => ( - + {lines.map((line, lineIndex) => ( + // eslint-disable-next-line react/no-array-index-key -- static log lines never reorder + {highlightLogLine(line)} - {i < lines.length - 1 ? "\n" : null} + {lineIndex < lines.length - 1 ? "\n" : null} ))} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index c85a9b76e..77e822b97 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -104,22 +104,6 @@ function makeReadModel(thread: OrchestrationReadModel["threads"][number]): Orche }; } -function makeReadModelProject( - overrides: Partial, -): OrchestrationReadModel["projects"][number] { - return { - id: ProjectId.makeUnsafe("project-1"), - title: "Project", - workspaceRoot: "/tmp/project", - defaultModel: "gpt-5.3-codex", - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - deletedAt: null, - scripts: [], - ...overrides, - }; -} - describe("store pure functions", () => { it("markThreadUnread moves lastVisitedAt before completion for a completed thread", () => { const latestTurnCompletedAt = "2026-02-25T12:30:00.000Z";