From 4b5f378b0b65243fcc99e131a5821917dac43a06 Mon Sep 17 00:00:00 2001 From: yappologistic Date: Fri, 27 Mar 2026 00:23:33 -0600 Subject: [PATCH 1/6] Add unified usage dashboard --- .docs/provider-settings.md | 10 + README.md | 3 +- .../Layers/ProviderRuntimeIngestion.test.ts | 7 +- .../Layers/ProviderRuntimeIngestion.ts | 5 + apps/web/src/components/ChatView.browser.tsx | 83 +++ apps/web/src/components/ChatView.tsx | 65 +- .../web/src/components/PiProvider.browser.tsx | 2 + .../components/chat/ProviderModelPicker.tsx | 41 +- .../components/chat/UsageDashboardDialog.tsx | 573 ++++++++++++++++++ apps/web/src/lib/contextWindow.ts | 38 +- apps/web/src/lib/usageDashboard.test.ts | 123 ++++ apps/web/src/lib/usageDashboard.ts | 257 ++++++++ 12 files changed, 1143 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/components/chat/UsageDashboardDialog.tsx create mode 100644 apps/web/src/lib/usageDashboard.test.ts create mode 100644 apps/web/src/lib/usageDashboard.ts diff --git a/.docs/provider-settings.md b/.docs/provider-settings.md index 5b908871a34..26fb89b1665 100644 --- a/.docs/provider-settings.md +++ b/.docs/provider-settings.md @@ -161,6 +161,16 @@ Codex also has a per-turn `Fast Mode` toggle in the composer controls. This is s CUT3 hides the "token context left" UI for OpenRouter-routed models because the routed model can change and the remaining-context display is not reliable enough there. +### Usage dashboard + +The composer context ring is now a full `Usage dashboard` trigger instead of only a passive status badge. + +- Click the context ring to open a dialog with the current selection's documented/live context window, token breakdown, latest matching runtime snapshot metadata, and latest reported spend when the provider exposes it. +- The model picker footer also includes a `Usage` shortcut so you can open the same dashboard while browsing providers/models. +- If the latest stored runtime snapshot belongs to a different provider/model than the current selection, CUT3 calls that out explicitly instead of silently showing stale numbers. +- GitHub Copilot selections also include current premium-request quota information in the dashboard. +- Cost reporting depends on the provider runtime. CUT3 shows the latest reported USD amount when the provider supplies it, otherwise the spend card stays unavailable instead of guessing. + ### Image attachments The composer also supports lightweight image input: diff --git a/README.md b/README.md index aea1646b06a..15041d918c7 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,12 @@ Open Settings in the app to configure provider-specific behavior on the current - **Provider overrides**: set custom binary paths for Codex, Copilot, OpenCode, or Kimi, plus an optional Codex home path, a shared OpenRouter API key, and a Kimi API key. Pi is embedded through CUT3's Node dependency instead of a separate binary override; CUT3 reads Pi auth/models config from `~/.pi/agent`, keeps Pi packages, AGENTS files, system prompts, extensions, skills, prompt templates, and themes disabled so workspace instructions still come only from CUT3, and now surfaces authenticated Pi provider/model ids directly in the picker and `/model` suggestions instead of only showing a static `pi/default` placeholder. OpenCode account authentication still happens outside CUT3 through `opencode auth login` and `opencode auth logout`, while MCP server auth/debug remains server-specific through commands like `opencode mcp auth ` and `opencode mcp debug `. The OpenCode settings panel inspects the resolved OpenCode config paths plus `opencode auth list`, `opencode mcp list`, and `opencode mcp auth list` so CUT3 can show current credentials, provider-specific MCP status (including disabled and auth-gated entries), and copyable recovery commands. Kimi CLI authentication can use either `kimi login` or the in-shell `/login` flow when you are not using an API key, and new OpenCode sessions now inherit that shared OpenRouter key as `OPENROUTER_API_KEY` when the OpenCode provider config expects it. - **OpenRouter free models**: review the current OpenRouter entries that are explicitly free-locked and compatible with CUT3's native tool-calling path (`tools` plus `tool_choice`), keep the built-in `openrouter/free` router handy, and pin any listed model into the picker. - **Custom model slugs**: save extra model ids for GitHub Copilot, OpenCode, Kimi, Pi provider/model ids such as `github-copilot/claude-sonnet-4.5`, custom Codex models, or current OpenRouter `:free` slugs so they appear in the model picker and `/model` suggestions. -- **Picker controls**: the chat composer now uses a searchable grouped model picker with direct `Connect provider` and `Manage models` actions. +- **Picker controls**: the chat composer now uses a searchable grouped model picker with direct `Usage`, `Connect provider`, and `Manage models` actions. - **Model visibility**: hide or restore discovered and saved models without deleting them; hidden models are removed from both the picker and `/model` suggestions until you show them again. - **Thread defaults**: choose whether new draft threads start in `Local` or `New worktree`, and set thread sharing to `Manual`, `Auto` (create a share link after a new server-backed thread settles), or `Disabled` for new links. - **Codex service tier**: choose `Automatic`, `Fast`, or `Flex` as the default service tier for new Codex turns. - **Per-turn controls**: the composer exposes provider-aware reasoning controls where CUT3 has a provider-specific contract today. Codex and GitHub Copilot expose provider-specific reasoning levels, Codex also supports a per-turn `Fast Mode` toggle, and Pi now surfaces its live model reasoning capability plus Pi thinking levels (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`) for reasoning-capable Pi models while still preserving Pi defaults until you choose an override. +- **Usage dashboard**: click the composer context ring or the picker `Usage` action to open a unified usage dashboard for the current selection, including documented/live context limits, token breakdowns from the latest matching runtime snapshot, latest reported spend when the provider exposes it, and GitHub Copilot quota details. - **Response visibility**: choose whether assistant messages stream token-by-token and whether tool/work-log entries stay visible in the main timeline. - **Permission policies**: save persistent app-wide or project-scoped approval rules with `allow`, `ask`, or `deny` actions, request-kind filters, request-type/detail matching, and Build/Plan/Review presets. diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 34148c42557..a4c536af2cd 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -274,17 +274,19 @@ describe("ProviderRuntimeIngestion", () => { modelUsage: { inputTokens: 321, }, + totalCostUsd: 0.42, }, }); const thread = await waitForThread(harness.engine, (entry) => { const tokenUsage = entry.session?.tokenUsage as - | { kind?: string; usage?: { inputTokens?: number } } + | { kind?: string; usage?: { inputTokens?: number }; totalCostUsd?: number } | undefined; return ( entry.session?.status === "ready" && tokenUsage?.kind === "turn" && - tokenUsage.usage?.inputTokens === 321 + tokenUsage.usage?.inputTokens === 321 && + tokenUsage.totalCostUsd === 0.42 ); }); @@ -299,6 +301,7 @@ describe("ProviderRuntimeIngestion", () => { modelUsage: { inputTokens: 321, }, + totalCostUsd: 0.42, }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index a103b64e11b..03555690c03 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -137,6 +137,7 @@ function createSessionTokenUsageSnapshot(input: { model: string; usage?: unknown; modelUsage?: Readonly>; + totalCostUsd?: number; }) { return { provider: input.event.provider, @@ -145,6 +146,7 @@ function createSessionTokenUsageSnapshot(input: { model: input.model, ...(input.usage !== undefined ? { usage: normalizeRuntimeTokenUsage(input.usage) } : {}), ...(input.modelUsage !== undefined ? { modelUsage: input.modelUsage } : {}), + ...(typeof input.totalCostUsd === "number" ? { totalCostUsd: input.totalCostUsd } : {}), }; } @@ -940,6 +942,9 @@ const make = Effect.gen(function* () { >, } : {}), + ...(typeof turnCompletedPayload.totalCostUsd === "number" + ? { totalCostUsd: turnCompletedPayload.totalCostUsd } + : {}), }) : undefined; const nextActiveTurnId = diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 20486011eec..565ef16ced9 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -570,6 +570,23 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { if (tag === WS_METHODS.serverGetOpenCodeState) { return fixture.openCodeState ?? createUnavailableOpenCodeState(); } + if (tag === WS_METHODS.serverGetCopilotUsage) { + return { + status: "available", + source: "copilot_internal_user", + fetchedAt: NOW_ISO, + login: "octocat", + plan: "individual", + entitlement: 500, + remaining: 320, + used: 180, + percentRemaining: 64, + overagePermitted: true, + overageCount: 0, + unlimited: false, + resetAt: isoAt(86_400), + }; + } if (tag === WS_METHODS.gitListBranches) { return { isRepo: true, @@ -1629,6 +1646,72 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("opens the usage dashboard with spend and Copilot quota details", async () => { + const snapshot = withActiveThreadProvider( + createSnapshotForTargetUser({ + targetMessageId: "msg-user-usage-dashboard" as MessageId, + targetText: "usage dashboard target", + }), + "copilot", + ); + const snapshotWithUsage = { + ...snapshot, + threads: snapshot.threads.map((thread) => { + if (thread.id !== THREAD_ID || !thread.session) { + return thread; + } + return Object.assign({}, thread, { + model: "claude-sonnet-4.5", + session: { + ...thread.session, + providerName: "copilot", + tokenUsage: { + provider: "copilot", + kind: "turn", + observedAt: NOW_ISO, + model: "claude-sonnet-4.5", + totalCostUsd: 0.42, + usage: { + inputTokens: 1_200, + outputTokens: 340, + thoughtTokens: 56, + }, + }, + }, + }); + }), + } satisfies OrchestrationReadModel; + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: snapshotWithUsage, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [createReadyProviderStatus("codex"), createReadyProviderStatus("copilot")], + }; + }, + }); + + try { + const contextStatus = await waitForComposerControl("context-status"); + contextStatus.click(); + + const dashboard = await waitForElement( + () => document.querySelector("[data-usage-dashboard='true']"), + "Unable to find the usage dashboard dialog.", + ); + + expect(normalizeTextContent(document.body.textContent)).toContain("Usage dashboard"); + expect(normalizeTextContent(dashboard.textContent)).toContain("$0.4200"); + expect(normalizeTextContent(dashboard.textContent)).toContain("320 / 500"); + expect(normalizeTextContent(dashboard.textContent)).toContain("GitHub Copilot billing"); + expect(normalizeTextContent(dashboard.textContent)).toContain("1,596 tokens"); + } finally { + await mounted.cleanup(); + } + }); + it("shows a pointer cursor for the running stop button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 863d35d80b2..8f6c82f1b19 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -82,7 +82,11 @@ import { serverOpenCodeStateQueryOptions, serverQueryKeys, } from "~/lib/serverReactQuery"; -import { describeContextWindowState, shouldHideContextWindowForModel } from "~/lib/contextWindow"; +import { + describeContextWindowState, + getDocumentedContextWindowOverride, + shouldHideContextWindowForModel, +} from "~/lib/contextWindow"; import { isCut3CompatibleOpenRouterModelOption, isOpenRouterGuaranteedFreeSlug, @@ -258,6 +262,7 @@ import { ThreadTasksPanel } from "./chat/ThreadTasksPanel"; import { ThreadExportDialog } from "./chat/ThreadExportDialog"; import { ThreadShareDialog } from "./chat/ThreadShareDialog"; import { ComposerSkillPicker } from "./chat/ComposerSkillPicker"; +import { UsageDashboardDialog } from "./chat/UsageDashboardDialog"; import { commandForProjectScript, nextProjectScriptId, @@ -981,6 +986,7 @@ export default function ChatView({ threadId }: ChatViewProps) { useState(null); const [isProviderSetupDialogOpen, setIsProviderSetupDialogOpen] = useState(false); const [isManageModelsDialogOpen, setIsManageModelsDialogOpen] = useState(false); + const [isUsageDashboardOpen, setIsUsageDashboardOpen] = useState(false); const [isRefreshingProviderSetupState, setIsRefreshingProviderSetupState] = useState(false); const [isOpenRouterApiKeyDialogOpen, setIsOpenRouterApiKeyDialogOpen] = useState(false); const [openRouterApiKeyDraft, setOpenRouterApiKeyDraft] = useState(""); @@ -6872,6 +6878,7 @@ export default function ChatView({ threadId }: ChatViewProps) { hasHiddenModels={hasHiddenPickerModels} onOpenProviderSetup={openProviderSetupDialog} onOpenManageModels={openManageModelsDialog} + onOpenUsageDashboard={() => setIsUsageDashboardOpen(true)} onProviderModelChange={onProviderModelSelectFromPicker} /> @@ -6886,10 +6893,12 @@ export default function ChatView({ threadId }: ChatViewProps) { setIsUsageDashboardOpen(true)} /> {isComposerFooterCompact ? ( @@ -7491,6 +7500,16 @@ export default function ChatView({ threadId }: ChatViewProps) { )} + + ; -}) { - if (input.provider !== "opencode") { - return {}; - } - - const normalizedModel = normalizeModelSlug(input.model, "opencode"); - if (!normalizedModel) { - return {}; - } - - const documentedTotalTokens = input.opencodeContextLengthsBySlug?.get(normalizedModel) ?? null; - if (documentedTotalTokens === null) { - return {}; - } - - return { - documentedTotalTokens, - documentedNote: OPENCODE_MODELS_DEV_CONTEXT_NOTE, - }; -} - function formatContextUsagePercent(percentUsed: number): string { if (!Number.isFinite(percentUsed) || percentUsed <= 0) { return "0%"; @@ -8652,11 +8643,13 @@ function getContextIndicatorLabel(input: { } const ComposerContextWindowStatus = memo(function ComposerContextWindowStatus(props: { + language: AppLanguage; provider: ProviderKind; model: string | null | undefined; tokenUsage?: unknown; opencodeContextLengthsBySlug?: ReadonlyMap; compact?: boolean; + onOpenUsageDashboard: () => void; }) { if (shouldHideContextWindowForModel(props.provider, props.model)) { return null; @@ -8716,13 +8709,16 @@ const ComposerContextWindowStatus = memo(function ComposerContextWindowStatus(pr {indicatorLabel} - + } /> @@ -8773,6 +8769,11 @@ const ComposerContextWindowStatus = memo(function ComposerContextWindowStatus(pr {state.note ? (
{state.note}
) : null} +
+ {props.language === "fa" + ? "برای جزئیات کامل توکن و هزینه کلیک کنید." + : "Click for full token and spend details."} +
diff --git a/apps/web/src/components/PiProvider.browser.tsx b/apps/web/src/components/PiProvider.browser.tsx index 32c2b4e3c3c..408604a1850 100644 --- a/apps/web/src/components/PiProvider.browser.tsx +++ b/apps/web/src/components/PiProvider.browser.tsx @@ -142,6 +142,7 @@ describe("Pi provider GUI", () => { hasHiddenModels={false} onOpenProviderSetup={() => undefined} onOpenManageModels={() => undefined} + onOpenUsageDashboard={() => undefined} onProviderModelChange={onProviderModelChange} /> , @@ -235,6 +236,7 @@ describe("Pi provider GUI", () => { hasHiddenModels={false} onOpenProviderSetup={() => undefined} onOpenManageModels={() => undefined} + onOpenUsageDashboard={() => undefined} onProviderModelChange={onProviderModelChange} /> , diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 09430282536..c628969f7f4 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,5 +1,5 @@ import { type ModelSlug, type ProviderKind, type ServerCopilotUsage } from "@t3tools/contracts"; -import { getModelDisplayName, normalizeModelSlug } from "@t3tools/shared/model"; +import { getModelDisplayName } from "@t3tools/shared/model"; import { formatGitHubCopilotPlan } from "@t3tools/shared/copilotPlan"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; @@ -23,6 +23,7 @@ import { import { getModelPickerOptionDisplayParts } from "../../lib/modelPickerOptionDisplay"; import { describeContextWindowState, + getDocumentedContextWindowOverride, shouldHideContextWindowForModel, } from "../../lib/contextWindow"; import { serverCopilotUsageQueryOptions } from "../../lib/serverReactQuery"; @@ -30,6 +31,7 @@ import { type AppServiceTier, shouldShowFastTierIcon } from "../../appSettings"; import { getAppLanguageDetails, type AppLanguage } from "../../appLanguage"; import { + BarChart3Icon, BotIcon, ChevronDownIcon, CheckIcon, @@ -115,31 +117,6 @@ function formatCopilotQuotaDate(iso: string, language: AppLanguage): string { // Context window summary // --------------------------------------------------------------------------- -const OPENCODE_MODELS_DEV_CONTEXT_NOTE = - "OpenCode uses provider/model metadata from models.dev and reads limit.context when the selected model publishes it."; - -function getDocumentedContextWindowOverride(input: { - provider: ProviderKind; - model: string | null | undefined; - opencodeContextLengthsBySlug?: ReadonlyMap; -}) { - if (input.provider !== "opencode") { - return {}; - } - const normalizedModel = normalizeModelSlug(input.model, "opencode"); - if (!normalizedModel) { - return {}; - } - const documentedTotalTokens = input.opencodeContextLengthsBySlug?.get(normalizedModel) ?? null; - if (documentedTotalTokens === null) { - return {}; - } - return { - documentedTotalTokens, - documentedNote: OPENCODE_MODELS_DEV_CONTEXT_NOTE, - }; -} - function renderProviderContextWindowSummary(input: { provider: ProviderKind; model: string | null | undefined; @@ -449,6 +426,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { disabled?: boolean; onOpenProviderSetup: () => void; onOpenManageModels: () => void; + onOpenUsageDashboard: () => void; onProviderModelChange: (provider: AvailableProviderPickerKind, model: ModelSlug) => void; }) { const [isOpen, setIsOpen] = useState(false); @@ -836,6 +814,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {props.hasHiddenModels ? copy.hiddenModelsHint : copy.pickModelHint}

+ + + + + ); +}); diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 8313594328f..46e3e7be5b5 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -12,6 +12,7 @@ export type ThreadContextUsageSnapshot = { model?: string; usage?: unknown; modelUsage?: Record; + totalCostUsd?: number; }; export type ContextWindowState = { @@ -25,6 +26,34 @@ export type ContextWindowState = { usageScope: "thread" | "turn" | null; }; +export const OPENCODE_MODELS_DEV_CONTEXT_NOTE = + "OpenCode uses provider/model metadata from models.dev and reads limit.context when the selected model publishes it."; + +export function getDocumentedContextWindowOverride(input: { + provider: ProviderKind; + model: string | null | undefined; + opencodeContextLengthsBySlug?: ReadonlyMap; +}) { + if (input.provider !== "opencode") { + return {}; + } + + const normalizedModel = normalizeModelSlug(input.model, "opencode"); + if (!normalizedModel) { + return {}; + } + + const documentedTotalTokens = input.opencodeContextLengthsBySlug?.get(normalizedModel) ?? null; + if (documentedTotalTokens === null) { + return {}; + } + + return { + documentedTotalTokens, + documentedNote: OPENCODE_MODELS_DEV_CONTEXT_NOTE, + }; +} + function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" ? (value as Record) : null; } @@ -166,6 +195,7 @@ export function parseThreadContextUsageSnapshot(value: unknown): ThreadContextUs const modelUsage = asRecord(record.modelUsage ?? record.model_usage ?? null); const observedAtValue = record.observedAt ?? record.observed_at ?? null; const observedAt = typeof observedAtValue === "string" ? observedAtValue : undefined; + const totalCostUsd = readNumber(record, ["totalCostUsd", "total_cost_usd"]); const structuredSnapshot: ThreadContextUsageSnapshot = { ...(typeof record.provider === "string" ? { provider: record.provider } : {}), @@ -174,6 +204,7 @@ export function parseThreadContextUsageSnapshot(value: unknown): ThreadContextUs ...(typeof record.model === "string" ? { model: record.model } : {}), ...(record.usage !== undefined ? { usage: record.usage } : {}), ...(modelUsage ? { modelUsage } : {}), + ...(totalCostUsd !== null ? { totalCostUsd } : {}), }; if ( @@ -182,7 +213,8 @@ export function parseThreadContextUsageSnapshot(value: unknown): ThreadContextUs structuredSnapshot.observedAt !== undefined || structuredSnapshot.model !== undefined || structuredSnapshot.usage !== undefined || - structuredSnapshot.modelUsage !== undefined + structuredSnapshot.modelUsage !== undefined || + structuredSnapshot.totalCostUsd !== undefined ) { return structuredSnapshot; } @@ -253,7 +285,7 @@ function formatCompactUsedTokenCount(tokens: number): string { return String(tokens); } -function snapshotMatchesSelection( +export function doesThreadContextUsageSnapshotMatchSelection( snapshot: ThreadContextUsageSnapshot | null, provider: ProviderKind, model: string | null | undefined, @@ -320,7 +352,7 @@ export function describeContextWindowState(input: { }): ContextWindowState { const info = getModelContextWindowInfo(input.model, input.provider); const snapshot = parseThreadContextUsageSnapshot(input.tokenUsage); - const matchingSnapshot = snapshotMatchesSelection( + const matchingSnapshot = doesThreadContextUsageSnapshotMatchSelection( snapshot, input.provider, input.model, diff --git a/apps/web/src/lib/usageDashboard.test.ts b/apps/web/src/lib/usageDashboard.test.ts new file mode 100644 index 00000000000..81c8a1394f0 --- /dev/null +++ b/apps/web/src/lib/usageDashboard.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { + describeUsageDashboardSnapshot, + describeUsageSpendState, + describeUsageTokenBreakdown, +} from "./usageDashboard"; + +describe("describeUsageDashboardSnapshot", () => { + it("flags when the latest snapshot does not match the current selection", () => { + expect( + describeUsageDashboardSnapshot({ + provider: "copilot", + model: "claude-sonnet-4.6", + tokenUsage: { + provider: "copilot", + kind: "turn", + model: "gpt-5.4", + usage: { totalTokens: 12_000 }, + }, + }), + ).toMatchObject({ + latestSnapshot: { + provider: "copilot", + kind: "turn", + model: "gpt-5.4", + usage: { totalTokens: 12_000 }, + }, + matchingSnapshot: null, + hasSelectionMismatch: true, + }); + }); +}); + +describe("describeUsageTokenBreakdown", () => { + it("prefers Codex thread `last` usage for the latest working-set breakdown", () => { + expect( + describeUsageTokenBreakdown({ + kind: "thread", + usage: { + modelContextWindow: 400_000, + total: { + totalTokens: 180_000, + inputTokens: 120_000, + cachedInputTokens: 20_000, + outputTokens: 40_000, + }, + last: { + totalTokens: 121_900, + inputTokens: 92_000, + cachedInputTokens: 8_000, + outputTokens: 21_900, + }, + }, + }), + ).toEqual({ + totalTokens: 121_900, + inputTokens: 92_000, + outputTokens: 21_900, + reasoningTokens: null, + cacheReadTokens: 8_000, + cacheWriteTokens: null, + }); + }); + + it("reads Kimi nested token_usage breakdown fields", () => { + expect( + describeUsageTokenBreakdown({ + kind: "turn", + usage: { + token_usage: { + input_other: 4_500, + output: 900, + input_cache_read: 350, + input_cache_creation: 250, + }, + }, + }), + ).toEqual({ + totalTokens: 6_000, + inputTokens: 4_500, + outputTokens: 900, + reasoningTokens: null, + cacheReadTokens: 350, + cacheWriteTokens: 250, + }); + }); +}); + +describe("describeUsageSpendState", () => { + it("prefers explicit totalCostUsd captured on the snapshot", () => { + expect( + describeUsageSpendState({ + kind: "turn", + totalCostUsd: 0.42, + usage: { + cost: { + total: 0.1, + }, + }, + }), + ).toEqual({ + totalCostUsd: 0.42, + source: "snapshot", + }); + }); + + it("falls back to provider usage cost totals when explicit snapshot cost is absent", () => { + expect( + describeUsageSpendState({ + kind: "turn", + usage: { + cost: { + total: 0.1, + }, + }, + }), + ).toEqual({ + totalCostUsd: 0.1, + source: "usage", + }); + }); +}); diff --git a/apps/web/src/lib/usageDashboard.ts b/apps/web/src/lib/usageDashboard.ts new file mode 100644 index 00000000000..2b8977f507c --- /dev/null +++ b/apps/web/src/lib/usageDashboard.ts @@ -0,0 +1,257 @@ +import type { ProviderKind } from "@t3tools/contracts"; + +import { + doesThreadContextUsageSnapshotMatchSelection, + parseThreadContextUsageSnapshot, + type ThreadContextUsageSnapshot, +} from "./contextWindow"; + +export type UsageDashboardSnapshotState = { + latestSnapshot: ThreadContextUsageSnapshot | null; + matchingSnapshot: ThreadContextUsageSnapshot | null; + hasSelectionMismatch: boolean; +}; + +export type UsageTokenBreakdown = { + totalTokens: number | null; + inputTokens: number | null; + outputTokens: number | null; + reasoningTokens: number | null; + cacheReadTokens: number | null; + cacheWriteTokens: number | null; +}; + +export type UsageSpendState = { + totalCostUsd: number | null; + source: "snapshot" | "usage" | null; +}; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" ? (value as Record) : null; +} + +function readNumber( + record: Record | null, + keys: ReadonlyArray, +): number | null { + if (!record) { + return null; + } + + for (const key of keys) { + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } + + return null; +} + +function readUsageEnvelope(snapshot: ThreadContextUsageSnapshot | null) { + const usage = asRecord(snapshot?.usage); + const nestedUsage = asRecord(usage?.tokenUsage ?? usage?.token_usage ?? null); + const lastUsage = asRecord( + usage?.last ?? + usage?.lastTokenUsage ?? + usage?.last_token_usage ?? + nestedUsage?.last ?? + nestedUsage?.lastTokenUsage ?? + nestedUsage?.last_token_usage ?? + null, + ); + const modelUsage = asRecord(snapshot?.modelUsage ?? null); + + return { + usage, + nestedUsage, + lastUsage, + modelUsage, + }; +} + +function firstNumber( + records: ReadonlyArray | null>, + keys: ReadonlyArray, +): number | null { + for (const record of records) { + const value = readNumber(record, keys); + if (value !== null) { + return value; + } + } + return null; +} + +export function describeUsageDashboardSnapshot(input: { + provider: ProviderKind; + model: string | null | undefined; + tokenUsage?: unknown; + requireExactModelMatch?: boolean; +}): UsageDashboardSnapshotState { + const latestSnapshot = parseThreadContextUsageSnapshot(input.tokenUsage); + const matchingSnapshot = doesThreadContextUsageSnapshotMatchSelection( + latestSnapshot, + input.provider, + input.model, + input.requireExactModelMatch ?? false, + ) + ? latestSnapshot + : null; + + return { + latestSnapshot, + matchingSnapshot, + hasSelectionMismatch: latestSnapshot !== null && matchingSnapshot === null, + }; +} + +export function describeUsageTokenBreakdown( + snapshot: ThreadContextUsageSnapshot | null, +): UsageTokenBreakdown { + const envelope = readUsageEnvelope(snapshot); + const preferredRecords = + snapshot?.kind === "thread" + ? [envelope.lastUsage, envelope.usage, envelope.nestedUsage, envelope.modelUsage] + : [envelope.usage, envelope.nestedUsage, envelope.modelUsage, envelope.lastUsage]; + + const inputTokens = firstNumber(preferredRecords, [ + "inputOther", + "input_other", + "inputTokens", + "input_tokens", + "promptTokens", + "prompt_tokens", + "promptTokenCount", + "prompt_token_count", + "inputTokenCount", + "input_token_count", + ]); + const outputTokens = firstNumber(preferredRecords, [ + "output", + "outputTokens", + "output_tokens", + "completionTokens", + "completion_tokens", + "candidateTokens", + "candidate_token_count", + "candidatesTokenCount", + "candidates_token_count", + "outputTokenCount", + "output_token_count", + ]); + const reasoningTokens = firstNumber(preferredRecords, [ + "reasoningTokens", + "reasoning_tokens", + "reasoningOutputTokens", + "reasoning_output_tokens", + "thoughtTokens", + "thought_tokens", + "thought_token_count", + "thoughtsTokenCount", + "thoughts_token_count", + ]); + const cacheReadTokens = firstNumber(preferredRecords, [ + "cachedInputTokens", + "cached_input_tokens", + "cacheReadInputTokens", + "cache_read_input_tokens", + "inputCacheRead", + "input_cache_read", + "cachedReadTokens", + "cached_read_tokens", + "cachedContentTokenCount", + "cached_content_token_count", + ]); + const cacheWriteTokens = firstNumber(preferredRecords, [ + "cacheCreationInputTokens", + "cache_creation_input_tokens", + "inputCacheCreation", + "input_cache_creation", + "cachedWriteTokens", + "cached_write_tokens", + ]); + const directTotal = firstNumber(preferredRecords, [ + "used", + "contextTokens", + "context_tokens", + "totalTokens", + "total_tokens", + "totalTokenCount", + "total_token_count", + "total", + ]); + const fallbackTotal = [ + inputTokens, + outputTokens, + reasoningTokens, + cacheReadTokens, + cacheWriteTokens, + ] + .filter((value): value is number => value !== null) + .reduce((sum, value) => sum + value, 0); + const totalTokens = directTotal ?? (fallbackTotal > 0 ? fallbackTotal : null); + + return { + totalTokens, + inputTokens, + outputTokens, + reasoningTokens, + cacheReadTokens, + cacheWriteTokens, + }; +} + +export function describeUsageSpendState( + snapshot: ThreadContextUsageSnapshot | null, +): UsageSpendState { + if (!snapshot) { + return { totalCostUsd: null, source: null }; + } + + if (typeof snapshot.totalCostUsd === "number" && Number.isFinite(snapshot.totalCostUsd)) { + return { + totalCostUsd: snapshot.totalCostUsd, + source: "snapshot", + }; + } + + const envelope = readUsageEnvelope(snapshot); + const totalCostUsd = firstNumber( + [envelope.lastUsage, envelope.usage, envelope.nestedUsage, envelope.modelUsage], + ["totalCostUsd", "total_cost_usd"], + ); + if (totalCostUsd !== null) { + return { + totalCostUsd, + source: "usage", + }; + } + + const nestedCostTotal = firstNumber( + [ + asRecord(envelope.lastUsage?.cost ?? null), + asRecord(envelope.usage?.cost ?? null), + asRecord(envelope.nestedUsage?.cost ?? null), + asRecord(envelope.modelUsage?.cost ?? null), + ], + ["total"], + ); + if (nestedCostTotal !== null) { + return { + totalCostUsd: nestedCostTotal, + source: "usage", + }; + } + + return { + totalCostUsd: null, + source: null, + }; +} From b668bf93cfb5cc73ebb7023ef6875e61b71f8f06 Mon Sep 17 00:00:00 2001 From: yappologistic Date: Fri, 27 Mar 2026 02:03:42 -0600 Subject: [PATCH 2/6] Add provider readiness and onboarding QoL --- .docs/codex-prerequisites.md | 2 +- .docs/provider-settings.md | 6 +- .docs/quick-start.md | 5 +- AGENTS.md | 3 + README.md | 8 +- apps/web/src/appSettings.ts | 103 ++- apps/web/src/components/ChatView.tsx | 733 ++++++++++++++---- .../web/src/components/PiProvider.browser.tsx | 11 +- apps/web/src/components/Sidebar.tsx | 154 +--- .../components/ThreadNewButton.browser.tsx | 92 +++ .../components/chat/EmptyChatOnboarding.tsx | 183 +++++ .../components/chat/ProviderModelPicker.tsx | 36 +- .../src/hooks/useProjectCreationActions.ts | 146 ++++ apps/web/src/lib/modelPickerHelpers.test.ts | 38 + apps/web/src/lib/modelPickerHelpers.ts | 12 +- apps/web/src/lib/modelPreferences.test.ts | 36 + apps/web/src/lib/modelPreferences.ts | 76 ++ apps/web/src/lib/openRouterModels.test.ts | 70 ++ apps/web/src/lib/openRouterModels.ts | 128 ++- apps/web/src/routes/_chat.index.tsx | 7 +- apps/web/src/routes/_chat.settings.tsx | 28 +- 21 files changed, 1585 insertions(+), 292 deletions(-) create mode 100644 apps/web/src/components/chat/EmptyChatOnboarding.tsx create mode 100644 apps/web/src/hooks/useProjectCreationActions.ts create mode 100644 apps/web/src/lib/modelPreferences.test.ts create mode 100644 apps/web/src/lib/modelPreferences.ts diff --git a/.docs/codex-prerequisites.md b/.docs/codex-prerequisites.md index 1095b1242fe..43f6f5241a9 100644 --- a/.docs/codex-prerequisites.md +++ b/.docs/codex-prerequisites.md @@ -11,7 +11,7 @@ Optional app settings for Codex: - Override the Codex home path if you keep Codex state in a non-default location. - Add an OpenRouter API key if you want to use Codex with `openrouter/free` or specific OpenRouter `:free` model ids. - Set the default Codex service tier in Settings. -- Use the **OpenRouter Free Models** settings card to browse the live OpenRouter entries that are both free-locked and compatible with CUT3's native tool-calling path (`tools` plus `tool_choice`), then pin them into the picker. +- Use the **OpenRouter Free Models** settings card to browse the live OpenRouter entries that are both free-locked and compatible with CUT3's native tool-calling path (`tools` plus `tool_choice`), then pin them into the picker. If the next live refresh fails, CUT3 falls back to the last known-good compatible catalog and marks it as stale instead of collapsing the list. - Save extra OpenRouter `:free` model ids such as `google/gemma-3n-e4b-it:free` or custom Codex model ids if you want them in the model picker and `/model` suggestions. - Use the composer controls to choose Codex reasoning effort and per-turn `Fast Mode`. OpenRouter models may advertise reasoning support, but CUT3 does not expose Codex-specific reasoning-effort levels for those free models. diff --git a/.docs/provider-settings.md b/.docs/provider-settings.md index 26fb89b1665..b6d2dab4cc6 100644 --- a/.docs/provider-settings.md +++ b/.docs/provider-settings.md @@ -99,6 +99,7 @@ CUT3 now shows OpenRouter free models in their own settings card and their own t - The settings page fetches OpenRouter's live model catalog, but CUT3 only lists models that are explicitly free-locked (`openrouter/free` or `:free`) and advertise the full native tool-calling surface CUT3 needs (`tools` plus `tool_choice`). - You can pin any listed OpenRouter free model into the picker and `/model` suggestions with one click. - If the live catalog cannot be fetched, CUT3 surfaces that state in Settings instead of silently hiding it. +- CUT3 now keeps a last-known-good compatible catalog locally, so the picker and the Settings card can continue showing the previous free-model list with a stale/offline warning instead of collapsing back to only the router entry. - If a pinned OpenRouter `:free` model cannot be served because the route is unavailable, overloaded, rate-limited, or filtered out by provider/privacy constraints, CUT3 automatically retries the turn through `openrouter/free` and shows a warning banner so the turn does not silently drift onto a billed model. CUT3 does not auto-retry Responses API validation failures or payment/credit errors, because those need explicit user action instead of a silent reroute. - OpenRouter free models still depend on OpenRouter account limits. New accounts only get a small free allowance, purchased credits raise the daily free-model limit, and negative balances can still produce `402 Payment Required` even for `openrouter/free`. @@ -130,8 +131,9 @@ The chat composer now exposes a richer model picker instead of only nested provi - The picker is searchable across provider names, model labels, and raw model slugs. - Models are grouped by provider, with OpenRouter kept as its own top-level section. -- `Connect provider` opens an in-chat setup panel that shows provider health, lets you add or update the shared OpenRouter key and Kimi API key, reminds you that OpenCode auth still lives in `opencode auth login` / `opencode auth logout`, and shows Pi guidance for the external `pi` / `bunx pi` + `/login` flow plus `~/.pi/agent` auth/config. -- `Manage models` opens an in-chat model management surface with per-model visibility toggles. +- `Provider readiness` opens an in-chat onboarding surface that summarizes local provider health, groups providers into ready / attention / unavailable states, offers copyable login commands where CUT3 knows the real CLI flow, lets you jump straight into OpenRouter/Kimi key entry, and links back to Settings for deeper runtime configuration. +- `Manage models` opens an in-chat model management surface with per-model visibility toggles plus favorite pinning. +- Favorites stay pinned near the top of the picker, and recent model selections are also surfaced ahead of the long tail so switching providers or models takes fewer searches. - Hidden models are removed from both the main picker and `/model` suggestions, but they stay saved locally so you can restore them later with `Show all`. ## Composer controls diff --git a/.docs/quick-start.md b/.docs/quick-start.md index 87d39ba5c13..e8790418c6f 100644 --- a/.docs/quick-start.md +++ b/.docs/quick-start.md @@ -48,11 +48,12 @@ Use the matching host OS when possible. Cross-platform packaging is not the defa - Open **Settings** to configure appearance, including theme presets, per-mode palette/font controls, an English/Persian language switch, and an optional chat background image, plus provider binary overrides, OpenRouter and Kimi API keys, OpenCode binary selection, Pi guidance, model preferences, thread sharing mode (`Manual`, `Auto`, `Disabled`), and whether tool/work-log entries stay visible in the main timeline. If you use Kimi without an API key, authenticate in Kimi Code CLI with `kimi login` or the in-shell `/login` flow. If you want to use Pi, authenticate it outside CUT3 through the Pi CLI (`pi` or `bunx pi`) and `/login`, or populate `~/.pi/agent/auth.json` / provider env vars first. - Use **Settings > Permission policies** to save app-wide or project-scoped approval rules when you want CUT3 to automatically `allow`, `ask`, or `deny` repeated approval requests. -- Use the **OpenRouter Free Models** card in Settings to review the current OpenRouter catalog entries that are both free-locked and CUT3-compatible, then pin any of them into the picker. -- Save extra GitHub Copilot, OpenCode, Kimi, Pi provider/model ids, custom Codex ids, or currently listed OpenRouter `:free` model ids if you want them in the picker and `/model` suggestions. +- Use the **OpenRouter Free Models** card in Settings to review the current OpenRouter catalog entries that are both free-locked and CUT3-compatible, then pin any of them into the picker. If the next live refresh fails, CUT3 now falls back to the last known-good catalog and labels it as stale instead of collapsing the list unexpectedly. +- Save extra GitHub Copilot, OpenCode, Kimi, Pi provider/model ids, custom Codex ids, or currently listed OpenRouter `:free` model ids if you want them in the picker and `/model` suggestions. You can also pin favorites in `Manage models`, and CUT3 now keeps recent picks near the top of the picker so repeated model switches take fewer steps. - For Codex, choose a default service tier in Settings, use the top-level OpenRouter section in the model picker when you want `openrouter/free` or another current free OpenRouter model, and adjust reasoning / `Fast Mode` per turn from the composer. OpenRouter models can advertise reasoning support, but CUT3 does not expose Codex-specific reasoning-effort levels for them. Pi reasoning-capable models now also expose Pi thinking levels from the composer while leaving Pi's own default thinking untouched until you choose an override. If CUT3 has to retry a pinned OpenRouter free model through `openrouter/free`, the chat shows a warning banner instead of switching silently. - Put repo-local skills in `.cut3/skills//SKILL.md` with `name` and `description` frontmatter, then select them from the composer Skills picker before sending a turn. - Use the paperclip button, drag-and-drop, or paste to attach up to 8 images per message. CUT3 accepts image files only and limits each image to 10 MB. +- On a fresh install with no configured projects, the empty chat view now guides you through adding your first project folder and opens the first draft thread automatically. - Pick `Full access` or `Supervised` in the toolbar depending on whether you want direct execution or approval-gated actions. - Switch between `Chat` and `Plan` when you want plan-first collaboration with the plan sidebar. - While a turn is running, use the composer Queue/Steer controls to line up the next follow-up. `Enter` uses the currently selected follow-up mode, and `Cmd/Ctrl+Enter` uses the opposite mode for that one message. diff --git a/AGENTS.md b/AGENTS.md index 8e4f2650555..1895be4650b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,8 @@ - Keep Kimi auth UX aligned with the official Kimi CLI docs: user-facing guidance should mention `kimi login` and the in-shell `/login` path, plus the CUT3 Kimi API key setting, instead of assuming only one auth flow. - Keep Pi auth and discovery UX aligned with the real Pi surfaces: CUT3 embeds Pi through the Node SDK, but Pi credentials still live in `~/.pi/agent/auth.json` / Pi env vars or the external `pi` `/login` flow, and CUT3 should keep Pi packages, AGENTS files, system prompts, extensions, skills, prompt templates, and themes disabled so CUT3 injects workspace instructions only once. - Keep Pi model discovery live in the chat UI: `server.getConfig` / provider refreshes must rerun provider health, and when Pi already exposes authenticated models from local `~/.pi/agent` state, the picker and `/model` suggestions should surface those provider/model ids instead of collapsing back to a static `pi/default` placeholder. +- Keep provider onboarding/readiness UX centralized in the chat provider-readiness surface plus shared provider health/state helpers; do not scatter provider-specific setup copy, login commands, or readiness summaries across multiple unrelated components. +- Keep model-picker ranking state centralized through app settings + shared model-preference helpers. Favorites, recents, hidden-model state, picker ordering, and `/model` suggestion ordering must stay aligned instead of each view inventing its own ranking rules. - Keep Pi reasoning UX aligned with the real Pi SDK surface: CUT3 should trust Pi's live `model.reasoning` catalog flag plus `AgentSession.getAvailableThinkingLevels()` / `setThinkingLevel()` instead of hardcoding Codex-style assumptions, and should preserve Pi defaults until the user explicitly picks a thinking level override. - Keep GitHub Copilot reasoning UX aligned with the real CLI/ACP surface: current Copilot CLI builds expose `xhigh` reasoning for some models, so contracts, probes, and composer docs must not clamp Copilot to only low/medium/high. - Keep GitHub Copilot model slugs aligned with live ACP session metadata. Do not hard-code blanket slug rewrites; mirror whatever the runtime actually advertises, and treat stale picker-only entries that never appear in live Copilot/OpenCode model catalogs as bugs. @@ -33,6 +35,7 @@ - When testing hot orchestration streams backed by PubSub, avoid `fork + sleep` subscription races. Start the collector with an explicit readiness handshake (for example `Effect.forkScoped` plus `Effect.yieldNow`, or another deterministic subscription barrier) before dispatching commands. - Keep interactive controls properly disabled during in-flight async operations (e.g. export, share, revoke): users must not be able to trigger conflicting actions while a prior action is still completing. Guard format toggles, download buttons, and secondary actions behind the relevant `isSaving`/`isRevoking` flags. - Keep sidebar organization logic centralized. Pin/archive/search/filter/sort behavior should stay in shared helpers/stores (`apps/web/src/components/Sidebar.logic.ts`, `apps/web/src/lib/threadOrdering.ts`, `apps/web/src/sidebarPreferencesStore.ts`) instead of being reimplemented ad hoc inside multiple sidebar render branches. +- Keep project-creation and first-run onboarding flows centralized through the shared project-creation hook/component path. Empty-state onboarding and the sidebar add-project affordance should reuse the same project-create + first-thread navigation logic instead of drifting into separate implementations. - Keep chat follow-up queue state centralized in `apps/web/src/threadSendQueue.ts` instead of duplicating per-thread queue bookkeeping inside individual composer controls. - Keep ARIA semantics aligned with visual affordances: disclosure/expand buttons need `aria-expanded`, toggle-style buttons need `aria-pressed` or `role="radio"` with `aria-checked`, icon-only buttons need explicit `aria-label`, tree-like file lists need `role="tree"`, and controls revealed only on hover (e.g. terminal close buttons) must also be revealed on `focus-visible` so keyboard users can reach them. - Keep `aria-label` values on interactive groups and their trigger buttons accurate and descriptive of the actual feature. Do not leave placeholder labels from copy-paste (e.g. "Subscription actions" for an editor picker, "Copy options" for an editor menu). diff --git a/README.md b/README.md index 15041d918c7..c97b515d03e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ If this fork does not currently publish desktop releases, build one locally with Published CUT3 builds are listed on the [CUT3 Releases page](https://github.com/yappologistic/t3code/releases). -Once the app is running, choose Codex, GitHub Copilot, OpenCode, Kimi Code, or Pi from the provider picker before starting a session. +Once the app is running, choose Codex, GitHub Copilot, OpenCode, Kimi Code, or Pi from the provider picker before starting a session. If this is your first run and CUT3 does not know any projects yet, the empty chat view now walks you through adding a project folder and immediately opens the first draft thread for it. ## Workspace instructions and slash commands @@ -108,10 +108,10 @@ Open Settings in the app to configure provider-specific behavior on the current - **Appearance**: choose the base light/dark/system mode, switch to integrated presets like Lilac, and configure a custom chat background image with adjustable fade and blur. - **Language**: switch the settings experience and shared app shell between English and Persian. Persian also flips document direction and locale-aware time/date formatting in the web UI. - **Provider overrides**: set custom binary paths for Codex, Copilot, OpenCode, or Kimi, plus an optional Codex home path, a shared OpenRouter API key, and a Kimi API key. Pi is embedded through CUT3's Node dependency instead of a separate binary override; CUT3 reads Pi auth/models config from `~/.pi/agent`, keeps Pi packages, AGENTS files, system prompts, extensions, skills, prompt templates, and themes disabled so workspace instructions still come only from CUT3, and now surfaces authenticated Pi provider/model ids directly in the picker and `/model` suggestions instead of only showing a static `pi/default` placeholder. OpenCode account authentication still happens outside CUT3 through `opencode auth login` and `opencode auth logout`, while MCP server auth/debug remains server-specific through commands like `opencode mcp auth ` and `opencode mcp debug `. The OpenCode settings panel inspects the resolved OpenCode config paths plus `opencode auth list`, `opencode mcp list`, and `opencode mcp auth list` so CUT3 can show current credentials, provider-specific MCP status (including disabled and auth-gated entries), and copyable recovery commands. Kimi CLI authentication can use either `kimi login` or the in-shell `/login` flow when you are not using an API key, and new OpenCode sessions now inherit that shared OpenRouter key as `OPENROUTER_API_KEY` when the OpenCode provider config expects it. -- **OpenRouter free models**: review the current OpenRouter entries that are explicitly free-locked and compatible with CUT3's native tool-calling path (`tools` plus `tool_choice`), keep the built-in `openrouter/free` router handy, and pin any listed model into the picker. +- **OpenRouter free models**: review the current OpenRouter entries that are explicitly free-locked and compatible with CUT3's native tool-calling path (`tools` plus `tool_choice`), keep the built-in `openrouter/free` router handy, and pin any listed model into the picker. CUT3 now keeps a last-known-good OpenRouter free-model catalog locally so the picker and settings can stay usable even when the next live catalog refresh fails. - **Custom model slugs**: save extra model ids for GitHub Copilot, OpenCode, Kimi, Pi provider/model ids such as `github-copilot/claude-sonnet-4.5`, custom Codex models, or current OpenRouter `:free` slugs so they appear in the model picker and `/model` suggestions. -- **Picker controls**: the chat composer now uses a searchable grouped model picker with direct `Usage`, `Connect provider`, and `Manage models` actions. -- **Model visibility**: hide or restore discovered and saved models without deleting them; hidden models are removed from both the picker and `/model` suggestions until you show them again. +- **Picker controls**: the chat composer now uses a searchable grouped model picker with direct `Usage`, `Provider readiness`, and `Manage models` actions. +- **Favorites, recents, and visibility**: pin favorite models so they stay at the top of the picker, let CUT3 surface recent model choices ahead of the long tail, and hide or restore discovered/saved models without deleting them. Hidden models are removed from both the picker and `/model` suggestions until you show them again. - **Thread defaults**: choose whether new draft threads start in `Local` or `New worktree`, and set thread sharing to `Manual`, `Auto` (create a share link after a new server-backed thread settles), or `Disabled` for new links. - **Codex service tier**: choose `Automatic`, `Fast`, or `Flex` as the default service tier for new Codex turns. - **Per-turn controls**: the composer exposes provider-aware reasoning controls where CUT3 has a provider-specific contract today. Codex and GitHub Copilot expose provider-specific reasoning levels, Codex also supports a per-turn `Fast Mode` toggle, and Pi now surfaces its live model reasoning capability plus Pi thinking levels (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`) for reasoning-capable Pi models while still preserving Pi defaults until you choose an override. diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5516bf0c04d..f60185d979b 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -20,10 +20,13 @@ import { import { APP_LANGUAGE_OPTIONS, DEFAULT_APP_LANGUAGE, type AppLanguage } from "./appLanguage"; import { normalizeApprovalRules } from "./approvalRules"; import { CUSTOM_THEME_IDS } from "./lib/customThemes"; +import { normalizeModelPreferenceSlugs } from "./lib/modelPreferences"; import { isOpenRouterGuaranteedFreeSlug } from "./lib/openRouterModels"; const APP_SETTINGS_STORAGE_KEY = "cut3:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; +const MAX_FAVORITE_MODEL_COUNT = 32; +const MAX_RECENT_MODEL_COUNT = 12; const MAX_HIDDEN_MODEL_COUNT = 512; export const MAX_CUSTOM_MODEL_LENGTH = 256; export const MAX_CHAT_BACKGROUND_IMAGE_BYTES = 10 * 1024 * 1024; @@ -184,6 +187,36 @@ const AppSettingsSchema = Schema.Struct({ customPiModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + favoriteCodexModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + favoriteCopilotModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + favoriteOpencodeModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + favoriteKimiModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + favoritePiModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + recentCodexModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + recentCopilotModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + recentOpencodeModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + recentKimiModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), + recentPiModels: Schema.Array(Schema.String).pipe( + Schema.withConstructorDefault(() => Option.some([])), + ), hiddenCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), @@ -296,23 +329,9 @@ export function normalizeModelVisibilitySlugs( models: Iterable, provider: ProviderKind = "codex", ): string[] { - const normalizedModels: string[] = []; - const seen = new Set(); - - for (const candidate of models) { - const normalized = normalizeModelSlug(candidate, provider); - if (!normalized || normalized.length > MAX_CUSTOM_MODEL_LENGTH || seen.has(normalized)) { - continue; - } - - seen.add(normalized); - normalizedModels.push(normalized); - if (normalizedModels.length >= MAX_HIDDEN_MODEL_COUNT) { - break; - } - } - - return normalizedModels; + return normalizeModelPreferenceSlugs(models, provider, MAX_HIDDEN_MODEL_COUNT).filter( + (normalized) => normalized.length <= MAX_CUSTOM_MODEL_LENGTH, + ); } function normalizeAppSettings(settings: AppSettings): AppSettings { @@ -335,6 +354,56 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { customOpencodeModels: normalizeCustomModelSlugs(settings.customOpencodeModels, "opencode"), customKimiModels: normalizeCustomModelSlugs(settings.customKimiModels, "kimi"), customPiModels: normalizeCustomModelSlugs(settings.customPiModels, "pi"), + favoriteCodexModels: normalizeModelPreferenceSlugs( + settings.favoriteCodexModels, + "codex", + MAX_FAVORITE_MODEL_COUNT, + ), + favoriteCopilotModels: normalizeModelPreferenceSlugs( + settings.favoriteCopilotModels, + "copilot", + MAX_FAVORITE_MODEL_COUNT, + ), + favoriteOpencodeModels: normalizeModelPreferenceSlugs( + settings.favoriteOpencodeModels, + "opencode", + MAX_FAVORITE_MODEL_COUNT, + ), + favoriteKimiModels: normalizeModelPreferenceSlugs( + settings.favoriteKimiModels, + "kimi", + MAX_FAVORITE_MODEL_COUNT, + ), + favoritePiModels: normalizeModelPreferenceSlugs( + settings.favoritePiModels, + "pi", + MAX_FAVORITE_MODEL_COUNT, + ), + recentCodexModels: normalizeModelPreferenceSlugs( + settings.recentCodexModels, + "codex", + MAX_RECENT_MODEL_COUNT, + ), + recentCopilotModels: normalizeModelPreferenceSlugs( + settings.recentCopilotModels, + "copilot", + MAX_RECENT_MODEL_COUNT, + ), + recentOpencodeModels: normalizeModelPreferenceSlugs( + settings.recentOpencodeModels, + "opencode", + MAX_RECENT_MODEL_COUNT, + ), + recentKimiModels: normalizeModelPreferenceSlugs( + settings.recentKimiModels, + "kimi", + MAX_RECENT_MODEL_COUNT, + ), + recentPiModels: normalizeModelPreferenceSlugs( + settings.recentPiModels, + "pi", + MAX_RECENT_MODEL_COUNT, + ), hiddenCodexModels: normalizeModelVisibilitySlugs(settings.hiddenCodexModels, "codex"), hiddenCopilotModels: normalizeModelVisibilitySlugs(settings.hiddenCopilotModels, "copilot"), hiddenOpencodeModels: normalizeModelVisibilitySlugs(settings.hiddenOpencodeModels, "opencode"), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8f6c82f1b19..e7ecc81b126 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -94,6 +94,7 @@ import { supportsOpenRouterReasoningEffortControl, } from "~/lib/openRouterModels"; import { getModelPickerOptionDisplayParts } from "~/lib/modelPickerOptionDisplay"; +import { buildRecentModelSelection, prioritizeModelOptions } from "~/lib/modelPreferences"; import { type PickerModelOption, mergeModelOptions, @@ -181,6 +182,7 @@ import { useTheme } from "../hooks/useTheme"; import { useChatBackgroundImage } from "../hooks/useChatBackgroundImage"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useNewThreadActions } from "../hooks/useNewThread"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { @@ -259,6 +261,7 @@ import { COMING_SOON_PROVIDER_OPTIONS, } from "./chat/ProviderModelPicker"; import { ThreadTasksPanel } from "./chat/ThreadTasksPanel"; +import { EmptyChatOnboarding } from "./chat/EmptyChatOnboarding"; import { ThreadExportDialog } from "./chat/ThreadExportDialog"; import { ThreadShareDialog } from "./chat/ThreadShareDialog"; import { ComposerSkillPicker } from "./chat/ComposerSkillPicker"; @@ -455,6 +458,24 @@ type ProviderCustomModelSettings = { readonly customPiModels: readonly string[]; }; +type ProviderFavoriteModelSettings = Pick< + AppSettings, + | "favoriteCodexModels" + | "favoriteCopilotModels" + | "favoriteOpencodeModels" + | "favoriteKimiModels" + | "favoritePiModels" +>; + +type ProviderRecentModelSettings = Pick< + AppSettings, + | "recentCodexModels" + | "recentCopilotModels" + | "recentOpencodeModels" + | "recentKimiModels" + | "recentPiModels" +>; + type ProviderHiddenModelSettings = Pick< AppSettings, | "hiddenCodexModels" @@ -464,6 +485,46 @@ type ProviderHiddenModelSettings = Pick< | "hiddenPiModels" >; +function getFavoriteModelsForProvider( + provider: ProviderKind, + settings: ProviderFavoriteModelSettings, +): readonly string[] { + switch (provider) { + case "codex": + return settings.favoriteCodexModels; + case "copilot": + return settings.favoriteCopilotModels; + case "opencode": + return settings.favoriteOpencodeModels; + case "kimi": + return settings.favoriteKimiModels; + case "pi": + return settings.favoritePiModels; + default: + return settings.favoriteCodexModels; + } +} + +function getRecentModelsForProvider( + provider: ProviderKind, + settings: ProviderRecentModelSettings, +): readonly string[] { + switch (provider) { + case "codex": + return settings.recentCodexModels; + case "copilot": + return settings.recentCopilotModels; + case "opencode": + return settings.recentOpencodeModels; + case "kimi": + return settings.recentKimiModels; + case "pi": + return settings.recentPiModels; + default: + return settings.recentCodexModels; + } +} + function getHiddenModelsForProvider( provider: ProviderKind, settings: ProviderHiddenModelSettings, @@ -484,6 +545,46 @@ function getHiddenModelsForProvider( } } +function patchFavoriteModels( + provider: ProviderKind, + favoriteModels: readonly string[], +): Partial { + switch (provider) { + case "codex": + return { favoriteCodexModels: [...favoriteModels] }; + case "copilot": + return { favoriteCopilotModels: [...favoriteModels] }; + case "opencode": + return { favoriteOpencodeModels: [...favoriteModels] }; + case "kimi": + return { favoriteKimiModels: [...favoriteModels] }; + case "pi": + return { favoritePiModels: [...favoriteModels] }; + default: + return { favoriteCodexModels: [...favoriteModels] }; + } +} + +function patchRecentModels( + provider: ProviderKind, + recentModels: readonly string[], +): Partial { + switch (provider) { + case "codex": + return { recentCodexModels: [...recentModels] }; + case "copilot": + return { recentCopilotModels: [...recentModels] }; + case "opencode": + return { recentOpencodeModels: [...recentModels] }; + case "kimi": + return { recentKimiModels: [...recentModels] }; + case "pi": + return { recentPiModels: [...recentModels] }; + default: + return { recentCodexModels: [...recentModels] }; + } +} + function patchHiddenModels( provider: ProviderKind, hiddenModels: readonly string[], @@ -1302,6 +1403,38 @@ export default function ChatView({ threadId }: ChatViewProps) { const [providerStatusModelOptionsByProvider, setProviderStatusModelOptionsByProvider] = useState< Record> >(EMPTY_PROVIDER_STATUS_MODEL_OPTIONS_BY_PROVIDER); + const providerFavoriteModelSettings = useMemo( + () => ({ + favoriteCodexModels: settings.favoriteCodexModels, + favoriteCopilotModels: settings.favoriteCopilotModels, + favoriteOpencodeModels: settings.favoriteOpencodeModels, + favoriteKimiModels: settings.favoriteKimiModels, + favoritePiModels: settings.favoritePiModels, + }), + [ + settings.favoriteCodexModels, + settings.favoriteCopilotModels, + settings.favoriteKimiModels, + settings.favoriteOpencodeModels, + settings.favoritePiModels, + ], + ); + const providerRecentModelSettings = useMemo( + () => ({ + recentCodexModels: settings.recentCodexModels, + recentCopilotModels: settings.recentCopilotModels, + recentOpencodeModels: settings.recentOpencodeModels, + recentKimiModels: settings.recentKimiModels, + recentPiModels: settings.recentPiModels, + }), + [ + settings.recentCodexModels, + settings.recentCopilotModels, + settings.recentKimiModels, + settings.recentOpencodeModels, + settings.recentPiModels, + ], + ); const providerHiddenModelSettings = useMemo( () => ({ hiddenCodexModels: settings.hiddenCodexModels, @@ -1400,7 +1533,9 @@ export default function ChatView({ threadId }: ChatViewProps) { providerStatusModelOptionsByProvider.pi, ]); const openRouterCatalogQuery = useQuery(openRouterFreeModelsQueryOptions()); - const hasLiveOpenRouterCatalog = openRouterCatalogQuery.data?.status === "available"; + const hasLiveOpenRouterCatalog = + openRouterCatalogQuery.data?.status === "available" && + openRouterCatalogQuery.data.source === "live"; const openRouterModels = useMemo( () => openRouterCatalogQuery.data?.models ?? [], [openRouterCatalogQuery.data?.models], @@ -1936,27 +2071,34 @@ export default function ChatView({ threadId }: ChatViewProps) { (option) => lockedProvider === null || getProviderPickerBackingProvider(option.value) === lockedProvider, - ).flatMap((option) => - getModelOptionsForProviderPicker( - option.value, - visibleModelOptionsByProvider, - visibleOpenRouterModelOptions, - visibleOpencodeModelOptions, + ).flatMap((option) => { + const backingProvider = getProviderPickerBackingProvider(option.value) ?? "codex"; + return prioritizeModelOptions( + getModelOptionsForProviderPicker( + option.value, + visibleModelOptionsByProvider, + visibleOpenRouterModelOptions, + visibleOpencodeModelOptions, + ), + getFavoriteModelsForProvider(backingProvider, providerFavoriteModelSettings), + getRecentModelsForProvider(backingProvider, providerRecentModelSettings), ).map(({ slug, name }) => ({ - provider: getProviderPickerBackingProvider(option.value) ?? "codex", + provider: backingProvider, providerLabel: option.label, slug, name, searchSlug: slug.toLowerCase(), searchName: name.toLowerCase(), searchProvider: option.label.toLowerCase(), - })), - ), + })); + }), [ lockedProvider, visibleModelOptionsByProvider, visibleOpenRouterModelOptions, visibleOpencodeModelOptions, + providerFavoriteModelSettings, + providerRecentModelSettings, ], ); const hasHiddenPickerModels = useMemo( @@ -5804,6 +5946,20 @@ export default function ChatView({ threadId }: ChatViewProps) { settings.customPiModels, ], ); + const onFavoriteModelChange = useCallback( + (provider: ProviderKind, model: string, favorite: boolean) => { + const favoriteModels = new Set( + getFavoriteModelsForProvider(provider, providerFavoriteModelSettings), + ); + if (favorite) { + favoriteModels.add(model); + } else { + favoriteModels.delete(model); + } + updateSettings(patchFavoriteModels(provider, [...favoriteModels])); + }, + [providerFavoriteModelSettings, updateSettings], + ); const onModelVisibilityChange = useCallback( (provider: ProviderKind, model: string, visible: boolean) => { const hiddenModels = new Set( @@ -5827,6 +5983,28 @@ export default function ChatView({ threadId }: ChatViewProps) { hiddenPiModels: [], }); }, [updateSettings]); + useEffect(() => { + if (!activeThread) { + return; + } + const nextRecentModels = buildRecentModelSelection( + getRecentModelsForProvider(selectedProvider, providerRecentModelSettings), + selectedProvider, + selectedModel, + 12, + ); + const currentRecentModels = getRecentModelsForProvider( + selectedProvider, + providerRecentModelSettings, + ); + if ( + nextRecentModels.length === currentRecentModels.length && + nextRecentModels.every((entry, index) => entry === currentRecentModels[index]) + ) { + return; + } + updateSettings(patchRecentModels(selectedProvider, nextRecentModels)); + }, [activeThread, providerRecentModelSettings, selectedModel, selectedProvider, updateSettings]); const openProviderSetupDialog = useCallback(() => { setIsProviderSetupDialogOpen(true); }, []); @@ -6300,10 +6478,8 @@ export default function ChatView({ threadId }: ChatViewProps) { No active thread
)} -
-
-

Select a thread or create a new one to get started.

-
+
+
); @@ -6876,6 +7052,20 @@ export default function ChatView({ threadId }: ChatViewProps) { opencodeContextLengthsBySlug={openCodeContextLengthsBySlug} serviceTierSetting={selectedServiceTierSetting} hasHiddenModels={hasHiddenPickerModels} + favoriteModelsByProvider={{ + codex: providerFavoriteModelSettings.favoriteCodexModels, + copilot: providerFavoriteModelSettings.favoriteCopilotModels, + opencode: providerFavoriteModelSettings.favoriteOpencodeModels, + kimi: providerFavoriteModelSettings.favoriteKimiModels, + pi: providerFavoriteModelSettings.favoritePiModels, + }} + recentModelsByProvider={{ + codex: providerRecentModelSettings.recentCodexModels, + copilot: providerRecentModelSettings.recentCopilotModels, + opencode: providerRecentModelSettings.recentOpencodeModels, + kimi: providerRecentModelSettings.recentKimiModels, + pi: providerRecentModelSettings.recentPiModels, + }} onOpenProviderSetup={openProviderSetupDialog} onOpenManageModels={openManageModelsDialog} onOpenUsageDashboard={() => setIsUsageDashboardOpen(true)} @@ -7518,26 +7708,36 @@ export default function ChatView({ threadId }: ChatViewProps) { openCodeState={openCodeStateQuery.data ?? null} hasOpenRouterApiKey={settings.openRouterApiKey.trim().length > 0} hasKimiApiKey={settings.kimiApiKey.trim().length > 0} + codexBinaryPath={settings.codexBinaryPath} + copilotBinaryPath={settings.copilotBinaryPath} + opencodeBinaryPath={settings.opencodeBinaryPath} + kimiBinaryPath={settings.kimiBinaryPath} isRefreshing={isRefreshingProviderSetupState} onRefresh={refreshProviderSetupState} onOpenOpenRouterKeyDialog={openOpenRouterApiKeyDialogFromProviderSetup} onOpenKimiKeyDialog={openKimiApiKeyDialogFromProviderSetup} onOpenManageModels={openManageModelsFromProviderSetup} + onOpenSettings={() => { + setIsProviderSetupDialogOpen(false); + void navigate({ to: "/settings" }); + }} /> void; onOpenOpenRouterKeyDialog: () => void; onOpenKimiKeyDialog: () => void; onOpenManageModels: () => void; + onOpenSettings: () => void; }) { const chatCopy = getChatSurfaceCopy(props.language); + const copy = + props.language === "fa" + ? { + title: "آماده سازی ارائه دهنده", + description: + "وضعیت runtime های محلی را بررسی کنید، کلیدها را اضافه کنید، و قدم بعدی هر ارائه دهنده را بدون خروج از چت ببینید.", + snapshotTitle: "نمای آماده سازی ارائه دهنده", + snapshotDescription: + "CUT3 وضعیت runtime های محلی را می خواند. احراز هویت OpenCode، Codex، Copilot، Kimi، و Pi همچنان در ابزارهای خود آنها مدیریت می شود.", + ready: "آماده", + attention: "نیاز به توجه", + unavailable: "ناموجود", + refresh: "نوسازی وضعیت", + addKey: "افزودن کلید", + updateKey: "به روز رسانی کلید", + copied: "کپی شد", + copyLogin: "کپی ورود", + copyLaunch: "کپی اجرای CLI", + manageModels: "مدیریت مدل ها", + settings: "تنظیمات", + done: "انجام شد", + notAvailableYet: "هنوز در دسترس نیست", + credentials: (count: number) => `${count} اعتبار`, + models: (count: number) => `${count} مدل`, + mcpServers: (count: number) => `${count} سرور MCP`, + } + : { + title: "Provider readiness", + description: + "Check local runtime health, add keys, and see the next step for each provider without leaving chat.", + snapshotTitle: "Provider readiness snapshot", + snapshotDescription: + "CUT3 inspects your local runtimes here. Authentication for OpenCode, Codex, Copilot, Kimi, and Pi still lives in their own CLIs and config files.", + ready: "Ready", + attention: "Needs attention", + unavailable: "Unavailable", + refresh: "Refresh status", + addKey: "Add key", + updateKey: "Update key", + copied: "Copied", + copyLogin: "Copy login", + copyLaunch: "Copy CLI launch", + manageModels: "Manage models", + settings: "Settings", + done: "Done", + notAvailableYet: "Not available yet", + credentials: (count: number) => `${count} credential${count === 1 ? "" : "s"}`, + models: (count: number) => `${count} model${count === 1 ? "" : "s"}`, + mcpServers: (count: number) => `${count} MCP server${count === 1 ? "" : "s"}`, + }; const openCodeCredentialCount = props.openCodeState?.credentials.length ?? 0; const openCodeModelCount = props.openCodeState?.models.length ?? 0; + const openCodeMcpServerCount = props.openCodeState?.mcpServers.length ?? 0; + const [lastCopiedCommandId, setLastCopiedCommandId] = useState(null); + const { copyToClipboard, isCopied } = useCopyToClipboard<{ id: string }>({ + onCopy: ({ id }) => setLastCopiedCommandId(id), + }); + + const formatCommandBinary = useCallback((binaryPath: string, fallback: string) => { + const trimmed = binaryPath.trim(); + if (!trimmed) { + return fallback; + } + return `'${trimmed.replaceAll("'", "'\\''")}'`; + }, []); + + const codexCommand = formatCommandBinary(props.codexBinaryPath, "codex"); + const copilotCommand = formatCommandBinary(props.copilotBinaryPath, "copilot"); + const opencodeCommand = formatCommandBinary(props.opencodeBinaryPath, "opencode"); + const kimiCommand = formatCommandBinary(props.kimiBinaryPath, "kimi"); + + const renderCopyCommandButton = useCallback( + (input: { id: string; label: string; command: string }) => { + const copied = isCopied && lastCopiedCommandId === input.id; + return ( + + ); + }, + [copy.copied, copyToClipboard, isCopied, lastCopiedCommandId], + ); + + const readinessSummary = useMemo(() => { + return [...AVAILABLE_PROVIDER_OPTIONS].reduce( + (counts, option) => { + if (option.value === "openrouter") { + if (props.hasOpenRouterApiKey) { + counts.ready += 1; + } else { + counts.attention += 1; + } + return counts; + } + + const backingProvider = getProviderPickerBackingProvider(option.value); + if (!backingProvider) { + return counts; + } + const status = findProviderStatus(props.providerStatuses, backingProvider); + if (!status || !status.available || status.status === "error") { + counts.unavailable += 1; + return counts; + } + if (status.status === "ready" && status.authStatus !== "unauthenticated") { + counts.ready += 1; + return counts; + } + counts.attention += 1; + return counts; + }, + { ready: 0, attention: 0, unavailable: 0 }, + ); + }, [props.hasOpenRouterApiKey, props.providerStatuses]); return ( - + - Connect provider - - Review provider health, add API keys, and refresh runtime state without leaving the chat - surface. - + {copy.title} + {copy.description} -
-
-

Provider status snapshot

-

- CUT3 reads local provider status here. OpenCode authentication still stays in - OpenCode itself via opencode auth login. -

+
+
+
+

{copy.snapshotTitle}

+

{copy.snapshotDescription}

+
+
+ + {copy.ready} · {readinessSummary.ready} + + + {copy.attention} · {readinessSummary.attention} + + + {copy.unavailable} · {readinessSummary.unavailable} + +
@@ -8869,26 +9198,63 @@ export function ProviderSetupDialog(props: { let badge = getProviderStatusBadge(providerStatus); let description = getProviderPickerSectionDescription(option.value); let message = providerStatus?.message?.trim() || null; - let action: ReactNode = null; let footer: ReactNode = null; + const actions: ReactNode[] = []; if (option.value === "openrouter") { badge = props.hasOpenRouterApiKey ? { label: "Key saved", variant: "success" } : { label: "Needs key", variant: "warning" }; description = props.hasOpenRouterApiKey - ? "Shared OpenRouter key is ready for router-backed Codex sessions." - : "Add an OpenRouter API key to use router-backed Codex models."; + ? "Shared OpenRouter key is ready for OpenRouter-routed sessions." + : "Add the shared OpenRouter API key to unlock OpenRouter-routed models."; message = - "Used for openrouter/free and any saved OpenRouter model ids. New OpenCode sessions also inherit it as OPENROUTER_API_KEY when needed."; - action = ( - + {props.hasOpenRouterApiKey ? copy.updateKey : copy.addKey} + , ); } + if (option.value === "codex") { + description = + providerStatus?.authStatus === "authenticated" + ? "Native Codex models are ready through your local Codex runtime." + : "Authenticate or repair the local Codex runtime, then refresh CUT3."; + if (providerStatus?.authStatus !== "authenticated") { + actions.push( + renderCopyCommandButton({ + id: "codex-login", + label: copy.copyLogin, + command: `${codexCommand} login`, + }), + ); + } + } + + if (option.value === "copilot") { + description = + providerStatus?.authStatus === "authenticated" + ? "GitHub Copilot is available from the local runtime CUT3 is connected to." + : "Sign into the local Copilot CLI/runtime, then refresh CUT3."; + if (providerStatus?.authStatus !== "authenticated") { + actions.push( + renderCopyCommandButton({ + id: "copilot-login", + label: copy.copyLogin, + command: `${copilotCommand} login`, + }), + ); + } + } + if (option.value === "kimi") { badge = props.hasKimiApiKey ? { label: "Key saved", variant: "success" } @@ -8896,39 +9262,68 @@ export function ProviderSetupDialog(props: { ? { label: "Ready", variant: "success" } : { label: "Needs auth", variant: "warning" }; description = props.hasKimiApiKey - ? "Kimi API key is configured for new Kimi Code sessions." - : "Authenticate in the Kimi CLI with `kimi login` or `/login`, or add an API key here."; + ? "A Kimi API key is configured for new Kimi Code sessions." + : "Use kimi login / /login, or add a Kimi API key here."; message = props.hasKimiApiKey ? "CUT3 will inject the saved Kimi API key into new Kimi Code sessions." : providerStatus?.message?.trim() || - "If you prefer not to store an API key in CUT3, run `kimi login` or `/login` in the local Kimi CLI and refresh this panel."; - action = ( - + {props.hasKimiApiKey ? copy.updateKey : copy.addKey} + , ); - footer = ( -

- Kimi also supports CLI auth via kimi login or the in-shell{" "} - /login command. CUT3 reads the runtime state and can optionally - inject a saved API key for new sessions. -

- ); - } - - if (option.value === "copilot") { - description = - providerStatus?.authStatus === "unauthenticated" - ? "Authenticate with the local GitHub Copilot CLI/runtime, then refresh CUT3." - : "GitHub Copilot availability is read from the local runtime that CUT3 talks to."; + if (!props.hasKimiApiKey) { + actions.push( + renderCopyCommandButton({ + id: "kimi-login", + label: copy.copyLogin, + command: `${kimiCommand} login`, + }), + ); + } } - if (option.value === "codex") { + if (option.value === "opencode") { + badge = + props.openCodeState?.status === "available" + ? { label: "Ready", variant: "success" } + : openCodeCredentialCount > 0 + ? { label: "Check setup", variant: "warning" } + : { label: "Needs auth", variant: "warning" }; description = - providerStatus?.authStatus === "unauthenticated" - ? "Authenticate Codex outside CUT3, then refresh this panel." - : "Native Codex models use your existing local Codex authentication."; + props.openCodeState?.status === "available" + ? `${copy.credentials(openCodeCredentialCount)} · ${copy.models(openCodeModelCount)}` + : "Manage OpenCode credentials in OpenCode itself, then refresh CUT3."; + message = props.openCodeState?.message?.trim() || null; + footer = ( +
+ + {copy.credentials(openCodeCredentialCount)} + + + {copy.models(openCodeModelCount)} + + {props.openCodeState?.mcpSupported ? ( + + {copy.mcpServers(openCodeMcpServerCount)} + + ) : null} +
+ ); + actions.push( + renderCopyCommandButton({ + id: "opencode-auth-login", + label: copy.copyLogin, + command: `${opencodeCommand} auth login`, + }), + ); } if (option.value === "pi") { @@ -8944,42 +9339,29 @@ export function ProviderSetupDialog(props: { providerStatus?.authStatus === "authenticated" ? providerStatus.status === "warning" ? "Pi is authenticated, but the local Pi config still needs attention." - : "Pi is ready using your local ~/.pi/agent auth/models config." - : "Authenticate Pi outside CUT3, then refresh this panel."; + : "Pi is ready from the local ~/.pi/agent auth/models state." + : "Run pi or bunx pi, complete /login, then refresh CUT3."; message = providerStatus?.message?.trim() || null; footer = (

- CUT3 embeds Pi through its Node SDK, but intentionally disables Pi packages, - AGENTS, system prompts, extensions, skills, prompt templates, and themes here so - CUT3 remains the only source of workspace instructions for Pi threads. + CUT3 embeds Pi through its Node SDK, but keeps Pi packages, AGENTS, prompts, + extensions, skills, and themes disabled so CUT3 remains the only source of + workspace instructions.

); - } - - if (option.value === "opencode") { - badge = - props.openCodeState?.status === "available" - ? { label: "Ready", variant: "success" } - : openCodeCredentialCount > 0 - ? { label: "Check setup", variant: "warning" } - : { label: "Needs auth", variant: "warning" }; - description = - props.openCodeState?.status === "available" - ? `${openCodeCredentialCount} credentials · ${openCodeModelCount} models discovered` - : "Manage credentials in OpenCode itself, then refresh CUT3."; - message = props.openCodeState?.message?.trim() || null; - footer = ( -

- Use opencode auth login or opencode auth logout in a - terminal. CUT3 only reads the current state here. -

+ actions.push( + renderCopyCommandButton({ + id: "pi-launch", + label: copy.copyLaunch, + command: "bunx pi", + }), ); } return (
@@ -9010,7 +9392,9 @@ export function ProviderSetupDialog(props: { ) : null} {footer}
- {action ?
{action}
: null} + {actions.length > 0 ? ( +
{actions}
+ ) : null}
); @@ -9019,7 +9403,7 @@ export function ProviderSetupDialog(props: {
- Not available yet + {copy.notAvailableYet} {[...UNAVAILABLE_PROVIDER_OPTIONS, ...COMING_SOON_PROVIDER_OPTIONS].map((option) => { const OptionIcon = "icon" in option ? option.icon : PROVIDER_ICON_BY_PROVIDER[option.value]; @@ -9041,12 +9425,15 @@ export function ProviderSetupDialog(props: {
+ @@ -9058,20 +9445,66 @@ function ManageModelsDialog(props: { open: boolean; onOpenChange: (open: boolean) => void; language: AppLanguage; - selectedProvider: ProviderKind; selectedProviderPickerKind: AvailableProviderPickerKind; allModelOptionsByProvider: Record>; openRouterModelOptions: ReadonlyArray; opencodeModelOptions: ReadonlyArray; hiddenModelsByProvider: ProviderHiddenModelSettings; + favoriteModelsByProvider: ProviderFavoriteModelSettings; + recentModelsByProvider: ProviderRecentModelSettings; openRouterContextLengthsBySlug: ReadonlyMap; opencodeContextLengthsBySlug: ReadonlyMap; serviceTierSetting: AppServiceTier; + onFavoriteModelChange: (provider: ProviderKind, model: string, favorite: boolean) => void; onModelVisibilityChange: (provider: ProviderKind, model: string, visible: boolean) => void; onShowAll: () => void; onOpenProviderSetup: () => void; }) { const [query, setQuery] = useState(""); + const copy = + props.language === "fa" + ? { + title: "مدیریت مدل ها", + description: + "مدل ها را برای نمایش یا پنهان سازی در picker و پیشنهادهای /model مدیریت کنید و موارد محبوب را برای دسترسی سریع سنجاق کنید.", + searchPlaceholder: "جستجوی مدل ها یا ارائه دهندگان", + allVisible: "همه نمایش داده می شوند", + hiddenCount: (count: number) => `${count} مورد مخفی`, + showAllHidden: "نمایش همه مدل های مخفی", + noMatchesTitle: "هیچ مدلی با این جستجو مطابقت ندارد.", + noMatchesDescription: "نام، ارائه دهنده، یا slug دیگری را امتحان کنید.", + clearSearch: "پاک کردن جستجو", + current: "فعلی", + shown: "نمایش داده شده", + hidden: "مخفی", + favorite: "محبوب", + pin: "سنجاق کردن", + unpin: "برداشتن سنجاق", + recent: "اخیر", + providerSetup: "آماده سازی ارائه دهنده", + done: "انجام شد", + } + : { + title: "Manage models", + description: + "Control which models appear in the picker and /model suggestions, and pin favorites for faster access.", + searchPlaceholder: "Search models or providers", + allVisible: "All visible", + hiddenCount: (count: number) => `${count} hidden`, + showAllHidden: "Show all hidden models", + noMatchesTitle: "No models match this search.", + noMatchesDescription: "Try a different provider, model slug, or partial name.", + clearSearch: "Clear search", + current: "Current", + shown: "Shown", + hidden: "Hidden", + favorite: "Favorite", + pin: "Pin", + unpin: "Unpin", + recent: "Recent", + providerSetup: "Provider readiness", + done: "Done", + }; const totalHiddenCount = props.hiddenModelsByProvider.hiddenCodexModels.length + props.hiddenModelsByProvider.hiddenCopilotModels.length + @@ -9111,6 +9544,14 @@ function ManageModelsDialog(props: { const hiddenModels = new Set( getHiddenModelsForProvider(backingProvider, props.hiddenModelsByProvider), ); + const favoriteModels = getFavoriteModelsForProvider( + backingProvider, + props.favoriteModelsByProvider, + ); + const recentModels = getRecentModelsForProvider( + backingProvider, + props.recentModelsByProvider, + ); const allOptions = getModelOptionsForProviderPicker( option.value, props.allModelOptionsByProvider, @@ -9119,22 +9560,26 @@ function ManageModelsDialog(props: { : props.openRouterModelOptions, props.opencodeModelOptions, ); - const filteredOptions = allOptions.filter((modelOption) => { - if (!normalizedQuery) { - return true; - } - const displayParts = getModelPickerOptionDisplayParts(modelOption); - const haystack = [ - option.label, - modelOption.slug, - modelOption.name, - displayParts.providerLabel, - displayParts.modelLabel, - ] - .join(" ") - .toLowerCase(); - return haystack.includes(normalizedQuery); - }); + const filteredOptions = prioritizeModelOptions( + allOptions.filter((modelOption) => { + if (!normalizedQuery) { + return true; + } + const displayParts = getModelPickerOptionDisplayParts(modelOption); + const haystack = [ + option.label, + modelOption.slug, + modelOption.name, + displayParts.providerLabel, + displayParts.modelLabel, + ] + .join(" ") + .toLowerCase(); + return haystack.includes(normalizedQuery); + }), + favoriteModels, + recentModels, + ); if (filteredOptions.length === 0) { return null; @@ -9143,6 +9588,8 @@ function ManageModelsDialog(props: { return { option, backingProvider, + favoriteModels, + recentModels, filteredOptions, hiddenCount: filteredOptions.filter((modelOption) => hiddenModels.has(modelOption.slug)) .length, @@ -9154,6 +9601,8 @@ function ManageModelsDialog(props: { orderedProviders, props.allModelOptionsByProvider, props.hiddenModelsByProvider, + props.favoriteModelsByProvider, + props.recentModelsByProvider, props.openRouterModelOptions, props.opencodeModelOptions, ], @@ -9163,11 +9612,8 @@ function ManageModelsDialog(props: { - Manage models - - Hide models from the picker and from /model suggestions without deleting any saved model - ids. - + {copy.title} + {copy.description}
@@ -9177,13 +9623,13 @@ function ManageModelsDialog(props: { type="search" value={query} onChange={(event) => setQuery(event.target.value)} - placeholder="Search models or providers" + placeholder={copy.searchPlaceholder} className="pl-9" />
0 ? "warning" : "outline"} size="sm"> - {totalHiddenCount > 0 ? `${totalHiddenCount} hidden` : "All visible"} + {totalHiddenCount > 0 ? copy.hiddenCount(totalHiddenCount) : copy.allVisible}
{sections.length === 0 ? (
-

No models match this search.

-

- Try a different provider, model slug, or partial name. -

+

{copy.noMatchesTitle}

+

{copy.noMatchesDescription}

{normalizedQuery ? ( ) : null} {totalHiddenCount > 0 ? ( ) : null}
@@ -9235,7 +9679,7 @@ function ManageModelsDialog(props: { {isActiveSection ? ( - Current + {copy.current} ) : null}
@@ -9245,8 +9689,8 @@ function ManageModelsDialog(props: {
0 ? "warning" : "outline"} size="sm"> {section.hiddenCount > 0 - ? `${section.filteredOptions.length - section.hiddenCount} shown · ${section.hiddenCount} hidden` - : `${section.filteredOptions.length} shown`} + ? `${section.filteredOptions.length - section.hiddenCount} ${copy.shown.toLowerCase()} · ${section.hiddenCount} ${copy.hidden.toLowerCase()}` + : `${section.filteredOptions.length} ${copy.shown.toLowerCase()}`}
@@ -9273,6 +9717,15 @@ function ManageModelsDialog(props: { {displayParts.modelLabel} + {section.favoriteModels.includes(modelOption.slug) ? ( + + {copy.favorite} + + ) : section.recentModels.includes(modelOption.slug) ? ( + + {copy.recent} + + ) : null} {section.backingProvider === "codex" && shouldShowFastTierIcon( modelOption.slug, @@ -9304,9 +9757,29 @@ function ManageModelsDialog(props: { {contextLabel}
-
+
+ - {visible ? "Shown" : "Hidden"} + {visible ? copy.shown : copy.hidden} diff --git a/apps/web/src/components/PiProvider.browser.tsx b/apps/web/src/components/PiProvider.browser.tsx index 408604a1850..4b705eb65a3 100644 --- a/apps/web/src/components/PiProvider.browser.tsx +++ b/apps/web/src/components/PiProvider.browser.tsx @@ -75,11 +75,16 @@ describe("Pi provider GUI", () => { openCodeState={null} hasOpenRouterApiKey={false} hasKimiApiKey={false} + codexBinaryPath="" + copilotBinaryPath="" + opencodeBinaryPath="" + kimiBinaryPath="" isRefreshing={false} onRefresh={() => undefined} onOpenOpenRouterKeyDialog={() => undefined} onOpenKimiKeyDialog={() => undefined} onOpenManageModels={() => undefined} + onOpenSettings={() => undefined} /> , ); @@ -95,7 +100,7 @@ describe("Pi provider GUI", () => { expect(piCard.textContent).toContain("Pi"); expect(piCard.textContent).toContain("CUT3 embeds Pi through its Node SDK"); - expect(piCard.textContent).toContain("Authenticate Pi outside CUT3"); + expect(piCard.textContent).toContain("bunx pi"); } finally { await screen.unmount(); queryClient.clear(); @@ -140,6 +145,8 @@ describe("Pi provider GUI", () => { opencodeContextLengthsBySlug={new Map()} serviceTierSetting="auto" hasHiddenModels={false} + favoriteModelsByProvider={{ codex: [], copilot: [], kimi: [], opencode: [], pi: [] }} + recentModelsByProvider={{ codex: [], copilot: [], kimi: [], opencode: [], pi: [] }} onOpenProviderSetup={() => undefined} onOpenManageModels={() => undefined} onOpenUsageDashboard={() => undefined} @@ -234,6 +241,8 @@ describe("Pi provider GUI", () => { opencodeContextLengthsBySlug={new Map()} serviceTierSetting="auto" hasHiddenModels={false} + favoriteModelsByProvider={{ codex: [], copilot: [], kimi: [], opencode: [], pi: [] }} + recentModelsByProvider={{ codex: [], copilot: [], kimi: [], opencode: [], pi: [] }} onOpenProviderSetup={() => undefined} onOpenManageModels={() => undefined} onOpenUsageDashboard={() => undefined} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 69a0a266663..6938d3926c8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -31,7 +31,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, ProjectId, ThreadId, @@ -45,12 +44,13 @@ import { getAppLanguageDetails, type AppLanguage } from "../appLanguage"; import { isElectron } from "../env"; import { APP_BASE_NAME, APP_VERSION } from "../branding"; import { resolveServerHttpUrl } from "../lib/serverUrl"; -import { cn, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { cn, isMacPlatform, newCommandId } from "../lib/utils"; import { useStore } from "../store"; import { shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { useNewThreadActions } from "../hooks/useNewThread"; +import { useProjectCreationActions } from "../hooks/useProjectCreationActions"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; @@ -89,7 +89,6 @@ import { } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; -import { isNonEmpty as isNonEmptyString } from "effect/String"; import { buildSidebarProjectEntries, resolveSidebarNewThreadEnvMode, @@ -99,7 +98,7 @@ import { } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useSidebarPreferencesStore } from "../sidebarPreferencesStore"; -import { compareThreadsByRecency, type SidebarArchiveFilterMode } from "../lib/threadOrdering"; +import { type SidebarArchiveFilterMode } from "../lib/threadOrdering"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 10; @@ -505,10 +504,17 @@ export default function Sidebar() { const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); const [addingProject, setAddingProject] = useState(false); - const [newCwd, setNewCwd] = useState(""); - const [isPickingFolder, setIsPickingFolder] = useState(false); - const [isAddingProject, setIsAddingProject] = useState(false); - const [addProjectError, setAddProjectError] = useState(null); + const { + addProjectError, + addProjectFromPath, + canAddProject, + clearProjectCreationError, + isAddingProject, + isPickingFolder, + newCwd, + pickProjectFolder, + setNewCwd, + } = useProjectCreationActions(); const addProjectInputRef = useRef(null); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); @@ -656,118 +662,44 @@ export default function Sidebar() { [sidebarCopy.genericError, sidebarCopy.linkOpeningUnavailable, sidebarCopy.unableToOpenPrLink], ); - const focusMostRecentThreadForProject = useCallback( - (projectId: ProjectId) => { - const latestThread = threads - .filter((thread) => thread.projectId === projectId && !archivedThreadIds.has(thread.id)) - .toSorted((left, right) => { - const leftPinned = pinnedThreadIds.has(left.id); - const rightPinned = pinnedThreadIds.has(right.id); - if (leftPinned !== rightPinned) { - return leftPinned ? -1 : 1; - } - return compareThreadsByRecency(left, right); - })[0]; - if (!latestThread) return; - - navigateToThread(latestThread.id); - }, - [archivedThreadIds, navigateToThread, pinnedThreadIds, threads], - ); - - const addProjectFromPath = useCallback( - async (rawCwd: string) => { - const cwd = rawCwd.trim(); - if (!cwd || isAddingProject) return; - const api = readNativeApi(); - if (!api) return; - - setIsAddingProject(true); - const finishAddingProject = () => { - setIsAddingProject(false); - setNewCwd(""); - setAddProjectError(null); + const handleAddProject = () => { + void addProjectFromPath(newCwd).then((result) => { + if (result.ok) { setAddingProject(false); - }; - - try { - const existing = projects.find((project) => project.cwd === cwd); - if (existing) { - focusMostRecentThreadForProject(existing.id); - finishAddingProject(); - return; - } - - const projectId = newProjectId(); - const createdAt = new Date().toISOString(); - const title = cwd.split(/[/\\]/).findLast(isNonEmptyString) ?? cwd; - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), - projectId, - title, - workspaceRoot: cwd, - defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, - createdAt, - }); - await openNewThread(projectId, { - envMode: appSettings.defaultThreadEnvMode, - }).catch(() => undefined); - } catch (error) { - const description = - error instanceof Error ? error.message : sidebarCopy.addProjectUnexpectedError; - setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { - toastManager.add({ - type: "error", - title: sidebarCopy.failedToAddProject, - description, - }); - } else { - setAddProjectError(description); - } return; } - finishAddingProject(); - }, - [ - focusMostRecentThreadForProject, - isAddingProject, - openNewThread, - projects, - shouldBrowseForProjectImmediately, - appSettings.defaultThreadEnvMode, - sidebarCopy.addProjectUnexpectedError, - sidebarCopy.failedToAddProject, - ], - ); - - const handleAddProject = () => { - void addProjectFromPath(newCwd); + if (shouldBrowseForProjectImmediately) { + toastManager.add({ + type: "error", + title: sidebarCopy.failedToAddProject, + description: result.message, + }); + } + }); }; - const canAddProject = newCwd.trim().length > 0 && !isAddingProject; - const handlePickFolder = async () => { - const api = readNativeApi(); - if (!api || isPickingFolder) return; - setIsPickingFolder(true); - let pickedPath: string | null = null; - try { - pickedPath = await api.dialogs.pickFolder(); - } catch { - // Ignore picker failures and leave the current thread selection unchanged. - } + const pickedPath = await pickProjectFolder(); if (pickedPath) { - await addProjectFromPath(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { + const result = await addProjectFromPath(pickedPath); + if (!result.ok) { + toastManager.add({ + type: "error", + title: sidebarCopy.failedToAddProject, + description: result.message, + }); + } else { + setAddingProject(false); + } + return; + } + if (!shouldBrowseForProjectImmediately) { addProjectInputRef.current?.focus(); } - setIsPickingFolder(false); }; const handleStartAddProject = () => { - setAddProjectError(null); + clearProjectCreationError(); if (shouldBrowseForProjectImmediately) { void handlePickFolder(); return; @@ -1680,13 +1612,13 @@ export default function Sidebar() { value={newCwd} onChange={(event) => { setNewCwd(event.target.value); - setAddProjectError(null); + clearProjectCreationError(); }} onKeyDown={(event) => { if (event.key === "Enter") handleAddProject(); if (event.key === "Escape") { setAddingProject(false); - setAddProjectError(null); + clearProjectCreationError(); } }} autoFocus @@ -1711,7 +1643,7 @@ export default function Sidebar() { className="text-[11px] text-sidebar-foreground/75 transition-colors hover:text-sidebar-foreground" onClick={() => { setAddingProject(false); - setAddProjectError(null); + clearProjectCreationError(); }} > {sidebarCopy.cancel} diff --git a/apps/web/src/components/ThreadNewButton.browser.tsx b/apps/web/src/components/ThreadNewButton.browser.tsx index ade3780ef18..b3792f78e2f 100644 --- a/apps/web/src/components/ThreadNewButton.browser.tsx +++ b/apps/web/src/components/ThreadNewButton.browser.tsx @@ -129,6 +129,9 @@ function buildFixture(): TestFixture { } function resolveWsRpc(tag: string): unknown { + if (tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { sequence: pushSequence++ }; + } if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; } @@ -340,4 +343,93 @@ describe("ThreadNewButton", () => { await mounted.cleanup(); } }); + + it("shows first-run onboarding on the empty root route and adds a project from a path", async () => { + fixture.snapshot = { + ...fixture.snapshot, + projects: [], + threads: [], + }; + fixture.welcome = { + cwd: "/repo/empty", + projectName: "Empty", + }; + + const mounted = await mountApp("/"); + + try { + await waitForElement( + () => + Array.from(document.querySelectorAll("p, h1, h2, h3")).find((node) => + node.textContent?.includes("Add your first project"), + ) ?? null, + "Empty route should show the first-run onboarding title.", + ); + const input = await waitForElement( + () => document.querySelector('input[placeholder="/path/to/project"]'), + "First-run onboarding should render a project-path input.", + ); + await page.getByPlaceholder("/path/to/project").fill("/repo/new-project"); + expect(input.value).toBe("/repo/new-project"); + + const addButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Add project", + ) ?? null, + "First-run onboarding should render the add-project button.", + ); + addButton.click(); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "Adding a project from the empty route should open a new draft thread.", + ); + await waitForComposerEditor(); + } finally { + await mounted.cleanup(); + } + }); + + it("offers a new-thread CTA on the empty root route when projects already exist", async () => { + fixture.snapshot = { + ...fixture.snapshot, + threads: [], + }; + fixture.welcome = { + cwd: "/repo/project", + projectName: "Project", + }; + + const mounted = await mountApp("/"); + + try { + await waitForElement( + () => + Array.from(document.querySelectorAll("p, h1, h2, h3")).find((node) => + node.textContent?.includes("Start a new thread"), + ) ?? null, + "Empty route should offer a new-thread CTA when projects exist.", + ); + + const createThreadButton = await waitForElement( + () => + Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Create new thread", + ) ?? null, + "Empty route should render the create-thread button.", + ); + createThreadButton.click(); + + await waitForURL( + mounted.router, + (path) => UUID_ROUTE_RE.test(path), + "The empty-state create-thread CTA should navigate to a draft thread.", + ); + await waitForComposerEditor(); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/chat/EmptyChatOnboarding.tsx b/apps/web/src/components/chat/EmptyChatOnboarding.tsx new file mode 100644 index 00000000000..0f6d0096af0 --- /dev/null +++ b/apps/web/src/components/chat/EmptyChatOnboarding.tsx @@ -0,0 +1,183 @@ +import { FolderIcon, MessageSquarePlusIcon } from "lucide-react"; +import { useMemo, useRef } from "react"; + +import { useAppSettings } from "../../appSettings"; +import { isElectron } from "../../env"; +import { useProjectCreationActions } from "../../hooks/useProjectCreationActions"; +import { useNewThreadActions } from "../../hooks/useNewThread"; +import { useStore } from "../../store"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; + +function getEmptyChatOnboardingCopy(language: "en" | "fa") { + if (language === "fa") { + return { + loadingTitle: "در حال آماده سازی پروژه ها...", + loadingDescription: "CUT3 در حال همگام سازی فضای کاری محلی شما است.", + noProjectsTitle: "اولین پروژه خود را اضافه کنید", + noProjectsDescription: + "CUT3 به یک پوشه پروژه نیاز دارد تا بتواند thread ها را ایجاد کند، دستورهای محلی را کشف کند، و AGENTS.md و skill های فضای کاری را بخواند.", + browse: "مرور پوشه", + addProject: "افزودن پروژه", + pathLabel: "مسیر پروژه", + pathPlaceholder: "/path/to/project", + nextStepHint: "بعد از افزودن پروژه، CUT3 فوراً اولین thread پیش نویس را برای شما باز می کند.", + existingProjectsTitle: "یک thread جدید شروع کنید", + existingProjectsDescription: (count: number) => + `${count} پروژه از قبل در CUT3 موجود است. می توانید یک thread جدید بسازید یا از سایدبار یک thread قبلی را ادامه دهید.`, + createThread: "ایجاد thread جدید", + sidebarHint: "یا یک thread موجود را از سایدبار انتخاب کنید.", + }; + } + + return { + loadingTitle: "Preparing your projects...", + loadingDescription: "CUT3 is syncing the local workspace state.", + noProjectsTitle: "Add your first project", + noProjectsDescription: + "CUT3 needs a project folder before it can create threads, discover repo-local commands, and read workspace AGENTS.md and skills.", + browse: "Browse for folder", + addProject: "Add project", + pathLabel: "Project path", + pathPlaceholder: "/path/to/project", + nextStepHint: + "After you add a project, CUT3 immediately opens your first draft thread so you can start working right away.", + existingProjectsTitle: "Start a new thread", + existingProjectsDescription: (count: number) => + `${count} project${count === 1 ? " is" : "s are"} already available in CUT3. Start a new thread or resume one from the sidebar.`, + createThread: "Create new thread", + sidebarHint: "Or pick an existing thread from the sidebar.", + }; +} + +export function EmptyChatOnboarding() { + const { + settings: { language }, + } = useAppSettings(); + const copy = useMemo(() => getEmptyChatOnboardingCopy(language), [language]); + const threadsHydrated = useStore((store) => store.threadsHydrated); + const projects = useStore((store) => store.projects); + const projectCount = projects.length; + const { defaultProjectId, openDefaultNewThread } = useNewThreadActions(); + const { + addProjectError, + addProjectFromPath, + canAddProject, + clearProjectCreationError, + isAddingProject, + isPickingFolder, + newCwd, + pickProjectFolder, + setNewCwd, + } = useProjectCreationActions(); + const inputRef = useRef(null); + + const handleAddProject = () => { + void addProjectFromPath(newCwd); + }; + + const handlePickFolder = async () => { + const pickedPath = await pickProjectFolder(); + if (pickedPath) { + await addProjectFromPath(pickedPath); + return; + } + inputRef.current?.focus(); + }; + + if (!threadsHydrated) { + return ( +
+

{copy.loadingTitle}

+

{copy.loadingDescription}

+
+ ); + } + + if (projectCount === 0) { + return ( +
+
+

+ {copy.noProjectsTitle} +

+

+ {copy.noProjectsDescription} +

+
+ +
+ {isElectron ? ( + + ) : null} + + + + {addProjectError ?

{addProjectError}

: null} +

{copy.nextStepHint}

+
+
+ ); + } + + return ( +
+
+

+ {copy.existingProjectsTitle} +

+

+ {copy.existingProjectsDescription(projectCount)} +

+
+
+ +

{copy.sidebarHint}

+
+
+ ); +} diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index c628969f7f4..fad31b39019 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -276,11 +276,13 @@ function getChatPickerCopy(language: AppLanguage) { "نام مدل یا ارائه دهنده دیگری را امتحان کنید یا مدیریت مدل ها را باز کنید.", clearSearch: "پاک کردن جستجو", manageModels: "مدیریت مدل ها", - connectProvider: "اتصال ارائه دهنده", + connectProvider: "آماده سازی ارائه دهنده", hiddenModelsHint: "برخی مدل ها مخفی هستند. از مدیریت مدل ها برای بازیابی آنها استفاده کنید.", pickModelHint: "یک مدل انتخاب کنید تا فوراً این thread تغییر کند.", models: (count: number) => `${count} مدل`, selected: "انتخاب شده", + favorite: "محبوب", + recent: "اخیر", locked: "قفل شده", current: "فعلی", }; @@ -293,11 +295,13 @@ function getChatPickerCopy(language: AppLanguage) { "Try a different model slug or open Manage models to restore hidden entries.", clearSearch: "Clear search", manageModels: "Manage models", - connectProvider: "Connect provider", + connectProvider: "Provider readiness", hiddenModelsHint: "Some models are hidden. Use Manage models to restore them.", pickModelHint: "Pick a model to switch this thread instantly.", models: (count: number) => `${count} model${count === 1 ? "" : "s"}`, selected: "Selected", + favorite: "Favorite", + recent: "Recent", locked: "Locked", current: "Current", }; @@ -312,6 +316,10 @@ const PickerModelRow = memo(function PickerModelRow(props: { backingProvider: ProviderKind; providerPickerKind: AvailableProviderPickerKind; isSelected: boolean; + isFavorite: boolean; + isRecent: boolean; + favoriteLabel: string; + recentLabel: string; isDisabledByProviderLock: boolean; disabled: boolean; serviceTierSetting: AppServiceTier; @@ -375,6 +383,16 @@ const PickerModelRow = memo(function PickerModelRow(props: { {/* Capability badges + selection mark */}
+ {props.isFavorite ? ( + + + {props.favoriteLabel} + + ) : props.isRecent ? ( + + {props.recentLabel} + + ) : null} {props.modelOption.supportsReasoning ? ( ; serviceTierSetting: AppServiceTier; hasHiddenModels: boolean; + favoriteModelsByProvider: Record>; + recentModelsByProvider: Record>; modelLabelOverride?: string; compact?: boolean; disabled?: boolean; @@ -508,6 +528,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { visibleModelOptionsByProvider: props.visibleModelOptionsByProvider, openRouterModelOptions: props.openRouterModelOptions, opencodeModelOptions: props.opencodeModelOptions, + favoriteModelsByProvider: props.favoriteModelsByProvider, + recentModelsByProvider: props.recentModelsByProvider, lockedProvider: props.lockedProvider, normalizedQuery, }), @@ -516,6 +538,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { props.openRouterModelOptions, props.opencodeModelOptions, props.visibleModelOptionsByProvider, + props.favoriteModelsByProvider, + props.recentModelsByProvider, props.lockedProvider, ], ); @@ -754,6 +778,10 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const isSelected = section.option.value === props.providerPickerKind && modelOption.slug === props.model; + const providerFavorites = + props.favoriteModelsByProvider[section.backingProvider]; + const providerRecents = + props.recentModelsByProvider[section.backingProvider]; return ( segment.trim().length > 0) ?? cwd; +} + +export function useProjectCreationActions() { + const { settings } = useAppSettings(); + const projects = useStore((store) => store.projects); + const threads = useStore((store) => store.threads); + const archivedThreadIds = useSidebarPreferencesStore((store) => store.archivedThreadIds); + const { openNewThread } = useNewThreadActions(); + const navigate = useNavigate(); + const [newCwd, setNewCwd] = useState(""); + const [isPickingFolder, setIsPickingFolder] = useState(false); + const [isAddingProject, setIsAddingProject] = useState(false); + const [addProjectError, setAddProjectError] = useState(null); + + const openThread = useCallback( + async (threadId: ThreadId) => { + await navigate({ + to: "/$threadId", + params: { threadId }, + }); + }, + [navigate], + ); + + const focusBestProjectTarget = useCallback( + async (projectId: ProjectId) => { + const latestThread = threads + .filter((thread) => thread.projectId === projectId && !archivedThreadIds.has(thread.id)) + .toSorted(compareThreadsByRecency)[0]; + + if (latestThread) { + await openThread(latestThread.id); + return; + } + + await openNewThread(projectId, { + envMode: settings.defaultThreadEnvMode, + }); + }, + [archivedThreadIds, openNewThread, openThread, settings.defaultThreadEnvMode, threads], + ); + + const clearProjectCreationError = useCallback(() => { + setAddProjectError(null); + }, []); + + const addProjectFromPath = useCallback( + async (rawCwd: string): Promise<{ ok: true } | { ok: false; message: string }> => { + const cwd = rawCwd.trim(); + if (!cwd || isAddingProject) { + return { ok: false, message: "Workspace path is required." }; + } + const api = readNativeApi(); + if (!api) { + return { ok: false, message: "Native API not found." }; + } + + setIsAddingProject(true); + setAddProjectError(null); + try { + const existing = projects.find((project) => project.cwd === cwd); + if (existing) { + await focusBestProjectTarget(existing.id); + setNewCwd(""); + return { ok: true }; + } + + const projectId = newProjectId(); + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title: titleFromWorkspacePath(cwd), + workspaceRoot: cwd, + defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + createdAt: new Date().toISOString(), + }); + await openNewThread(projectId, { + envMode: settings.defaultThreadEnvMode, + }).catch(() => undefined); + setNewCwd(""); + return { ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to add project."; + setAddProjectError(message); + return { ok: false, message }; + } finally { + setIsAddingProject(false); + } + }, + [ + focusBestProjectTarget, + isAddingProject, + openNewThread, + projects, + settings.defaultThreadEnvMode, + ], + ); + + const pickProjectFolder = useCallback(async (): Promise => { + const api = readNativeApi(); + if (!api || isPickingFolder) { + return null; + } + + setIsPickingFolder(true); + try { + return await api.dialogs.pickFolder(); + } catch { + return null; + } finally { + setIsPickingFolder(false); + } + }, [isPickingFolder]); + + const canAddProject = useMemo( + () => newCwd.trim().length > 0 && !isAddingProject, + [isAddingProject, newCwd], + ); + + return { + addProjectError, + addProjectFromPath, + canAddProject, + clearProjectCreationError, + isAddingProject, + isPickingFolder, + newCwd, + pickProjectFolder, + setNewCwd, + } as const; +} diff --git a/apps/web/src/lib/modelPickerHelpers.test.ts b/apps/web/src/lib/modelPickerHelpers.test.ts index 31898f8d66d..45bf830e0bf 100644 --- a/apps/web/src/lib/modelPickerHelpers.test.ts +++ b/apps/web/src/lib/modelPickerHelpers.test.ts @@ -166,6 +166,8 @@ describe("buildPickerProviderSections", () => { visibleModelOptionsByProvider: modelOptionsByProvider, openRouterModelOptions: [], opencodeModelOptions: [], + favoriteModelsByProvider: { codex: [], copilot: [], kimi: [], opencode: [], pi: [] }, + recentModelsByProvider: { codex: [], copilot: [], kimi: [], opencode: [], pi: [] }, lockedProvider: null, normalizedQuery: "", }); @@ -180,6 +182,8 @@ describe("buildPickerProviderSections", () => { visibleModelOptionsByProvider: modelOptionsByProvider, openRouterModelOptions: [], opencodeModelOptions: [], + favoriteModelsByProvider: { codex: [], copilot: [], kimi: [], opencode: [], pi: [] }, + recentModelsByProvider: { codex: [], copilot: [], kimi: [], opencode: [], pi: [] }, lockedProvider: null, normalizedQuery: "5.4", }); @@ -194,6 +198,8 @@ describe("buildPickerProviderSections", () => { visibleModelOptionsByProvider: modelOptionsByProvider, openRouterModelOptions: [], opencodeModelOptions: [], + favoriteModelsByProvider: { codex: [], copilot: [], kimi: [], opencode: [], pi: [] }, + recentModelsByProvider: { codex: [], copilot: [], kimi: [], opencode: [], pi: [] }, lockedProvider: null, normalizedQuery: "zzz-no-match", }); @@ -206,10 +212,42 @@ describe("buildPickerProviderSections", () => { visibleModelOptionsByProvider: modelOptionsByProvider, openRouterModelOptions: [], opencodeModelOptions: [], + favoriteModelsByProvider: { codex: [], copilot: [], kimi: [], opencode: [], pi: [] }, + recentModelsByProvider: { codex: [], copilot: [], kimi: [], opencode: [], pi: [] }, lockedProvider: "copilot", normalizedQuery: "", }); expect(sections).toHaveLength(1); expect(sections[0]!.isDisabledByProviderLock).toBe(true); }); + + it("prioritizes favorite and recent models before the rest", () => { + const sections = buildPickerProviderSections({ + availableOptions, + visibleModelOptionsByProvider: modelOptionsByProvider, + openRouterModelOptions: [], + opencodeModelOptions: [], + favoriteModelsByProvider: { + codex: ["gpt-5.3-codex"], + copilot: [], + kimi: [], + opencode: [], + pi: [], + }, + recentModelsByProvider: { + codex: ["gpt-5.4"], + copilot: [], + kimi: [], + opencode: [], + pi: [], + }, + lockedProvider: null, + normalizedQuery: "", + }); + + expect(sections[0]?.modelOptions.map((option) => option.slug)).toEqual([ + "gpt-5.3-codex", + "gpt-5.4", + ]); + }); }); diff --git a/apps/web/src/lib/modelPickerHelpers.ts b/apps/web/src/lib/modelPickerHelpers.ts index 9a59563ad11..a2931c0fe2c 100644 --- a/apps/web/src/lib/modelPickerHelpers.ts +++ b/apps/web/src/lib/modelPickerHelpers.ts @@ -11,6 +11,7 @@ import { } from "../session-logic"; import { formatCopilotRequestCost } from "./copilotBilling"; import { formatCompactTokenCount } from "./contextWindow"; +import { prioritizeModelOptions } from "./modelPreferences"; // --------------------------------------------------------------------------- // Picker model option type @@ -364,6 +365,8 @@ export function buildPickerProviderSections(input: { visibleModelOptionsByProvider: Record>; openRouterModelOptions: ReadonlyArray; opencodeModelOptions: ReadonlyArray; + favoriteModelsByProvider: Record>; + recentModelsByProvider: Record>; lockedProvider: ProviderKind | null; normalizedQuery: string; }): PickerProviderSection[] { @@ -387,6 +390,11 @@ export function buildPickerProviderSections(input: { const haystack = [option.label, modelOption.slug, modelOption.name].join(" ").toLowerCase(); return haystack.includes(input.normalizedQuery); }); + const prioritizedModelOptions = prioritizeModelOptions( + filteredModelOptions, + input.favoriteModelsByProvider[backingProvider], + input.recentModelsByProvider[backingProvider], + ); if (filteredModelOptions.length === 0 && input.normalizedQuery) { return null; @@ -395,12 +403,12 @@ export function buildPickerProviderSections(input: { const isDisabledByProviderLock = input.lockedProvider !== null && input.lockedProvider !== backingProvider; - const families = groupModelsByFamily(filteredModelOptions, option.value); + const families = groupModelsByFamily(prioritizedModelOptions, option.value); return { option, backingProvider, - modelOptions: filteredModelOptions, + modelOptions: prioritizedModelOptions, families, isDisabledByProviderLock, }; diff --git a/apps/web/src/lib/modelPreferences.test.ts b/apps/web/src/lib/modelPreferences.test.ts new file mode 100644 index 00000000000..e5365b4d551 --- /dev/null +++ b/apps/web/src/lib/modelPreferences.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { + buildRecentModelSelection, + normalizeModelPreferenceSlugs, + prioritizeModelOptions, +} from "./modelPreferences"; + +describe("normalizeModelPreferenceSlugs", () => { + it("normalizes and deduplicates provider model references", () => { + expect( + normalizeModelPreferenceSlugs([" gpt-5.4 ", "5.4", "custom/model", "custom/model"], "codex"), + ).toEqual(["gpt-5.4", "custom/model"]); + }); +}); + +describe("buildRecentModelSelection", () => { + it("moves the newest model to the front and keeps the list bounded", () => { + expect(buildRecentModelSelection(["gpt-5.3-codex", "gpt-5.4"], "codex", "gpt-5.4", 2)).toEqual([ + "gpt-5.4", + "gpt-5.3-codex", + ]); + }); +}); + +describe("prioritizeModelOptions", () => { + it("sorts favorites first, then recents, then preserves the rest", () => { + const ordered = prioritizeModelOptions( + [{ slug: "gpt-5.4" }, { slug: "gpt-5.3-codex" }, { slug: "gpt-5.2" }], + ["gpt-5.3-codex"], + ["gpt-5.4"], + ); + + expect(ordered.map((option) => option.slug)).toEqual(["gpt-5.3-codex", "gpt-5.4", "gpt-5.2"]); + }); +}); diff --git a/apps/web/src/lib/modelPreferences.ts b/apps/web/src/lib/modelPreferences.ts new file mode 100644 index 00000000000..2201806b6ca --- /dev/null +++ b/apps/web/src/lib/modelPreferences.ts @@ -0,0 +1,76 @@ +import { type ProviderKind } from "@t3tools/contracts"; +import { normalizeModelSlug } from "@t3tools/shared/model"; + +const DEFAULT_MODEL_PREFERENCE_LIMIT = 32; + +export function normalizeModelPreferenceSlugs( + models: Iterable, + provider: ProviderKind = "codex", + limit = DEFAULT_MODEL_PREFERENCE_LIMIT, + maxSlugLength = 256, +): string[] { + const normalizedModels: string[] = []; + const seen = new Set(); + + for (const candidate of models) { + const normalized = normalizeModelSlug(candidate, provider); + if (!normalized || normalized.length > maxSlugLength || seen.has(normalized)) { + continue; + } + + seen.add(normalized); + normalizedModels.push(normalized); + if (normalizedModels.length >= limit) { + break; + } + } + + return normalizedModels; +} + +export function buildRecentModelSelection( + existing: ReadonlyArray, + provider: ProviderKind, + model: string, + limit = DEFAULT_MODEL_PREFERENCE_LIMIT, +): string[] { + const normalizedModel = normalizeModelSlug(model, provider); + if (!normalizedModel) { + return [...existing]; + } + + return normalizeModelPreferenceSlugs([normalizedModel, ...existing], provider, limit); +} + +export function prioritizeModelOptions( + options: ReadonlyArray, + favorites: ReadonlyArray, + recents: ReadonlyArray, +): T[] { + if (options.length <= 1) { + return [...options]; + } + + const favoriteOrder = new Map(favorites.map((slug, index) => [slug, index] as const)); + const recentOrder = new Map(recents.map((slug, index) => [slug, index] as const)); + + return [...options].toSorted((left, right) => { + const leftFavorite = favoriteOrder.get(left.slug); + const rightFavorite = favoriteOrder.get(right.slug); + if (leftFavorite !== undefined || rightFavorite !== undefined) { + if (leftFavorite === undefined) return 1; + if (rightFavorite === undefined) return -1; + return leftFavorite - rightFavorite; + } + + const leftRecent = recentOrder.get(left.slug); + const rightRecent = recentOrder.get(right.slug); + if (leftRecent !== undefined || rightRecent !== undefined) { + if (leftRecent === undefined) return 1; + if (rightRecent === undefined) return -1; + return leftRecent - rightRecent; + } + + return 0; + }); +} diff --git a/apps/web/src/lib/openRouterModels.test.ts b/apps/web/src/lib/openRouterModels.test.ts index ab9e307b4d2..34611191b3d 100644 --- a/apps/web/src/lib/openRouterModels.test.ts +++ b/apps/web/src/lib/openRouterModels.test.ts @@ -226,4 +226,74 @@ describe("openRouterModels", () => { }), ); }); + + it("reuses the last known-good catalog when the live fetch fails", async () => { + const storage = new Map(); + storage.set( + "cut3:openrouter-free-models-cache:v1", + JSON.stringify({ + fetchedAt: "2026-03-27T10:00:00.000Z", + models: [ + { + slug: "openrouter/free", + name: "OpenRouter Free Router", + description: null, + contextLength: 200000, + supportsTools: true, + supportsToolChoice: true, + supportsImages: true, + supportsReasoning: true, + source: "router", + }, + { + slug: "openai/gpt-oss-120b:free", + name: "GPT OSS 120B", + description: null, + contextLength: 131072, + supportsTools: true, + supportsToolChoice: true, + supportsImages: false, + supportsReasoning: true, + source: "catalog", + }, + ], + }), + ); + const previousWindow = (globalThis as { window?: unknown }).window; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + }, + }, + }); + + try { + const fetchImpl = vi.fn().mockRejectedValue(new Error("network down")); + + await expect(readOpenRouterFreeModelCatalog(fetchImpl)).resolves.toEqual({ + status: "available", + fetchedAt: "2026-03-27T10:00:00.000Z", + source: "cache", + staleReason: "network down", + models: [ + expect.objectContaining({ slug: "openrouter/free" }), + expect.objectContaining({ slug: "openai/gpt-oss-120b:free" }), + ], + }); + } finally { + if (previousWindow === undefined) { + Reflect.deleteProperty(globalThis, "window"); + } else { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: previousWindow, + }); + } + } + }); }); diff --git a/apps/web/src/lib/openRouterModels.ts b/apps/web/src/lib/openRouterModels.ts index e7f64a99c5d..5c6f8c6a8ef 100644 --- a/apps/web/src/lib/openRouterModels.ts +++ b/apps/web/src/lib/openRouterModels.ts @@ -20,6 +20,8 @@ export type OpenRouterFreeModelCatalog = readonly status: "available"; readonly fetchedAt: string; readonly models: ReadonlyArray; + readonly source: "live" | "cache"; + readonly staleReason?: string; } | { readonly status: "unavailable"; @@ -28,6 +30,8 @@ export type OpenRouterFreeModelCatalog = readonly models: ReadonlyArray; }; +const OPENROUTER_FREE_MODEL_CACHE_STORAGE_KEY = "cut3:openrouter-free-models-cache:v1"; + export const OPENROUTER_FREE_ROUTER_OPTION: OpenRouterFreeModelOption = { slug: OPENROUTER_FREE_ROUTER_MODEL, name: "OpenRouter Free Router", @@ -191,6 +195,110 @@ export function extractOpenRouterFreeModels( ].filter((entry): entry is OpenRouterFreeModelOption => entry !== undefined); } +function readCachedOpenRouterFreeModelOption(value: unknown): OpenRouterFreeModelOption | null { + const record = asRecord(value); + const slug = asNonEmptyString(record?.slug); + const name = asNonEmptyString(record?.name); + const source = record?.source; + if (!slug || !name || (source !== "router" && source !== "catalog")) { + return null; + } + + return { + slug, + name, + description: record?.description === null ? null : asNonEmptyString(record?.description), + contextLength: asNumber(record?.contextLength), + supportsTools: record?.supportsTools === true, + supportsToolChoice: record?.supportsToolChoice === true, + supportsImages: record?.supportsImages === true, + supportsReasoning: record?.supportsReasoning === true, + source, + }; +} + +function readCachedOpenRouterFreeModelCatalog(): { + readonly fetchedAt: string; + readonly models: ReadonlyArray; +} | null { + if (typeof window === "undefined") { + return null; + } + + try { + const raw = window.localStorage.getItem(OPENROUTER_FREE_MODEL_CACHE_STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as { + fetchedAt?: unknown; + models?: unknown; + }; + if (typeof parsed.fetchedAt !== "string" || !Array.isArray(parsed.models)) { + return null; + } + const cachedModels = parsed.models + .map(readCachedOpenRouterFreeModelOption) + .filter((entry): entry is OpenRouterFreeModelOption => entry !== null); + const cachedRouterModel = + cachedModels.find((entry) => entry.slug === OPENROUTER_FREE_ROUTER_OPTION.slug) ?? + OPENROUTER_FREE_ROUTER_OPTION; + const models = [ + { ...OPENROUTER_FREE_ROUTER_OPTION, ...cachedRouterModel, source: "router" as const }, + ...cachedModels.filter((entry) => entry.slug !== OPENROUTER_FREE_ROUTER_OPTION.slug), + ]; + return { + fetchedAt: parsed.fetchedAt, + models, + }; + } catch { + return null; + } +} + +function writeCachedOpenRouterFreeModelCatalog( + catalog: Pick, +): void { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + OPENROUTER_FREE_MODEL_CACHE_STORAGE_KEY, + JSON.stringify({ + fetchedAt: catalog.fetchedAt, + models: catalog.models, + }), + ); + } catch { + // Best-effort cache only. + } +} + +function buildUnavailableOpenRouterFreeModelCatalog( + fetchedAt: string, + message: string, +): OpenRouterFreeModelCatalog { + const cachedCatalog = readCachedOpenRouterFreeModelCatalog(); + if (cachedCatalog) { + return { + status: "available", + fetchedAt: cachedCatalog.fetchedAt, + models: cachedCatalog.models, + source: "cache", + staleReason: message, + }; + } + + return { + status: "unavailable", + fetchedAt, + message, + models: [OPENROUTER_FREE_ROUTER_OPTION], + }; +} + export async function readOpenRouterFreeModelCatalog( fetchImpl: typeof fetch = fetch, ): Promise { @@ -205,30 +313,26 @@ export async function readOpenRouterFreeModelCatalog( }); if (!response.ok) { - return { - status: "unavailable", + return buildUnavailableOpenRouterFreeModelCatalog( fetchedAt, - message: `OpenRouter returned ${response.status}.`, - models: [OPENROUTER_FREE_ROUTER_OPTION], - }; + `OpenRouter returned ${response.status}.`, + ); } const payload = (await response.json()) as unknown; + const models = extractOpenRouterFreeModels(payload); + writeCachedOpenRouterFreeModelCatalog({ fetchedAt, models }); return { status: "available", fetchedAt, - models: extractOpenRouterFreeModels(payload), + models, + source: "live", }; } catch (error) { const message = error instanceof Error && error.message.trim().length > 0 ? error.message.trim() : "Could not fetch the OpenRouter model catalog."; - return { - status: "unavailable", - fetchedAt, - message, - models: [OPENROUTER_FREE_ROUTER_OPTION], - }; + return buildUnavailableOpenRouterFreeModelCatalog(fetchedAt, message); } } diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 2e902dce454..086a142922d 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { isElectron } from "../env"; import ThreadNewButton from "../components/ThreadNewButton"; import ThreadSidebarToggle from "../components/ThreadSidebarToggle"; +import { EmptyChatOnboarding } from "../components/chat/EmptyChatOnboarding"; function ChatIndexRouteView() { return ( @@ -25,10 +26,8 @@ function ChatIndexRouteView() {
)} -
-
-

Select a thread or create a new one to get started.

-
+
+
); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index bb35bea1930..ff06e76fcb5 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -22,6 +22,7 @@ import { isElectron } from "../env"; import { useChatBackgroundImage } from "../hooks/useChatBackgroundImage"; import { removeChatBackgroundBlob, saveChatBackgroundBlob } from "../lib/chatBackgroundStorage"; import { formatCompactTokenCount } from "../lib/contextWindow"; +import { cn } from "../lib/utils"; import { isCut3CompatibleOpenRouterModelOption, isOpenRouterGuaranteedFreeSlug, @@ -188,6 +189,8 @@ function getSettingsCopy(language: AppLanguage) { openRouterChecking: "در حال بررسی OpenRouter برای فهرست فعلی مدل های رایگان...", openRouterAvailable: (count: number) => `${count} مدل رایگان زنده OpenRouter در حال حاضر با مسیر بومی ابزار CUT3 سازگار ${count === 1 ? "است" : "هستند"}، به علاوه روتر داخلی.`, + openRouterCached: (count: number) => + `CUT3 آخرین کاتالوگ سالم OpenRouter را نشان می دهد (${count} مدل رایگان سازگار به علاوه روتر داخلی) چون واکشی زنده فعلاً در دسترس نیست.`, openRouterUnavailable: "کشف زنده مدل های رایگان OpenRouter در حال حاضر در دسترس نیست.", openRouterFilteringNote: (routerSlug: string) => `CUT3 فقط انتخاب هایی را نشان می دهد که روی :free یا ${routerSlug} قفل شده باشند و از ابزارها پشتیبانی کنند.`, @@ -382,6 +385,8 @@ function getSettingsCopy(language: AppLanguage) { openRouterChecking: "Checking OpenRouter for the current free-model list...", openRouterAvailable: (count: number) => `${count} live OpenRouter free model${count === 1 ? " is" : "s are"} currently compatible with CUT3's native tool-calling path, plus the built-in router.`, + openRouterCached: (count: number) => + `CUT3 is showing the last known-good OpenRouter catalog (${count} compatible free model${count === 1 ? "" : "s"} plus the built-in router) because the live fetch is currently unavailable.`, openRouterUnavailable: "Live OpenRouter free-model discovery is currently unavailable.", openRouterFilteringNote: (routerSlug: string) => `CUT3 only lists OpenRouter picks that are locked to :free or ${routerSlug} and advertise tool use.`, @@ -608,7 +613,9 @@ function SettingsRouteView() { () => openRouterCatalogQuery.data?.models ?? [OPENROUTER_FREE_ROUTER_OPTION], [openRouterCatalogQuery.data?.models], ); - const hasLiveOpenRouterCatalog = openRouterCatalogQuery.data?.status === "available"; + const hasLiveOpenRouterCatalog = + openRouterCatalogQuery.data?.status === "available" && + openRouterCatalogQuery.data.source === "live"; const compatibleOpenRouterFreeModels = useMemo( () => openRouterFreeModels.filter(isCut3CompatibleOpenRouterModelOption), [openRouterFreeModels], @@ -809,12 +816,16 @@ function SettingsRouteView() { ? copy.openRouterChecking : hasLiveOpenRouterCatalog ? copy.openRouterAvailable(openRouterCatalogModelCount) - : copy.openRouterUnavailable; + : openRouterCatalogQuery.data?.status === "available" + ? copy.openRouterCached(openRouterCatalogModelCount) + : copy.openRouterUnavailable; const openRouterCatalogError = openRouterCatalogQuery.data?.status === "unavailable" ? openRouterCatalogQuery.data.message - : null; + : openRouterCatalogQuery.data?.status === "available" + ? (openRouterCatalogQuery.data.staleReason ?? null) + : null; const renderCustomModelsCard = (providerSettings: (typeof MODEL_PROVIDER_SETTINGS)[number]) => { const provider = providerSettings.provider; @@ -1570,7 +1581,16 @@ function SettingsRouteView() {

) : null} {openRouterCatalogError ? ( -

{openRouterCatalogError}

+

+ {openRouterCatalogError} +

) : null}
From 7e226f8d292866d4809ca7a543cd54c64f391d55 Mon Sep 17 00:00:00 2001 From: yappologistic Date: Fri, 27 Mar 2026 02:26:44 -0600 Subject: [PATCH 3/6] polish: micro UI/UX refinements across chat, composer, timeline, and global styles - Global: add text-rendering optimizeLegibility, antialiased font smoothing, brand-aware ::selection highlight, consistent :focus-visible ring, and pill-shaped scrollbars with smooth transitions - Markdown: improved list marker styling, link underline transitions, primary-tinted blockquote borders, blended code block backgrounds with inset shadows, distinct table header styling with row hover highlights - Timeline: user message bubbles get subtle depth shadow, completion divider uses gradient fade lines, working indicator dots use primary color, timestamps bumped from 30% to 42-45% opacity for readability - Composer: placeholder opacity raised from 35% to 48% - Onboarding: animated loading dots, softer card borders, CTA button glow, better visual hierarchy in descriptions - Terminal: resize handle gets visible pill affordance on hover/active - Chat header: thread title bumped to font-semibold - Plan sidebar: softer background with backdrop blur --- .../src/components/ComposerPromptEditor.tsx | 2 +- apps/web/src/components/PlanSidebar.tsx | 8 +- .../src/components/ThreadTerminalDrawer.tsx | 2 +- apps/web/src/components/chat/ChatHeader.tsx | 2 +- .../components/chat/EmptyChatOnboarding.tsx | 22 +-- .../src/components/chat/MessagesTimeline.tsx | 28 ++-- apps/web/src/index.css | 126 +++++++++++++++--- 7 files changed, 143 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index ab68f1fcbdc..b21e34783af 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -888,7 +888,7 @@ function ComposerPromptEditorInner({ /> } placeholder={ -
+
{placeholder}
} diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 16b51862f98..13747639d38 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -118,7 +118,7 @@ const PlanSidebar = memo(function PlanSidebar({ }, [planMarkdown, workspaceRoot]); return ( -
+
{/* Header */}
@@ -180,7 +180,7 @@ const PlanSidebar = memo(function PlanSidebar({
{/* Explanation */} {activePlan?.explanation ? ( -

+

{activePlan.explanation}

) : null} @@ -251,8 +251,8 @@ const PlanSidebar = memo(function PlanSidebar({ {/* Empty state */} {!activePlan && !planMarkdown ? (
-

No active plan yet.

-

+

No active plan yet.

+

Plans will appear here when generated.

diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 193efffe4a0..a5ac3641d20 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -747,7 +747,7 @@ export default function ThreadTerminalDrawer({ role="separator" aria-orientation="horizontal" aria-label="Resize terminal" - className="absolute inset-x-0 top-0 z-20 h-1.5 cursor-row-resize" + className="resize-handle-affordance absolute inset-x-0 top-0 z-20 h-2 cursor-row-resize" onPointerDown={handleResizePointerDown} onPointerMove={handleResizePointerMove} onPointerUp={handleResizePointerEnd} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index ea7f911bec4..215f8326b80 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -58,7 +58,7 @@ export const ChatHeader = memo(function ChatHeader({

{activeThreadTitle} diff --git a/apps/web/src/components/chat/EmptyChatOnboarding.tsx b/apps/web/src/components/chat/EmptyChatOnboarding.tsx index 0f6d0096af0..06d54fb0d49 100644 --- a/apps/web/src/components/chat/EmptyChatOnboarding.tsx +++ b/apps/web/src/components/chat/EmptyChatOnboarding.tsx @@ -87,26 +87,31 @@ export function EmptyChatOnboarding() { if (!threadsHydrated) { return ( -
+
+
+ + + +

{copy.loadingTitle}

-

{copy.loadingDescription}

+

{copy.loadingDescription}

); } if (projectCount === 0) { return ( -
+

{copy.noProjectsTitle}

-

+

{copy.noProjectsDescription}

-
+
{isElectron ? (
); } return ( -
+

{copy.existingProjectsTitle}

-

+

{copy.existingProjectsDescription(projectCount)}

@@ -172,6 +177,7 @@ export function EmptyChatOnboarding() { onClick={() => { void openDefaultNewThread(); }} + className="shadow-[0_4px_14px_-4px_--alpha(var(--color-primary)/35%)]" > {copy.createThread} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2af36e17112..8584fdb5093 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -427,14 +427,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); return (
-
+
{userImages.length > 0 && (
{userImages.map( (image: NonNullable[number]) => (
{image.previewUrl ? (
-

+

{formatTimestamp(row.message.createdAt, timestampFormat)}

@@ -512,12 +512,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( <> {row.showCompletionDivider && ( -
- - +
+ + {completionSummary ? `Response • ${completionSummary}` : "Response"} - +
)}
+

Changed files ({changedFileCountLabel}) @@ -602,7 +602,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({

-

+

{formatMessageMeta( row.message.createdAt, row.message.streaming @@ -629,11 +629,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {row.kind === "working" && (

-
+
- - - + + + {row.createdAt @@ -649,7 +649,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (!hasMessages && !isWorking) { return (
-

{emptyStateLabel}

+

{emptyStateLabel}

); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 813e2e7ef81..8f0c06d752a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -220,6 +220,19 @@ } body { @apply bg-background text-foreground relative; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + ::selection { + background: --alpha(var(--color-primary) / 22%); + color: var(--foreground); + } + + :focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; } } @@ -896,6 +909,7 @@ html[data-pointer-cursors="off"] [data-slot="sidebar-trigger"] { /* Scrollbar styling */ ::-webkit-scrollbar { width: 6px; + height: 6px; } ::-webkit-scrollbar-track { @@ -903,20 +917,36 @@ html[data-pointer-cursors="off"] [data-slot="sidebar-trigger"] { } ::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); - border-radius: 3px; + background: rgba(0, 0, 0, 0.12); + border-radius: 9999px; + border: 1px solid transparent; + background-clip: content-box; + transition: background-color 180ms ease; } ::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.25); + background: rgba(0, 0, 0, 0.22); + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:active { + background: rgba(0, 0, 0, 0.3); + background-clip: content-box; } .dark ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.08); + background-clip: content-box; } .dark ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.16); + background-clip: content-box; +} + +.dark ::-webkit-scrollbar-thumb:active { + background: rgba(255, 255, 255, 0.22); + background-clip: content-box; } .turn-chip-strip { @@ -1007,32 +1037,48 @@ label:has(> select#reasoning-effort) select { } .chat-markdown li + li { - margin-top: 0.25rem; + margin-top: 0.3rem; +} + +.chat-markdown li::marker { + color: var(--muted-foreground); + opacity: 0.55; +} + +.chat-markdown li > p:first-child { + margin-top: 0; +} + +.chat-markdown li > p:last-child { + margin-bottom: 0; } .chat-markdown a { color: var(--info-foreground); text-decoration: underline; - text-decoration-color: color-mix(in srgb, var(--info-foreground) 40%, transparent); + text-underline-offset: 3px; + text-decoration-color: color-mix(in srgb, var(--info-foreground) 30%, transparent); + transition: text-decoration-color 150ms ease; } .chat-markdown a:hover { - opacity: 0.8; + text-decoration-color: color-mix(in srgb, var(--info-foreground) 65%, transparent); } .chat-markdown blockquote { - border-left: 2px solid var(--border); - padding-left: 0.8rem; + border-left: 3px solid color-mix(in srgb, var(--primary) 35%, var(--border)); + padding-left: 0.85rem; color: var(--muted-foreground); + margin-left: 0; } .chat-markdown :not(pre) > code { - border: 1px solid var(--border); + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); border-radius: 0.375rem; - background: var(--muted); - padding: 0.1rem 0.35rem; + background: color-mix(in srgb, var(--muted) 65%, var(--background)); + padding: 0.125rem 0.4rem; color: var(--foreground); - font-size: 0.75rem; + font-size: 0.8125rem; font-family: var(--font-code-snippet); } @@ -1041,9 +1087,10 @@ label:has(> select#reasoning-effort) select { overflow-x: auto; border: 1px solid var(--border); border-radius: 0.75rem; - background: var(--muted); - padding: 0.8rem 0.9rem; + background: color-mix(in srgb, var(--muted) 80%, var(--background)); + padding: 0.85rem 1rem; font-family: var(--font-code-snippet); + box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.04); } .chat-markdown pre code { @@ -1095,6 +1142,7 @@ label:has(> select#reasoning-effort) select { .chat-markdown .chat-markdown-copy-button:hover { color: var(--foreground); border-color: color-mix(in srgb, var(--border) 70%, var(--foreground)); + background: color-mix(in srgb, var(--background) 92%, transparent); } .chat-markdown .chat-markdown-shiki .shiki { @@ -1116,11 +1164,53 @@ label:has(> select#reasoning-effort) select { border-collapse: collapse; } -.chat-markdown th, +.chat-markdown th { + border: 1px solid var(--border); + padding: 0.4rem 0.6rem; + text-align: left; + font-weight: 600; + font-size: 0.8125rem; + background: color-mix(in srgb, var(--muted) 45%, transparent); + color: var(--muted-foreground); +} + .chat-markdown td { border: 1px solid var(--border); - padding: 0.35rem 0.45rem; + padding: 0.4rem 0.6rem; text-align: left; + font-size: 0.875rem; +} + +.chat-markdown tr:hover td { + background: color-mix(in srgb, var(--accent) 30%, transparent); +} + +/* Resize handle affordance */ +.resize-handle-affordance { + position: relative; +} + +.resize-handle-affordance::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 32px; + height: 3px; + border-radius: 9999px; + background: var(--border); + opacity: 0; + transition: opacity 200ms ease; +} + +.resize-handle-affordance:hover::after { + opacity: 0.7; +} + +.resize-handle-affordance:active::after { + opacity: 1; + background: var(--primary); } /* Diffs theme bridge (match diff surfaces to app palette) */ From c744925e0dc79e6231b43f954b4a767881cf42be Mon Sep 17 00:00:00 2001 From: yappologistic Date: Fri, 27 Mar 2026 04:44:39 -0600 Subject: [PATCH 4/6] feat: add command palette and QoL improvements --- TODO.md | 12 +- apps/server/src/keybindings.ts | 2 + apps/web/src/appSettings.ts | 3 + apps/web/src/components/ChatMarkdown.tsx | 11 +- apps/web/src/components/ChatView.browser.tsx | 66 ++++++ apps/web/src/components/ChatView.tsx | 95 ++++++++- apps/web/src/components/CommandPalette.tsx | 188 ++++++++++++++++++ .../components/KeybindingsToast.browser.tsx | 170 +++++++++++++++- apps/web/src/index.css | 19 ++ apps/web/src/notifications.ts | 89 +++++++++ apps/web/src/routes/_chat.settings.tsx | 116 ++++++++++- apps/web/src/routes/_chat.tsx | 181 ++++++++++++++++- packages/contracts/src/keybindings.ts | 2 + 13 files changed, 937 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/CommandPalette.tsx create mode 100644 apps/web/src/notifications.ts diff --git a/TODO.md b/TODO.md index 3d856996d8d..cf7692ff49d 100644 --- a/TODO.md +++ b/TODO.md @@ -2,12 +2,12 @@ ## Small things -- [ ] Submitting new messages should scroll to bottom -- [ ] Only show last 10 threads for a given project -- [ ] Thread archiving -- [ ] New projects should go on top -- [ ] Projects should be sorted by latest thread update +- [x] Submitting new messages should scroll to bottom +- [x] Only show last 10 threads for a given project +- [x] Thread archiving +- [x] New projects should go on top +- [x] Projects should be sorted by latest thread update ## Bigger things -- [ ] Queueing messages +- [x] Queueing messages diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 99e859bab42..991b2292d96 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -74,6 +74,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "escape", command: "chat.interrupt", when: "!terminalFocus" }, + { key: "mod+shift+p", command: "commandPalette.toggle" }, { key: "mod+o", command: "editor.openFavorite" }, ]; diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index f60185d979b..2a0c9fe8f88 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -163,6 +163,9 @@ const AppSettingsSchema = Schema.Struct({ Schema.withConstructorDefault(() => Option.some(false)), ), showToolDetails: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), + enableDesktopNotifications: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), codexServiceTier: AppServiceTierSchema.pipe( Schema.withConstructorDefault(() => Option.some("auto")), ), diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c60ddea1b77..b4d3b275f47 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -234,10 +234,15 @@ function SuspenseShikiCodeBlock({ const cacheKey = createHighlightCacheKey(code, language, themeName); const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; + // Show line numbers for code blocks with 4+ lines. + const lineCount = code.split("\n").length; + const showLineNumbers = lineCount >= 4; + if (cachedHighlightedHtml != null) { return (
); @@ -269,7 +274,11 @@ function SuspenseShikiCodeBlock({ }, [cacheKey, code, highlightedHtml, isStreaming]); return ( -
+
); } diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 565ef16ced9..4b915f1eb4f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1777,6 +1777,72 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("dispatches a turn interrupt from the keyboard escape shortcut", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotWithInterruptFallbackTurn(TurnId.makeUnsafe("turn-running-escape")), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "chat.interrupt", + shortcut: { + key: "escape", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + }); + + try { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + const interruptRequest = wsRequests.find( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.command && + typeof request.command === "object" && + !Array.isArray(request.command) && + "type" in request.command && + request.command.type === "thread.turn.interrupt" && + "turnId" in request.command && + request.command.turnId === "turn-running-escape", + ); + expect(interruptRequest).toMatchObject({ + _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, + command: { + type: "thread.turn.interrupt", + threadId: THREAD_ID, + turnId: "turn-running-escape", + }, + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("queues a follow-up while a turn is running and drains it after the session settles", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e7ecc81b126..602b6d1db78 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -335,6 +335,7 @@ import { } from "../threadActivityMetadata"; import { findMatchingApprovalRule, type ApprovalRule } from "../approvalRules"; import { formatTimestamp } from "../timestampFormat"; +import { showTurnCompleteNotification } from "../notifications"; const LAST_EDITOR_KEY = "cut3:last-editor"; const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "cut3:last-invoked-script-by-project"; @@ -2115,6 +2116,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + + // ── Desktop notification on turn completion ────────────────────── + // Track phase/thread transitions separately so notifications only fire for + // completed turns in the current thread, not after route switches. + const previousNotificationStateRef = useRef({ + threadId: activeThreadId, + phase, + }); + const lastNotifiedTurnIdRef = useRef(null); const followUpMode = followUpModeByThreadId[threadId] ?? "queue"; const hasQueuedTurns = queuedTurnsForThread.length > 0; const hasFailedQueuedTurn = queuedTurnsForThread.some((turn) => turn.status === "failed"); @@ -2447,6 +2457,50 @@ export default function ChatView({ threadId }: ChatViewProps) { } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); + + // ── Desktop notification on turn completion (runs after timelineMessages) ── + useEffect(() => { + const previousState = previousNotificationStateRef.current; + previousNotificationStateRef.current = { + threadId: activeThreadId, + phase, + }; + + if (previousState.threadId !== activeThreadId) { + return; + } + if (previousState.phase !== "running" || phase === "running") { + return; + } + if (!latestTurnSettled || activeLatestTurn?.state !== "completed") { + return; + } + if (!activeLatestTurn?.turnId || lastNotifiedTurnIdRef.current === activeLatestTurn.turnId) { + return; + } + + lastNotifiedTurnIdRef.current = activeLatestTurn.turnId; + const lastMessage = timelineMessages.at(-1); + const snippet = + lastMessage?.role === "assistant" + ? (lastMessage.text ?? "").slice(0, 120) + : settings.language === "fa" + ? "کار agent تمام شد." + : "Agent finished working."; + showTurnCompleteNotification({ + threadTitle: activeThread?.title ?? "", + messageSnippet: snippet, + }); + }, [ + activeLatestTurn, + activeThread?.title, + activeThreadId, + latestTurnSettled, + phase, + settings.language, + timelineMessages, + ]); + const visibleWorkLogEntries = useMemo( () => (settings.showToolDetails ? workLogEntries : []), [settings.showToolDetails, workLogEntries], @@ -4109,6 +4163,36 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } + if (command === "chat.interrupt") { + if (phase === "running" && !isInterruptingTurn && activeThread) { + event.preventDefault(); + event.stopPropagation(); + const api = readNativeApi(); + if (api) { + const targetTurnId = activeInterruptTurnId; + setPendingInterruptRequest({ + threadId: activeThread.id, + turnId: targetTurnId, + }); + void api.orchestration + .dispatchCommand({ + type: "thread.turn.interrupt", + commandId: newCommandId(), + threadId: activeThread.id, + ...(targetTurnId ? { turnId: targetTurnId } : {}), + createdAt: new Date().toISOString(), + }) + .catch((err) => { + setPendingInterruptRequest(null); + const message = err instanceof Error ? err.message : "Failed to stop generation."; + setThreadError(activeThread.id, message); + }); + } + } + // If not running, let Escape propagate for other handlers (e.g. clear selection). + return; + } + const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; const script = activeProject.scripts.find((entry) => entry.id === scriptId); @@ -4120,13 +4204,18 @@ export default function ChatView({ threadId }: ChatViewProps) { window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [ + activeInterruptTurnId, activeProject, + activeThread, terminalState.terminalOpen, terminalState.activeTerminalId, activeThreadId, closeTerminal, createNewTerminal, + isInterruptingTurn, + phase, setTerminalOpen, + setThreadError, runProjectScript, splitTerminal, keybindings, @@ -10246,7 +10335,7 @@ const OpenInPicker = memo(function OpenInPicker({ }, [effectiveEditor, keybindings, openInCwd]); return ( - +
+ + {/* Desktop notifications */} +
+
+

+ {copy.desktopNotifications} +

+

+ {copy.desktopNotificationsDescription} +

+ {notificationPermission === "unsupported" ? ( +

+ {copy.desktopNotificationsUnsupported} +

+ ) : notificationPermission === "denied" ? ( +

+ {copy.desktopNotificationsBlocked} +

+ ) : notificationPermission === "granted" ? ( +

+ {copy.desktopNotificationsGranted} +

+ ) : null} +
+ { + void handleDesktopNotificationsChange(Boolean(checked)); + }} + aria-label={copy.desktopNotifications} + /> +
{settings.enableAssistantStreaming !== defaults.enableAssistantStreaming || - settings.showToolDetails !== defaults.showToolDetails ? ( + settings.showToolDetails !== defaults.showToolDetails || + settings.enableDesktopNotifications !== defaults.enableDesktopNotifications ? (
+ {addProjectError ?

{addProjectError}

: null}

{copy.sidebarHint}

diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index f0e842f5d93..a095016a8ee 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -11,7 +11,8 @@ const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; -const USER_AVG_CHAR_WIDTH_PX = 7.23; +// Calibrated against the production Geist user-message font in browser parity tests. +const USER_AVG_CHAR_WIDTH_PX = 7.77; const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 35f92d98e9b..1a4d0268238 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -7,6 +7,7 @@ import { useComposerDraftStore, } from "../composerDraftStore"; import { newThreadId } from "../lib/utils"; +import { buildNewThreadDraftContextPatch } from "../lib/newThreadDraftContext"; import { useStore } from "../store"; export function useHandleNewThread() { @@ -41,21 +42,15 @@ export function useHandleNewThread() { setDraftThreadContext, setProjectDraftThreadId, } = useComposerDraftStore.getState(); - const hasBranchOption = options?.branch !== undefined; - const hasWorktreePathOption = options?.worktreePath !== undefined; - const hasEnvModeOption = options?.envMode !== undefined; + const draftContextPatch = buildNewThreadDraftContextPatch(options); const storedDraftThread = getDraftThreadByProjectId(projectId); const latestActiveDraftThread: DraftThreadState | null = routeThreadId ? getDraftThread(routeThreadId) : null; if (storedDraftThread) { return (async () => { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.threadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); + if (draftContextPatch) { + setDraftThreadContext(storedDraftThread.threadId, draftContextPatch); } setProjectDraftThreadId(projectId, storedDraftThread.threadId); if (routeThreadId === storedDraftThread.threadId) { @@ -75,12 +70,8 @@ export function useHandleNewThread() { routeThreadId && latestActiveDraftThread.projectId === projectId ) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(routeThreadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); + if (draftContextPatch) { + setDraftThreadContext(routeThreadId, draftContextPatch); } setProjectDraftThreadId(projectId, routeThreadId); return Promise.resolve(); diff --git a/apps/web/src/hooks/useNewThread.ts b/apps/web/src/hooks/useNewThread.ts index d031ebd34ea..e5207bc2a92 100644 --- a/apps/web/src/hooks/useNewThread.ts +++ b/apps/web/src/hooks/useNewThread.ts @@ -4,6 +4,7 @@ import { useCallback, useMemo } from "react"; import { type DraftThreadEnvMode, useComposerDraftStore } from "../composerDraftStore"; import { newThreadId } from "../lib/utils"; +import { buildNewThreadDraftContextPatch } from "../lib/newThreadDraftContext"; import { useStore } from "../store"; export interface NewThreadOptions { @@ -39,18 +40,12 @@ export function useNewThreadActions() { const openNewThread = useCallback( (projectId: ProjectId, options?: NewThreadOptions): Promise => { - const hasBranchOption = options?.branch !== undefined; - const hasWorktreePathOption = options?.worktreePath !== undefined; - const hasEnvModeOption = options?.envMode !== undefined; + const draftContextPatch = buildNewThreadDraftContextPatch(options); const storedDraftThread = getDraftThreadByProjectId(projectId); if (storedDraftThread) { return (async () => { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(storedDraftThread.threadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); + if (draftContextPatch) { + setDraftThreadContext(storedDraftThread.threadId, draftContextPatch); } setProjectDraftThreadId(projectId, storedDraftThread.threadId); if (routeThreadId === storedDraftThread.threadId) { @@ -67,12 +62,8 @@ export function useNewThreadActions() { const routeDraftThread = routeThreadId ? getDraftThread(routeThreadId) : null; if (routeDraftThread && routeThreadId && routeDraftThread.projectId === projectId) { - if (hasBranchOption || hasWorktreePathOption || hasEnvModeOption) { - setDraftThreadContext(routeThreadId, { - ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), - ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), - }); + if (draftContextPatch) { + setDraftThreadContext(routeThreadId, draftContextPatch); } setProjectDraftThreadId(projectId, routeThreadId); return Promise.resolve(); diff --git a/apps/web/src/hooks/useProjectCreationActions.ts b/apps/web/src/hooks/useProjectCreationActions.ts index 26f8214e520..0460ee3fb4f 100644 --- a/apps/web/src/hooks/useProjectCreationActions.ts +++ b/apps/web/src/hooks/useProjectCreationActions.ts @@ -9,6 +9,7 @@ import { newCommandId, newProjectId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; import { useStore } from "../store"; import { useSidebarPreferencesStore } from "../sidebarPreferencesStore"; +import { workspacePathsLikelyMatch } from "../lib/projectPathMatching"; function titleFromWorkspacePath(cwd: string): string { return cwd.split(/[/\\]/).findLast((segment) => segment.trim().length > 0) ?? cwd; @@ -72,7 +73,7 @@ export function useProjectCreationActions() { setIsAddingProject(true); setAddProjectError(null); try { - const existing = projects.find((project) => project.cwd === cwd); + const existing = projects.find((project) => workspacePathsLikelyMatch(project.cwd, cwd)); if (existing) { await focusBestProjectTarget(existing.id); setNewCwd(""); @@ -89,9 +90,18 @@ export function useProjectCreationActions() { defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, createdAt: new Date().toISOString(), }); - await openNewThread(projectId, { - envMode: settings.defaultThreadEnvMode, - }).catch(() => undefined); + try { + await openNewThread(projectId, { + envMode: settings.defaultThreadEnvMode, + }); + } catch (error) { + const message = + error instanceof Error && error.message.trim().length > 0 + ? `Project was added, but CUT3 could not open its first draft thread: ${error.message}` + : "Project was added, but CUT3 could not open its first draft thread."; + setAddProjectError(message); + return { ok: false, message }; + } setNewCwd(""); return { ok: true }; } catch (error) { diff --git a/apps/web/src/lib/newThreadDraftContext.test.ts b/apps/web/src/lib/newThreadDraftContext.test.ts new file mode 100644 index 00000000000..dfc7fb46dcc --- /dev/null +++ b/apps/web/src/lib/newThreadDraftContext.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { buildNewThreadDraftContextPatch } from "./newThreadDraftContext"; + +describe("buildNewThreadDraftContextPatch", () => { + it("returns null when no context overrides are provided", () => { + expect(buildNewThreadDraftContextPatch(undefined)).toBeNull(); + expect(buildNewThreadDraftContextPatch({})).toBeNull(); + }); + + it("preserves explicit worktree overrides", () => { + expect( + buildNewThreadDraftContextPatch({ + branch: "feature/worktree", + worktreePath: "/tmp/feature-worktree", + envMode: "worktree", + }), + ).toEqual({ + branch: "feature/worktree", + worktreePath: "/tmp/feature-worktree", + envMode: "worktree", + }); + }); + + it("clears inherited branch and worktree state when switching an existing draft back to local", () => { + expect( + buildNewThreadDraftContextPatch({ + envMode: "local", + }), + ).toEqual({ + branch: null, + worktreePath: null, + envMode: "local", + }); + }); + + it("keeps an explicit local branch override while still clearing worktree reuse", () => { + expect( + buildNewThreadDraftContextPatch({ + branch: "main", + envMode: "local", + }), + ).toEqual({ + branch: "main", + worktreePath: null, + envMode: "local", + }); + }); +}); diff --git a/apps/web/src/lib/newThreadDraftContext.ts b/apps/web/src/lib/newThreadDraftContext.ts new file mode 100644 index 00000000000..55b51920391 --- /dev/null +++ b/apps/web/src/lib/newThreadDraftContext.ts @@ -0,0 +1,37 @@ +import { type DraftThreadEnvMode } from "../composerDraftStore"; + +export interface NewThreadDraftContextOptions { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; +} + +export function buildNewThreadDraftContextPatch( + options: NewThreadDraftContextOptions | undefined, +): { + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; +} | null { + const hasBranchOption = options?.branch !== undefined; + const hasWorktreePathOption = options?.worktreePath !== undefined; + const hasEnvModeOption = options?.envMode !== undefined; + + if (!hasBranchOption && !hasWorktreePathOption && !hasEnvModeOption) { + return null; + } + + if (options?.envMode === "local") { + return { + branch: hasBranchOption ? (options.branch ?? null) : null, + worktreePath: hasWorktreePathOption ? (options.worktreePath ?? null) : null, + envMode: "local", + }; + } + + return { + ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), + ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + }; +} diff --git a/apps/web/src/lib/projectPathMatching.test.ts b/apps/web/src/lib/projectPathMatching.test.ts new file mode 100644 index 00000000000..5b5a1e8a891 --- /dev/null +++ b/apps/web/src/lib/projectPathMatching.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + normalizeWorkspacePathForComparison, + workspacePathsLikelyMatch, +} from "./projectPathMatching"; + +describe("normalizeWorkspacePathForComparison", () => { + it("returns null for empty values", () => { + expect(normalizeWorkspacePathForComparison(undefined)).toBeNull(); + expect(normalizeWorkspacePathForComparison(null)).toBeNull(); + expect(normalizeWorkspacePathForComparison(" ")).toBeNull(); + }); + + it("trims whitespace and trailing separators", () => { + expect(normalizeWorkspacePathForComparison(" /repo/project/// ")).toBe("/repo/project"); + }); + + it("normalizes windows separators, dot segments, and case-insensitive casing", () => { + expect(normalizeWorkspacePathForComparison("C:\\Users\\Me\\Repo\\")).toBe("c:/users/me/repo"); + expect(normalizeWorkspacePathForComparison("C:\\Users\\Me\\Repo\\.\\src\\..\\")).toBe( + "c:/users/me/repo", + ); + expect(normalizeWorkspacePathForComparison("/repo/../../workspace")).toBe("/workspace"); + expect(normalizeWorkspacePathForComparison("C:\\repo\\..\\..\\workspace")).toBe("c:/workspace"); + }); +}); + +describe("workspacePathsLikelyMatch", () => { + it("matches paths that only differ by trailing separators", () => { + expect(workspacePathsLikelyMatch("/repo/project", "/repo/project/")).toBe(true); + }); + + it("matches windows paths that only differ by slash style, casing, or dot segments", () => { + expect(workspacePathsLikelyMatch("C:\\Users\\Me\\Repo", "c:/Users/Me/Repo/")).toBe(true); + expect(workspacePathsLikelyMatch("C:\\Users\\Me\\Repo", "c:/users/me/repo/./src/..")).toBe( + true, + ); + }); + + it("does not collapse genuinely different paths", () => { + expect(workspacePathsLikelyMatch("/repo/project", "/repo/project-two")).toBe(false); + }); +}); diff --git a/apps/web/src/lib/projectPathMatching.ts b/apps/web/src/lib/projectPathMatching.ts new file mode 100644 index 00000000000..d8d2536f65b --- /dev/null +++ b/apps/web/src/lib/projectPathMatching.ts @@ -0,0 +1,77 @@ +function buildNormalizedSegments(rawPath: string, clampAboveRoot: boolean): string[] { + const segments: string[] = []; + + for (const segment of rawPath.split(/\/+/)) { + if (segment.length === 0 || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length > 0 && segments[segments.length - 1] !== "..") { + segments.pop(); + } else if (!clampAboveRoot) { + segments.push(segment); + } + continue; + } + segments.push(segment); + } + + return segments; +} + +export function normalizeWorkspacePathForComparison( + path: string | null | undefined, +): string | null { + if (typeof path !== "string") { + return null; + } + + const trimmed = path.trim(); + if (!trimmed) { + return null; + } + + const normalizedSeparators = trimmed.replace(/\\+/g, "/"); + const isUncPath = normalizedSeparators.startsWith("//"); + const driveMatch = normalizedSeparators.match(/^([A-Za-z]:)(?:\/|$)/); + const isWindowsStyle = isUncPath || driveMatch !== null || trimmed.includes("\\"); + + let prefix = ""; + let remainder = normalizedSeparators; + + if (driveMatch) { + prefix = driveMatch[1]!.toLowerCase(); + remainder = normalizedSeparators.slice(driveMatch[0].length); + } else if (isUncPath) { + const uncBody = normalizedSeparators.slice(2); + const [server = "", share = "", ...rest] = uncBody.split(/\/+/); + prefix = `//${server}/${share}`; + remainder = rest.join("/"); + } else if (normalizedSeparators.startsWith("/")) { + prefix = "/"; + remainder = normalizedSeparators.slice(1); + } + + const normalizedSegments = buildNormalizedSegments(remainder, prefix.length > 0); + const joinedSegments = normalizedSegments.join("/"); + + let normalizedPath = prefix; + if (prefix === "/") { + normalizedPath = joinedSegments.length > 0 ? `/${joinedSegments}` : "/"; + } else if (prefix.length > 0) { + normalizedPath = joinedSegments.length > 0 ? `${prefix}/${joinedSegments}` : `${prefix}/`; + } else { + normalizedPath = joinedSegments.length > 0 ? joinedSegments : "."; + } + + return isWindowsStyle ? normalizedPath.toLowerCase() : normalizedPath; +} + +export function workspacePathsLikelyMatch( + left: string | null | undefined, + right: string | null | undefined, +): boolean { + const normalizedLeft = normalizeWorkspacePathForComparison(left); + const normalizedRight = normalizeWorkspacePathForComparison(right); + return normalizedLeft !== null && normalizedLeft === normalizedRight; +} From 37c50d78465c289f8dd8b79ca9e80eb80da28d48 Mon Sep 17 00:00:00 2001 From: yappologistic Date: Sat, 28 Mar 2026 00:19:14 -0600 Subject: [PATCH 6/6] test: stabilize linux chat timeline browser parity --- apps/web/src/components/ChatView.browser.tsx | 31 +++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 38ba5f18c35..faa9dc7d1d7 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -72,18 +72,41 @@ interface ViewportSpec { attachmentTolerancePx: number; } +// Chromium font metrics for Geist differ noticeably on Linux CI, so the +// browser parity thresholds need a little more headroom there than on +// Windows/macOS to avoid false negatives while still catching real drifts. +const IS_LINUX_BROWSER = typeof navigator !== "undefined" && /linux/i.test(navigator.userAgent); + const DEFAULT_VIEWPORT: ViewportSpec = { name: "desktop", width: 960, height: 1_100, - textTolerancePx: 44, + textTolerancePx: IS_LINUX_BROWSER ? 96 : 44, attachmentTolerancePx: 56, }; const TEXT_VIEWPORT_MATRIX = [ DEFAULT_VIEWPORT, - { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, + { + name: "tablet", + width: 720, + height: 1_024, + textTolerancePx: IS_LINUX_BROWSER ? 96 : 44, + attachmentTolerancePx: 56, + }, + { + name: "mobile", + width: 430, + height: 932, + textTolerancePx: IS_LINUX_BROWSER ? 160 : 56, + attachmentTolerancePx: 56, + }, + { + name: "narrow", + width: 320, + height: 700, + textTolerancePx: IS_LINUX_BROWSER ? 160 : 84, + attachmentTolerancePx: 56, + }, ] as const satisfies readonly ViewportSpec[]; const ATTACHMENT_VIEWPORT_MATRIX = [ DEFAULT_VIEWPORT,