diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 3d856996d8..0000000000 --- a/TODO.md +++ /dev/null @@ -1,13 +0,0 @@ -# TODO - -## 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 - -## Bigger things - -- [ ] Queueing messages diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 71b6b7df4e..d49ec4f274 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -62,6 +62,7 @@ const clientSettings: ClientSettings = { sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", timestampFormat: "24-hour", + toolCallSummaries: true, }; const savedRegistryRecord: PersistedSavedEnvironmentRecord = { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c5507c6fb0..205c4676ec 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -170,6 +170,12 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM continue; } + // Header items are decorative section labels for the web fallback only — + // Electron's native menu has no equivalent affordance, so we skip them. + if (sourceItem.header === true) { + continue; + } + const normalizedItem: ContextMenuItem = { id: sourceItem.id, label: sourceItem.label, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index d650f62308..c4e16a3548 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -314,6 +314,7 @@ export const makeOrchestrationIntegrationHarness = ( const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), + generateToolWorkLogSummary: () => Effect.succeed({ line: "Example activity" }), } as unknown as TextGenerationShape); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index efaa2b3b6c..18b634cfd4 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -151,8 +151,6 @@ const buildCmd = Command.make( cwd: serverDir, stdout: config.verbose ? "inherit" : "ignore", stderr: "inherit", - // Windows needs shell mode to resolve `.cmd` shims on PATH. - shell: process.platform === "win32", }), ); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 33bf7bc014..3d32f3f889 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -20,6 +20,7 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildToolWorkLogSummaryPrompt, } from "../Prompts.ts"; import { normalizeCliError, @@ -87,7 +88,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateToolWorkLogSummary"; cwd: string; prompt: string; outputSchemaJson: S; @@ -315,10 +317,36 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }; }); + const generateToolWorkLogSummary: TextGenerationShape["generateToolWorkLogSummary"] = Effect.fn( + "ClaudeTextGeneration.generateToolWorkLogSummary", + )(function* (input) { + const { prompt, outputSchema } = buildToolWorkLogSummaryPrompt({ + label: input.label, + toolTitle: input.toolTitle, + itemType: input.itemType, + requestKind: input.requestKind, + command: input.command, + detailSnippet: input.detailSnippet, + }); + + const generated = yield* runClaudeJson({ + operation: "generateToolWorkLogSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + line: generated.line, + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateToolWorkLogSummary, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index d4bc8f1632..974324a593 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -18,6 +18,7 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildToolWorkLogSummaryPrompt, } from "../Prompts.ts"; import { normalizeCliError, @@ -97,7 +98,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateToolWorkLogSummary", attachments: BranchNameGenerationInput["attachments"], ): Effect.fn.Return { if (!attachments || attachments.length === 0) { @@ -141,7 +143,8 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateToolWorkLogSummary"; cwd: string; prompt: string; outputSchemaJson: S; @@ -379,11 +382,37 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } satisfies ThreadTitleGenerationResult; }); + const generateToolWorkLogSummary: TextGenerationShape["generateToolWorkLogSummary"] = Effect.fn( + "CodexTextGeneration.generateToolWorkLogSummary", + )(function* (input) { + const { prompt, outputSchema } = buildToolWorkLogSummaryPrompt({ + label: input.label, + toolTitle: input.toolTitle, + itemType: input.itemType, + requestKind: input.requestKind, + command: input.command, + detailSnippet: input.detailSnippet, + }); + + const generated = yield* runCodexJson({ + operation: "generateToolWorkLogSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + line: generated.line, + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateToolWorkLogSummary, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts index c94c6dd180..0cc868fe67 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -14,6 +14,7 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildToolWorkLogSummaryPrompt, } from "../Prompts.ts"; import { extractJsonObject, @@ -33,7 +34,8 @@ function mapCursorAcpError( | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateToolWorkLogSummary", detail: string, cause: unknown, ): TextGenerationError { @@ -74,7 +76,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateToolWorkLogSummary"; cwd: string; prompt: string; outputSchemaJson: S; @@ -270,11 +273,37 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu } satisfies ThreadTitleGenerationResult; }); + const generateToolWorkLogSummary: TextGenerationShape["generateToolWorkLogSummary"] = Effect.fn( + "CursorTextGeneration.generateToolWorkLogSummary", + )(function* (input) { + const { prompt, outputSchema } = buildToolWorkLogSummaryPrompt({ + label: input.label, + toolTitle: input.toolTitle, + itemType: input.itemType, + requestKind: input.requestKind, + command: input.command, + detailSnippet: input.detailSnippet, + }); + + const generated = yield* runCursorJson({ + operation: "generateToolWorkLogSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + line: generated.line, + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateToolWorkLogSummary, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index b8eeb54189..051d238152 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -84,6 +84,11 @@ interface FakeGitTextGeneration { message: string; modelSelection: ModelSelection; }) => Effect.Effect<{ title: string }, TextGenerationError>; + generateToolWorkLogSummary: (input: { + cwd: string; + label: string; + modelSelection: ModelSelection; + }) => Effect.Effect<{ line: string }, TextGenerationError>; } type FakePullRequest = NonNullable; @@ -292,6 +297,10 @@ function createTextGeneration(overrides: Partial = {}): T Effect.succeed({ title: "Update workflow", }), + generateToolWorkLogSummary: () => + Effect.succeed({ + line: "Task Example tool activity", + }), ...overrides, }; @@ -340,6 +349,17 @@ function createTextGeneration(overrides: Partial = {}): T }), ), ), + generateToolWorkLogSummary: (input) => + implementation.generateToolWorkLogSummary(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateToolWorkLogSummary", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), }; } diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index f2d0b4b272..8f5055b792 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -17,6 +17,7 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildToolWorkLogSummaryPrompt, } from "../Prompts.ts"; import { type TextGenerationShape } from "../Services/TextGeneration.ts"; import { @@ -156,7 +157,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateToolWorkLogSummary"; }) => sharedServerMutex.withPermit( Effect.gen(function* () { @@ -266,7 +268,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateToolWorkLogSummary"; readonly cwd: string; readonly prompt: string; readonly outputSchemaJson: S; @@ -455,10 +458,35 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }; }); + const generateToolWorkLogSummary: TextGenerationShape["generateToolWorkLogSummary"] = Effect.fn( + "OpenCodeTextGeneration.generateToolWorkLogSummary", + )(function* (input) { + const { prompt, outputSchema } = buildToolWorkLogSummaryPrompt({ + label: input.label, + toolTitle: input.toolTitle, + itemType: input.itemType, + requestKind: input.requestKind, + command: input.command, + detailSnippet: input.detailSnippet, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateToolWorkLogSummary", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + line: generated.line, + }; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, + generateToolWorkLogSummary, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/TextGenerationLive.test.ts b/apps/server/src/git/Layers/TextGenerationLive.test.ts index 3b03696eb4..476691b71d 100644 --- a/apps/server/src/git/Layers/TextGenerationLive.test.ts +++ b/apps/server/src/git/Layers/TextGenerationLive.test.ts @@ -17,6 +17,8 @@ const makeStubTextGeneration = (overrides: Partial): TextGe generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + generateToolWorkLogSummary: () => + Effect.die("generateToolWorkLogSummary stub not configured for this test"), ...overrides, }); diff --git a/apps/server/src/git/Layers/TextGenerationLive.ts b/apps/server/src/git/Layers/TextGenerationLive.ts index 58e1541d55..3e031129d6 100644 --- a/apps/server/src/git/Layers/TextGenerationLive.ts +++ b/apps/server/src/git/Layers/TextGenerationLive.ts @@ -36,12 +36,14 @@ import { } from "../../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; +import { sanitizeToolWorkLogSummaryLine } from "../Utils.ts"; type TextGenerationOp = | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateToolWorkLogSummary"; const resolveInstance = ( registry: ProviderInstanceRegistryShape, @@ -85,6 +87,11 @@ export const makeTextGenerationFromRegistry = ( resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((tg) => tg.generateThreadTitle(input)), ), + generateToolWorkLogSummary: (input) => + resolveInstance(registry, "generateToolWorkLogSummary", input.modelSelection.instanceId).pipe( + Effect.flatMap((tg) => tg.generateToolWorkLogSummary(input)), + Effect.map((r) => ({ line: sanitizeToolWorkLogSummaryLine(r.line, input.label) })), + ), }); /** diff --git a/apps/server/src/git/Prompts.ts b/apps/server/src/git/Prompts.ts index 4092358825..02bd07f473 100644 --- a/apps/server/src/git/Prompts.ts +++ b/apps/server/src/git/Prompts.ts @@ -200,3 +200,53 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { return { prompt, outputSchema }; } + +// --------------------------------------------------------------------------- +// Tool work log summary (chat activity list) +// --------------------------------------------------------------------------- + +export interface ToolWorkLogSummaryPromptInput { + label: string; + toolTitle?: string | undefined; + itemType?: string | undefined; + requestKind?: "command" | "file-read" | "file-change" | undefined; + command?: string | undefined; + detailSnippet?: string | undefined; +} + +export function buildToolWorkLogSummaryPrompt(input: ToolWorkLogSummaryPromptInput) { + const lines: string[] = [ + "You rewrite coding-agent tool and shell activity into one short, friendly log line.", + "Return a JSON object with key: line (a single string).", + "Rules:", + "- Plain English, sentence case, no trailing period", + "- Start with a short category word when obvious: Bash, Read, Write, Search, MCP, Task, Plan, or similar", + "- Describe what is being done in a few words (aim for ~12 words or fewer), not raw parameters", + "- Do not include XML/JSON dumps, stack traces, or long paths (a single filename is okay)", + "- Do not prefix with >_ or shell prompts — only the readable phrase", + "", + `Activity label: ${limitSection(input.label, 500)}`, + ]; + if (input.toolTitle) { + lines.push("", `Tool title: ${limitSection(input.toolTitle, 400)}`); + } + if (input.itemType) { + lines.push("", `Item type: ${limitSection(input.itemType, 120)}`); + } + if (input.requestKind) { + lines.push("", `Approval/kind: ${input.requestKind}`); + } + if (input.command) { + lines.push("", `Command (may be truncated):`, limitSection(input.command, 1_500)); + } + if (input.detailSnippet) { + lines.push("", `Detail (may be truncated):`, limitSection(input.detailSnippet, 2_500)); + } + + const prompt = lines.join("\n"); + const outputSchema = Schema.Struct({ + line: Schema.String, + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 78d37a0108..d53382bfd7 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -73,6 +73,21 @@ export interface ThreadTitleGenerationResult { title: string; } +export interface ToolWorkLogSummaryGenerationInput { + cwd: string; + label: string; + toolTitle?: string | undefined; + itemType?: string | undefined; + requestKind?: "command" | "file-read" | "file-change" | undefined; + command?: string | undefined; + detailSnippet?: string | undefined; + modelSelection: ModelSelection; +} + +export interface ToolWorkLogSummaryGenerationResult { + line: string; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, @@ -113,6 +128,13 @@ export interface TextGenerationShape { readonly generateThreadTitle: ( input: ThreadTitleGenerationInput, ) => Effect.Effect; + + /** + * Rewrite tool / work-log activity metadata into one short human-readable line. + */ + readonly generateToolWorkLogSummary: ( + input: ToolWorkLogSummaryGenerationInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index 15015e8cda..b0e8346c53 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -122,6 +122,33 @@ export function sanitizeThreadTitle(raw: string): string { return `${normalized.slice(0, 47).trimEnd()}...`; } +/** Normalise model output for tool work-log rows (UI adds the >_ prefix). */ +export function sanitizeToolWorkLogSummaryLine(raw: string, fallbackLabel: string): string { + const stripped = raw + .trim() + .split(/\r?\n/g)[0] + ?.replace(/^>\s*[__]\s*/i, "") + .replace(/^>\s*/, "") + .trim() + .replace(/\s+/g, " "); + + const base = + stripped && stripped.length > 0 + ? stripped + : (fallbackLabel.trim().split(/\r?\n/g)[0]?.trim() ?? "").replace(/\s+/g, " "); + + if (!base || base.length === 0) { + return "Working"; + } + + const withoutTrailingPeriod = base.replace(/[.]+$/g, "").trim(); + const singleLine = withoutTrailingPeriod.length > 0 ? withoutTrailingPeriod : base; + if (singleLine.length <= 160) { + return singleLine; + } + return `${singleLine.slice(0, 157).trimEnd()}...`; +} + /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ function cliLabel(cliName: string): string { const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c44f291504..0c1388f936 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -274,6 +274,15 @@ describe("ProviderCommandReactor", () => { }), ), ); + const generateToolWorkLogSummary = vi.fn( + (_) => + Effect.fail( + new TextGenerationError({ + operation: "generateToolWorkLogSummary", + detail: "disabled in test harness", + }), + ), + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -338,6 +347,7 @@ describe("ProviderCommandReactor", () => { Layer.mock(TextGeneration, { generateBranchName, generateThreadTitle, + generateToolWorkLogSummary, }), ), Layer.provideMerge(ServerSettingsService.layerTest()), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b7a4c195a5..ce2c8bb874 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1124,16 +1124,18 @@ const make = Effect.gen(function* () { if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } - // Only the active turn may close the lifecycle state. if (activeTurnId !== null && eventTurnId !== undefined) { return sameId(activeTurnId, eventTurnId); } - // If no active turn is tracked, accept completion scoped to this thread. return true; default: return true; } })(); + + const shouldForceClearActiveTurn = shouldApplyThreadLifecycle + ? false + : event.type === "turn.completed" && activeTurnId !== null; const acceptedTurnStartedSourcePlan = event.type === "turn.started" && shouldApplyThreadLifecycle ? yield* getSourceProposedPlanReferenceForAcceptedTurnStart(thread.id, eventTurnId) @@ -1221,6 +1223,35 @@ const make = Effect.gen(function* () { }, createdAt: now, }); + } else if (shouldForceClearActiveTurn) { + yield* Effect.logWarning( + "provider runtime ingestion: lifecycle guard blocked turn completed but active turn is stuck — force-clearing", + { + eventId: event.eventId, + eventType: event.type, + threadId: thread.id, + activeTurnId: activeTurnId ?? undefined, + eventTurnId: eventTurnId ?? undefined, + }, + ); + yield* orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: providerCommandId(event, "thread-session-set-force-clear"), + threadId: thread.id, + session: { + threadId: thread.id, + status, + providerName: event.provider, + ...(event.providerInstanceId !== undefined + ? { providerInstanceId: event.providerInstanceId } + : {}), + runtimeMode: thread.session?.runtimeMode ?? "full-access", + activeTurnId: null, + lastError, + updatedAt: now, + }, + createdAt: now, + }); } } @@ -1463,7 +1494,36 @@ const make = Effect.gen(function* () { ? { providerInstanceId: event.providerInstanceId } : {}), runtimeMode: thread.session?.runtimeMode ?? "full-access", - activeTurnId: eventTurnId ?? null, + activeTurnId: null, + lastError: runtimeErrorMessage, + updatedAt: now, + }, + createdAt: now, + }); + } else if (activeTurnId !== null) { + yield* Effect.logWarning( + "provider runtime ingestion: lifecycle guard blocked runtime.error but active turn is stuck — force-clearing", + { + eventId: event.eventId, + eventType: event.type, + threadId: thread.id, + activeTurnId: activeTurnId ?? undefined, + eventTurnId: eventTurnId ?? undefined, + }, + ); + yield* orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId: providerCommandId(event, "runtime-error-session-set-force-clear"), + threadId: thread.id, + session: { + threadId: thread.id, + status: "error", + providerName: event.provider, + ...(event.providerInstanceId !== undefined + ? { providerInstanceId: event.providerInstanceId } + : {}), + runtimeMode: thread.session?.runtimeMode ?? "full-access", + activeTurnId: null, lastError: runtimeErrorMessage, updatedAt: now, }, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 4c71a016f3..194fb76f27 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3296,6 +3296,11 @@ describe("ClaudeAdapterLive", () => { assert.equal(typeof requestId, "string"); assert.equal(requestedEvent.value.payload.questions.length, 1); assert.equal(requestedEvent.value.payload.questions[0]?.question, "Which framework?"); + assert.deepEqual(requestedEvent.value.payload.questions[0]?.options, [ + { label: "React", description: "React.js" }, + { label: "Vue", description: "Vue.js" }, + { label: "Other", description: "Type your own answer" }, + ]); // Regression for #2388: `id` must equal the full question text so the // UI's draft-answer key matches what the SDK looks up downstream. assert.equal(requestedEvent.value.payload.questions[0]?.id, "Which framework?"); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2fd40f6ecc..92ef3abe04 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -84,6 +84,7 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { withCustomUserInputOption } from "../userInputOptions.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = ProviderDriverKind.make("claudeAgent"); @@ -2573,12 +2574,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( id: typeof q.question === "string" && q.question.length > 0 ? q.question : `q-${idx}`, header: typeof q.header === "string" ? q.header : `Question ${idx + 1}`, question: typeof q.question === "string" ? q.question : "", - options: Array.isArray(q.options) - ? q.options.map((opt: Record) => ({ - label: typeof opt.label === "string" ? opt.label : "", - description: typeof opt.description === "string" ? opt.description : "", - })) - : [], + options: withCustomUserInputOption( + Array.isArray(q.options) + ? q.options.map((opt: Record) => ({ + label: typeof opt.label === "string" ? opt.label : "", + description: typeof opt.description === "string" ? opt.description : "", + })) + : [], + ), multiSelect: typeof q.multiSelect === "boolean" ? q.multiSelect : false, }), ); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 4350596700..bd8efe7c35 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -357,6 +357,75 @@ type ClaudeCapabilitiesProbe = { readonly slashCommands: ReadonlyArray; }; +type ClaudeAuthStatusProbe = { + readonly authenticated: boolean | undefined; + readonly authMethod: string | undefined; + readonly email: string | undefined; +}; + +function booleanFromUnknown(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function stringFromUnknown(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function parseClaudeAuthStatusOutput(output: string): ClaudeAuthStatusProbe | undefined { + const trimmed = output.trim(); + if (!trimmed) return undefined; + + try { + const parsed = JSON.parse(trimmed) as Record; + const account = + parsed.account && typeof parsed.account === "object" + ? (parsed.account as Record) + : undefined; + return { + authenticated: + booleanFromUnknown(parsed.loggedIn) ?? + booleanFromUnknown(parsed.authenticated) ?? + booleanFromUnknown(parsed.isAuthenticated) ?? + booleanFromUnknown(parsed.isLoggedIn), + authMethod: + stringFromUnknown(parsed.authMethod) ?? + stringFromUnknown(parsed.tokenSource) ?? + stringFromUnknown(parsed.type), + email: stringFromUnknown(account?.email) ?? stringFromUnknown(parsed.email), + }; + } catch { + const lower = trimmed.toLowerCase(); + if (lower.includes("not logged in") || lower.includes("not authenticated")) { + return { authenticated: false, authMethod: undefined, email: undefined }; + } + if (lower.includes("logged in") || lower.includes("authenticated")) { + return { authenticated: true, authMethod: undefined, email: undefined }; + } + return undefined; + } +} + +function claudeUnauthenticatedProvider(input: { + readonly claudeSettings: ClaudeSettings; + readonly checkedAt: string; + readonly models: ReadonlyArray; + readonly version: string | null; +}) { + return buildServerProvider({ + presentation: CLAUDE_PRESENTATION, + enabled: input.claudeSettings.enabled, + checkedAt: input.checkedAt, + models: input.models, + probe: { + installed: true, + version: input.version, + status: "error", + auth: { status: "unauthenticated" }, + message: "Claude CLI is not authenticated. Run `claude login` and try again.", + }, + }); +} + function parseClaudeInitializationCommands( commands: ReadonlyArray | undefined, ): ReadonlyArray { @@ -617,6 +686,31 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ? undefined : formatClaudeOpus47UpgradeMessage(parsedVersion); + const authStatusProbe = yield* runClaudeCommand( + claudeSettings, + ["auth", "status"], + environment, + ).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.catchCause(() => Effect.succeed(Option.none())), + Effect.result, + ); + + if (Result.isSuccess(authStatusProbe) && Option.isSome(authStatusProbe.success)) { + const authStatusResult = authStatusProbe.success.value; + const parsedAuthStatus = parseClaudeAuthStatusOutput( + `${authStatusResult.stdout}\n${authStatusResult.stderr}`, + ); + if (parsedAuthStatus?.authenticated === false) { + return claudeUnauthenticatedProvider({ + claudeSettings, + checkedAt, + models, + version: parsedVersion, + }); + } + } + const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 4df4fb5d32..417a13e09b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -906,6 +906,16 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { assert.equal(events[0].requestId, "req-user-input-1"); assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode"); assert.equal(events[0].payload.questions[0]?.multiSelect, false); + assert.deepEqual(events[0].payload.questions[0]?.options, [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + { + label: "Other", + description: "Type your own answer", + }, + ]); } assert.equal(events[1]?.type, "user-input.resolved"); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 5186dc2962..74e24b84a5 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -43,6 +43,7 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { withCustomUserInputOption } from "../userInputOptions.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { @@ -342,7 +343,7 @@ function toUserInputQuestions(questions: ReadonlyArray ({ - label: option.label, - description: option.description, - })), + options: withCustomUserInputOption( + question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + ), ...(question.multiple ? { multiSelect: true } : {}), })); } @@ -1183,7 +1186,7 @@ export function makeOpenCodeAdapter( const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); context.activeTurnId = turnId; - context.activeAgent = agent ?? (input.interactionMode === "plan" ? "plan" : undefined); + context.activeAgent = input.interactionMode === "plan" ? "plan" : agent; context.activeVariant = variant; updateProviderSession( context, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index c7487d7d52..366ca32df5 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -26,7 +26,7 @@ import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = ProviderDriverKind.make("opencode"); const OPENCODE_PRESENTATION = { displayName: "OpenCode", - showInteractionModeToggle: false, + showInteractionModeToggle: true, } as const; const MINIMUM_OPENCODE_VERSION = "1.14.19"; @@ -462,7 +462,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu }, message: connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + ? `Authenticated through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"} with ${connectedCount} connected model provider${connectedCount === 1 ? "" : "s"}.` : isExternalServer ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." : "OpenCode is available, but it did not report any connected upstream providers.", diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 75f9c42936..8a193ec587 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1279,9 +1279,12 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( claudeCapabilities(), ); assert.strictEqual(status.status, "ready"); - assert.deepStrictEqual( - recorded.commands.map((command) => command.env?.HOME), - [claudeHome], + const recordedHomes = recorded.commands.map((command) => command.env?.HOME); + assert.strictEqual(recordedHomes.length, 2); + assert.ok( + recordedHomes.every((homePath) => + homePath?.replaceAll("\\", "/").endsWith("/tmp/t3code-claude-home"), + ), ); }).pipe(Effect.provide(recorded.layer)); }); @@ -1436,18 +1439,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); - it.effect("returns warning when the Claude initialization result is unavailable", () => + it.effect("returns unauthenticated when Claude auth status reports logged out", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( defaultClaudeSettings, noClaudeCapabilities, ); - assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual(status.auth.status, "unauthenticated"); assert.strictEqual( status.message, - "Could not verify Claude authentication status from initialization result.", + "Claude CLI is not authenticated. Run `claude login` and try again.", ); }).pipe( Effect.provide( diff --git a/apps/server/src/provider/acp/CursorAcpExtension.test.ts b/apps/server/src/provider/acp/CursorAcpExtension.test.ts index 91d50c4a9b..eccc202a95 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.test.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.test.ts @@ -33,6 +33,7 @@ describe("CursorAcpExtension", () => { options: [ { label: "TypeScript", description: "TypeScript" }, { label: "Rust", description: "Rust" }, + { label: "Other", description: "Type your own answer" }, ], }, ]); @@ -62,6 +63,7 @@ describe("CursorAcpExtension", () => { options: [ { label: "Agent", description: "Agent" }, { label: "Plan", description: "Plan" }, + { label: "Other", description: "Type your own answer" }, ], }, ]); diff --git a/apps/server/src/provider/acp/CursorAcpExtension.ts b/apps/server/src/provider/acp/CursorAcpExtension.ts index dff65535c9..423a7f0eb1 100644 --- a/apps/server/src/provider/acp/CursorAcpExtension.ts +++ b/apps/server/src/provider/acp/CursorAcpExtension.ts @@ -5,6 +5,8 @@ import type { UserInputQuestion } from "@t3tools/contracts"; import { Schema } from "effect"; +import { withCustomUserInputOption } from "../userInputOptions.ts"; + const CursorAskQuestionOption = Schema.Struct({ id: Schema.String, label: Schema.String, @@ -61,13 +63,14 @@ export function extractAskQuestions( header: "Question", question: question.prompt, multiSelect: question.allowMultiple === true, - options: + options: withCustomUserInputOption( question.options.length > 0 ? question.options.map((option) => ({ label: option.label, description: option.label, })) : [{ label: "OK", description: "Continue" }], + ), })); } diff --git a/apps/server/src/provider/userInputOptions.test.ts b/apps/server/src/provider/userInputOptions.test.ts new file mode 100644 index 0000000000..dff388395c --- /dev/null +++ b/apps/server/src/provider/userInputOptions.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { withCustomUserInputOption } from "./userInputOptions.ts"; + +describe("withCustomUserInputOption", () => { + it("preserves all preset options and appends Other as the custom prompt", () => { + expect( + withCustomUserInputOption([ + { label: "One", description: "First" }, + { label: "Two", description: "Second" }, + { label: "Three", description: "Third" }, + { label: "Four", description: "Fourth" }, + ]), + ).toEqual([ + { label: "One", description: "First" }, + { label: "Two", description: "Second" }, + { label: "Three", description: "Third" }, + { label: "Four", description: "Fourth" }, + { label: "Other", description: "Type your own answer" }, + ]); + }); + + it("preserves a provider-supplied Other option but keeps it last", () => { + expect( + withCustomUserInputOption([ + { label: "Other", description: "Write one" }, + { label: "One", description: "First" }, + ]), + ).toEqual([ + { label: "One", description: "First" }, + { label: "Other", description: "Write one" }, + ]); + }); +}); diff --git a/apps/server/src/provider/userInputOptions.ts b/apps/server/src/provider/userInputOptions.ts new file mode 100644 index 0000000000..79bf01fd11 --- /dev/null +++ b/apps/server/src/provider/userInputOptions.ts @@ -0,0 +1,17 @@ +import type { UserInputQuestionOption } from "@t3tools/contracts"; + +export const CUSTOM_USER_INPUT_OPTION: UserInputQuestionOption = { + label: "Other", + description: "Type your own answer", +}; + +export function withCustomUserInputOption( + options: ReadonlyArray, +): ReadonlyArray { + const presetOptions = options.filter((option) => option.label.trim().toLowerCase() !== "other"); + const customOption = + options.find((option) => option.label.trim().toLowerCase() === "other") ?? + CUSTOM_USER_INPUT_OPTION; + + return [...presetOptions, customOption]; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e699ad8339..3c127a9bc7 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -60,6 +60,7 @@ import { } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; +import { TextGeneration } from "./git/Services/TextGeneration.ts"; import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; import { GitStatusBroadcaster, @@ -385,6 +386,13 @@ const buildAppUnderTest = (options?: { const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); + const textGenerationTestLayer = Layer.succeed(TextGeneration, { + generateCommitMessage: () => Effect.die("generateCommitMessage not used in server.test"), + generatePrContent: () => Effect.die("generatePrContent not used in server.test"), + generateBranchName: () => Effect.die("generateBranchName not used in server.test"), + generateThreadTitle: () => Effect.die("generateThreadTitle not used in server.test"), + generateToolWorkLogSummary: () => Effect.succeed({ line: "Stub tool activity" }), + }); const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), Layer.provideMerge(gitCoreLayer), @@ -443,6 +451,7 @@ const buildAppUnderTest = (options?: { ), Layer.provide(gitCoreLayer), Layer.provide(gitManagerLayer), + Layer.provide(textGenerationTestLayer), Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fd256b32df..d33e220f65 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -32,6 +32,7 @@ import { ServerConfig } from "./config.ts"; import { GitCore } from "./git/Services/GitCore.ts"; import { GitManager } from "./git/Services/GitManager.ts"; import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster.ts"; +import { TextGeneration } from "./git/Services/TextGeneration.ts"; import { Keybindings } from "./keybindings.ts"; import { Open, resolveAvailableEditors } from "./open.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; @@ -138,6 +139,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const open = yield* Open; const gitManager = yield* GitManager; const git = yield* GitCore; + const textGeneration = yield* TextGeneration; const gitStatusBroadcaster = yield* GitStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; @@ -894,6 +896,21 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, ), + [WS_METHODS.gitSummarizeToolWorkLog]: (input) => + observeRpcEffect( + WS_METHODS.gitSummarizeToolWorkLog, + textGeneration.generateToolWorkLogSummary({ + cwd: input.cwd, + label: input.label, + modelSelection: input.modelSelection, + ...(input.toolTitle !== undefined ? { toolTitle: input.toolTitle } : {}), + ...(input.itemType !== undefined ? { itemType: input.itemType } : {}), + ...(input.requestKind !== undefined ? { requestKind: input.requestKind } : {}), + ...(input.command !== undefined ? { command: input.command } : {}), + ...(input.detailSnippet !== undefined ? { detailSnippet: input.detailSnippet } : {}), + }), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitListBranches]: (input) => observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { "rpc.aggregate": "git", diff --git a/apps/web/components.json b/apps/web/components.json index 6d93c116a2..5977a2b065 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -22,6 +22,7 @@ "hooks": "~/hooks" }, "registries": { - "@coss": "https://coss.com/ui/r/{name}.json" + "@coss": "https://coss.com/ui/r/{name}.json", + "@spell": "https://spell.sh/r/{name}.json" } } diff --git a/apps/web/index.html b/apps/web/index.html index 88e1c8b4f2..dadef17d3b 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -48,6 +48,7 @@ background: #ffffff; color: #262626; font-family: + "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, @@ -83,12 +84,6 @@ object-fit: contain; } - - - T3 Code (Alpha) diff --git a/apps/web/package.json b/apps/web/package.json index 11e69d1248..cd425f56ce 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,8 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", + "@fontsource-variable/dm-sans": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "3.0.0-beta.44", "@lexical/react": "^0.41.0", diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 48c1d89218..cc2f1260bc 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -25,6 +25,7 @@ import { import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { SelectedModelBadge } from "./chat/SelectedModelBadge"; import { Button } from "./ui/button"; import { Menu, @@ -148,23 +149,39 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ value={effectiveEnvMode} onValueChange={(value) => onEnvModeChange(value as EnvMode)} > - - - {activeWorktreePath ? ( - - ) : ( - - )} - - {resolveCurrentWorkspaceLabel(activeWorktreePath)} + +
+ + {activeWorktreePath ? ( + + ) : ( + + )} + + {resolveCurrentWorkspaceLabel(activeWorktreePath)} + - + {effectiveEnvMode === "local" ? : null} +
- - - - {resolveEnvModeLabel("worktree")} - + +
+ + + {resolveEnvModeLabel("worktree")} + + {effectiveEnvMode === "worktree" ? : null} +
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index af5bd2360e..48a7d3a883 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -2,11 +2,12 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; import { useCallback, useDeferredValue, useEffect, + useLayoutEffect, useMemo, useOptimistic, useRef, @@ -32,6 +33,7 @@ import { shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; +import { Badge } from "./ui/badge"; import { Combobox, ComboboxEmpty, @@ -62,6 +64,33 @@ function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +type BranchRowKind = "current" | "worktree" | "remote" | "default"; + +function BranchRowKindBadge({ kind }: { kind: BranchRowKind }) { + const label = + kind === "current" + ? "Current" + : kind === "remote" + ? "Remote" + : kind === "worktree" + ? "Worktree" + : "Default"; + const isCurrent = kind === "current"; + return ( + + {label} + + ); +} + function getBranchTriggerLabel(input: { activeWorktreePath: string | null; effectiveEnvMode: "local" | "worktree"; @@ -426,7 +455,8 @@ export function BranchToolbarBranchSelector({ [branchCwd, environmentId, queryClient], ); - const branchListScrollElementRef = useRef(null); + const branchListScrollElementRef = useRef(null); + const [branchListBottomFadeVisible, setBranchListBottomFadeVisible] = useState(false); const maybeFetchNextBranchPage = useCallback(() => { if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { return; @@ -445,11 +475,54 @@ export function BranchToolbarBranchSelector({ void fetchNextPage().catch(() => undefined); }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); + + const syncBranchListScrollChrome = useCallback((scrollEl: HTMLElement | null) => { + if (!scrollEl) { + setBranchListBottomFadeVisible(false); + return; + } + const { scrollTop, scrollHeight, clientHeight } = scrollEl; + const canScroll = scrollHeight > clientHeight + 1; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + setBranchListBottomFadeVisible(canScroll && distanceFromBottom > 6); + }, []); + const branchListRef = useRef(null); const setBranchListRef = useCallback((element: HTMLDivElement | null) => { - branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null; + branchListScrollElementRef.current = element?.parentElement ?? null; }, []); + useEffect(() => { + if (isBranchMenuOpen) { + return; + } + setBranchListBottomFadeVisible(false); + }, [isBranchMenuOpen]); + + useLayoutEffect(() => { + if (!isBranchMenuOpen || !shouldVirtualizeBranchList) { + return; + } + + let frame = 0; + const measure = () => { + const el = branchListRef.current?.getScrollableNode?.(); + if (el instanceof HTMLElement) { + branchListScrollElementRef.current = el; + syncBranchListScrollChrome(el); + return; + } + frame = requestAnimationFrame(measure); + }; + frame = requestAnimationFrame(measure); + return () => cancelAnimationFrame(frame); + }, [ + isBranchMenuOpen, + shouldVirtualizeBranchList, + filteredBranchPickerItems.length, + syncBranchListScrollChrome, + ]); + useEffect(() => { if (!isBranchMenuOpen) { return; @@ -464,7 +537,7 @@ export function BranchToolbarBranchSelector({ useEffect(() => { const scrollElement = branchListScrollElementRef.current; - if (!scrollElement || !isBranchMenuOpen) { + if (!scrollElement || !isBranchMenuOpen || shouldVirtualizeBranchList) { return; } @@ -477,7 +550,7 @@ export function BranchToolbarBranchSelector({ return () => { scrollElement.removeEventListener("scroll", handleScroll); }; - }, [isBranchMenuOpen, maybeFetchNextBranchPage]); + }, [isBranchMenuOpen, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); useEffect(() => { if (shouldVirtualizeBranchList) return; @@ -498,6 +571,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + className="pe-2" onClick={() => { if (!prReference || !onCheckoutPullRequestRequest) { return; @@ -522,6 +596,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + className="pe-2" onClick={() => createBranch(trimmedBranchQuery)} > Create new branch "{trimmedBranchQuery}" @@ -534,7 +609,7 @@ export function BranchToolbarBranchSelector({ const hasSecondaryWorktree = branch.worktreePath && activeProjectCwd && branch.worktreePath !== activeProjectCwd; - const badge = branch.current + const branchRowKind: BranchRowKind | null = branch.current ? "current" : hasSecondaryWorktree ? "worktree" @@ -549,11 +624,12 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + className="pe-2" onClick={() => selectBranch(branch)} > -
- {itemValue} - {badge && {badge}} +
+ {itemValue} + {branchRowKind ? : null}
); @@ -583,11 +659,12 @@ export function BranchToolbarBranchSelector({ className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending} > + {triggerLabel} - + - -
+ +
setBranchQuery(event.target.value)} />
- No branches found. - - {shouldVirtualizeBranchList ? ( - - - ref={branchListRef} - data={filteredBranchPickerItems} - keyExtractor={(item) => item} - renderItem={({ item, index }) => renderPickerItem(item, index)} - estimatedItemSize={28} - drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - void fetchNextPage().catch(() => undefined); - } - }} - style={{ maxHeight: "14rem" }} - /> - - ) : ( - - {filteredBranchPickerItems.map((itemValue, index) => - renderPickerItem(itemValue, index), +
+ No branches found. +
+ {shouldVirtualizeBranchList ? ( + <> + + + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage().catch(() => undefined); + } + }} + onScroll={() => { + const target = branchListRef.current?.getScrollableNode?.(); + if (target instanceof HTMLElement) { + branchListScrollElementRef.current = target; + syncBranchListScrollChrome(target); + } + maybeFetchNextBranchPage(); + }} + style={{ maxHeight: "14rem" }} + /> + +
+ + ) : ( + + {filteredBranchPickerItems.map((itemValue, index) => + renderPickerItem(itemValue, index), + )} + )} - - )} - {branchStatusText ? {branchStatusText} : null} +
+ {branchStatusText ? {branchStatusText} : null} +
); diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 6d06882662..e245104c7f 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -7,6 +7,7 @@ import { resolveLockedWorkspaceLabel, type EnvMode, } from "./BranchToolbar.logic"; +import { SelectedModelBadge } from "./chat/SelectedModelBadge"; import { Select, SelectGroup, @@ -43,13 +44,13 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {activeWorktreePath ? ( <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} + + {resolveLockedWorkspaceLabel(activeWorktreePath)} ) : ( <> - - {resolveLockedWorkspaceLabel(activeWorktreePath)} + + {resolveLockedWorkspaceLabel(activeWorktreePath)} )} @@ -65,32 +66,40 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe > {effectiveEnvMode === "worktree" ? ( - + ) : activeWorktreePath ? ( - + ) : ( - + )} Workspace - - - {activeWorktreePath ? ( - - ) : ( - - )} - {resolveCurrentWorkspaceLabel(activeWorktreePath)} - + +
+ + {activeWorktreePath ? ( + + ) : ( + + )} + + {resolveCurrentWorkspaceLabel(activeWorktreePath)} + + + {effectiveEnvMode === "local" ? : null} +
- - - - {resolveEnvModeLabel("worktree")} - + +
+ + + {resolveEnvModeLabel("worktree")} + + {effectiveEnvMode === "worktree" ? : null} +
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4b53f8534f..343c1c6f14 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -29,6 +29,7 @@ import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -171,15 +172,17 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo return (
- + {children}
); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 40cd1b4210..7093eb212c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1436,7 +1436,11 @@ export default function ChatView(props: ChatViewProps) { // `codex_personal`) surfaces its own status/message in the banner rather // than the default Codex's. Falls back to first-match-by-kind when no // saved instance id is available or the instance no longer exists. + const selectedProviderInstanceId = + providerStatuses.find((status) => status.instanceId === selectedProviderByThreadId) + ?.instanceId ?? null; const activeProviderInstanceId = + selectedProviderInstanceId ?? activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? @@ -2800,8 +2804,6 @@ export default function ChatView(props: ChatViewProps) { }, }; }); - promptRef.current = ""; - composerRef.current?.resetCursorState({ cursor: 0 }); }, [activePendingProgress?.activeQuestion, activePendingUserInput], ); @@ -2840,6 +2842,25 @@ export default function ChatView(props: ChatViewProps) { [activePendingUserInput], ); + const onSetActivePendingUserInputCustomAnswer = useCallback( + (questionId: string, value: string) => { + if (!activePendingUserInput) { + return; + } + setPendingUserInputAnswersByRequestId((existing) => ({ + ...existing, + [activePendingUserInput.requestId]: { + ...existing[activePendingUserInput.requestId], + [questionId]: setPendingUserInputCustomAnswer( + existing[activePendingUserInput.requestId]?.[questionId], + value, + ), + }, + })); + }, + [activePendingUserInput], + ); + const onAdvanceActivePendingUserInput = useCallback(() => { if (!activePendingUserInput || !activePendingProgress) { return; @@ -3440,6 +3461,7 @@ export default function ChatView(props: ChatViewProps) { onChangeActivePendingUserInputCustomAnswer={ onChangeActivePendingUserInputCustomAnswer } + onSetActivePendingUserInputCustomAnswer={onSetActivePendingUserInputCustomAnswer} onProviderModelSelect={onProviderModelSelect} toggleInteractionMode={toggleInteractionMode} handleRuntimeModeChange={handleRuntimeModeChange} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 450f678dd5..b48e97ca01 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -92,6 +92,7 @@ export function buildProjectActionItems(input: { valuePrefix: string; icon: (project: Project) => ReactNode; runProject: (project: Project) => Promise; + shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ kind: "action", @@ -100,6 +101,7 @@ export function buildProjectActionItems(input: { title: project.name, description: project.cwd, icon: input.icon(project), + ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { await input.runProject(project); }, diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d64c80ff96..9ed6f5f250 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -102,7 +102,7 @@ import { CommandPanel, } from "./ui/command"; import { Button } from "./ui/button"; -import { Kbd, KbdGroup } from "./ui/kbd"; +import { Shortcut } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; @@ -468,6 +468,7 @@ function OpenCommandPaletteDialog() { buildProjectActionItems({ projects, valuePrefix: "new-thread-in", + shortcutCommand: "chat.new", icon: (project) => ( {submitActionLabel} - - {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} - +
+ + {hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"} + +
) : null}
@@ -1083,31 +1086,31 @@ function OpenCommandPaletteDialog() {
- - +
+ - - + + - + Navigate - +
{!canSubmitBrowsePath || hasHighlightedBrowseItem ? ( - - Enter +
+ Enter Select - +
) : null} {isSubmenu ? ( - - Backspace +
+ Backspace Back - +
) : null} - - Esc +
+ Esc Close - +
{canOpenProjectFromFileManager ? (
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 3f39b129ac..ddef5ba7d0 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -7,7 +7,13 @@ import type { } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; +import { + ChevronDownIcon, + CloudUploadIcon, + GitBranchPlusIcon, + GitCommitIcon, + InfoIcon, +} from "lucide-react"; import { GitHubIcon } from "./Icons"; import { buildGitActionProgressStages, @@ -192,11 +198,11 @@ const COMMIT_DIALOG_DESCRIPTION = function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { if (icon === "commit") return ; if (icon === "push") return ; - return ; + return ; } function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { - const iconClassName = "size-3.5"; + const iconClassName = "size-3.5 text-foreground"; if (quickAction.kind === "open_pr") return ; if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { @@ -872,7 +878,10 @@ export default function GitActionsControl({ disabled={initMutation.isPending} onClick={() => initMutation.mutate()} > - {initMutation.isPending ? "Initializing..." : "Initialize Git"} + + + {initMutation.isPending ? "Initializing..." : "Initialize Git"} + ) : ( diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index cd1f76ed2c..a2a801a3b4 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -30,7 +30,7 @@ export function NoActiveThreadState() { -
+
Pick a thread to continue diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index afd4bb2e0b..62bd1ba89d 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -32,14 +32,14 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; function stepStatusIcon(status: string): React.ReactNode { if (status === "completed") { return ( - + ); } if (status === "inProgress") { return ( - + ); @@ -139,13 +139,14 @@ const PlanSidebar = memo(function PlanSidebar({
{label} {activePlan ? ( - + {formatTimestamp(activePlan.createdAt, timestampFormat)} ) : null} @@ -211,12 +212,12 @@ const PlanSidebar = memo(function PlanSidebar({
-
{stepStatusIcon(step.status)}
+ {stepStatusIcon(step.status)}

= repository_path: "Group by repository path", separate: "Keep separate", }; +const SIDEBAR_ICON_ACTION_BUTTON_CLASS = + "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; function formatProjectMemberActionLabel( member: SidebarProjectGroupMember, @@ -625,13 +627,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : !isThreadRunning ? ( appSettingsConfirmThreadArchive ? ( -

+
+ +
+
+ +
+ ); +}); + export const ChangedFilesTree = memo(function ChangedFilesTree(props: { turnId: TurnId; files: ReadonlyArray; @@ -22,6 +92,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { () => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes], ); + const hasDirectoryNodes = directoryPathsKey.length > 0; const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`; const [directoryExpansionState, setDirectoryExpansionState] = useState<{ key: string; @@ -60,7 +131,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { @@ -452,6 +465,7 @@ export interface ChatComposerProps { expandedCursor: number, cursorAdjacentToMention: boolean, ) => void; + onSetActivePendingUserInputCustomAnswer: (questionId: string, value: string) => void; onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void; toggleInteractionMode: () => void; @@ -526,6 +540,7 @@ export const ChatComposer = memo( onAdvanceActivePendingUserInput, onPreviousActivePendingUserInputQuestion, onChangeActivePendingUserInputCustomAnswer, + onSetActivePendingUserInputCustomAnswer, onProviderModelSelect, toggleInteractionMode, handleRuntimeModeChange, @@ -925,6 +940,7 @@ export const ChatComposer = memo( const isComposerApprovalState = activePendingApproval !== null; const activePendingUserInput = pendingUserInputs[0] ?? null; + const pendingUserInputsForDisplay = pendingUserInputs; const hasComposerHeader = isComposerApprovalState || pendingUserInputs.length > 0 || @@ -1121,51 +1137,6 @@ export const ChatComposer = memo( composerMenuSearchKey, ]); - const lastSyncedPendingInputRef = useRef<{ - requestId: string | null; - questionId: string | null; - } | null>(null); - - useEffect(() => { - const nextCustomAnswer = activePendingProgress?.customAnswer; - if (typeof nextCustomAnswer !== "string") { - lastSyncedPendingInputRef.current = null; - return; - } - - const nextRequestId = activePendingUserInput?.requestId ?? null; - const nextQuestionId = activePendingProgress?.activeQuestion?.id ?? null; - const questionChanged = - lastSyncedPendingInputRef.current?.requestId !== nextRequestId || - lastSyncedPendingInputRef.current?.questionId !== nextQuestionId; - const textChangedExternally = promptRef.current !== nextCustomAnswer; - - lastSyncedPendingInputRef.current = { - requestId: nextRequestId, - questionId: nextQuestionId, - }; - - if (!questionChanged && !textChangedExternally) { - return; - } - - promptRef.current = nextCustomAnswer; - const nextCursor = collapseExpandedComposerCursor(nextCustomAnswer, nextCustomAnswer.length); - setComposerCursor(nextCursor); - setComposerTrigger( - detectComposerTrigger( - nextCustomAnswer, - expandCollapsedComposerCursor(nextCustomAnswer, nextCursor), - ), - ); - setComposerHighlightedItemId(null); - }, [ - activePendingProgress?.customAnswer, - activePendingProgress?.activeQuestion?.id, - activePendingUserInput?.requestId, - promptRef, - ]); - // ------------------------------------------------------------------ // Reset compositor state on thread/draft change // ------------------------------------------------------------------ @@ -1316,20 +1287,6 @@ export const ChatComposer = memo( cursorAdjacentToMention: boolean, terminalContextIds: string[], ) => { - if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { - setComposerCursor(nextCursor); - setComposerTrigger( - cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), - ); - onChangeActivePendingUserInputCustomAnswer( - activePendingProgress.activeQuestion.id, - nextPrompt, - nextCursor, - expandedCursor, - cursorAdjacentToMention, - ); - return; - } promptRef.current = nextPrompt; setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { @@ -1344,9 +1301,6 @@ export const ChatComposer = memo( ); }, [ - activePendingProgress?.activeQuestion, - pendingUserInputs.length, - onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, composerDraftTarget, @@ -1819,7 +1773,7 @@ export const ChatComposer = memo( >
- ) : pendingUserInputs.length > 0 ? ( + ) : pendingUserInputsForDisplay.length > 0 ? (
) : showPlanFollowUpPrompt && activeProposedPlan ? ( @@ -1851,34 +1806,33 @@ export const ChatComposer = memo(
) : null} -
- {composerMenuOpen && !isComposerApprovalState && ( -
- -
- )} + {pendingUserInputs.length === 0 ? ( +
+ {composerMenuOpen && !isComposerApprovalState && ( +
+ +
+ )} - {!isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.length > 0 && ( + {!isComposerApprovalState && composerImages.length > 0 && (
{composerImages.map((image) => (
)} - -
+ } + disabled={isConnecting || isComposerApprovalState} + /> +
+ ) : null} {/* Bottom toolbar */} {activePendingApproval ? ( @@ -1993,6 +1937,7 @@ export const ChatComposer = memo( data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"} className={cn( "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-2.5 pb-2.5 sm:px-3 sm:pb-3", + pendingUserInputs.length > 0 && "pt-2", isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", )} > diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 5d7c929247..938d08b47b 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -9,7 +9,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; -import { DiffIcon, TerminalSquareIcon } from "lucide-react"; +import { DiffIcon, FolderIcon, GitMergeIcon, TerminalSquareIcon } from "lucide-react"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; @@ -84,13 +84,21 @@ export const ChatHeader = memo(function ChatHeader({ {activeThreadTitle} {activeProjectName && ( - - {activeProjectName} + + + {activeProjectName} )} {activeProjectName && !isGitRepo && ( - - No Git + + + Git not present )}
@@ -128,7 +136,7 @@ export const ChatHeader = memo(function ChatHeader({ pressed={terminalOpen} onPressedChange={onToggleTerminal} aria-label="Toggle terminal drawer" - variant="outline" + variant="ghost" size="xs" disabled={!terminalAvailable} > @@ -152,7 +160,7 @@ export const ChatHeader = memo(function ChatHeader({ pressed={diffOpen} onPressedChange={onToggleDiff} aria-label="Toggle diff panel" - variant="outline" + variant="ghost" size="xs" disabled={!isGitRepo && !diffOpen} > diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index f1fbd193a6..5b42f09467 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -11,6 +11,15 @@ import { MenuSeparator as MenuDivider, MenuTrigger, } from "../ui/menu"; +import { SelectedModelBadge } from "./SelectedModelBadge"; + +const runtimeModeLabels: Record = { + "approval-required": "Supervised", + "auto-accept-edits": "Auto-accept edits", + "full-access": "Full access", +}; + +const runtimeModeOptions = Object.keys(runtimeModeLabels) as RuntimeMode[]; export const CompactComposerControlsMenu = memo(function CompactComposerControlsMenu(props: { activePlan: boolean; @@ -69,9 +78,14 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls props.onRuntimeModeChange(value as RuntimeMode); }} > - Supervised - Auto-accept edits - Full access + {runtimeModeOptions.map((mode) => ( + + + {runtimeModeLabels[mode]} + {mode === props.runtimeMode ? : null} + + + ))} {props.activePlan ? ( <> diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index f687ec7ba2..7f2b180ed0 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -141,34 +141,35 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { >
- - {groups.map((group, groupIndex) => ( -
- {groupIndex > 0 ? : null} - - {group.label ? ( - - {group.label} - - ) : null} - {group.items.map((item) => ( - - ))} - -
- ))} -
- {props.items.length === 0 ? ( -
+ {props.items.length > 0 ? ( + + {groups.map((group, groupIndex) => ( +
+ {groupIndex > 0 ? : null} + + {group.label ? ( + + {group.label} + + ) : null} + {group.items.map((item) => ( + + ))} + +
+ ))} +
+ ) : ( +
{props.triggerKind === "skill" ? ( @@ -192,7 +193,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {

)}
- ) : null} + )}
); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 3b4093e380..eea08383c8 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,5 +1,5 @@ import { type ApprovalRequestId } from "@t3tools/contracts"; -import { memo, useEffect, useEffectEvent, useRef } from "react"; +import { memo, useEffect, useEffectEvent, useRef, useState } from "react"; import { type PendingUserInput } from "../../session-logic"; import { derivePendingUserInputProgress, @@ -15,6 +15,7 @@ interface PendingUserInputPanelProps { questionIndex: number; onToggleOption: (questionId: string, optionLabel: string) => void; onAdvance: () => void; + onChangeCustomAnswer: (questionId: string, value: string) => void; } export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserInputPanel({ @@ -24,6 +25,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn questionIndex, onToggleOption, onAdvance, + onChangeCustomAnswer, }: PendingUserInputPanelProps) { if (pendingUserInputs.length === 0) return null; const activePrompt = pendingUserInputs[0]; @@ -38,6 +40,7 @@ export const ComposerPendingUserInputPanel = memo(function ComposerPendingUserIn questionIndex={questionIndex} onToggleOption={onToggleOption} onAdvance={onAdvance} + onChangeCustomAnswer={onChangeCustomAnswer} /> ); }); @@ -49,6 +52,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionIndex, onToggleOption, onAdvance, + onChangeCustomAnswer, }: { prompt: PendingUserInput; isResponding: boolean; @@ -56,16 +60,43 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionIndex: number; onToggleOption: (questionId: string, optionLabel: string) => void; onAdvance: () => void; + onChangeCustomAnswer: (questionId: string, value: string) => void; }) { const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex); const activeQuestion = progress.activeQuestion; + const otherInputRef = useRef(null); const autoAdvanceTimerRef = useRef(null); const onAdvanceRef = useRef(onAdvance); + const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ + questionId: string; + optionLabel: string; + } | null>(null); useEffect(() => { onAdvanceRef.current = onAdvance; }, [onAdvance]); + useEffect(() => { + if (!activeQuestion || activeQuestion.multiSelect || !optimisticSingleSelect) { + return; + } + if (optimisticSingleSelect.questionId !== activeQuestion.id) { + setOptimisticSingleSelect(null); + return; + } + if ( + progress.customAnswer.trim().length === 0 && + progress.selectedOptionLabels.includes(optimisticSingleSelect.optionLabel) + ) { + setOptimisticSingleSelect(null); + } + }, [ + activeQuestion, + optimisticSingleSelect, + progress.customAnswer, + progress.selectedOptionLabels, + ]); + // Clear auto-advance timer on unmount useEffect(() => { return () => { @@ -76,10 +107,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }, []); const handleOptionSelection = useEffectEvent((questionId: string, optionLabel: string) => { - onToggleOption(questionId, optionLabel); if (activeQuestion?.multiSelect) { + onToggleOption(questionId, optionLabel); return; } + setOptimisticSingleSelect({ questionId, optionLabel }); + onToggleOption(questionId, optionLabel); if (autoAdvanceTimerRef.current !== null) { window.clearTimeout(autoAdvanceTimerRef.current); } @@ -113,6 +146,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const option = activeQuestion.options[optionIndex]; if (!option) return; event.preventDefault(); + if (option.label === "Other") { + otherInputRef.current?.focus(); + return; + } handleOptionSelection(activeQuestion.id, option.label); }; document.addEventListener("keydown", handler); @@ -125,62 +162,104 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( return (
-
-
- {prompt.questions.length > 1 ? ( - - {questionIndex + 1}/{prompt.questions.length} - - ) : null} - - {activeQuestion.header} + {prompt.questions.length > 1 ? ( +
+ + {questionIndex + 1}/{prompt.questions.length}
-
-

{activeQuestion.question}

+ ) : null} +

{activeQuestion.question}

{activeQuestion.multiSelect ? (

Select one or more options.

) : null}
{activeQuestion.options.map((option, index) => { - const isSelected = progress.selectedOptionLabels.includes(option.label); + const isOther = option.label === "Other"; + const customAnswerActive = progress.customAnswer.trim().length > 0; + const isOptimisticallySelected = + optimisticSingleSelect?.questionId === activeQuestion.id && + optimisticSingleSelect.optionLabel === option.label; + const isSelected = isOther + ? customAnswerActive && optimisticSingleSelect?.questionId !== activeQuestion.id + : isOptimisticallySelected || + (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); const shortcutKey = index < 9 ? index + 1 : null; - return ( - + + ); + if (isOther) { + return ( + + ); + } + return ( +
{ + if (isResponding) return; + handleOptionSelection(activeQuestion.id, option.label); + }} + className={className} + > + {content} +
); })}
diff --git a/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx index 49b03f7724..ea193edfef 100644 --- a/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx +++ b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import { Badge } from "../ui/badge"; export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({ planTitle, @@ -8,7 +9,13 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann return (
- Plan ready + + Plan Ready + {planTitle ? ( {planTitle} ) : null} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 2bc40fe6ae..43b6266480 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -3,6 +3,7 @@ import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Spinner } from "../ui/spinner"; interface PendingActionState { questionIndex: number; @@ -110,7 +111,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ return ( )} + {displayedUserMessage.copyText && ( + + )}
-

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

@@ -395,13 +418,15 @@ function TimelineRowContent({ row }: { row: TimelineRow }) { {row.showCompletionDivider && (
- - {ctx.completionSummary ? `Response • ${ctx.completionSummary}` : "Response"} + + {row.completionDividerDuration + ? `Worked for ${row.completionDividerDuration}` + : "Worked for 0s"}
)} -
+
-
-

- {row.message.streaming ? ( - - ) : ( - formatMessageMeta( - row.message.createdAt, - formatElapsed(row.durationStart, row.message.completedAt), - ctx.timestampFormat, - ) + {row.showAssistantMeta ? ( +

+ {assistantCopyState.visible ? ( + + ) : null} + {!row.message.streaming && ( + + } + > + {formatChatTimestamp(row.message.completedAt ?? row.message.createdAt)} + + + {formatChatTimestampTooltip( + row.message.completedAt ?? row.message.createdAt, + ctx.timestampFormat, + )} + + )} -

- {assistantCopyState.visible ? ( -
- -
- ) : null} -
+
+ ) : null}
); @@ -458,7 +478,7 @@ function TimelineRowContent({ row }: { row: TimelineRow }) { {row.kind === "working" && (
-
+
@@ -493,28 +513,11 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { const id = setInterval(() => setNowMs(Date.now()), 1000); return () => clearInterval(id); }, [createdAt]); - return <>{formatWorkingTimer(createdAt, new Date(nowMs).toISOString()) ?? "0s"}; -} - -/** Live timestamp + elapsed duration for a streaming assistant message. */ -function LiveMessageMeta({ - createdAt, - durationStart, - timestampFormat, -}: { - createdAt: string; - durationStart: string | null | undefined; - timestampFormat: TimestampFormat; -}) { - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - const id = setInterval(() => setNowMs(Date.now()), 1000); - return () => clearInterval(id); - }, [durationStart]); - const elapsed = durationStart - ? formatElapsed(durationStart, new Date(nowMs).toISOString()) - : null; - return <>{formatMessageMeta(createdAt, elapsed, timestampFormat)}; + return ( + + {formatWorkingTimer(createdAt, new Date(nowMs).toISOString()) ?? "0s"} + + ); } // --------------------------------------------------------------------------- @@ -522,8 +525,7 @@ function LiveMessageMeta({ // re-render only the affected row, not the entire list. // --------------------------------------------------------------------------- -/** Owns its own expand/collapse state so toggling re-renders only this row. - * State resets on unmount which is fine — work groups start collapsed. */ +/** Collapsed state shows the earliest chunk so "Show more" only appends rows downward. */ const WorkGroupSection = memo(function WorkGroupSection({ groupedEntries, }: { @@ -531,38 +533,49 @@ const WorkGroupSection = memo(function WorkGroupSection({ }) { const { workspaceRoot } = use(TimelineRowCtx); const [isExpanded, setIsExpanded] = useState(false); - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const nonEmptyEntries = useMemo( + () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)), + [groupedEntries], + ); + const hasOverflow = nonEmptyEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleEntries = hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const showHeader = hasOverflow || !onlyToolEntries; - const groupLabel = onlyToolEntries ? "Tool calls" : "Work log"; + ? nonEmptyEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : nonEmptyEntries; + const hiddenCount = nonEmptyEntries.length - visibleEntries.length; + const headerTitle = + nonEmptyEntries.length === 1 ? "1 tool call" : `${nonEmptyEntries.length} tool calls`; + + if (nonEmptyEntries.length === 0) return null; return ( -
- {showHeader && ( -
-

- {groupLabel} ({groupedEntries.length}) -

- {hasOverflow && ( - - )} -
- )} +
+
+

{headerTitle}

+ {hasOverflow && ( + + )} +
{visibleEntries.map((workEntry) => ( @@ -619,50 +632,18 @@ function AssistantChangedFilesSectionInner({ (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); - const summaryStat = summarizeTurnDiffStats(checkpointFiles); - const changedFileCountLabel = String(checkpointFiles.length); return ( -
-
-

- Changed files ({changedFileCountLabel}) - {hasNonZeroStat(summaryStat) && ( - <> - - - - )} -

-
- - -
-
- -
+ + setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) + } + onOpenTurnDiff={onOpenTurnDiff} + /> ); } @@ -821,15 +802,6 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } -function formatMessageMeta( - createdAt: string, - duration: string | null, - timestampFormat: TimestampFormat, -): string { - if (!duration) return formatTimestamp(createdAt, timestampFormat); - return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; -} - function workToneIcon(tone: TimelineWorkEntry["tone"]): { icon: LucideIcon; className: string; @@ -849,7 +821,7 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { if (tone === "info") { return { icon: CheckIcon, - className: "text-foreground/92", + className: "text-muted-foreground", }; } return { @@ -858,13 +830,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { }; } -function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { - if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; - if (tone === "tool") return "text-muted-foreground/70"; - if (tone === "thinking") return "text-muted-foreground/50"; - return "text-muted-foreground/40"; -} - function workEntryPreview( workEntry: Pick, workspaceRoot: string | undefined, @@ -890,7 +855,30 @@ function workEntryRawCommand( return rawCommand === workEntry.command.trim() ? null : rawCommand; } +function buildToolCallExpandedBody(workEntry: TimelineWorkEntry): string | null { + const blocks: string[] = []; + if (workEntry.detail?.trim()) { + blocks.push(workEntry.detail.trim()); + } + const raw = workEntryRawCommand(workEntry); + if (raw?.trim()) { + blocks.push(`Full command\n${raw.trim()}`); + } else if (workEntry.command?.trim()) { + blocks.push(`Command\n${workEntry.command.trim()}`); + } + if (blocks.length === 0) { + return null; + } + return blocks.join("\n\n"); +} + function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { + if ( + workEntry.sourceActivityKind === "user-input.requested" || + workEntry.sourceActivityKind === "user-input.resolved" + ) { + return MessageCircleIcon; + } if (workEntry.requestKind === "command") return TerminalIcon; if (workEntry.requestKind === "file-read") return EyeIcon; if (workEntry.requestKind === "file-change") return SquarePenIcon; @@ -935,6 +923,13 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workspaceRoot: string | undefined; }) { const { workEntry, workspaceRoot } = props; + const ctx = use(TimelineRowCtx); + const friendlySummary = useToolWorkLogFriendlyLine( + ctx.activeThreadEnvironmentId, + workspaceRoot ?? ctx.markdownCwd, + workEntry, + ); + const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); @@ -946,88 +941,237 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ? null : rawPreview; const rawCommand = workEntryRawCommand(workEntry); - const displayText = preview ? `${heading} - ${preview}` : heading; + const displayText = preview ? `${heading} ${preview}` : heading; + const friendlyDisplay = + friendlySummary !== null + ? friendlySummary.replace(/^>\s*[__]?\s*/i, "").trim() || friendlySummary + : null; + const showToolSummaryPending = friendlySummary === null && workLogEntryIsToolLike(workEntry); + const expandedBody = buildToolCallExpandedBody(workEntry); + const canExpand = expandedBody !== null; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; + const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; + const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); + const showDestructiveRowStyle = + showFailedIndicator && + (workEntry.sourceActivityKind === "runtime.error" || !workLogEntryIsToolLike(workEntry)); + const iconWrapperClass = cn( + "flex size-5 shrink-0 items-center justify-center", + showWarningIndicator + ? "text-warning" + : showDestructiveRowStyle + ? "text-destructive" + : workEntry.tone === "tool" || showFailedIndicator + ? "text-muted-foreground" + : iconConfig.className, + ); + const headingClass = showWarningIndicator + ? "font-medium text-warning" + : showDestructiveRowStyle + ? "font-medium text-destructive" + : "font-medium text-foreground"; + const turnSettled = !ctx.activeTurnInProgress; + const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); + const showSuccessIndicator = + workEntryIndicatesToolSuccess(workEntry) || + (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); + const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); + + const rowToggleProps = canExpand + ? { + role: "button" as const, + tabIndex: 0 as const, + onClick: () => setExpanded((v) => !v), + onKeyDown: (e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setExpanded((v) => !v); + } + }, + } + : {}; return ( -
+
- - + + -
- {rawCommand ? ( -
+
+
+ {friendlySummary !== null ? (

- - {heading} + + {friendlyDisplay} - {preview && ( - - - {" "} - - {preview} - - } - /> - -

- {rawCommand} -
- - - )}

-
- ) : ( - - + {heading} +

+ ) : rawCommand ? ( +

- - {heading} - - {preview && - {preview}} -

- - -

- {displayText} + {heading} + {preview && ( + + + {preview} + + } + /> + +

+ {rawCommand} +
+
+ + )}

- - - )} +
+ ) : ( + + +

+ {heading} + {preview && ( + + {preview} + + )} +

+
+ +

+ {displayText} +

+
+
+ )} +
+
+ + {canExpand ? ( + + ) : null} + + + {showWarningIndicator ? ( + + } + > + + + Warning + + ) : showFailedIndicator ? ( + + } + > + + + Failed + + ) : showSuccessIndicator ? ( + + } + > + + + + + Completed + + ) : showNeutralIndicator ? ( + + } + > + + + Empty + + ) : null} + +
+ {expanded && canExpand && expandedBody ? ( +
+
+            {expandedBody}
+          
+
+ ) : null} {hasChangedFiles && !previewIsChangedFiles && ( -
+
{workEntry.changedFiles?.slice(0, 4).map((filePath) => { const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot); return ( diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 064df338e4..d9fd3f3d08 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -3,21 +3,23 @@ import { memo } from "react"; import { StarIcon } from "lucide-react"; import { getDisplayModelName, + getModelProviderDisplayName, getTriggerDisplayModelLabel, type ModelEsque, PROVIDER_ICON_BY_PROVIDER, } from "./providerIconUtils"; import { ComboboxItem } from "../ui/combobox"; -import { Kbd } from "../ui/kbd"; +import { Shortcut } from "../ui/kbd"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SelectedModelBadge } from "./SelectedModelBadge"; import { cn } from "~/lib/utils"; export const ModelListRow = memo(function ModelListRow(props: { index: number; model: ModelEsque; - /** Instance the model belongs to — the routing key used in combobox values. */ + /** Instance the model belongs to - the routing key used in combobox values. */ instanceId: ProviderInstanceId; - /** Driver kind of the instance — used for the provider icon glyph. */ + /** Driver kind of the instance - used for the provider icon glyph. */ driverKind: ProviderDriverKind; /** * Display name to show in the secondary line (provider footer). Usually @@ -27,6 +29,7 @@ export const ModelListRow = memo(function ModelListRow(props: { providerDisplayName: string; providerAccentColor?: string | undefined; isFavorite: boolean; + isSelected: boolean; showProvider: boolean; preferShortName?: boolean; useTriggerLabel?: boolean; @@ -35,89 +38,91 @@ export const ModelListRow = memo(function ModelListRow(props: { onToggleFavorite: () => void; }) { const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[props.driverKind] ?? null; - const providerLabel = props.model.subProvider - ? `${props.providerDisplayName} · ${props.model.subProvider}` - : props.providerDisplayName; + const providerLabel = getModelProviderDisplayName( + props.driverKind, + props.providerDisplayName, + props.model, + ); return ( - - { - event.stopPropagation(); - props.onToggleFavorite(); - }} - onKeyDown={(event) => { - event.stopPropagation(); - }} - type="button" - aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} - > - - - } - /> - - {props.isFavorite ? "Remove from favorites" : "Add to favorites"} - - -
-
-
- - {props.useTriggerLabel - ? getTriggerDisplayModelLabel(props.model) - : getDisplayModelName( - props.model, - props.preferShortName ? { preferShortName: true } : undefined, - )} - - {props.showNewBadge ? ( - - New - - ) : null} +
+
+ {props.useTriggerLabel + ? getTriggerDisplayModelLabel(props.model) + : getDisplayModelName( + props.model, + props.preferShortName ? { preferShortName: true } : undefined, + )}
- {props.jumpLabel ? ( - - {props.jumpLabel} - + {props.isSelected ? : null} + {props.showNewBadge ? ( + + New + ) : null}
{props.showProvider && ( -
+
{ProviderIcon ? : null} - {props.providerAccentColor ? ( - - ) : null} - + {providerLabel}
)}
+ +
+ {props.jumpLabel ? ( + + {props.jumpLabel} + + ) : null} + + { + event.stopPropagation(); + props.onToggleFavorite(); + }} + onKeyDown={(event) => { + event.stopPropagation(); + }} + type="button" + aria-label={props.isFavorite ? "Remove from favorites" : "Add to favorites"} + > + + + } + /> + + {props.isFavorite ? "Remove from favorites" : "Add to favorites"} + + +
); }); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c3468ef8c6..4efb604abb 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1,6 +1,6 @@ import { type ProviderInstanceId, - type ProviderDriverKind, + ProviderDriverKind, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; @@ -11,7 +11,7 @@ import { ModelPickerSidebar } from "./ModelPickerSidebar"; import { isModelPickerNewModel } from "./modelPickerModelHighlights"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxList } from "../ui/combobox"; -import { ModelEsque, PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; +import { getOpenCodeModelLane, ModelEsque, type OpenCodeModelLane } from "./providerIconUtils"; import { modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, @@ -20,6 +20,7 @@ import { } from "../../keybindings"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; +import { Tabs, TabsList, TabsTrigger } from "../ui/tabs"; import { TooltipProvider } from "../ui/tooltip"; import type { ProviderInstanceEntry } from "../../providerInstances"; import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; @@ -37,6 +38,7 @@ type ModelPickerItem = { }; const EMPTY_MODEL_JUMP_LABELS = new Map(); +const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); // Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs // can contain colons (e.g. some vendor model ids), so we only split on the @@ -104,6 +106,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return favorites.length > 0 ? "favorites" : props.activeInstanceId; }, ); + const [openCodeLane, setOpenCodeLane] = useState("go"); const keybindings = useMemo( () => providedKeybindings ?? [], [providedKeybindings], @@ -156,6 +159,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { () => new Map(instanceEntries.map((entry) => [entry.instanceId, entry])), [instanceEntries], ); + const selectedInstanceEntry = + selectedInstanceId === "favorites" ? null : (entryByInstanceId.get(selectedInstanceId) ?? null); const matchesLockedProvider = useCallback( (entry: Pick): boolean => { if (props.lockedProvider === null) return true; @@ -213,21 +218,82 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const isLocked = props.lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; - const lockedInstanceEntries = useMemo( + const lockedDisabledInstanceIds = useMemo(() => { + if (!isLocked) { + return undefined; + } + const disabled = new Set(); + for (const entry of instanceEntries) { + if (!matchesLockedProvider(entry)) { + disabled.add(entry.instanceId); + } + } + return disabled; + }, [instanceEntries, isLocked, matchesLockedProvider]); + const sidebarInstanceEntries = useMemo(() => { + if (!isLocked) { + return instanceEntries; + } + const available: ProviderInstanceEntry[] = []; + const disabled: ProviderInstanceEntry[] = []; + for (const entry of instanceEntries) { + if (matchesLockedProvider(entry)) { + available.push(entry); + } else { + disabled.push(entry); + } + } + return [...available, ...disabled]; + }, [instanceEntries, isLocked, matchesLockedProvider]); + const showSidebar = !isSearching && sidebarInstanceEntries.length > 0; + const openCodeContextInstanceId = + props.lockedProvider === OPENCODE_DRIVER_KIND + ? props.activeInstanceId + : selectedInstanceEntry?.driverKind === OPENCODE_DRIVER_KIND + ? selectedInstanceEntry.instanceId + : null; + const openCodeContextModels = useMemo( () => - props.lockedProvider ? instanceEntries.filter((entry) => matchesLockedProvider(entry)) : [], - [instanceEntries, matchesLockedProvider, props.lockedProvider], + openCodeContextInstanceId + ? flatModels.filter( + (model) => + model.instanceId === openCodeContextInstanceId && + model.driverKind === OPENCODE_DRIVER_KIND, + ) + : [], + [flatModels, openCodeContextInstanceId], + ); + const showOpenCodeTabs = openCodeContextModels.length > 0; + const matchesOpenCodeLane = useCallback( + (model: ModelPickerItem) => { + if (!showOpenCodeTabs || !openCodeContextInstanceId) { + return true; + } + return ( + model.instanceId === openCodeContextInstanceId && + getOpenCodeModelLane(model) === openCodeLane + ); + }, + [openCodeContextInstanceId, openCodeLane, showOpenCodeTabs], ); - const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; - const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); - const sidebarInstanceEntries = showLockedInstanceSidebar - ? lockedInstanceEntries - : instanceEntries; const instanceOrder = useMemo( () => instanceEntries.map((entry) => entry.instanceId), [instanceEntries], ); + useEffect(() => { + if (!showOpenCodeTabs) { + return; + } + const activeOpenCodeModel = + openCodeContextModels.find( + (model) => model.instanceId === props.activeInstanceId && model.slug === props.model, + ) ?? openCodeContextModels[0]; + if (activeOpenCodeModel) { + setOpenCodeLane(getOpenCodeModelLane(activeOpenCodeModel)); + } + }, [openCodeContextModels, props.activeInstanceId, props.model, showOpenCodeTabs]); + // Filter models based on search query and selected instance const filteredModels = useMemo(() => { let result = flatModels; @@ -271,8 +337,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { // When searching, we only respect locked provider (by driver kind), // ignoring sidebar selection so account-scoped searches can find a // model before the user chooses a specific instance rail item. + const openCodeScopedMatches = + showOpenCodeTabs && !searchQuery.trim() + ? rankedMatches.filter((rankedModel) => matchesOpenCodeLane(rankedModel.model)) + : rankedMatches; + if (props.lockedProvider !== null) { - return rankedMatches + return openCodeScopedMatches .filter((rankedModel) => matchesLockedProvider(rankedModel.model)) .toSorted((a, b) => { const scoreDelta = a.score - b.score; @@ -287,7 +358,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { .map((rankedModel) => rankedModel.model); } - return rankedMatches + return openCodeScopedMatches .toSorted((a, b) => { const scoreDelta = a.score - b.score; if (scoreDelta !== 0) { @@ -303,7 +374,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (props.lockedProvider !== null) { result = result.filter((m) => matchesLockedProvider(m)); - if (showLockedInstanceSidebar) { + if (selectedInstanceId === "favorites") { + result = result.filter((m) => favoritesSet.has(providerModelKey(m.instanceId, m.slug))); + } else { result = result.filter((m) => m.instanceId === selectedInstanceId); } } else if (selectedInstanceId === "favorites") { @@ -312,6 +385,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { result = result.filter((m) => m.instanceId === selectedInstanceId); } + if (showOpenCodeTabs) { + result = result.filter(matchesOpenCodeLane); + } + return sortProviderModelItems(result, { favoriteModelKeys: favoritesSet, groupFavorites: selectedInstanceId !== "favorites", @@ -321,10 +398,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { favoritesSet, flatModels, instanceOrder, + matchesOpenCodeLane, matchesLockedProvider, props.lockedProvider, searchQuery, - showLockedInstanceSidebar, + showOpenCodeTabs, selectedInstanceId, ]); @@ -363,25 +441,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [favorites, updateSettings], ); - const LockedProviderIcon = - isLocked && props.lockedProvider ? PROVIDER_ICON_BY_PROVIDER[props.lockedProvider] : null; - // Header label for locked mode. Use the active instance's displayName - // when the lock narrows to exactly one instance (so "Codex Personal" - // shows instead of the generic driver label); fall back to the first - // matching entry otherwise. - const lockedHeaderLabel = useMemo(() => { - if (!isLocked || !props.lockedProvider) return null; - const matches = instanceEntries.filter((entry) => matchesLockedProvider(entry)); - if (matches.length === 0) return null; - const active = matches.find((entry) => entry.instanceId === props.activeInstanceId); - return (active ?? matches[0])?.displayName ?? null; - }, [ - isLocked, - matchesLockedProvider, - props.lockedProvider, - props.activeInstanceId, - instanceEntries, - ]); const modelJumpCommandByKey = useMemo(() => { const mapping = new Map< string, @@ -518,27 +577,24 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
- {/* Locked provider header (only shown in locked mode) */} - {isLocked && !showLockedInstanceSidebar && LockedProviderIcon && lockedHeaderLabel && ( -
- - {lockedHeaderLabel} -
- )} - - {/* Sidebar (only in unlocked mode) */} + {/* Sidebar */} {showSidebar && ( + `${entry.displayName} is unavailable in this thread. Start a new thread to switch providers.`, + } + : {})} /> )} @@ -565,45 +621,61 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
{/* Search bar */}
- } - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - e.preventDefault(); - e.stopPropagation(); - props.onRequestClose?.(); - return; - } - if (e.key === "Enter" && highlightedModelKeyRef.current) { - ( - e as typeof e & { preventBaseUIHandler?: () => void } - ).preventBaseUIHandler?.(); - e.preventDefault(); - e.stopPropagation(); - const { instanceId, slug } = splitInstanceModelKey( - highlightedModelKeyRef.current, - ); - handleModelSelect(slug, instanceId); - return; - } - e.stopPropagation(); - }} - onMouseDown={(e) => e.stopPropagation()} - onTouchStart={(e) => e.stopPropagation()} - size="sm" - /> +
+
+ } + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + props.onRequestClose?.(); + return; + } + if (e.key === "Enter" && highlightedModelKeyRef.current) { + ( + e as typeof e & { preventBaseUIHandler?: () => void } + ).preventBaseUIHandler?.(); + e.preventDefault(); + e.stopPropagation(); + const { instanceId, slug } = splitInstanceModelKey( + highlightedModelKeyRef.current, + ); + handleModelSelect(slug, instanceId); + return; + } + e.stopPropagation(); + }} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + size="sm" + /> +
+ {showOpenCodeTabs ? ( + setOpenCodeLane(value as OpenCodeModelLane)} + className="shrink-0" + > + + Go + Zen + + + ) : null} +
{/* Model list */} @@ -611,7 +683,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ref={listRegionRef} className="relative min-h-0 flex-1 before:pointer-events-none before:absolute before:inset-0 before:bg-muted/40" > - + {filteredModelKeys.map((modelKey, index) => { const model = filteredModelByKey.get(modelKey); if (!model) { @@ -627,9 +699,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { providerDisplayName={model.instanceDisplayName} providerAccentColor={model.instanceAccentColor} isFavorite={favoritesSet.has(modelKey)} - showProvider={!isLocked || showLockedInstanceSidebar} + isSelected={modelKey === `${props.activeInstanceId}:${props.model}`} + showProvider preferShortName={!isLocked} - useTriggerLabel={isLocked && !showLockedInstanceSidebar} + useTriggerLabel={false} showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} onToggleFavorite={() => toggleFavorite(model.instanceId, model.slug)} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 121b5267a3..de62c7c3f9 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,9 +1,8 @@ import { type ProviderInstanceId } from "@t3tools/contracts"; -import { memo, useMemo } from "react"; +import { memo, useMemo, useState } from "react"; import { Clock3Icon, SparklesIcon, StarIcon } from "lucide-react"; import { Gemini, GithubCopilotIcon } from "../Icons"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; -import { ScrollArea } from "../ui/scroll-area"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; import type { ProviderInstanceEntry } from "../../providerInstances"; @@ -30,7 +29,8 @@ function describeUnavailableInstance(entry: ProviderInstanceEntry): string { return msg ? `${label} — ${kind}. ${msg}` : `${label} — ${kind}.`; } -const SELECTED_BUTTON_CLASS = "bg-background text-foreground shadow-sm"; +const SELECTED_BUTTON_CLASS = + "bg-background text-foreground shadow-sm before:pointer-events-none before:absolute before:inset-y-0 before:-right-1 before:w-1 before:bg-muted/30"; const SELECTED_INDICATOR_CLASS = "pointer-events-none absolute -right-1 top-1/2 z-10 h-5 w-0.5 -translate-y-1/2 rounded-l-full bg-primary"; const BADGE_BASE_CLASS = @@ -40,6 +40,7 @@ const SOON_BADGE_CLASS = `${BADGE_BASE_CLASS} text-muted-foreground `; /** Opens toward the rail so the list stays readable (not over the model names). */ const PICKER_TOOLTIP_SIDE = "left" as const; +const PICKER_TOOLTIP_SIDE_OFFSET = 8; const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { @@ -56,6 +57,9 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { showFavorites?: boolean; /** Render non-configured coming-soon provider entries. Hidden in scoped rails. */ showComingSoon?: boolean; + /** Instance ids shown in the rail but unavailable for the current picker context. */ + disabledInstanceIds?: ReadonlySet; + getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string; /** * Instance id values that should render the "new" sparkle badge. Callers * pass the subset of default built-in ids they want flagged (custom @@ -68,6 +72,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { }; const showFavorites = props.showFavorites ?? true; const showComingSoon = props.showComingSoon ?? true; + const [hoveredInstanceId, setHoveredInstanceId] = useState(null); const duplicateDriverCounts = useMemo(() => { const counts = new Map(); for (const entry of props.instanceEntries) { @@ -77,187 +82,217 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { }, [props.instanceEntries]); return ( - -
- {/* Favorites section */} - {showFavorites ? ( -
-
- {props.selectedInstanceId === "favorites" && ( -
- )} +
+
+ {/* Favorites section */} + {showFavorites ? ( +
+
+ {props.selectedInstanceId === "favorites" && ( +
+ )} + + handleSelect("favorites")} + type="button" + data-model-picker-provider="favorites" + aria-label="Favorites" + > + + + } + /> + + Favorites + + +
+
+ ) : null} + + {/* Instance buttons (one per configured instance — built-in + custom) */} + {props.instanceEntries.map((entry) => { + const isUnavailable = !entry.isAvailable || entry.status !== "ready"; + const isContextDisabled = props.disabledInstanceIds?.has(entry.instanceId) ?? false; + const isDisabled = isUnavailable || isContextDisabled; + const isSelected = props.selectedInstanceId === entry.instanceId; + const isHovered = hoveredInstanceId === entry.instanceId; + const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; + const showInstanceBadge = + Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; + + const tooltip = isUnavailable + ? describeUnavailableInstance(entry) + : isContextDisabled + ? (props.getDisabledInstanceTooltip?.(entry) ?? entry.displayName) + : showNewBadge + ? `${entry.displayName} — New` + : entry.displayName; + + const button = ( + + ); + + const trigger = isDisabled ? ( + {button} + ) : ( + button + ); + + return ( +
+ {isSelected &&
} + + + + {tooltip} + + +
+ ); + })} + + {showComingSoon ? ( + <> + {/* Gemini button (coming soon) */} handleSelect("favorites")} - type="button" - data-model-picker-provider="favorites" - aria-label="Favorites" - > - - + + + } /> - Favorites + Gemini — Coming soon -
-
- ) : null} - - {/* Instance buttons (one per configured instance — built-in + custom) */} - {props.instanceEntries.map((entry) => { - const isDisabled = !entry.isAvailable || entry.status !== "ready"; - const isSelected = props.selectedInstanceId === entry.instanceId; - const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; - const showInstanceBadge = - Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; - - const tooltip = isDisabled - ? describeUnavailableInstance(entry) - : showNewBadge - ? `${entry.displayName} — New` - : entry.displayName; - - const button = ( - - ); - - const trigger = isDisabled ? ( - {button} - ) : ( - button - ); - - return ( -
- {isSelected &&
} + {/* Github Copilot button (coming soon) */} - + + + + } + /> - {tooltip} + Github Copilot — Coming soon -
- ); - })} - - {showComingSoon ? ( - <> - {/* Gemini button (coming soon) */} - - - - - } - /> - - Gemini — Coming soon - - - {/* Github Copilot button (coming soon) */} - - - - - } - /> - - Github Copilot — Coming soon - - - - ) : null} + + ) : null} +
- +
); }); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 11480074a4..625e20542d 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -5,7 +5,8 @@ import { usePreferredEditor } from "../../editorPreferences"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; -import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Shortcut } from "../ui/kbd"; import { AntigravityIcon, CursorIcon, @@ -153,7 +154,9 @@ export const OpenInPicker = memo(function OpenInPicker({
))} - {booleanDescriptors.map((descriptor, index) => ( + {visibleBooleanDescriptors.map((descriptor, index) => (
- {index > 0 || selectDescriptors.length > 0 ? : null} + {index > 0 || visibleSelectDescriptors.length > 0 ? : null}
{descriptor.label}
- { - updateDescriptors( - replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"), - ); - }} - > - On - Off - + {(() => { + const selectedValue = descriptor.currentValue === true ? "on" : "off"; + return ( + { + updateDescriptors( + replaceDescriptorCurrentValue(descriptors, descriptor.id, value === "on"), + ); + onRequestClose?.(); + }} + > + {(["on", "off"] as const).map((value) => ( + + + {value === "on" ? "On" : "Off"} + {value === selectedValue ? : null} + + + ))} + + ); + })()}
))} @@ -349,11 +395,12 @@ export const TraitsPicker = memo(function TraitsPicker({ onPromptChange, modelOptions, allowPromptInjectedEffort = true, + hiddenDescriptorIds, + showTriggerSeparators = true, triggerVariant, triggerClassName, ...persistence }: TraitsMenuContentProps & TraitsPersistence) { - const [isMenuOpen, setIsMenuOpen] = useState(false); const { descriptors, primarySelectDescriptor, ultrathinkPromptControlled } = getTraitsSectionVisibility({ provider, @@ -363,6 +410,13 @@ export const TraitsPicker = memo(function TraitsPicker({ modelOptions, allowPromptInjectedEffort, }); + const hiddenDescriptorIdSet = new Set(hiddenDescriptorIds ?? []); + const visibleDescriptors = descriptors.filter( + (descriptor) => !hiddenDescriptorIdSet.has(descriptor.id), + ); + const isCodexStyle = provider === "codex"; + const [openDescriptorId, setOpenDescriptorId] = useState(null); + if ( !shouldRenderTraitsControls({ provider, @@ -371,75 +425,108 @@ export const TraitsPicker = memo(function TraitsPicker({ prompt, modelOptions, allowPromptInjectedEffort, - }) + }) || + visibleDescriptors.length === 0 ) { return null; } - const triggerLabel = - descriptors - .map((descriptor) => { - if (ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id) { - return "Ultrathink"; - } - if (descriptor.type === "boolean") { - if (descriptor.id === "fastMode") { - return descriptor.currentValue === true ? "Fast" : "Normal"; - } - return `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}`; - } - return getProviderOptionCurrentLabel(descriptor); - }) - .filter((label): label is string => typeof label === "string" && label.length > 0) - .join(" · ") || ""; - - const isCodexStyle = provider === "codex"; - return ( - { - setIsMenuOpen(open); - }} - > - - } - > - {isCodexStyle ? ( - - {triggerLabel} - - ) : ( - <> - {triggerLabel} - - - - - + <> + {visibleDescriptors.map((descriptor, descriptorIndex) => { + const resolvedSelectLabel = getProviderOptionCurrentLabel(descriptor); + const triggerLabel = + ultrathinkPromptControlled && descriptor.id === primarySelectDescriptor?.id + ? "Ultrathink" + : descriptor.type === "boolean" + ? descriptor.id === "fastMode" + ? descriptor.currentValue === true + ? "Fast" + : "Normal" + : `${descriptor.label} ${descriptor.currentValue === true ? "On" : "Off"}` + : resolvedSelectLabel && resolvedSelectLabel.length > 0 + ? resolvedSelectLabel + : descriptor.label; + const descriptorIdLower = descriptor.id.toLowerCase(); + const descriptorLabelLower = descriptor.label.toLowerCase(); + const isThinkingLike = + descriptorIdLower.includes("reason") || + descriptorLabelLower.includes("reason") || + descriptorIdLower === "variant" || + descriptorLabelLower === "variant" || + descriptorIdLower.includes("thinking") || + descriptorLabelLower.includes("thinking"); + const isContextWindow = + descriptorIdLower === "contextwindow" || + descriptorIdLower === "context_window" || + descriptorIdLower.includes("contextwindow") || + descriptorLabelLower.includes("context window"); + const TriggerIcon = + descriptor.id === "fastMode" + ? ZapIcon + : isThinkingLike + ? BrainIcon + : isContextWindow + ? BookOpenIcon + : null; + const popupHiddenDescriptorIds = [ + ...(hiddenDescriptorIds ?? []), + ...visibleDescriptors + .filter((visibleDescriptor) => visibleDescriptor.id !== descriptor.id) + .map((visibleDescriptor) => visibleDescriptor.id), + ]; + + return ( + + {showTriggerSeparators && descriptorIndex > 0 ? ( + + ) : null} + setOpenDescriptorId(open ? descriptor.id : null)} + > + + } + > + + {TriggerIcon ? ( + + + + setOpenDescriptorId(null)} + {...persistence} + /> + + + + ); + })} + ); }); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index dc61bd1e48..100cd84afd 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -65,7 +65,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame", - composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", + composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.07)_inset]", modelPickerIconClassName: "ultrathink-chroma", } : {}), @@ -81,7 +81,14 @@ function renderTraitsControl( const hasTarget = threadRef !== undefined || draftId !== undefined; if ( !hasTarget || - !shouldRenderTraitsControls({ provider, models, model, modelOptions, prompt }) + !shouldRenderTraitsControls({ + provider, + models, + model, + modelOptions, + prompt, + hiddenDescriptorIds: ["agent"], + }) ) { return null; } @@ -95,6 +102,7 @@ function renderTraitsControl( modelOptions={modelOptions} prompt={prompt} onPromptChange={onPromptChange} + hiddenDescriptorIds={["agent"]} /> ); } diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 88b56295f3..23bdedffd6 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -27,14 +27,30 @@ export type ModelEsque = { subProvider?: string | undefined; }; +export type OpenCodeModelLane = "go" | "zen"; + +const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function stripLeadingQualifier(value: string, qualifier: string | null | undefined): string { + const trimmedQualifier = qualifier?.trim(); + if (!trimmedQualifier) { + return value; + } + + const pattern = new RegExp(`^${escapeRegExp(trimmedQualifier)}(?:\\s*[.:/-]\\s*|\\s+)`, "iu"); + return value.replace(pattern, "").trim() || value; +} + export function getDisplayModelName( model: ModelEsque, options?: { preferShortName?: boolean }, ): string { - if (options?.preferShortName && model.shortName) { - return model.shortName; - } - return model.name; + const name = options?.preferShortName && model.shortName ? model.shortName : model.name; + return stripLeadingQualifier(name, model.subProvider); } export function getTriggerDisplayModelName(model: ModelEsque): string { @@ -42,6 +58,24 @@ export function getTriggerDisplayModelName(model: ModelEsque): string { } export function getTriggerDisplayModelLabel(model: ModelEsque): string { - const title = getTriggerDisplayModelName(model); - return model.subProvider ? `${model.subProvider} · ${title}` : title; + return getTriggerDisplayModelName(model); +} + +export function getOpenCodeModelLane( + model: Pick, +): OpenCodeModelLane { + const providerId = model.slug.split("/", 1)[0] ?? ""; + const source = `${model.subProvider ?? ""} ${providerId}`.toLowerCase(); + return /(^|[^a-z0-9])zen([^a-z0-9]|$)/u.test(source) ? "zen" : "go"; +} + +export function getModelProviderDisplayName( + driverKind: ProviderDriverKind, + providerDisplayName: string, + model: ModelEsque, +): string { + if (driverKind === OPENCODE_DRIVER_KIND) { + return getOpenCodeModelLane(model) === "zen" ? "OpenCode Zen" : "OpenCode Go"; + } + return model.subProvider ? `${providerDisplayName} · ${model.subProvider}` : providerDisplayName; } diff --git a/apps/web/src/components/color-selector.tsx b/apps/web/src/components/color-selector.tsx new file mode 100644 index 0000000000..20e7ef3fd8 --- /dev/null +++ b/apps/web/src/components/color-selector.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "~/lib/utils"; + +interface ColorSelectorProps { + colors: string[]; + size?: "default" | "sm" | "lg"; + defaultValue: string; + name?: string; + onColorSelect?: (color: string) => void; + className?: string; +} + +const colorMap = { + default: "var(--foreground)", + red: "var(--color-red-500)", + green: "var(--color-green-500)", + blue: "var(--color-blue-500)", + yellow: "var(--color-yellow-500)", + purple: "var(--color-purple-500)", + pink: "var(--color-pink-500)", + indigo: "var(--color-indigo-500)", + orange: "var(--color-orange-500)", + teal: "var(--color-teal-500)", + cyan: "var(--color-cyan-500)", + lime: "var(--color-lime-500)", + emerald: "var(--color-emerald-500)", + violet: "var(--color-violet-500)", + fuchsia: "var(--color-fuchsia-500)", + rose: "var(--color-rose-500)", + sky: "var(--color-sky-500)", + amber: "var(--color-amber-500)", +} as const; + +function getSizeClass(size: "default" | "sm" | "lg") { + switch (size) { + case "sm": + return "size-4"; + case "default": + return "size-5"; + case "lg": + return "size-6"; + default: + return "size-5"; + } +} + +function getColorValue(color: string): string { + return colorMap[color as keyof typeof colorMap] || color; +} + +export function ColorSelector({ + colors, + size = "default", + defaultValue, + name, + onColorSelect, + className, +}: ColorSelectorProps) { + const [selectedColor, setSelectedColor] = useState(defaultValue); + + const handleColorSelect = (color: string) => { + setSelectedColor(color); + onColorSelect?.(color); + }; + + const sizeClass = getSizeClass(size); + + return ( +
+ {name && } + {colors.map((color) => { + const colorValue = getColorValue(color); + return ( +
handleColorSelect(color)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleColorSelect(color); + } + }} + tabIndex={0} + role="button" + aria-label={`Select ${color} color`} + aria-pressed={selectedColor === color} + /> + ); + })} +
+ ); +} diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index b8bce8f0cd..1180211986 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -27,15 +27,7 @@ import { Input } from "../ui/input"; import { RadioGroup } from "../ui/radio-group"; import { toastManager } from "../ui/toast"; import { DRIVER_OPTION_BY_VALUE, DRIVER_OPTIONS, type DriverOption } from "./providerDriverMeta"; - -const PROVIDER_ACCENT_SWATCHES = [ - "#2563eb", - "#16a34a", - "#ea580c", - "#dc2626", - "#7c3aed", - "#0891b2", -] as const; +import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; /** * Normalize a user-provided label into a slug suffix for the instance id. @@ -387,50 +379,12 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns
- Accent color -
- setAccentColor(event.target.value)} - aria-label="Provider instance accent color" - className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5" - /> -
- {PROVIDER_ACCENT_SWATCHES.map((swatch) => { - const selected = accentColor.toLowerCase() === swatch; - return ( -
- {accentColor ? ( - - ) : null} -
- - Optional marker shown in the picker. - +
{driverOption.fields.length > 0 ? ( diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 880c4376e2..f8d560f816 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1171,7 +1171,7 @@ export function ConnectionsSettings() { }} aria-label="Enable network access" /> - + {pendingDesktopServerExposureMode === "network-accessible" @@ -1305,7 +1305,7 @@ export function ConnectionsSettings() { } /> - + Add Environment Pair another environment to this client. diff --git a/apps/web/src/components/settings/ProviderAccentColorPicker.tsx b/apps/web/src/components/settings/ProviderAccentColorPicker.tsx new file mode 100644 index 0000000000..d352257257 --- /dev/null +++ b/apps/web/src/components/settings/ProviderAccentColorPicker.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { PipetteIcon, XIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; + +import { ColorSelector } from "../color-selector"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { normalizeProviderAccentColor } from "../../providerInstances"; +import { cn } from "../../lib/utils"; + +const PROVIDER_ACCENT_SWATCHES = [ + "#2563eb", + "#16a34a", + "#ea580c", + "#dc2626", + "#7c3aed", + "#0891b2", +] as const; + +const FALLBACK_ACCENT_COLOR = PROVIDER_ACCENT_SWATCHES[0]; + +function clamp(value: number, min = 0, max = 1) { + return Math.min(max, Math.max(min, value)); +} + +function hexToHsv(hex: string) { + const normalized = normalizeProviderAccentColor(hex) ?? FALLBACK_ACCENT_COLOR; + const numeric = Number.parseInt(normalized.slice(1), 16); + const red = ((numeric >> 16) & 255) / 255; + const green = ((numeric >> 8) & 255) / 255; + const blue = (numeric & 255) / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + + let hue = 0; + if (delta !== 0) { + if (max === red) { + hue = ((green - blue) / delta) % 6; + } else if (max === green) { + hue = (blue - red) / delta + 2; + } else { + hue = (red - green) / delta + 4; + } + hue *= 60; + if (hue < 0) hue += 360; + } + + return { + h: hue, + s: max === 0 ? 0 : delta / max, + v: max, + }; +} + +function hsvToHex(hue: number, saturation: number, value: number) { + const chroma = value * saturation; + const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)); + const match = value - chroma; + const [red, green, blue] = + hue < 60 + ? [chroma, x, 0] + : hue < 120 + ? [x, chroma, 0] + : hue < 180 + ? [0, chroma, x] + : hue < 240 + ? [0, x, chroma] + : hue < 300 + ? [x, 0, chroma] + : [chroma, 0, x]; + + return `#${[red, green, blue] + .map((channel) => + Math.round((channel + match) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`; +} + +function ProviderCustomColorPanel(props: { + readonly value: string; + readonly onCommit: (value: string) => void; +}) { + const { onCommit } = props; + const initialHsv = useMemo(() => hexToHsv(props.value), [props.value]); + const [hsv, setHsv] = useState(initialHsv); + const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v); + + const commitHsv = useCallback( + (nextHsv: typeof hsv) => { + setHsv(nextHsv); + onCommit(hsvToHex(nextHsv.h, nextHsv.s, nextHsv.v)); + }, + [onCommit], + ); + + const updateFromPlane = useCallback( + (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const saturation = clamp((event.clientX - bounds.left) / bounds.width); + const value = 1 - clamp((event.clientY - bounds.top) / bounds.height); + commitHsv({ ...hsv, s: saturation, v: value }); + }, + [commitHsv, hsv], + ); + + const updateFromHue = useCallback( + (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + commitHsv({ ...hsv, h: clamp((event.clientX - bounds.left) / bounds.width) * 360 }); + }, + [commitHsv, hsv], + ); + + const handlePointerDown = (handler: (event: PointerEvent) => void) => { + return (event: PointerEvent) => { + event.currentTarget.setPointerCapture(event.pointerId); + handler(event); + }; + }; + + return ( +
+
{ + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + updateFromPlane(event); + } + }} + > + +
+
+
{ + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + updateFromHue(event); + } + }} + > + +
+ { + const nextColor = event.currentTarget.value; + if (!/^#[\da-f]{6}$/i.test(nextColor)) return; + setHsv(hexToHsv(nextColor)); + props.onCommit(nextColor); + }} + className="h-8 rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground outline-none transition-colors focus:border-ring" + aria-label="Custom hex accent color" + spellCheck={false} + /> +
+
+ ); +} + +function ProviderCustomColorPicker(props: { + readonly displayName: string; + readonly value: string | undefined; + readonly selected: boolean; + readonly onCommit: (value: string) => void; +}) { + const normalized = normalizeProviderAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR; + + return ( + + + + + } + /> + + + + + ); +} + +export function ProviderAccentColorPicker(props: { + readonly displayName: string; + readonly value: string | undefined; + readonly onCommit: (value: string) => void; + readonly description?: string; + readonly commitDelayMs?: number; +}) { + const { commitDelayMs = 0, description, displayName, onCommit, value } = props; + const [optimisticValue, setOptimisticValue] = useState(() => value ?? ""); + const commitTimeoutRef = useRef | null>(null); + const pendingCommitRef = useRef(null); + const onCommitRef = useRef(onCommit); + + useEffect(() => { + onCommitRef.current = onCommit; + }, [onCommit]); + + useEffect(() => { + if (pendingCommitRef.current !== null) return; + setOptimisticValue(value ?? ""); + }, [value]); + + useEffect(() => { + return () => { + if (commitTimeoutRef.current !== null) { + clearTimeout(commitTimeoutRef.current); + } + const pendingCommit = pendingCommitRef.current; + if (pendingCommit !== null) { + onCommitRef.current(pendingCommit); + } + }; + }, []); + + const commitAccentColor = useCallback( + (value: string) => { + const normalizedValue = normalizeProviderAccentColor(value) ?? ""; + setOptimisticValue(normalizedValue); + + if (commitDelayMs <= 0) { + pendingCommitRef.current = null; + if (commitTimeoutRef.current !== null) { + clearTimeout(commitTimeoutRef.current); + commitTimeoutRef.current = null; + } + onCommit(normalizedValue); + return; + } + + pendingCommitRef.current = normalizedValue; + if (commitTimeoutRef.current !== null) { + clearTimeout(commitTimeoutRef.current); + } + commitTimeoutRef.current = setTimeout(() => { + commitTimeoutRef.current = null; + const pendingCommit = pendingCommitRef.current; + pendingCommitRef.current = null; + if (pendingCommit !== null) { + onCommitRef.current(pendingCommit); + } + }, commitDelayMs); + }, + [commitDelayMs, onCommit], + ); + + const normalized = normalizeProviderAccentColor(optimisticValue); + const selectedValue = + normalized && + PROVIDER_ACCENT_SWATCHES.includes(normalized as (typeof PROVIDER_ACCENT_SWATCHES)[number]) + ? normalized + : ""; + const customSelected = Boolean(normalized && selectedValue === ""); + + return ( +
+ Accent color +
+ + + +
+ {description ? {description} : null} +
+ ); +} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 21889412c3..d08ef48687 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; +import { ChevronDownIcon, InfoIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { isProviderDriverKind, @@ -16,13 +16,17 @@ import { cn } from "../../lib/utils"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { DraftInput } from "../ui/draft-input"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Switch } from "../ui/switch"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import type { DriverOption } from "./providerDriverMeta"; import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { PROVIDER_STATUS_STYLES, getProviderSummary, @@ -30,15 +34,6 @@ import { type ProviderStatusKey, } from "./providerStatus"; -const PROVIDER_ACCENT_SWATCHES = [ - "#2563eb", - "#16a34a", - "#ea580c", - "#dc2626", - "#7c3aed", - "#0891b2", -] as const; - const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const REDACTED_EMAIL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; @@ -191,16 +186,16 @@ function ProviderAuthEmail(props: { if (!trimmed) return null; return ( - + {props.separator ? · : null} - {props.prefix ? {props.prefix} : null} + {props.prefix ? {props.prefix} : null} setRevealed((value) => !value)} @@ -218,92 +213,25 @@ function ProviderAuthEmail(props: { ); } -function ProviderAccentColorPicker(props: { - readonly displayName: string; - readonly value: string | undefined; - readonly onCommit: (value: string) => void; -}) { - const [draft, setDraft] = useState(props.value ?? ""); - const [isEditing, setIsEditing] = useState(false); - const draftColor = normalizeProviderAccentColor(draft); - - useEffect(() => { - if (isEditing) return; - setDraft(props.value ?? ""); - }, [isEditing, props.value]); - - const commitDraft = () => { - setIsEditing(false); - props.onCommit(draftColor ?? ""); - }; - - const commitSwatch = (swatch: string) => { - setIsEditing(false); - setDraft(swatch); - props.onCommit(swatch); - }; +function getProviderStatusTooltip(input: { + readonly provider: ServerProvider | undefined; + readonly enabled: boolean; +}): string { + const { provider, enabled } = input; + if (!enabled || provider?.enabled === false) return "Disabled"; + if (!provider) return "Checking"; + if (!provider.installed) return "Missing Binary"; + if (provider.auth.status === "authenticated") return "Authenticated"; + if (provider.auth.status === "unauthenticated") return "Unauthenticated"; + if (provider.status === "warning") return "Needs Attention"; + if (provider.status === "error") return "Unavailable"; + return "Available"; +} - return ( -
- Accent color -
- setIsEditing(true)} - onInput={(event) => { - setIsEditing(true); - setDraft(event.currentTarget.value); - }} - onChange={(event) => { - setIsEditing(true); - setDraft(event.currentTarget.value); - }} - onBlur={commitDraft} - aria-label={`Accent color for ${props.displayName}`} - className="h-8 w-10 cursor-pointer rounded border border-input bg-background p-0.5" - /> -
- {PROVIDER_ACCENT_SWATCHES.map((swatch) => { - const selected = draftColor?.toLowerCase() === swatch; - return ( -
- {draftColor ? ( - - ) : null} -
- - Used to distinguish this instance in picker rails and model lists. - -
- ); +function formatAuthenticatedUsageLabel(label: string | null | undefined): string | null { + const trimmed = label?.trim(); + if (!trimmed) return null; + return trimmed.replace(/\bSubscription\b/gu, "subscription"); } function ProviderEnvironmentSection(props: { @@ -389,59 +317,83 @@ function ProviderEnvironmentSection(props: { Add variables to pass API keys, base URLs, or other per-instance CLI settings.

) : ( -
- {rows.map((variable, index) => ( -
- updateVariable(variable.id, { name: name.trim() })} - placeholder="VARIABLE_NAME" - spellCheck={false} - aria-label={`Environment variable name ${index + 1}`} - /> - updateVariable(variable.id, { value })} - type={variable.sensitive ? "password" : undefined} - autoComplete="off" - placeholder={ - variable.valueRedacted ? "Stored secret - enter a new value to replace" : "Value" - } - spellCheck={false} - aria-label={`Environment variable value ${index + 1}`} - /> - - -
- ))} +
+ + + + Variable + Value + Sensitive + + Options + + + + + {rows.map((variable, index) => ( + + + updateVariable(variable.id, { name: name.trim() })} + placeholder="VARIABLE_NAME" + spellCheck={false} + aria-label={`Environment variable name ${index + 1}`} + /> + + + updateVariable(variable.id, { value })} + type={variable.sensitive ? "password" : undefined} + autoComplete="off" + placeholder={ + variable.valueRedacted + ? "Stored secret - enter a new value to replace" + : "Value" + } + spellCheck={false} + aria-label={`Environment variable value ${index + 1}`} + /> + + +
+ { + const sensitive = Boolean(checked); + updateVariable(variable.id, { + sensitive, + ...(sensitive && variable.valueRedacted === undefined + ? {} + : { valueRedacted: sensitive ? variable.valueRedacted : false }), + }); + }} + aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`} + /> +
+
+ +
+ +
+
+
+ ))} +
+
)} @@ -535,9 +487,10 @@ export function ProviderInstanceCard({ const hasAuthenticatedEmail = liveProvider?.auth.status === "authenticated" && Boolean(authEmail?.trim()); const authenticatedDetail = hasAuthenticatedEmail - ? (liveProvider?.auth.label ?? liveProvider?.auth.type ?? null) + ? formatAuthenticatedUsageLabel(liveProvider?.auth.label ?? liveProvider?.auth.type ?? null) : null; const summary = rawSummary; + const statusTooltip = getProviderStatusTooltip({ provider: liveProvider, enabled }); const versionLabel = getProviderVersionLabel(liveProvider?.version); const FallbackIconComponent = driverOption?.icon; const displayName = @@ -617,31 +570,62 @@ export function ProviderInstanceCard({
- {driverKind ? ( - - ) : FallbackIconComponent ? ( - - - - - ) : ( - - )} + + {driverKind ? ( + + + + + } + /> + {statusTooltip} + + ) : FallbackIconComponent ? ( + + + + + + } + /> + {statusTooltip} + + ) : ( + + + + + } + /> + {statusTooltip} + + )} +

{displayName}

{String(instanceId) !== String(instance.driver) ? ( // Hide the id chip on a default slot whose id === the @@ -687,20 +671,44 @@ export function ProviderInstanceCard({
) : null}
-

+

{hasAuthenticatedEmail ? ( <> - Authenticated as + Authenticated as - {authenticatedDetail ? · {authenticatedDetail} : null} + {authenticatedDetail ? using your {authenticatedDetail}. : null} ) : ( - <> + {summary.headline} - + {summary.detail ? ( + + + + + } + /> + + {summary.detail} + + + ) : null} + )} - {summary.detail ? - {summary.detail} : null}

@@ -749,6 +757,8 @@ export function ProviderInstanceCard({ displayName={displayName} value={accentColor} onCommit={updateAccentColor} + commitDelayMs={120} + description="Used to distinguish this instance in picker rails and model lists." />
diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 5db713495b..ebae49e7d8 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -23,6 +23,7 @@ import { sortModelsForProviderInstance } from "../../modelOrdering"; import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; /** @@ -241,20 +242,21 @@ export function ProviderModelsSection({ {model.name} {hasDetails ? ( - - + + > + + } - > - - - + /> +
{model.slug} {capLabels.length > 0 ? ( @@ -267,11 +269,8 @@ export function ProviderModelsSection({
) : null}
- - - ) : null} - {isHidden ? ( - hidden + + ) : null} {model.isCustom ? ( custom diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a58f0ebc39..1822ff0f84 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -176,8 +176,7 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } {lastCheckedRelative.suffix ? ( <> - Checked {lastCheckedRelative.value}{" "} - {lastCheckedRelative.suffix} + Checked {lastCheckedRelative.value} {lastCheckedRelative.suffix} ) : ( <>Checked {lastCheckedRelative.value} @@ -540,17 +539,6 @@ export function GeneralSettingsPanel() { ), ); const logsDirectoryPath = observability?.logsDirectoryPath ?? null; - const diagnosticsDescription = (() => { - const exports: string[] = []; - if (observability?.otlpTracesEnabled && observability.otlpTracesUrl) { - exports.push(`traces to ${observability.otlpTracesUrl}`); - } - if (observability?.otlpMetricsEnabled && observability.otlpMetricsUrl) { - exports.push(`metrics to ${observability.otlpMetricsUrl}`); - } - const mode = observability?.localTracingEnabled ? "Local trace file" : "Terminal logs only"; - return exports.length > 0 ? `${mode}. OTLP exporting ${exports.join(" and ")}.` : `${mode}.`; - })(); const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenInstanceId = textGenerationModelSelection.instanceId; @@ -618,6 +606,8 @@ export function GeneralSettingsPanel() { const openDiagnosticsError = openPathErrorByTarget.logsDirectory ?? null; const isOpeningKeybindings = openingPathByTarget.keybindings; const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; + const keybindingsConfigPathLabel = keybindingsConfigPath ?? "Resolving keybindings path..."; + const logsDirectoryPathLabel = logsDirectoryPath ?? "Resolving logs directory..."; const lastCheckedAt = serverProviders.length > 0 @@ -1110,9 +1100,34 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + toolCallSummaries: DEFAULT_UNIFIED_SETTINGS.toolCallSummaries, + }) + } + /> + ) : null + } + control={ + updateSettings({ toolCallSummaries: Boolean(checked) })} + aria-label="Tool call summaries" + /> + } + /> + {}} modelOptions={textGenModelOptions} allowPromptInjectedEffort={false} + hiddenDescriptorIds={["agent"]} + showTriggerSeparators={false} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { @@ -1317,19 +1334,20 @@ export function GeneralSettingsPanel() { - - {keybindingsConfigPath ?? "Resolving keybindings path..."} - - {openKeybindingsError ? ( - {openKeybindingsError} - ) : ( - Opens in your preferred editor. - )} + Keybindings are saved in a file to disk. You can open it at{" "} + + {keybindingsConfigPathLabel} + {" "} + to edit it directly. } + status={ + openKeybindingsError ? ( + {openKeybindingsError} + ) : null + } control={
-

{description}

+

+ {description} +

{status ?
{status}
: null}
{control ? ( diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index 5c65f261f5..c1343cf81e 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -17,8 +17,9 @@ function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) { function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) { return ( + ); +} + function AlertDialogPopup({ className, bottomStickOnMobile = true, + centered = false, ...props }: AlertDialogPrimitive.Popup.Props & { bottomStickOnMobile?: boolean; + centered?: boolean; }) { + const Viewport = centered ? AlertDialogCenteredViewport : AlertDialogViewport; return ( - - + ); } @@ -138,4 +153,5 @@ export { AlertDialogDescription, AlertDialogClose, AlertDialogViewport, + AlertDialogCenteredViewport, }; diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx index 41013f95b1..20be44dd3e 100644 --- a/apps/web/src/components/ui/alert.tsx +++ b/apps/web/src/components/ui/alert.tsx @@ -1,55 +1,101 @@ import { cva, type VariantProps } from "class-variance-authority"; +import { Children, isValidElement } from "react"; import type * as React from "react"; import { cn } from "~/lib/utils"; -const alertVariants = cva( - "relative grid w-full items-start gap-x-2 gap-y-0.5 rounded-xl border px-3.5 py-3 text-card-foreground text-sm has-[>svg]:has-data-[slot=alert-action]:grid-cols-[calc(var(--spacing)*4)_1fr_auto] has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-data-[slot=alert-action]:grid-cols-[1fr_auto] has-[>svg]:gap-x-2 [&>svg]:h-lh [&>svg]:w-4", - { - defaultVariants: { - variant: "default", - }, - variants: { - variant: { - default: "bg-transparent dark:bg-input/32 [&>svg]:text-muted-foreground", - error: "border-destructive/32 bg-destructive/4 [&>svg]:text-destructive", - info: "border-info/32 bg-info/4 [&>svg]:text-info", - success: "border-success/32 bg-success/4 [&>svg]:text-success", - warning: "border-warning/32 bg-warning/4 [&>svg]:text-warning", - }, +const alertVariants = cva("relative rounded-xl border px-3.5 py-3 text-card-foreground text-sm", { + defaultVariants: { + variant: "default", + }, + variants: { + variant: { + default: "bg-transparent dark:bg-input/32 [&_svg]:text-muted-foreground", + error: + "border-destructive/32 bg-destructive/4 text-destructive-foreground [&_[data-slot=alert-description]]:text-destructive-foreground/80 [&_svg]:text-destructive", + info: "border-info/32 bg-info/4 [&_svg]:text-info", + success: "border-success/32 bg-success/4 [&_svg]:text-success", + warning: "border-warning/32 bg-warning/4 [&_svg]:text-warning", }, }, -); +}); + +function alertChildSlot(child: React.ReactElement): string | undefined { + const propsSlot = (child.props as Record)["data-slot"]; + if (propsSlot) { + return propsSlot; + } + + const type = child.type as { displayName?: string; name?: string }; + switch (type.displayName ?? type.name) { + case "AlertAction": + return "alert-action"; + case "AlertTitle": + return "alert-title"; + case "AlertDescription": + return "alert-description"; + default: + return undefined; + } +} function Alert({ className, variant, + children, ...props }: React.ComponentProps<"div"> & VariantProps) { + const icon: React.ReactNode[] = []; + const content: React.ReactNode[] = []; + const action: React.ReactNode[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + content.push(child); + return; + } + const slot = alertChildSlot(child); + if (slot === "alert-action") { + action.push(child); + } else if (slot === "alert-title" || slot === "alert-description") { + content.push(child); + } else { + icon.push(child); + } + }); + return (
+ > +
+ {icon.length > 0 && ( +
+ {icon} +
+ )} + {content.length > 0 && ( +
{content}
+ )} + {action.length > 0 && ( +
{action}
+ )} +
+
); } function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); + return
; } function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { return (
@@ -57,16 +103,11 @@ function AlertDescription({ className, ...props }: React.ComponentProps<"div">) } function AlertAction({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); + return
; } +AlertTitle.displayName = "AlertTitle"; +AlertDescription.displayName = "AlertDescription"; +AlertAction.displayName = "AlertAction"; + export { Alert, AlertTitle, AlertDescription, AlertAction }; diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index d6db87d48c..2d099762f2 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -125,7 +125,7 @@ function AutocompleteItem({ className, children, ...props }: AutocompletePrimiti return (
{children}
diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx index 10edb9ce3b..3f3402d3c3 100644 --- a/apps/web/src/components/ui/command.tsx +++ b/apps/web/src/components/ui/command.tsx @@ -30,7 +30,7 @@ function CommandDialogBackdrop({ className, ...props }: CommandDialogPrimitive.B return ( + ); +} + function DialogPopup({ className, children, showCloseButton = true, bottomStickOnMobile = true, + centered = false, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean; bottomStickOnMobile?: boolean; + centered?: boolean; }) { + const Viewport = centered ? DialogCenteredViewport : DialogViewport; return ( - )} - + ); } @@ -178,4 +193,5 @@ export { DialogDescription, DialogPanel, DialogViewport, + DialogCenteredViewport, }; diff --git a/apps/web/src/components/ui/kbd.tsx b/apps/web/src/components/ui/kbd.tsx index 04a23f2b6c..5ae2ae6f82 100644 --- a/apps/web/src/components/ui/kbd.tsx +++ b/apps/web/src/components/ui/kbd.tsx @@ -15,9 +15,9 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { ); } -function KbdGroup({ className, ...props }: React.ComponentProps<"kbd">) { +function KbdGroup({ className, ...props }: React.ComponentProps<"span">) { return ( - ) { ); } -export { Kbd, KbdGroup }; +function Shortcut(props: React.ComponentProps) { + if (typeof props.children !== "string") { + return ; + } + + const parts = splitShortcutLabel(props.children); + if (parts.length <= 1) { + return ; + } + + const { children: _children, className, ...kbdProps } = props; + const classNames = className?.split(/\s+/).filter(Boolean) ?? []; + const groupClassName = classNames.filter(isAutoMarginClassName).join(" "); + const keyClassName = classNames.filter((name) => !isAutoMarginClassName(name)).join(" "); + return ( + + {buildShortcutKeyParts(parts).map((part) => ( + + {part.label} + + ))} + + ); +} + +function splitShortcutLabel(label: string): string[] { + if (label.includes("+")) { + const parts = label.split("+").filter(Boolean); + return label.endsWith("+") ? [...parts, "+"] : parts; + } + + if (label.includes(" ")) { + return label.split(/\s+/).filter(Boolean); + } + + const modifierParts = label.match(/^[⇧⌘⌥⌃]+/)?.[0] ?? ""; + if (!modifierParts) { + return [label]; + } + + const key = label.slice(modifierParts.length); + return [...modifierParts, ...(key ? [key] : [])]; +} + +function buildShortcutKeyParts(parts: readonly string[]): Array<{ key: string; label: string }> { + const seenCounts = new Map(); + const keyedParts: Array<{ key: string; label: string }> = []; + for (const part of parts) { + const seenCount = seenCounts.get(part) ?? 0; + seenCounts.set(part, seenCount + 1); + keyedParts.push({ key: `${part}:${seenCount}`, label: part }); + } + return keyedParts; +} + +function isAutoMarginClassName(className: string): boolean { + return /^(m[eslrxy]?|[a-z]+:m[eslrxy]?)-auto$/.test(className); +} + +export { Kbd, KbdGroup, Shortcut, splitShortcutLabel }; diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 8f2833e41b..e9ee42c8f6 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -78,7 +78,7 @@ function MenuItem({ return ( svg]:-mx-0.5 flex min-h-8 cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-inset:ps-8 data-[variant=destructive]:text-destructive-foreground data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&>svg:not([class*='opacity-'])]:opacity-80 [&>svg:not([class*='size-'])]:size-4.5 sm:[&>svg:not([class*='size-'])]:size-4 [&>svg]:pointer-events-none [&>svg]:shrink-0", + "[&>svg]:-mx-0.5 flex min-h-8 cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-base text-foreground outline-none data-disabled:pointer-events-none data-highlighted:bg-accent data-inset:ps-8 data-[variant=destructive]:text-destructive-foreground data-highlighted:text-accent-foreground data-disabled:opacity-64 sm:min-h-7 sm:text-sm [&>svg:not([class*='opacity-'])]:opacity-80 [&>svg:not([class*='size-'])]:size-4.5 sm:[&>svg:not([class*='size-'])]:size-4 [&>svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:[&>svg:not([class*='text-'])]:text-current [&>svg]:pointer-events-none [&>svg]:shrink-0", className, )} data-inset={inset} @@ -147,32 +147,42 @@ function MenuRadioGroup(props: MenuPrimitive.RadioGroup.Props) { return ; } -function MenuRadioItem({ className, children, ...props }: MenuPrimitive.RadioItem.Props) { +function MenuRadioItem({ + className, + children, + hideIndicator = false, + ...props +}: MenuPrimitive.RadioItem.Props & { + hideIndicator?: boolean; +}) { return ( - - - - - - {children} + {hideIndicator ? null : ( + + + + + + )} + {children} ); } @@ -235,7 +245,7 @@ function MenuSubTrigger({ return ( {variant === "ghost" ? ( - + ) : ( )} @@ -87,7 +87,7 @@ function SelectTrigger({ > {children} - + ); @@ -200,7 +200,10 @@ function SelectItem({ )} {children} diff --git a/apps/web/src/components/ui/table.tsx b/apps/web/src/components/ui/table.tsx new file mode 100644 index 0000000000..d3600f8699 --- /dev/null +++ b/apps/web/src/components/ui/table.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; + +import { cn } from "~/lib/utils"; + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ); +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ; +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ); +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + {...props} + /> + ); +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ); +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
+ ); +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ); +} + +function TableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return ( +
+ ); +} + +export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }; diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx new file mode 100644 index 0000000000..d50c6ce86e --- /dev/null +++ b/apps/web/src/components/ui/tabs.tsx @@ -0,0 +1,129 @@ +"use client"; + +import type * as React from "react"; +import { createContext, useCallback, useContext, useMemo, useState } from "react"; + +import { cn } from "~/lib/utils"; + +type TabsContextValue = { + value: string; + onValueChange: (value: string) => void; +}; + +const TabsContext = createContext(null); + +function useTabsContext() { + const context = useContext(TabsContext); + if (!context) { + throw new Error("Tabs components must be rendered inside Tabs."); + } + return context; +} + +function Tabs({ + className, + value, + defaultValue = "", + onValueChange, + ...props +}: React.ComponentProps<"div"> & { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; +}) { + const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); + const currentValue = value ?? uncontrolledValue; + const handleValueChange = useCallback( + (nextValue: string) => { + if (value === undefined) { + setUncontrolledValue(nextValue); + } + onValueChange?.(nextValue); + }, + [onValueChange, value], + ); + const context = useMemo( + () => ({ value: currentValue, onValueChange: handleValueChange }), + [currentValue, handleValueChange], + ); + + return ( + +
+ + ); +} + +function TabsList({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function TabsTrigger({ + className, + value, + disabled, + onClick, + ...props +}: Omit, "value"> & { + value: string; +}) { + const context = useTabsContext(); + const isActive = context.value === value; + + return ( +