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/package.json b/apps/desktop/package.json index cd20211bfe..b30163c6d1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,16 +16,16 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@t3tools/client-runtime": "workspace:*", + "@t3tools/shared": "workspace:*", + "@t3tools/ssh": "workspace:*", + "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "electron": "40.9.3", "electron-updater": "^6.6.2" }, "devDependencies": { - "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", - "@t3tools/shared": "workspace:*", - "@t3tools/ssh": "workspace:*", - "@t3tools/tailscale": "workspace:*", "@types/node": "catalog:", "effect-acp": "workspace:*", "tsdown": "catalog:", diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index d4c4768d2c..5cac37fce7 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -63,6 +63,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 9c097fc9bd..272462133a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -185,6 +185,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 8945294555..8009a72114 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -316,6 +316,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/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index b5c9c0338f..f201827249 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -98,6 +98,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; @@ -315,6 +320,10 @@ function createTextGeneration(overrides: Partial = {}): T Effect.succeed({ title: "Update workflow", }), + generateToolWorkLogSummary: () => + Effect.succeed({ + line: "Task Example tool activity", + }), ...overrides, }; @@ -363,6 +372,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/Utils.ts b/apps/server/src/git/Utils.ts index 6faf3e99c7..294d971519 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -1,6 +1,191 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; +import { Schema } from "effect"; +import { TextGenerationError } from "@t3tools/contracts"; export function isGitRepository(cwd: string): boolean { return existsSync(join(cwd, ".git")); } + +/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ +export function toJsonSchemaObject(schema: Schema.Top): unknown { + const document = Schema.toJsonSchemaDocument(schema); + if (document.definitions && Object.keys(document.definitions).length > 0) { + return { ...document.schema, $defs: document.definitions }; + } + return document.schema; +} + +/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ +export function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + const truncated = value.slice(0, maxChars); + return `${truncated}\n\n[truncated]`; +} + +export function extractJsonObject(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + const start = trimmed.indexOf("{"); + if (start < 0) { + return trimmed; + } + + let depth = 0; + let inString = false; + let escaping = false; + for (let index = start; index < trimmed.length; index += 1) { + const char = trimmed[index]; + if (inString) { + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "{") { + depth += 1; + continue; + } + + if (char === "}") { + depth -= 1; + if (depth === 0) { + return trimmed.slice(start, index + 1); + } + } + } + + return trimmed.slice(start); +} + +/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ +export function sanitizeCommitSubject(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); + if (withoutTrailingPeriod.length === 0) { + return "Update project files"; + } + + if (withoutTrailingPeriod.length <= 72) { + return withoutTrailingPeriod; + } + return withoutTrailingPeriod.slice(0, 72).trimEnd(); +} + +/** Normalise a raw PR title to a single line with a sensible fallback. */ +export function sanitizePrTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + if (singleLine.length > 0) { + return singleLine; + } + return "Update project changes"; +} + +/** Normalise a raw thread title to a compact single-line sidebar-safe label. */ +export function sanitizeThreadTitle(raw: string): string { + const normalized = raw + .trim() + .split(/\r?\n/g)[0] + ?.trim() + .replace(/^['"`]+|['"`]+$/g, "") + .trim() + .replace(/\s+/g, " "); + + if (!normalized || normalized.trim().length === 0) { + return "New thread"; + } + + if (normalized.length <= 50) { + return normalized; + } + + 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); + return `${capitalized} CLI (\`${cliName}\`)`; +} + +/** + * Normalize an unknown error from a CLI text generation process into a + * typed `TextGenerationError`. Parameterized by CLI name so both Codex + * and Claude (and future providers) can share the same logic. + */ +export function normalizeCliError( + cliName: string, + operation: string, + error: unknown, + fallback: string, +): TextGenerationError { + if (Schema.is(TextGenerationError)(error)) { + return error; + } + + if (error instanceof Error) { + const lower = error.message.toLowerCase(); + if ( + error.message.includes(`Command not found: ${cliName}`) || + lower.includes(`spawn ${cliName}`) || + lower.includes("enoent") + ) { + return new TextGenerationError({ + operation, + detail: `${cliLabel(cliName)} is required but not available on PATH.`, + cause: error, + }); + } + return new TextGenerationError({ + operation, + detail: `${fallback}: ${error.message}`, + cause: error, + }); + } + + return new TextGenerationError({ + operation, + detail: fallback, + cause: error, + }); +} diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f641eef037..3fced139f7 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -272,6 +272,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 = { @@ -345,6 +354,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 2e86623f8d..2dc956b267 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1209,16 +1209,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) @@ -1306,6 +1308,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, + }); } } @@ -1550,7 +1581,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..2c1772ccac 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3296,6 +3296,10 @@ 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" }, + ]); // 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 556504d6cf..15ef5f4312 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"); @@ -351,7 +352,7 @@ function normalizeClaudeTokenUsage( return { usedTokens, lastUsedTokens: usedTokens, - ...(totalProcessedTokens > usedTokens ? { totalProcessedTokens } : {}), + ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), ...(inputTokens > 0 ? { inputTokens } : {}), ...(outputTokens > 0 ? { outputTokens } : {}), ...(maxTokens !== undefined ? { maxTokens } : {}), @@ -361,6 +362,7 @@ function normalizeClaudeTokenUsage( ...(typeof usage.duration_ms === "number" && Number.isFinite(usage.duration_ms) ? { durationMs: usage.duration_ms } : {}), + compactsAutomatically: true, }; } @@ -2573,12 +2575,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..7dfa2fa67a 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -96,7 +96,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }), buildBooleanOptionDescriptor({ id: "fastMode", - label: "Fast Mode", + label: "Speed", }), buildSelectOptionDescriptor({ id: "contextWindow", @@ -127,7 +127,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }), buildBooleanOptionDescriptor({ id: "fastMode", - label: "Fast Mode", + label: "Speed", }), ], }), @@ -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..e29e806e62 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -906,6 +906,12 @@ 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", + }, + ]); } 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..c42f37d949 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 { @@ -159,9 +160,7 @@ function normalizeCodexTokenUsage( return { usedTokens, - ...(totalProcessedTokens !== undefined && totalProcessedTokens > usedTokens - ? { totalProcessedTokens } - : {}), + ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), ...(maxTokens !== undefined ? { maxTokens } : {}), ...(inputTokens !== undefined ? { inputTokens } : {}), ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), @@ -342,7 +341,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..ea2f1f6c34 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -190,7 +190,7 @@ const codexModelCapabilities = createModelCapabilities({ { id: "high", label: "High", isDefault: true }, { id: "low", label: "Low" }, ]), - booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("fastMode", "Speed"), ], }) satisfies NonNullable; @@ -431,7 +431,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( selectDescriptor("reasoning", "Reasoning", [ { id: "high", label: "High", isDefault: true }, ]), - booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("fastMode", "Speed"), booleanDescriptor("thinking", "Thinking"), ], }), @@ -471,7 +471,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( selectDescriptor("reasoning", "Reasoning", [ { id: "high", label: "High", isDefault: true }, ]), - booleanDescriptor("fastMode", "Fast Mode"), + booleanDescriptor("fastMode", "Speed"), booleanDescriptor("thinking", "Thinking"), ], }), @@ -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.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/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index ff66476380..f36464b4ee 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -10,7 +10,7 @@ const fastModeCapabilities = createModelCapabilities({ optionDescriptors: [ { id: "fastMode", - label: "Fast Mode", + label: "Speed", type: "boolean", }, ], diff --git a/apps/server/src/provider/userInputOptions.test.ts b/apps/server/src/provider/userInputOptions.test.ts new file mode 100644 index 0000000000..cd7ecf51e3 --- /dev/null +++ b/apps/server/src/provider/userInputOptions.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { withCustomUserInputOption } from "./userInputOptions.ts"; + +describe("withCustomUserInputOption", () => { + it("preserves preset options without appending Other", () => { + 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" }, + ]); + }); + + it("drops provider-supplied Other options", () => { + expect( + withCustomUserInputOption([ + { label: "Other", description: "Write one" }, + { label: "One", description: "First" }, + ]), + ).toEqual([{ label: "One", description: "First" }]); + }); +}); diff --git a/apps/server/src/provider/userInputOptions.ts b/apps/server/src/provider/userInputOptions.ts new file mode 100644 index 0000000000..7c2b14c5eb --- /dev/null +++ b/apps/server/src/provider/userInputOptions.ts @@ -0,0 +1,7 @@ +import type { UserInputQuestionOption } from "@t3tools/contracts"; + +export function withCustomUserInputOption( + options: ReadonlyArray, +): ReadonlyArray { + return options.filter((option) => option.label.trim().toLowerCase() !== "other"); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 65cb638f7e..18aae6e649 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -63,6 +63,7 @@ import { type CheckpointDiffQueryShape, } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { GitManager, type GitManagerShape } from "./git/GitManager.ts"; +import { TextGeneration } from "./textGeneration/TextGeneration.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -472,6 +473,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(vcsDriverRegistryLayer), @@ -537,6 +545,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide(gitManagerLayer), + Layer.provide(textGenerationTestLayer), Layer.provide(gitVcsDriverLayer), Layer.provide(gitWorkflowLayer), Layer.provide(vcsProvisioningLayer), diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 2f0cbc509b..d7c7e8b1db 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -20,6 +20,7 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildToolWorkLogSummaryPrompt, } from "./TextGenerationPrompts.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/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 786a0be4c4..d7faa67a50 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -18,6 +18,7 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildToolWorkLogSummaryPrompt, } from "./TextGenerationPrompts.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,10 +382,36 @@ 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/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts index 1cde82d61b..8003e6ece7 100644 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -11,6 +11,7 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildToolWorkLogSummaryPrompt, } from "./TextGenerationPrompts.ts"; import { extractJsonObject, @@ -30,7 +31,8 @@ function mapCursorAcpError( | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle", + | "generateThreadTitle" + | "generateToolWorkLogSummary", detail: string, cause: unknown, ): TextGenerationError { @@ -71,7 +73,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateToolWorkLogSummary"; cwd: string; prompt: string; outputSchemaJson: S; @@ -267,10 +270,36 @@ 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/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index d646e4f2e5..448f094061 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -17,6 +17,7 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, + buildToolWorkLogSummaryPrompt, } from "./TextGenerationPrompts.ts"; import { type TextGenerationShape } from "./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/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts index 4f3d44f925..ba71be5412 100644 --- a/apps/server/src/textGeneration/TextGeneration.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.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/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 51796faf8a..6482d5a297 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -7,6 +7,7 @@ import { type ProviderInstanceRegistryShape, } from "../provider/Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import { sanitizeToolWorkLogSummaryLine } from "../git/Utils.ts"; export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; @@ -68,6 +69,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, @@ -108,6 +124,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; } /** @@ -121,7 +144,8 @@ type TextGenerationOp = | "generateCommitMessage" | "generatePrContent" | "generateBranchName" - | "generateThreadTitle"; + | "generateThreadTitle" + | "generateToolWorkLogSummary"; const resolveInstance = ( registry: ProviderInstanceRegistryShape, @@ -160,6 +184,11 @@ export const makeTextGenerationFromRegistry = ( resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), ), + generateToolWorkLogSummary: (input) => + resolveInstance(registry, "generateToolWorkLogSummary", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateToolWorkLogSummary(input)), + Effect.map((r) => ({ line: sanitizeToolWorkLogSummaryLine(r.line, input.label) })), + ), }); export const layer = Layer.effect( diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts index 43ae62047b..011e85e4eb 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -216,3 +216,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/ws.ts b/apps/server/src/ws.ts index 592097b6d9..ed5823cbba 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -29,6 +29,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; +import { TextGeneration } from "./textGeneration/TextGeneration.ts"; import { Keybindings } from "./keybindings.ts"; import { Open, resolveAvailableEditors } from "./open.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; @@ -150,6 +151,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const gitWorkflow = yield* GitWorkflowService; const vcsProvisioning = yield* VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; + const textGeneration = yield* TextGeneration; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; const config = yield* ServerConfig; @@ -959,6 +961,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.vcsListRefs]: (input) => observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { "rpc.aggregate": "vcs", 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 7fa8818109..a9eb27b218 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,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 27c5c311c6..81bad97ef5 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, @@ -164,23 +165,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 2e30edfc02..e6abaad602 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, VcsRef, 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, @@ -33,6 +34,7 @@ import { shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; +import { Badge } from "./ui/badge"; import { Combobox, ComboboxEmpty, @@ -63,6 +65,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"; @@ -432,7 +461,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; @@ -451,11 +481,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; @@ -470,7 +543,7 @@ export function BranchToolbarBranchSelector({ useEffect(() => { const scrollElement = branchListScrollElementRef.current; - if (!scrollElement || !isBranchMenuOpen) { + if (!scrollElement || !isBranchMenuOpen || shouldVirtualizeBranchList) { return; } @@ -483,7 +556,7 @@ export function BranchToolbarBranchSelector({ return () => { scrollElement.removeEventListener("scroll", handleScroll); }; - }, [isBranchMenuOpen, maybeFetchNextBranchPage]); + }, [isBranchMenuOpen, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); useEffect(() => { if (shouldVirtualizeBranchList) return; @@ -504,6 +577,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} + className="pe-2" onClick={() => { if (!prReference || !onCheckoutPullRequestRequest) { return; @@ -562,9 +636,9 @@ export function BranchToolbarBranchSelector({ value={itemValue} onClick={() => selectBranch(refName)} > -
- {itemValue} - {badge && {badge}} +
+ {itemValue} + {badge ? : null}
); @@ -594,11 +668,12 @@ export function BranchToolbarBranchSelector({ className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} > + {triggerLabel} - + - -
+ +
setBranchQuery(event.target.value)} />
- No refs 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 refs 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.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index a397d52a37..8ac0fbe2a1 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -63,7 +63,7 @@ describe("ChatMarkdown", () => { ); try { - const link = page.getByRole("link", { name: "PermissionRule.ts · L1" }); + const link = page.getByRole("link", { name: "PermissionRule.ts @ L1" }); await expect.element(link).toBeInTheDocument(); await expect.element(link).toHaveAttribute("href", `${filePath}#L1`); @@ -85,7 +85,7 @@ describe("ChatMarkdown", () => { ); try { - const link = page.getByRole("link", { name: "PermissionRule.ts · L1:C7" }); + const link = page.getByRole("link", { name: "PermissionRule.ts @ L1:C7" }); await expect.element(link).toBeInTheDocument(); await expect.element(link).toHaveAttribute("href", `${filePath}#L1C7`); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5d9fcea50a..76fdb50bdb 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -18,7 +18,6 @@ import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import { defaultUrlTransform } from "react-markdown"; import remarkGfm from "remark-gfm"; -import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { openInPreferredEditor } from "../editorPreferences"; @@ -29,6 +28,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 +171,17 @@ function MarkdownCodeBlock({ code, children }: { code: string; children: ReactNo return (
- + {children}
); @@ -271,78 +273,14 @@ interface MarkdownFileLinkProps { href: string; targetPath: string; displayPath: string; - filePath: string; label: string; - theme: "light" | "dark"; className?: string | undefined; } const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; -const MARKDOWN_FILE_LINK_CLASS_NAME = - "chat-markdown-file-link relative top-[2px] max-w-full no-underline"; -const MARKDOWN_FILE_LINK_ICON_CLASS_NAME = "chat-markdown-file-link-icon size-3.5 shrink-0"; +const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link max-w-full"; const MARKDOWN_FILE_LINK_LABEL_CLASS_NAME = "chat-markdown-file-link-label truncate"; -function pathParentSegments(path: string): string[] { - const normalized = path.replaceAll("\\", "/"); - const segments = normalized.split("/").filter((segment) => segment.length > 0); - return segments.slice(0, -1); -} - -function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray): Map { - const groups = new Map>(); - for (const filePath of filePaths) { - const pathSegments = filePath - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment.length > 0); - const basename = pathSegments[pathSegments.length - 1]; - if (!basename) continue; - const group = groups.get(basename) ?? new Set(); - group.add(filePath); - groups.set(basename, group); - } - - const suffixByPath = new Map(); - for (const group of groups.values()) { - const uniquePaths = [...group]; - if (uniquePaths.length < 2) continue; - - const parentSegmentsByPath = new Map( - uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), - ); - const minUniqueDepthByPath = new Map(); - - for (const filePath of uniquePaths) { - const segments = parentSegmentsByPath.get(filePath) ?? []; - let resolvedDepth = segments.length; - for (let depth = 1; depth <= segments.length; depth += 1) { - const candidate = segments.slice(-depth).join("/"); - const collision = uniquePaths.some((otherPath) => { - if (otherPath === filePath) return false; - const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; - return otherSegments.slice(-depth).join("/") === candidate; - }); - if (!collision) { - resolvedDepth = depth; - break; - } - } - minUniqueDepthByPath.set(filePath, resolvedDepth); - } - - for (const filePath of uniquePaths) { - const segments = parentSegmentsByPath.get(filePath) ?? []; - if (segments.length === 0) continue; - const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; - const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); - suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); - } - } - - return suffixByPath; -} - function extractMarkdownLinkHrefs(text: string): string[] { const hrefs: string[] = []; for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { @@ -361,9 +299,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ href, targetPath, displayPath, - filePath, label, - theme, className, }: MarkdownFileLinkProps) { const handleOpen = useCallback(() => { @@ -465,12 +401,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }} onContextMenu={handleContextMenu} > - {label} } @@ -495,9 +425,7 @@ function areMarkdownFileLinkPropsEqual( previous.href === next.href && previous.targetPath === next.targetPath && previous.displayPath === next.displayPath && - previous.filePath === next.filePath && previous.label === next.label && - previous.theme === next.theme && previous.className === next.className ); } @@ -520,10 +448,6 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { } return metaByHref; }, [cwd, text]); - const fileLinkParentSuffixByPath = useMemo(() => { - const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath); - return buildFileLinkParentSuffixByPath(filePaths); - }, [markdownFileLinkMetaByHref]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); @@ -536,11 +460,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return ; } - const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath); const labelParts = [fileLinkMeta.basename]; - if (typeof parentSuffix === "string" && parentSuffix.length > 0) { - labelParts.push(parentSuffix); - } if (fileLinkMeta.line) { labelParts.push( `L${fileLinkMeta.line}${fileLinkMeta.column ? `:C${fileLinkMeta.column}` : ""}`, @@ -552,9 +472,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { href={href ?? fileLinkMeta.targetPath} targetPath={fileLinkMeta.targetPath} displayPath={fileLinkMeta.displayPath} - filePath={fileLinkMeta.filePath} - label={labelParts.join(" · ")} - theme={resolvedTheme} + label={labelParts.join(" @ ")} className={props.className} /> ); @@ -581,13 +499,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); }, }), - [ - diffThemeName, - fileLinkParentSuffixByPath, - isStreaming, - markdownFileLinkMetaByHref, - resolvedTheme, - ], + [diffThemeName, isStreaming, markdownFileLinkMetaByHref], ); return ( diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 578dc7c045..b85021fe86 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -6065,7 +6065,7 @@ describe("ChatView timeline estimator parity (full app)", () => { isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + { id: "fastMode", label: "Speed", type: "boolean" as const }, ], }), }, @@ -6075,7 +6075,7 @@ describe("ChatView timeline estimator parity (full app)", () => { isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + { id: "fastMode", label: "Speed", type: "boolean" as const }, ], }), }, @@ -6085,7 +6085,7 @@ describe("ChatView timeline estimator parity (full app)", () => { isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ - { id: "fastMode", label: "Fast Mode", type: "boolean" as const }, + { id: "fastMode", label: "Speed", type: "boolean" as const }, ], }), }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9ef221e262..6d4f546299 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1636,7 +1636,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 ?? @@ -3017,8 +3021,6 @@ export default function ChatView(props: ChatViewProps) { }, }; }); - promptRef.current = ""; - composerRef.current?.resetCursorState({ cursor: 0 }); }, [activePendingProgress?.activeQuestion, activePendingUserInput], ); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 3f4997e215..acf05a983e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -93,6 +93,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", @@ -101,6 +102,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 027688a284..3508c1a934 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -113,7 +113,7 @@ import { CommandPanel, } from "./ui/command"; import { Button } from "./ui/button"; -import { Kbd, KbdGroup } from "./ui/kbd"; +import { Kbd, KbdGroup, Shortcut } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; @@ -661,6 +661,7 @@ function OpenCommandPaletteDialog() { buildProjectActionItems({ projects, valuePrefix: "new-thread-in", + shortcutCommand: "chat.new", icon: (project) => (
- + - - + + - + Navigate {addProjectCloneFlow?.step === "repository" ? ( @@ -1710,15 +1711,15 @@ function OpenCommandPaletteDialog() { ) : 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 01b84bd94a..4844a892b9 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -16,14 +16,17 @@ import { Option } from "effect"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { + AlertTriangleIcon, CheckIcon, ChevronDownIcon, CloudUploadIcon, ExternalLinkIcon, + GitBranchPlusIcon, GitCommitIcon, InfoIcon, LockIcon, GlobeIcon, + PencilIcon, } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; @@ -46,6 +49,7 @@ import { import { AnimatedHeight } from "./AnimatedHeight"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; +import { DiffStatLabel } from "~/components/chat/DiffStatLabel"; import { Dialog, DialogDescription, @@ -303,6 +307,15 @@ function getMenuActionDisabledReason({ const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; +function splitFilePath(path: string): { dir: string; base: string } { + const normalized = path.replaceAll("\\", "/"); + const lastSlash = normalized.lastIndexOf("/"); + if (lastSlash === -1) return { dir: "", base: normalized }; + return { + dir: `${normalized.slice(0, lastSlash + 1)}`, + base: normalized.slice(lastSlash + 1), + }; +} function GitActionItemIcon({ icon, @@ -1073,6 +1086,9 @@ export default function GitActionsControl({ const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; + const totalInsertions = selectedFiles.reduce((sum, f) => sum + f.insertions, 0); + const totalDeletions = selectedFiles.reduce((sum, f) => sum + f.deletions, 0); + const totalChangeMagnitude = totalInsertions + totalDeletions; const initMutation = useMutation( gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), @@ -1626,7 +1642,10 @@ export default function GitActionsControl({ disabled={initMutation.isPending} onClick={() => initMutation.mutate()} > - {initMutation.isPending ? "Initializing..." : "Initialize Git"} + + + {initMutation.isPending ? "Initializing..." : "Initialize Git"} + ) : ( @@ -1774,7 +1793,7 @@ export default function GitActionsControl({ } }} > - + {COMMIT_DIALOG_TITLE} {COMMIT_DIALOG_DESCRIPTION} @@ -1794,10 +1813,59 @@ export default function GitActionsControl({ )}
-
-
-
- {isEditingFiles && allFiles.length > 0 && ( + {isDefaultRef && ( + + + Default branch + + )} +
+ + {/* Changes section */} +
+
+
+

Changes

+ {allFiles.length > 0 && ( + + {isEditingFiles || !allSelected + ? `${selectedFiles.length} of ${allFiles.length}` + : `${allFiles.length} ${allFiles.length === 1 ? "file" : "files"}`} + + )} +
+ {selectedFiles.length > 0 && ( + + )} +
+ + {/* Aggregate diff bar */} + {selectedFiles.length > 0 && totalChangeMagnitude > 0 && ( + - {!gitStatusForActions || allFiles.length === 0 ? ( -

none

- ) : ( -
- -
- {allFiles.map((file) => { - const isExcluded = excludedFiles.has(file.path); - return ( -
+
    + {allFiles.map((file) => { + const isExcluded = excludedFiles.has(file.path); + const { dir, base } = splitFilePath(file.path); + return ( +
  • + {isEditingFiles && ( + { + setExcludedFiles((prev) => { + const next = new Set(prev); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + }} + /> + )} + -
- ); - })} -
-
-
- - +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} - - / - - -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)} - -
-
- )} + + {dir && {dir}} + {base} + + + + + ); + })} + + +
+ )} + + {allFiles.length > 0 && ( +
+ +
+ )} + + + {/* Commit message */} +
+
+ + + Optional, auto-generated if empty +
-
-
-

Commit message (optional)