diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 66f6cf38bc1c5..3005c0dff4b34 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -46,16 +46,23 @@ try { // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. const clientPlatform = process.platform; +const macosMajorVersion = + clientPlatform === "darwin" ? Number.parseInt(process.getSystemVersion()) : 0; + if (clientPlatform === "darwin") { // Native buttons ignore Chromium zoom; reserve native points even after reload. - const syncWindowControlInset = () => { - document.documentElement.style.setProperty( - "--desktop-window-controls-inset", - `${30 + 67 / webFrame.getZoomFactor()}px`, - ); + const syncWindowGeometry = () => { + const rootStyle = document.documentElement.style; + const zoomFactor = webFrame.getZoomFactor(); + rootStyle.setProperty("--desktop-window-controls-inset", `${90 / zoomFactor}px`); + // Measured from macOS 27 window captures; earlier releases draw other corners. + if (macosMajorVersion >= 27) { + rootStyle.setProperty("--desktop-window-corner-radius", `${31.5 / zoomFactor}px`); + rootStyle.setProperty("--desktop-window-corner-shape", "superellipse(2.12)"); + } }; - window.addEventListener("DOMContentLoaded", syncWindowControlInset, { once: true }); - window.addEventListener("resize", syncWindowControlInset); + window.addEventListener("DOMContentLoaded", syncWindowGeometry, { once: true }); + window.addEventListener("resize", syncWindowGeometry); } const syncHtmlFullscreen = () => { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 20f7d59f88eb9..7deffc59035c0 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -48,7 +48,6 @@ const clientSettings: ClientSettings = { fontSizePrompt: 14, fontSizeTerminal: 12, fontSmoothing: true, - onboardingCompletedAt: null, panelAnimationDurationMs: 0, proactivePanelsEnabled: true, showSkillsInSlashMenu: false, @@ -174,7 +173,7 @@ describe("DesktopClientSettings", () => { const settings = yield* DesktopClientSettings.DesktopClientSettings; const savedSettings = { ...clientSettings, - onboardingCompletedAt: "2026-09-05T12:00:00.000Z", + fontSmoothing: false, }; yield* settings.set(savedSettings); const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7925fd869799f..fd44f85151c8d 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -163,6 +163,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.filesystemGetMetadata]: AuthFilesystemReadScope, [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, + [WS_METHODS.agentSessionsImportWithProgress]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.assetsPersistChatAttachments]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 11a09f481ac4b..40fe31811cd82 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -1,5 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - The CLI loopback OAuth callback is a Node HTTP boundary. import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as Clock from "effect/Clock"; @@ -26,7 +28,10 @@ import * as HttpRouter from "effect/http/HttpRouter"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; import * as HttpServerResponse from "effect/http/HttpServerResponse"; -import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth"; +import { + buildConnectAuthorizeRequestUrl, + connectAuthorizeRequestCode, +} from "@t3tools/shared/connectAuth"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; @@ -49,16 +54,24 @@ const DEVICE_AUTHORIZATION_SLOW_DOWN_INCREMENT = Duration.seconds(5); const OAUTH_REQUEST_TIMEOUT = Duration.seconds(10); const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; -function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { +function formatLoopbackAuthorizationPrompt(authorizationUrl: string, code: string): string { return [ "Open this URL to authorize T3 Connect:", ` ${authorizationUrl}`, "", + `The browser shows this code: ${boldTerminalText(code)}`, + "", `Press ${boldTerminalText("Enter")} to open it in your browser.`, `No browser on this device? Press ${boldTerminalText("H")} to switch to headless mode.`, ].join("\n"); } +/** "host · shell" label the hosted /connect page shows for this terminal. */ +function describeCliDevice(): string { + const shell = process.env.SHELL ? NodePath.basename(process.env.SHELL) : ""; + return [NodeOS.hostname(), shell].filter((part) => part.length > 0).join(" · "); +} + export type LoopbackAuthorizationResult = | { readonly _tag: "AuthorizationCode"; readonly code: string } | { readonly _tag: "HeadlessRequested" }; @@ -513,13 +526,23 @@ export const make = Effect.gen(function* () { const metadata = yield* cloudCliOAuthConfig; const hostedAppUrl = yield* hostedAppUrlConfig; const { verifier, challenge, state } = yield* makePkceRequest; - const callback = yield* Deferred.make(); + const callback = yield* Deferred.make(); const layerCallbackRoute = HttpRouter.add( "GET", "/callback", Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const url = new URL(request.originalUrl, metadata.redirectUri); + // Cancel on the hosted /connect page returns here with access_denied. + if ( + url.searchParams.get("state") === state && + url.searchParams.get("error") === "access_denied" + ) { + yield* Deferred.fail(callback, new CloudCliAuthorizationDeniedError()); + return HttpServerResponse.text( + "T3 Connect authorization was cancelled. You can close this tab.", + ); + } const code = url.searchParams.get("code"); if (url.searchParams.get("state") !== state || !code) { return HttpServerResponse.text("Invalid T3 Connect authorization callback.", { @@ -549,8 +572,11 @@ export const make = Effect.gen(function* () { state, challenge, loopbackPort: metadata.loopbackPort, + device: describeCliDevice(), }); - yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl)); + yield* Console.log( + formatLoopbackAuthorizationPrompt(authorizationUrl, connectAuthorizeRequestCode(state)), + ); const authorization = yield* waitForLoopbackAuthorization({ authorizationUrl, callback: Deferred.await(callback).pipe( diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 813efbe53ea30..64cdb7ebbd99e 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -260,6 +260,7 @@ export const make = Effect.gen(function* () { threadActiveReorder: true, threadAutoSettleOptOut: true, threadProjectFromScratch: true, + agentSessionImportProgress: true, threadTitleRegeneration: true, commandOutputStreaming: true, threadVisitedTracking: true, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 4549ee8a33c10..0d8cf456b9c87 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -171,6 +171,7 @@ const RPC_AGGREGATES = { [WS_METHODS.filesystemGetMetadata]: "workspace", [WS_METHODS.agentSessionsScan]: "workspace", [WS_METHODS.agentSessionsImport]: "workspace", + [WS_METHODS.agentSessionsImportWithProgress]: "workspace", [WS_METHODS.assetsCreateUrl]: "workspace", [WS_METHODS.assetsPersistChatAttachments]: "orchestration", [WS_METHODS.attachmentsCreateUploadUrl]: "workspace", diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 18c0946144d4a..7af87e874d66f 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -18,6 +18,7 @@ import { import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -281,6 +282,7 @@ const makeImporter = Effect.fn(function* ( run: importer.importRecentAgentThreads({ projectId }), importNativeHistory: importer.importNativeHistory, published, + importer, }; }); @@ -1051,3 +1053,30 @@ it.effect("repeated imports reach linked-worktree sessions behind the scan budge expect(oldest.worktreePath).toBe(worktree); }).pipe(Effect.scoped, Effect.provide(Layer.merge(persistence, NodeServices.layer))), ); + +it.effect("paces progress per import while preserving processed counts", () => + Effect.gen(function* () { + const { importer } = yield* makeImporter( + Array.from({ length: 5 }, (_, index) => candidate(CODEX_MODEL_SELECTION, `paced-${index}`)), + TestClock.adjust(125), + ); + const progress: Array<{ processedCount: number; at: number }> = []; + const report = (processedCount: number) => + Clock.currentTimeMillis.pipe( + Effect.tap((at) => + Effect.sync(() => { + progress.push({ processedCount, at }); + }), + ), + Effect.asVoid, + ); + const result = yield* importer.importRecentAgentThreads({ projectId }, report); + expect(result).toEqual({ importedCount: 5, skippedCount: 0 }); + expect(progress.map(({ processedCount }) => processedCount)).toEqual([1, 3, 5]); + expect(progress.slice(1).map(({ at }, index) => at - progress[index]!.at)).toEqual([250, 250]); + progress.length = 0; + // Already-imported threads count as neither imported nor skipped. + yield* importer.importRecentAgentThreads({ projectId }, report); + expect(progress.map(({ processedCount }) => processedCount)).toEqual([0]); + }).pipe(Effect.scoped, Effect.provide(persistence)), +); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 0f8736986a2df..24e119e50594f 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -24,6 +24,7 @@ import { } from "@t3tools/contracts"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -47,6 +48,8 @@ const isProjectionStoreThreadNotFoundError = Schema.is( ProjectionStore.ProjectionStoreThreadNotFoundError, ); +/** Onboarding import counts are a label; a few updates a second is plenty. */ +const IMPORT_PROGRESS_INTERVAL_MS = 250; const IMPORT_EVENT_PREFIX = "agent-session-import:v2"; const CLAUDE_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -205,8 +208,12 @@ const make = Effect.gen(function* () { const threadCommands = yield* ThreadCommandExecutor.ThreadCommandExecutor; const settingsService = yield* ServerSettings.ServerSettingsService; const ceiling = settingsService.managedPolicy?.limits?.maxRuntimeMode; + /** + * `onProgress` hears the running imported + skipped count at most every 250 ms. + */ const importRecentAgentThreads = Effect.fn("importRecentAgentThreadsV2")(function* ( input: AgentSessionImportInput, + onProgress?: (processedCount: number) => Effect.Effect, ) { const project = yield* projects.getById(input.projectId).pipe( Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), @@ -266,6 +273,16 @@ const make = Effect.gen(function* () { const importedThreadIds = new Set(); let importedCount = 0; let skippedCount = 0; + let lastReportedAt = Number.NEGATIVE_INFINITY; + const reportProgress = + onProgress === undefined + ? Effect.void + : Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + if (now - lastReportedAt < IMPORT_PROGRESS_INTERVAL_MS) return; + lastReportedAt = now; + yield* onProgress(importedCount + skippedCount); + }); yield* Stream.runForEach(outcomes, (outcome) => Effect.gen(function* () { @@ -492,7 +509,7 @@ const make = Effect.gen(function* () { } else if (imported === false) { skippedCount += 1; } - }), + }).pipe(Effect.andThen(reportProgress)), ); return { importedCount, skippedCount } satisfies AgentSessionImportResult; diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index ce75e456158a8..f84c62dd9233f 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -1235,6 +1235,59 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect( + "refreshes a partial scan with a larger record budget and keeps recovered projects", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: '{"type":"summary"}\n'.repeat(1_001) + claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const initial = yield* scanner.scan; + expect(initial.truncated).toBe(true); + expect(initial.candidates).toEqual([]); + const recovered = yield* scanner.scan; + expect(recovered.truncated).not.toBe(true); + expect(recovered.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + const refreshed = yield* scanner.scan; + expect(refreshed.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath }))); + }), + ); + + it.effect("refreshes a partial scan with a larger total byte budget", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const record = claudeSessionLine(workspace); + const contents = " ".repeat(1024 * 1024 - record.length) + record; + for (let index = 0; index < 65; index += 1) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", `${index}.jsonl`), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + } + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + expect((yield* scanner.scan).truncated).toBe(true); + const recovered = yield* scanner.scan; + expect(recovered.truncated).not.toBe(true); + expect(recovered.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath }))); + }), + ); + it.effect("reads a complete transcript record at the exact chunk boundary", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 7a89f9c687de0..2af75457e6069 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -271,18 +271,22 @@ interface MetadataReadBudget { operationsRemaining: number; recordsRemaining: number; truncated: boolean; + readonly multiplier: number; } -function selectMetadataTranscripts(transcripts: ReadonlyArray) { +function selectMetadataTranscripts( + transcripts: ReadonlyArray, + limit = MAX_TRANSCRIPTS_PER_SOURCE, +) { const selected: Array = []; let pending = Array.from( Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(), (entries) => entries.values(), ); - while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) { + while (pending.length > 0 && selected.length < limit) { const nextRound: typeof pending = []; for (const iterator of pending) { - if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break; + if (selected.length === limit) break; const next = iterator.next(); if (next.done) continue; selected.push(next.value); @@ -1141,10 +1145,10 @@ export const make = Effect.gen(function* () { let remaining = ""; let bytesRead = 0; let recordsRead = 0; - const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, size); + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES * budget.multiplier, size); const reserveRecord = () => { if ( - recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || + recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT * budget.multiplier || budget.recordsRemaining === 0 ) { budget.truncated = true; @@ -1663,7 +1667,9 @@ export const make = Effect.gen(function* () { })); }); - const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { + const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* ( + multiplier = 1, + ) { const settings = yield* serverSettings.getSettings.pipe( Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), ); @@ -1767,9 +1773,10 @@ export const make = Effect.gen(function* () { const transcriptCandidates: Array = []; const baseOperationBudget = Math.floor( - MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length), + (MAX_DISCOVERY_OPERATIONS_PER_SOURCE * multiplier) / Math.max(1, homes.length), ); - const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length); + const extraOperationBudgets = + (MAX_DISCOVERY_OPERATIONS_PER_SOURCE * multiplier) % Math.max(1, homes.length); for (const [index, home] of homes.entries()) { const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0); if (operationBudget === 0) { @@ -1791,15 +1798,19 @@ export const make = Effect.gen(function* () { (left, right) => right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath), ); - if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) { + if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE * multiplier) { truncated = true; } - const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates); + const selectedTranscripts = selectMetadataTranscripts( + transcriptCandidates, + MAX_TRANSCRIPTS_PER_SOURCE * multiplier, + ); const metadataBudget: MetadataReadBudget = { - bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE, - operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE, - recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, + bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE * multiplier, + operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE * multiplier, + recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE * multiplier, truncated: false, + multiplier, }; raw.push( ...(yield* groupTranscriptsByCwd( @@ -1817,9 +1828,13 @@ export const make = Effect.gen(function* () { let cachedCandidates: Effect.Success> | null = null; + // Refreshing a partial scan searches farther without removing the normal startup bounds. + // Keep retries finite even for corrupt transcripts or an exceptionally large session archive. + let scanMultiplier = 1; const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { - cachedCandidates = yield* collectCandidates(); + cachedCandidates = yield* collectCandidates(scanMultiplier); const { candidates: raw, truncated } = cachedCandidates; + if (truncated) scanMultiplier = Math.min(scanMultiplier * 2, 16); const merged = new Map< string, @@ -1928,7 +1943,7 @@ export const make = Effect.gen(function* () { // Imports recorded since a budget-truncated collection can free budget for transcripts it cut off. if (cachedCandidates === null || cachedCandidates.metadataTruncated) { - cachedCandidates = yield* collectCandidates(); + cachedCandidates = yield* collectCandidates(scanMultiplier); } const { candidates } = cachedCandidates; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 8489cbbf50012..f4eda7fbf7c8f 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4761,6 +4761,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* isDefault: name === defaultBranch, worktreePath: worktreeMap.get(name) ?? null, ...(upstreamRef ? { upstreamRef } : {}), + ...(lastCommit > 0 ? { lastCommitAt: lastCommit } : {}), }, lastCommit, }); @@ -4780,6 +4781,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* parsedRemoteRef.branchName === defaultBranch, worktreePath: null, ...(parsedRemoteRef ? { remoteName: parsedRemoteRef.remoteName } : {}), + ...(lastCommit > 0 ? { lastCommitAt: lastCommit } : {}), }; remoteBranches.push({ ref: remoteBranch, lastCommit }); } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4d02161583214..d7ad7196929d0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -82,6 +82,7 @@ import { ProviderSetupError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, + type AgentSessionImportProgressEvent, type ServerConfig as ClientServerConfig, type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, @@ -1907,11 +1908,30 @@ export const layerWsRpc = ( listProjects: projectStore.listShells(), latestSequence: applicationEvents.latestApplicationSequence, }); + // Settings › Archive shows how much conversation each thread holds. + // Only this snapshot pays for the count; live shells stay lean. + const messageCountRows = yield* sql<{ threadId: string; messageCount: number }>` + SELECT message.thread_id AS "threadId", COUNT(*) AS "messageCount" + FROM orchestration_v2_projection_messages AS message + INNER JOIN orchestration_v2_projection_threads AS thread + ON thread.thread_id = message.thread_id + WHERE thread.archived_at IS NOT NULL + AND thread.deleted_at IS NULL + AND message.streaming = 0 + AND message.role IN ('user', 'assistant') + GROUP BY message.thread_id + `; + const messageCountByThread = new Map( + messageCountRows.map((row) => [row.threadId, Number(row.messageCount)] as const), + ); return { schemaVersion: threads.schemaVersion, snapshotSequence, projects, - threads: threads.archivedThreads, + threads: threads.archivedThreads.map((thread) => ({ + ...thread, + messageCount: messageCountByThread.get(thread.id) ?? 0, + })), } as const; }).pipe( Effect.flatMap((snapshot) => @@ -3015,6 +3035,22 @@ export const layerWsRpc = ( [WS_METHODS.agentSessionsScan]: () => agentSessionScanner.scan, [WS_METHODS.agentSessionsImport]: (input) => agentSessionImporter.importRecentAgentThreads(input), + [WS_METHODS.agentSessionsImportWithProgress]: (input) => + Stream.callback< + AgentSessionImportProgressEvent, + Effect.Error> + >((queue) => { + const reportProgress = (processedCount: number) => + Queue.offer(queue, { type: "progress", processedCount }).pipe(Effect.asVoid); + return agentSessionImporter.importRecentAgentThreads(input, reportProgress).pipe( + Effect.flatMap((result) => Queue.offer(queue, { type: "complete", result })), + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: () => Queue.end(queue), + }), + Effect.forkScoped, + ); + }), [WS_METHODS.assetsCreateUrl]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* (input) { diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index c474ed0fa6c8f..d0c5c002238e9 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -28,6 +28,7 @@ import { executeConnectedEnvironmentHttpRequest } from "@t3tools/client-runtime/ import { resolveCloudPublicConfig } from "./publicConfig"; import { finishRelayClientInstall, + onRelayClientInstallCancel, reportRelayClientInstallProgress, requestRelayClientInstallConfirmation, } from "./relayClientInstallDialog"; @@ -53,6 +54,19 @@ const relayClientRpcError = (message: string) => (cause: unknown) => cause, }); +const waitForRelayClientInstallCancel = Effect.callback( + (resume) => { + const unsubscribe = onRelayClientInstallCancel(() => { + resume( + Effect.fail( + new CloudEnvironmentLinkError({ message: "Relay client installation was cancelled." }), + ), + ); + }); + return Effect.sync(unsubscribe); + }, +); + const ensureRelayClientAvailable = Effect.fn("linkEnvironment.ensureRelayClientAvailable")( function* ( environmentId: EnvironmentId, @@ -88,6 +102,8 @@ const ensureRelayClientAvailable = Effect.fn("linkEnvironment.ensureRelayClientA .pipe( Stream.runLast, Effect.mapError(relayClientRpcError("Could not install the relay client.")), + // Cancel in the dialog interrupts the stream, and with it the server install. + Effect.raceFirst(waitForRelayClientInstallCancel), Effect.ensuring(Effect.sync(finishRelayClientInstall)), ); if (Option.isNone(installed) || installed.value.type !== "complete") { diff --git a/apps/web/src/cloud/relayClientInstallDialog.ts b/apps/web/src/cloud/relayClientInstallDialog.ts index fa09efee25943..587e20e513f4e 100644 --- a/apps/web/src/cloud/relayClientInstallDialog.ts +++ b/apps/web/src/cloud/relayClientInstallDialog.ts @@ -19,29 +19,34 @@ export class RelayClientInstallConfirmationConflictError extends Schema.TaggedEr } } +export interface RelayClientInstallingState { + readonly status: "installing"; + readonly version: string; + readonly stage: RelayClientInstallProgressStage; + /** Size of the finished download, once the server reports it. */ + readonly downloadedBytes?: number; + /** True once the download matched the pinned sha256. */ + readonly checksumVerified?: boolean; + /** Cancel was pressed; the install stream is being interrupted. */ + readonly cancelling?: boolean; +} + export type RelayClientInstallDialogState = | { readonly status: "idle" } | { readonly status: "confirming"; readonly version: string } - | { - readonly status: "installing"; - readonly version: string; - readonly stage: RelayClientInstallProgressStage; - } + | RelayClientInstallingState | { readonly status: "closing"; readonly view: | { readonly status: "confirming"; readonly version: string } - | { - readonly status: "installing"; - readonly version: string; - readonly stage: RelayClientInstallProgressStage; - }; + | RelayClientInstallingState; }; const idleState: RelayClientInstallDialogState = { status: "idle" }; let state: RelayClientInstallDialogState = idleState; let resolveConfirmation: ((confirmed: boolean) => void) | null = null; const listeners = new Set<() => void>(); +const cancelListeners = new Set<() => void>(); function publish(next: RelayClientInstallDialogState) { state = next; @@ -98,10 +103,33 @@ export function respondToRelayClientInstallConfirmation(confirmed: boolean): voi } export function reportRelayClientInstallProgress(event: RelayClientInstallProgressEvent): void { - if (state.status !== "installing" || event.type !== "progress") { + if (state.status !== "installing" || event.type !== "progress" || state.cancelling) { + return; + } + publish({ + ...state, + stage: event.stage, + ...(event.downloadedBytes !== undefined ? { downloadedBytes: event.downloadedBytes } : {}), + ...(event.checksumVerified ? { checksumVerified: true } : {}), + }); +} + +// Interrupt the install stream to release server temp state and locks; activation cannot be cancelled. +export function cancelRelayClientInstall(): void { + if (state.status !== "installing" || state.cancelling) { return; } - publish({ ...state, stage: event.stage }); + publish({ ...state, cancelling: true }); + for (const listener of cancelListeners) { + listener(); + } +} + +export function onRelayClientInstallCancel(listener: () => void): () => void { + cancelListeners.add(listener); + return () => { + cancelListeners.delete(listener); + }; } export function finishRelayClientInstall(): void { @@ -123,4 +151,5 @@ export function resetRelayClientInstallDialogForTests(): void { resolveConfirmation = null; publish(idleState); listeners.clear(); + cancelListeners.clear(); } diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx index 49f57f967518c..aff192a1cdf68 100644 --- a/apps/web/src/components/AppRouteModal.tsx +++ b/apps/web/src/components/AppRouteModal.tsx @@ -3,13 +3,23 @@ import { useRef, useState, type CSSProperties, type ReactNode } from "react"; import { useNativeTitlebarHeight } from "../lib/windowControlsOverlay"; import { useCloseAppModal } from "./sidebar/mainAppLocation"; +import { useThemeEditorStore } from "./settings/themeEditorStore"; import { Button } from "./ui/button"; import { Dialog, DialogClose, DialogPopup } from "./ui/dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; /** Settings and Usage own their shortcuts inside `[data-app-modal]` and return to the opener. */ -export function AppRouteModal({ label, children }: { label: string; children: ReactNode }) { +export function AppRouteModal({ + label, + children, + variant = "app", +}: { + label: string; + children: ReactNode; + variant?: "app" | "app-panel"; +}) { const closeAppModal = useCloseAppModal(); + const themeEditorOpen = useThemeEditorStore((store) => store.session !== null); const [open, setOpen] = useState(true); // Text selection that ends on the backdrop is not a dismissal. const backdropPressRef = useRef(false); @@ -24,6 +34,8 @@ export function AppRouteModal({ label, children }: { label: string; children: Re return ( { @@ -49,7 +61,7 @@ export function AppRouteModal({ label, children }: { label: string; children: Re }} > string) | undefined; getItemGroup?: ((value: string) => string) | undefined; children: ReactNode; + /** Compose the existing searchable list into a larger context menu. */ + renderPopup?: ((content: ReactNode) => ReactNode) | undefined; }) { const highlightedValueRef = useRef(null); const startFromOriginSwitchId = useId(); @@ -89,8 +94,112 @@ export function BranchPicker({ }; const branchScrollRef = useLegendListRef(branchListRef, updateBranchListScrollFades); + const content = ( + <> + { + highlightedValueRef.current = null; + onQueryChange(event.target.value); + void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); + }} + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing || event.keyCode === 229) + return; + const highlightedValue = highlightedValueRef.current; + const selectedValue = + highlightedValue !== null && filteredItems.includes(highlightedValue) + ? highlightedValue + : renderPopup + ? filteredItems[0] + : undefined; + if (selectedValue === undefined && !renderPopup) return; + (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + event.preventDefault(); + event.stopPropagation(); + highlightedValueRef.current = null; + if (selectedValue !== undefined) onSelectItem(selectedValue); + }} + /> +
+ No refs found. +
+ + + ref={branchScrollRef} + data={filteredItems} + initialScrollOffset={0} + keyExtractor={(item) => item} + {...(getItemType ? { getItemType } : {})} + renderItem={({ item, index }) => { + const group = getItemGroup?.(item); + const previous = index > 0 ? getItemGroup?.(filteredItems[index - 1]!) : null; + return ( + <> + {group && group !== previous ? ( +
{group}
+ ) : null} + {renderItem(item, index)} + + ); + }} + extraData={renderItem} + ItemSeparatorComponent={BranchPickerSeparator} + estimatedItemSize={33} + drawDistance={336} + onScroll={updateBranchListScrollFades} + onLayout={updateBranchListScrollFades} + onReady={updateBranchListScrollFades} + onEndReached={fetchNextBranchPage} + onEndReachedThreshold={0.5} + className={cn( + "min-h-0 max-h-56 scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain px-0.5 pb-0.5", + getVirtualizedScrollFadeClassName({ + top: showTopBranchScrollFade, + bottom: showBottomBranchScrollFade, + }), + )} + /> +
+
+ {originControl ? ( + + + originControl.onCheckedChange(Boolean(checked))} + /> + Start from remote + fetches first + + } + /> + + Uses the latest commit of the base branch’s tracked remote branch, even if its name + differs. Otherwise uses the same-named branch on origin. Uses your local branch if + that remote branch is missing. + + + ) : null} + {notice ? {notice} : null} + {statusText ? {statusText} : null} +
+ + ); + return ( {children} - - { - onQueryChange(event.target.value); - void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); - }} - onKeyDown={(event) => { - if (event.key !== "Enter" || event.nativeEvent.isComposing || event.keyCode === 229) - return; - const highlightedValue = highlightedValueRef.current; - if (highlightedValue === null || !filteredItems.includes(highlightedValue)) return; - ( - event as typeof event & { preventBaseUIHandler?: () => void } - ).preventBaseUIHandler?.(); - event.preventDefault(); - event.stopPropagation(); - highlightedValueRef.current = null; - onSelectItem(highlightedValue); - }} - /> -
- No refs found. -
- - - ref={branchScrollRef} - data={filteredItems} - initialScrollOffset={0} - keyExtractor={(item) => item} - {...(getItemType ? { getItemType } : {})} - renderItem={({ item, index }) => { - const group = getItemGroup?.(item); - const previous = index > 0 ? getItemGroup?.(filteredItems[index - 1]!) : null; - return ( - <> - {group && group !== previous ? ( -
- {group} -
- ) : null} - {renderItem(item, index)} - - ); - }} - extraData={renderItem} - ItemSeparatorComponent={BranchPickerSeparator} - estimatedItemSize={33} - drawDistance={336} - onScroll={updateBranchListScrollFades} - onLayout={updateBranchListScrollFades} - onReady={updateBranchListScrollFades} - onEndReached={fetchNextBranchPage} - onEndReachedThreshold={0.5} - className={cn( - "min-h-0 max-h-56 scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain px-0.5 pb-0.5", - getVirtualizedScrollFadeClassName({ - top: showTopBranchScrollFade, - bottom: showBottomBranchScrollFade, - }), - )} - /> -
-
- {originControl ? ( - - - originControl.onCheckedChange(Boolean(checked))} - /> - Start from remote - fetches first - - } - /> - - Uses the latest commit of the base branch’s tracked remote branch, even if its name - differs. Otherwise uses the same-named branch on origin. Uses your local branch if - that remote branch is missing. - - - ) : null} - {notice ? {notice} : null} - {statusText ? {statusText} : null} -
-
+ {renderPopup ? ( + renderPopup(content) + ) : ( + + {content} + + )}
); } +/** A section label ("Local", "Remote", "Pull requests") drawn above a group's first row. */ +export function BranchPickerGroupLabel({ children }: { children: ReactNode }) { + return
{children}
; +} + export function BranchPickerRefItem({ branch: refName, projectCwd: activeProjectCwd, index, value, + selected = false, + groupLabel = null, disabled, onClick, onContextMenu, @@ -224,6 +250,10 @@ export function BranchPickerRefItem({ projectCwd: string | null; index: number; value?: string; + /** The ref the thread is on, marked with a check. */ + selected?: boolean; + /** Set on the first row of a group to draw its section label above it. */ + groupLabel?: string | null; disabled?: boolean; onClick: ComponentProps["onClick"]; onContextMenu?: ComponentProps["onContextMenu"]; @@ -231,30 +261,37 @@ export function BranchPickerRefItem({ const itemValue = refName.name; const hasSecondaryWorktree = refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd; - const badge = hasSecondaryWorktree ? "worktree" : refName.isDefault ? "default" : null; - const Icon = refName.isRemote ? CloudIcon : hasSecondaryWorktree ? FolderGit2Icon : GitBranchIcon; + const badge = refName.current ? "current" : hasSecondaryWorktree ? "worktree" : null; + const relative = useRelativeTimeState( + refName.lastCommitAt === undefined ? null : new Date(refName.lastCommitAt * 1000).toISOString(), + ); + const age = + relative.status === "relative" ? (relative.suffix === null ? "now" : relative.value) : null; + const RefIcon = refName.isRemote ? CloudIcon : GitBranchIcon; return ( - -
- - - {badge ? ( - - {badge} - - ) : null} - {refName.current ? ( - - ) : null} -
-
+
+ {groupLabel ? {groupLabel} : null} + +
+ + + {badge ? {badge} : null} + {refName.isDefault ? default : null} + {selected ? ( + + ) : age ? ( + {age} + ) : null} +
+
+
); } diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index ea875128dc5c8..6b4d5bc58a14b 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -123,6 +123,8 @@ export function resolveLockedWorkspaceLabel( export interface PreviousWorktreeSeed { branch: string | null; worktreePath: string; + /** When a thread last touched the worktree, for the picker's "2h ago". */ + updatedAt: string; } export function resolvePreviousWorktreeSeed(input: { @@ -134,7 +136,12 @@ export function resolvePreviousWorktreeSeed(input: { }>; currentWorktreePath: string | null; }): PreviousWorktreeSeed | null { - let latest: { branch: string | null; worktreePath: string; updatedAt: number } | null = null; + let latest: { + branch: string | null; + worktreePath: string; + updatedAt: number; + updatedAtIso: string; + } | null = null; for (const thread of input.threads) { if ( !thread.worktreePath || @@ -152,10 +159,13 @@ export function resolvePreviousWorktreeSeed(input: { branch: thread.branch, worktreePath: thread.worktreePath, updatedAt, + updatedAtIso: thread.updatedAt, }; } } - return latest === null ? null : { branch: latest.branch, worktreePath: latest.worktreePath }; + return latest === null + ? null + : { branch: latest.branch, worktreePath: latest.worktreePath, updatedAt: latest.updatedAtIso }; } export function resolvePreviousWorktreeLabel(seed: PreviousWorktreeSeed): string { @@ -392,3 +402,29 @@ export function shouldIncludeBranchPickerItem(input: { lowerItemValue.includes(sanitizedQuery) ); } + +/** Ref names for the picker: local branches first, then remote refs, each in server order. */ +export function orderRefNamesByGroup(refs: ReadonlyArray): string[] { + return [ + ...refs.filter((ref) => !ref.isRemote).map((ref) => ref.name), + ...refs.filter((ref) => ref.isRemote).map((ref) => ref.name), + ]; +} + +/** + * The section label each group's first visible row carries, keyed by item value. Items whose + * group is null (create, checkout-by-reference) get no label and do not start a group. + */ +export function groupLabelsForPickerItems( + items: ReadonlyArray, + groupOf: (item: string) => string | null, +): ReadonlyMap { + const labels = new Map(); + let previousGroup: string | null = null; + for (const item of items) { + const group = groupOf(item); + if (group !== null && group !== previousGroup) labels.set(item, group); + previousGroup = group; + } + return labels; +} diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5dcce3c805b7d..5dde9fb10e89c 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -10,6 +10,7 @@ import { FolderGitIcon, FolderIcon, GitBranchIcon, + HistoryIcon, PlusIcon, ScaleIcon, } from "lucide-react"; @@ -38,16 +39,21 @@ import { resolveLockedWorkspaceLabel, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, - shouldShowEnvironmentIndicator, + type PreviousWorktreeSeed, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector, type BranchToolbarBranchSelectorHandle, } from "./BranchToolbarBranchSelector"; -import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { openAddProjectToMachine } from "./AddProjectToMachineDialog"; import { type AddProjectTarget, useAddProjectTargets } from "../hooks/useAddProjectTargets"; import { ComposerContextLabel } from "./ComposerContextLabel"; +import { StartTruncatedPath } from "./StartTruncatedPath"; +import { Badge } from "./ui/badge"; +import { useRelativeTimeState } from "../hooks/useRelativeTimeState"; +import { useConnectedEnvironmentIds } from "../state/environments"; +import { useEnvironmentQuery } from "../state/query"; +import { vcsEnvironment } from "../state/vcs"; import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { @@ -69,8 +75,8 @@ import { MenuSubTrigger, MenuTrigger, } from "./ui/menu"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerSurface } from "./chat/ComposerSurface"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; import { @@ -121,7 +127,17 @@ interface BranchToolbarProps { } interface WorkspaceMenuProps { - workspaceRoot: string | undefined; + branch?: { + label: string; + disabled: boolean; + open: boolean; + focusSearch: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; + onContextMenu: (event: ReactMouseEvent) => void; + }; + /** List Run on here because no machine chip is shown, as on local threads. */ + showRunOn: boolean; forceNewWorktree: boolean; autoEnvironmentLabel?: string | undefined; onAutoEnvironment?: (() => void) | undefined; @@ -134,16 +150,66 @@ interface WorkspaceMenuProps { effectiveEnvMode: EnvMode; activeWorktreePath: string | null; onEnvModeChange: (mode: EnvMode) => void; - previousWorktreeLabel: string | null; - previousWorktreeBranch: string | null; + workspaceRoot: string; + previousWorktree: PreviousWorktreeSeed | null; onUsePreviousWorktree: () => void; addTargets: readonly AddProjectTarget[]; onAddToMachine: (environmentId: EnvironmentId) => void; } -/** The workspace trigger stays separate from branch selection; only drafts expose workspace choices. */ +type MachineMenuProps = Pick< + WorkspaceMenuProps, + | "autoEnvironmentLabel" + | "onAutoEnvironment" + | "envLocked" + | "environmentId" + | "availableEnvironments" + | "showEnvironmentPicker" + | "onEnvironmentChange" + | "addTargets" + | "onAddToMachine" +> & { environmentUnavailable: boolean }; + +/** "2h ago · 3 files changed" for the previous worktree row; the count is read while open. */ +function PreviousWorktreeMeta({ + environmentId, + worktree, +}: { + environmentId: EnvironmentId; + worktree: PreviousWorktreeSeed; +}) { + const status = useEnvironmentQuery( + vcsEnvironment.status({ environmentId, input: { cwd: worktree.worktreePath } }), + ); + const changedFiles = status.data?.isRepo ? status.data.workingTree.files.length : null; + const relative = useRelativeTimeState(worktree.updatedAt); + const age = + relative.status === "relative" + ? relative.suffix + ? `${relative.value} ${relative.suffix}` + : relative.value + : ""; + const parts = [ + age, + changedFiles === null + ? null + : changedFiles === 0 + ? "no changes" + : `${changedFiles} ${changedFiles === 1 ? "file" : "files"} changed`, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? ( + + {parts.map((part) => ( + {part} + ))} + + ) : null; +} + +/** The composer's workspace and branch chip. Machines have their own chip, `MachineMenu`. */ const WorkspaceMenu = memo(function WorkspaceMenu({ - workspaceRoot, + branch, + showRunOn, forceNewWorktree, autoEnvironmentLabel, onAutoEnvironment, @@ -156,13 +222,18 @@ const WorkspaceMenu = memo(function WorkspaceMenu({ effectiveEnvMode, activeWorktreePath, onEnvModeChange, - previousWorktreeLabel, - previousWorktreeBranch, + workspaceRoot, + previousWorktree, onUsePreviousWorktree, addTargets, onAddToMachine, }: WorkspaceMenuProps) { const composerFloatingLayerProps = useComposerMenuProps(); + const connectedEnvironmentIds = useConnectedEnvironmentIds(); + const [workspaceOpen, setWorkspaceOpen] = useState(false); + const popupRef = useRef(null); + const open = branch?.open ?? workspaceOpen; + const setOpen = branch?.onOpenChange ?? setWorkspaceOpen; const inWorktree = forceNewWorktree || effectiveEnvMode === "worktree" || activeWorktreePath; const WorkspaceIcon = inWorktree ? FolderGit2Icon : GitBranchIcon; const workspaceLabel = forceNewWorktree @@ -172,10 +243,10 @@ const WorkspaceMenu = memo(function WorkspaceMenu({ : effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); - const canPickEnvironment = showEnvironmentPicker && !envLocked; + const canPickEnvironment = (showEnvironmentPicker && !envLocked) || addTargets.length > 0; const icon = ; - if (envModeLocked && !canPickEnvironment) { + if (envModeLocked && !(showRunOn && canPickEnvironment) && !branch) { return ( + { + if (nextOpen) { + popupRef.current + ?.querySelector( + branch?.focusSearch + ? 'input[role="combobox"]' + : '[data-composer-workspace] [aria-checked="true"]', + ) + ?.focus(); + } + }} + modal={false} + > } - className={envModeLocked ? "ps-2 pe-0.5" : "ps-2"} - aria-label={`Workspace: ${workspaceLabel}`} + className={branch ? "min-w-0 max-w-full" : envModeLocked ? "ps-2 pe-0.5" : "ps-2"} + aria-label={ + branch ? `Branch and workspace: ${branch.label}` : `Workspace: ${workspaceLabel}` + } + onContextMenu={branch?.onContextMenu} data-composer-context-control data-composer-shortcut={[ - canPickEnvironment ? "composer.host" : "", + showRunOn && canPickEnvironment ? "composer.host" : "", !envModeLocked ? "composer.workspace" : "", ].join(" ")} /> } > {icon} - {envModeLocked ? null : ( + {envModeLocked && !branch ? null : ( <> - {workspaceLabel} + + {branch?.label ?? workspaceLabel} + )} - {workspaceLabel} + + + {branch ? {branch.label} : null} + {workspaceLabel} + + - {availableEnvironments && - ((showEnvironmentPicker && onEnvironmentChange) || addTargets.length > 0) ? ( - <> - - Run on - - value === "auto" - ? onAutoEnvironment?.() - : onEnvironmentChange?.(value as EnvironmentId) - } - > - {onAutoEnvironment && ( - { - if (autoEnvironmentLabel) onAutoEnvironment?.(); - }} - > - - - - )} - {availableEnvironments.map((env) => ( - - - - {env.label} - - - ))} - - {addTargets.length > 0 ? ( - - - - - Add to another machine + {showRunOn && availableEnvironments && availableEnvironments.length > 0 ? ( + + Run on + + value === "auto" + ? onAutoEnvironment?.() + : onEnvironmentChange?.(value as EnvironmentId) + } + > + {availableEnvironments.map((env) => ( + + + + {env.label} + {env.isPrimary ? null : connectedEnvironmentIds.includes(env.environmentId) ? ( + Online + ) : ( + Offline + )} + + + ))} + {onAutoEnvironment && ( + { + if (autoEnvironmentLabel) onAutoEnvironment?.(); + }} + > + + - - {addTargets.map((target) => ( - onAddToMachine(target.environmentId)} - > - - - {target.label} - - - ))} - - - ) : null} - - - + + + )} + + {addTargets.length > 0 ? ( + + + + + Add to another machine + + + + {addTargets.map((target) => ( + onAddToMachine(target.environmentId)} + > + + + {target.label} + + + ))} + + + ) : null} + ) : null} - + Workspace { - if (value === "previous-worktree") { - onUsePreviousWorktree(); - return; - } - onEnvModeChange(value as EnvMode); - }} + onValueChange={(value) => onEnvModeChange(value as EnvMode)} > {activeWorktreePath ? ( - + ) : ( - + )} - - {workspaceRoot ? ( - - {workspaceRoot} - - ) : null} + {resolveCurrentWorkspaceLabel(activeWorktreePath)} + - - Worktree - + + {resolveEnvModeLabel("worktree")} + {forceNewWorktree ? "Each model starts in its own worktree." - : "Start in a new worktree"} + : "Starts in a fresh worktree."} - {previousWorktreeLabel ? ( - <> - Previous worktree - - - - - ) : null} + {previousWorktree ? ( + + Previous worktree + + + + + {previousWorktree.branch ?? previousWorktree.worktreePath} + + {open ? ( + + ) : null} + + + + ) : null} + {branch ? ( + <> + + + + {inWorktree && !activeWorktreePath ? "Base ref" : "Branch"} + + +
{ + if (event.key !== "Escape" && event.key !== "Tab") event.stopPropagation(); + }} + > + {branch.children} +
+ + ) : null} +
+
+ ); +}); + +/** The composer's machine chip, shown on remote threads: only the machines to run on. */ +const MachineMenu = memo(function MachineMenu({ + autoEnvironmentLabel, + onAutoEnvironment, + envLocked, + environmentId, + availableEnvironments, + showEnvironmentPicker, + onEnvironmentChange, + environmentUnavailable, + addTargets, + onAddToMachine, +}: MachineMenuProps) { + const composerFloatingLayerProps = useComposerMenuProps(); + const connectedEnvironmentIds = useConnectedEnvironmentIds(); + const popupRef = useRef(null); + const activeEnvironment = availableEnvironments?.find( + (env) => env.environmentId === environmentId, + ); + const environmentLabel = autoEnvironmentLabel ?? activeEnvironment?.label ?? environmentId; + const canPickEnvironment = (showEnvironmentPicker && !envLocked) || addTargets.length > 0; + const icon = autoEnvironmentLabel ? ( + + ) : ( + + ); + const tooltip = ( + + {environmentLabel} + {environmentUnavailable ? " (offline)" : ""} + + ); + + if (!canPickEnvironment) { + return ( + + + } + > + {icon} + {environmentLabel} + + {tooltip} + + ); + } + + return ( + { + if (nextOpen) { + popupRef.current?.querySelector('[aria-checked="true"]')?.focus(); + } + }} + modal={false} + > + + } + className={cn( + "min-w-0 max-w-full", + environmentUnavailable && "text-warning-foreground", + )} + aria-label={`Run on: ${environmentLabel}`} + data-composer-context-control + data-composer-shortcut="composer.host" + /> + } + > + {icon} + {environmentLabel} + + + {tooltip} + + + + Run on + + value === "auto" + ? onAutoEnvironment?.() + : onEnvironmentChange?.(value as EnvironmentId) + } + > + {availableEnvironments?.map((env) => ( + + + + {env.label} + {env.isPrimary ? null : connectedEnvironmentIds.includes(env.environmentId) ? ( + Online + ) : ( + Offline + )} + + + ))} + {onAutoEnvironment && ( + { + if (autoEnvironmentLabel) onAutoEnvironment?.(); + }} + > + + + + )} + + {addTargets.length > 0 ? ( + + + + + Add to another machine + + + + {addTargets.map((target) => ( + onAddToMachine(target.environmentId)} + > + + + {target.label} + + + ))} + + + ) : null} + ); @@ -929,9 +1239,6 @@ export const BranchToolbar = memo(function BranchToolbar({ currentWorktreePath: activeWorktreePath, }) : null; - const previousWorktreeLabel = previousWorktreeSeed - ? resolvePreviousWorktreeLabel(previousWorktreeSeed) - : null; const onUsePreviousWorktree = () => { if (!previousWorktreeSeed) return; setDraftThreadContext(draftId ?? threadRef, { @@ -969,11 +1276,6 @@ export const BranchToolbar = memo(function BranchToolbar({ const showEnvironmentPicker = hasMultipleEnvironments && onEnvironmentChange !== undefined; const activeEnvironmentOption = availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; - // Started threads get no handler but still show which machine they run on. - const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ - activeEnvironment: activeEnvironmentOption, - canPickEnvironment: hasMultipleEnvironments, - }); const [attachStrip, stripCompaction] = useStripCompaction(); if (!activeProject) return null; @@ -998,7 +1300,9 @@ export const BranchToolbar = memo(function BranchToolbar({ effectiveEnvMode={effectiveEnvMode} activeWorktreePath={activeWorktreePath} onEnvModeChange={onEnvModeChange} - previousWorktreeLabel={previousWorktreeLabel} + previousWorktreeLabel={ + previousWorktreeSeed ? resolvePreviousWorktreeLabel(previousWorktreeSeed) : null + } previousWorktreeBranch={previousWorktreeSeed?.branch ?? null} onUsePreviousWorktree={onUsePreviousWorktree} addTargets={addTargets} @@ -1029,33 +1333,50 @@ export const BranchToolbar = memo(function BranchToolbar({ ); } - const workspaceMenu = ( - + ) : null; + // Without Git the workspace chip stands alone, so the machine chip leads it here. + const workspaceMenu = ( + <> + {machineMenu} + + ); - // Local threads choose their machine from the workspace menu. - const showMachineChip = - availableEnvironments !== undefined && - showEnvironmentIndicator && - activeEnvironmentOption?.isPrimary === false; return ( - {showMachineChip ? ( - 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})} - /> - ) : null} - {!showGitControls && !showMachineChip ? workspaceMenu : null} + {!showGitControls ? workspaceMenu : null} + {showGitControls ? machineMenu : null} {showGitControls ? ( ( + + )} environmentId={environmentId} threadId={threadId} {...(draftId ? { draftId } : {})} diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 35be370580a62..6cdb5c55a8554 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,6 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; +import { capitalizeTerm } from "@t3tools/client-runtime/source-control-clients"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted, @@ -10,10 +11,12 @@ import { AuthSourceControlWriteScope, type ContextMenuItem, type EnvironmentId, + type ProjectId, + type PullRequestListEntry, type VcsRef, type ThreadId, } from "@t3tools/contracts"; -import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; +import { ChevronDownIcon, ExternalLinkIcon, GitBranchIcon } from "lucide-react"; import { useEffect, useEffectEvent, @@ -30,12 +33,14 @@ import { import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { readLocalApi } from "../localApi"; +import { useOpenPrLink } from "../lib/openPullRequestLink"; import { usePaginatedBranches } from "../state/queries"; import { useProjectClone } from "../state/projectClones"; import { readThreadShell, useProject, useQuietThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { threadEnvironment } from "../state/threads"; +import { pullRequestEnvironment } from "../state/pullRequests"; import { useAtomCommand } from "../state/use-atom-command"; import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; @@ -51,6 +56,8 @@ import { deriveLocalBranchNameFromRemoteRef, isBranchHoldCurrent, isWorktreeChangeBlocked, + groupLabelsForPickerItems, + orderRefNamesByGroup, resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, @@ -68,7 +75,7 @@ import { ComboboxItem, ComboboxTrigger } from "./ui/combobox"; import { ComposerControl } from "./chat/ComposerControl"; import { MiddleTruncate } from "./ui/middle-truncate"; import { Skeleton } from "./ui/skeleton"; -import { BranchPicker, BranchPickerRefItem } from "./BranchPicker"; +import { BranchPicker, BranchPickerGroupLabel, BranchPickerRefItem } from "./BranchPicker"; import { stackedThreadToast, toastManager } from "./ui/toast"; export interface BranchToolbarBranchSelectorHandle { @@ -80,12 +87,15 @@ interface BranchToolbarBranchSelectorProps { ref?: Ref; className?: string; displayMode?: "toolbar" | "panel"; - leading?: ReactNode; - /** Composer only: one chip for the branch, workspace mode, and machine. */ + /** Composer only: one chip for the branch and workspace mode. */ renderWorkspaceMenu?: (branch: { label: string; - onChooseRef: () => void; disabled: boolean; + open: boolean; + focusSearch: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; + onContextMenu: (event: ReactMouseEvent) => void; }) => ReactNode; environmentId: EnvironmentId; threadId: ThreadId; @@ -112,13 +122,80 @@ function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +// The picker's list keys on these arrays, so they stay cached across the +// queries and transitions the selector calls after computing them. +function buildBranchPickerItems({ + refs, + pullRequests, + projectId, + newRefName, + createBranchItemValue, + checkoutPullRequestItemValue, + normalizedQuery, + changeRequestGroupLabel, +}: { + refs: ReadonlyArray; + pullRequests: ReadonlyArray | undefined; + projectId: ProjectId | null; + newRefName: string; + createBranchItemValue: string | null; + checkoutPullRequestItemValue: string | null; + normalizedQuery: string; + changeRequestGroupLabel: string; +}) { + const branchByName = new Map(refs.map((refName) => [refName.name, refName] as const)); + const openPullRequests = (pullRequests ?? []) + .filter((entry) => entry.projectId === projectId) + .slice(0, 5); + const pullRequestByItemValue = new Map( + openPullRequests.map((entry) => [`__pull_request__:${entry.number}`, entry] as const), + ); + const branchPickerItems = [ + ...(checkoutPullRequestItemValue ? [checkoutPullRequestItemValue] : []), + ...orderRefNamesByGroup(refs), + ...pullRequestByItemValue.keys(), + ...(createBranchItemValue && !branchByName.has(newRefName) ? [createBranchItemValue] : []), + ]; + const filteredBranchPickerItems = + normalizedQuery.length === 0 + ? branchPickerItems + : branchPickerItems.filter((itemValue) => { + const pullRequest = pullRequestByItemValue.get(itemValue); + if (pullRequest) { + return ( + pullRequest.title.toLowerCase().includes(normalizedQuery) || + pullRequest.headBranch.toLowerCase().includes(normalizedQuery) || + `#${pullRequest.number}`.includes(normalizedQuery) + ); + } + return shouldIncludeBranchPickerItem({ + itemValue, + normalizedQuery, + createBranchItemValue, + checkoutPullRequestItemValue, + }); + }); + const pickerGroupLabels = groupLabelsForPickerItems(filteredBranchPickerItems, (itemValue) => { + if (pullRequestByItemValue.has(itemValue)) return changeRequestGroupLabel; + const ref = branchByName.get(itemValue); + if (!ref) return null; + return ref.isRemote ? "Remote" : "Local"; + }); + return { + branchByName, + pullRequestByItemValue, + branchPickerItems, + filteredBranchPickerItems, + pickerGroupLabels, + }; +} + export function BranchToolbarBranchSelector({ forceNewWorktree = false, ref, className, displayMode = "toolbar", renderWorkspaceMenu, - leading, environmentId, threadId, draftId, @@ -263,6 +340,7 @@ export function BranchToolbarBranchSelector({ }; const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); + const [focusBranchSearch, setFocusBranchSearch] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const branchStatusQuery = useEnvironmentQuery( @@ -288,11 +366,6 @@ export function BranchToolbarBranchSelector({ query: sanitizeNewRefName(trimmedBranchQuery), }); const refs = branchRefState.refs; - const branchByName = new Map(refs.map((refName) => [refName.name, refName] as const)); - const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); - const prReference = parsePullRequestReference(trimmedBranchQuery); - const isSelectingWorktreeBase = - effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; const isWorktreeChangeBlockedForRef = (refName: VcsRef) => activeProjectCwd !== null && isWorktreeChangeBlocked({ @@ -307,34 +380,6 @@ export function BranchToolbarBranchSelector({ refName, }).nextWorktreePath, }); - const checkoutPullRequestItemValue = - canChangeThreadBranch && prReference && onCheckoutPullRequestRequest - ? `__checkout_pull_request__:${prReference}` - : null; - const canCreateBranch = - canChangeThreadBranch && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; - const newRefName = sanitizeNewRefName(trimmedBranchQuery); - const hasExactBranchMatch = branchByName.has(newRefName); - const createBranchItemValue = canCreateBranch - ? `__create_new_branch__:${trimmedBranchQuery}` - : null; - const pickerCreateBranchItemValue = hasExactBranchMatch ? null : createBranchItemValue; - const branchPickerItems = [ - ...(checkoutPullRequestItemValue ? [checkoutPullRequestItemValue] : []), - ...refs.map((refName) => refName.name), - ...(pickerCreateBranchItemValue ? [pickerCreateBranchItemValue] : []), - ]; - const filteredBranchPickerItems = - normalizedBranchQuery.length === 0 - ? branchPickerItems - : branchPickerItems.filter((itemValue) => - shouldIncludeBranchPickerItem({ - itemValue, - normalizedQuery: normalizedBranchQuery, - createBranchItemValue: pickerCreateBranchItemValue, - checkoutPullRequestItemValue, - }), - ); const hasNextPage = branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; const isFetchingNextPage = branchRefState.isFetchingNextPage; @@ -360,6 +405,46 @@ export function BranchToolbarBranchSelector({ cwd: branchCwd, }); if (branchHold !== null && !branchHoldCurrent) setBranchHold(null); + const normalizedBranchQuery = trimmedBranchQuery.toLowerCase(); + const prReference = parsePullRequestReference(trimmedBranchQuery); + const isSelectingWorktreeBase = + effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; + const checkoutPullRequestItemValue = + canChangeThreadBranch && prReference && onCheckoutPullRequestRequest + ? `__checkout_pull_request__:${prReference}` + : null; + const canCreateBranch = + canChangeThreadBranch && !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; + const newRefName = sanitizeNewRefName(trimmedBranchQuery); + const createBranchItemValue = canCreateBranch + ? `__create_new_branch__:${trimmedBranchQuery}` + : null; + // Open change requests in this project, newest first, to start the thread from one. Read only + // while the picker is open; picking one runs the same prepare flow as a pasted reference. + const pullRequestListQuery = useEnvironmentQuery( + isBranchMenuOpen && canChangeThreadBranch && onCheckoutPullRequestRequest && activeProject + ? pullRequestEnvironment.list({ + environmentId, + input: { state: "open", projectId: activeProject.id, limit: 5 }, + }) + : null, + ); + const { + branchByName, + pullRequestByItemValue, + branchPickerItems, + filteredBranchPickerItems, + pickerGroupLabels, + } = buildBranchPickerItems({ + refs, + pullRequests: pullRequestListQuery.data?.entries, + projectId: activeProject?.id ?? null, + newRefName, + createBranchItemValue, + checkoutPullRequestItemValue, + normalizedQuery: normalizedBranchQuery, + changeRequestGroupLabel: capitalizeTerm(sourceControlPresentation.terminology.plural), + }); const [resolvedActiveBranch, setOptimisticBranch] = useOptimistic( branchHoldCurrent ? branchHold.branch : canonicalActiveBranch, (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, @@ -670,6 +755,7 @@ export function BranchToolbarBranchSelector({ }); } if (!open) { + setFocusBranchSearch(false); setBranchQuery(""); } }; @@ -679,6 +765,7 @@ export function BranchToolbarBranchSelector({ () => ({ open: () => { if (isInitialBranchesLoadPending || isBranchActionPending) return; + setFocusBranchSearch(true); handleOpenChange(true); }, }), @@ -715,13 +802,24 @@ export function BranchToolbarBranchSelector({ threadBranch: branchPrBranch, gitStatus: branchStatusQuery.data, }); + const openPrLink = useOpenPrLink(threadRef); function selectPickerItem(itemValue: string) { + if (isInitialBranchesLoadPending || isBranchActionPending) return; if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) { latestSelectionIdRef.current++; handleOpenChange(false); onComposerFocusRequest?.(); onCheckoutPullRequestRequest(prReference); + } else if ( + canChangeThreadBranch && + pullRequestByItemValue.has(itemValue) && + onCheckoutPullRequestRequest + ) { + const pullRequest = pullRequestByItemValue.get(itemValue)!; + handleOpenChange(false); + onComposerFocusRequest?.(); + onCheckoutPullRequestRequest(pullRequest.url); } else if (itemValue === createBranchItemValue) { createRef(trimmedBranchQuery); } else { @@ -766,6 +864,46 @@ export function BranchToolbarBranchSelector({ ); } + const pullRequest = pullRequestByItemValue.get(itemValue); + if (pullRequest) { + const groupLabel = pickerGroupLabels.get(itemValue); + return ( +
+ {groupLabel ? {groupLabel} : null} + selectPickerItem(itemValue)} + > +
+ + + #{pullRequest.number} {pullRequest.title} + + event.stopPropagation()} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + openPrLink(event, pullRequest.url); + }} + > + + +
+
+
+ ); + } + const refName = branchByName.get(itemValue); if (!refName) return null; @@ -775,6 +913,8 @@ export function BranchToolbarBranchSelector({ projectCwd={activeProjectCwd} index={index} value={itemValue} + selected={itemValue === resolvedActiveBranch} + groupLabel={pickerGroupLabels.get(itemValue) ?? null} disabled={ !canUpdateThreadBranch || (!canWriteSourceControl && @@ -819,22 +959,29 @@ export function BranchToolbarBranchSelector({ ? "checkout-pull-request" : item === createBranchItemValue ? "create-branch" - : "branch" - } - getItemGroup={(item) => - item === checkoutPullRequestItemValue - ? "Pull requests" - : item === createBranchItemValue - ? "Create" - : branchByName.get(item)?.isRemote - ? "Remote" - : "Local" + : pickerGroupLabels.has(item) + ? "row-with-label" + : "row" } originControl={ isSelectingWorktreeBase ? { checked: startFromOrigin, onCheckedChange: onStartFromOriginChange } : undefined } + renderPopup={ + displayMode === "toolbar" && renderWorkspaceMenu + ? (children) => + renderWorkspaceMenu({ + label: isActiveBranchResolving ? "Loading ref…" : triggerLabel, + disabled: isBranchActionPending, + open: isBranchMenuOpen, + focusSearch: focusBranchSearch, + onOpenChange: handleOpenChange, + children, + onContextMenu: (event) => handleBranchContextMenu(event, resolvedActiveBranch), + }) + : undefined + } popupProps={{ ...(displayMode === "toolbar" ? { @@ -855,12 +1002,11 @@ export function BranchToolbarBranchSelector({
- {leading} { @@ -870,14 +1016,7 @@ export function BranchToolbarBranchSelector({ }} onContextMenu={(event) => handleBranchContextMenu(event, resolvedActiveBranch)} > - {displayMode === "toolbar" && renderWorkspaceMenu ? ( - renderWorkspaceMenu({ - label: - resolvedActiveBranch ?? (isActiveBranchResolving ? "Loading ref…" : "Select ref"), - onChooseRef: () => handleOpenChange(true), - disabled: isInitialBranchesLoadPending || isBranchActionPending, - }) - ) : ( + {displayMode === "toolbar" && renderWorkspaceMenu ? null : ( - {leading ? null : ( - - )} + {isActiveBranchResolving ? ( diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx deleted file mode 100644 index a3160cb8a9045..0000000000000 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { Kbd } from "./ui/kbd"; -import { ComposerSelectControl } from "./chat/ComposerControl"; -import { ComposerContextLabel } from "./ComposerContextLabel"; -import { Tooltip, TooltipTrigger, TooltipPopup } from "./ui/tooltip"; -import { useAtomValue } from "@effect/atom-react"; -import { shortcutLabelForCommand } from "../keybindings"; -import { primaryServerKeybindingsAtom } from "../state/server"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { ChevronDownIcon, PlusIcon, ScaleIcon } from "lucide-react"; -import { memo } from "react"; - -import { ADD_TO_MACHINE_LABEL_CLASS, type EnvironmentOption } from "./BranchToolbar.logic"; -import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { cn } from "../lib/utils"; -import { useComposerMenuProps } from "./chat/composerEventScope"; -import { - Select, - SelectGroup, - SelectGroupLabel, - SelectItem, - SelectPopup, - SelectValue, -} from "./ui/select"; - -interface BranchToolbarEnvironmentSelectorProps { - autoEnvironmentLabel?: string | undefined; - onAutoEnvironment?: (() => void) | undefined; - envLocked: boolean; - environmentId: EnvironmentId; - availableEnvironments: readonly EnvironmentOption[]; - onEnvironmentChange?: (environmentId: EnvironmentId) => void; - onAddToMachine?: (() => void) | undefined; - /** The machine is offline or reconnecting: the chip turns amber; the tray notice explains. */ - unavailable?: boolean; -} - -// Action values cannot collide with server-minted environment IDs. -const AUTO_VALUE = "action:auto"; -const ADD_MACHINE_VALUE = "action:add-machine"; -const environmentValue = (id: EnvironmentId) => `env:${id}`; - -export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ - autoEnvironmentLabel, - onAutoEnvironment, - envLocked, - environmentId, - availableEnvironments, - onEnvironmentChange, - onAddToMachine, - unavailable = false, -}: BranchToolbarEnvironmentSelectorProps) { - const unavailableTone = unavailable; - const composerFloatingLayerProps = useComposerMenuProps(); - const shortcutLabel = shortcutLabelForCommand( - useAtomValue(primaryServerKeybindingsAtom), - "composer.host", - ); - const activeEnvironment = - availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; - - const environmentItems = [ - ...(onAutoEnvironment - ? [{ value: AUTO_VALUE, label: autoEnvironmentLabel ?? "Auto balance" }] - : []), - ...availableEnvironments.map((env) => ({ - value: environmentValue(env.environmentId), - label: env.label, - })), - ...(onAddToMachine ? [{ value: ADD_MACHINE_VALUE, label: "Add to another machine" }] : []), - ]; - - if (envLocked || (onEnvironmentChange === undefined && onAddToMachine === undefined)) { - const lockedRow = ( - - - {activeEnvironment?.label ?? "Run on"} - - ); - return ( - - - {activeEnvironment?.label ?? "Run on"} - - ); - } - - return ( - - ); -}); diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index 2de83745125c0..17e8ab66d6a36 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -2,11 +2,13 @@ import "~/index.css"; import { createMemoryHistory } from "@tanstack/react-router"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { + AgentSessionImportProjectChangedError, EnvironmentId, ORCHESTRATION_V2_WS_METHODS, RunId, ThreadId, WS_METHODS, + type AgentSessionImportInput, } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { @@ -41,6 +43,7 @@ import { dispatchedCommands, emitThreadEvent, hasPendingAssetUrl, + fakeDriver, installFakeEnvironment, rejectPendingCommand, threadWithHistory, @@ -235,6 +238,105 @@ it("a question that opens while a stashed file is checked keeps a machine stash" usePromptStashStore.getState().takeEntry("stash-late-question"); }); +it("imports Pi conversations through the environment RPC and can retry a changed project", async () => { + await using _viewport = await appViewport(); + using _environment = installFakeEnvironment([], { acceptCommands: true }); + const scan = vi.fn(() => + Effect.succeed({ + candidates: [ + { + path: "/workspace/render", + title: "Terminal sessions", + projectId: PROJECT_ID, + sources: ["claudeAgent", "codex", "pi"] as const, + threadCount: 3, + lastActiveAt: "2026-03-17T19:12:28.000Z", + alreadyImported: true, + }, + ], + scannedAt: "2026-03-18T09:00:30.000Z", + }), + ); + let changed = true; + const importSessions = vi.fn((_input: AgentSessionImportInput) => + changed + ? Effect.fail(new AgentSessionImportProjectChangedError({ projectId: PROJECT_ID })) + : Effect.succeed({ importedCount: 2, skippedCount: 1 }), + ); + const connect = fakeDriver.connect; + using _connection = vi.spyOn(fakeDriver, "connect").mockImplementation((entry) => + connect(entry).pipe( + Effect.map((lease) => ({ + ...lease, + session: { + ...lease.session, + client: new Proxy(lease.session.client, { + get(target, method, receiver) { + if (method === WS_METHODS.agentSessionsScan) return scan; + if (method === WS_METHODS.agentSessionsImport) return importSessions; + if (method === WS_METHODS.projectsReadFile) + return ({ relativePath }: { relativePath: string }) => + Effect.succeed({ + relativePath, + contents: "", + byteLength: 0, + truncated: false, + exists: false, + }); + return Reflect.get(target, method, receiver); + }, + }), + }, + })), + ), + ); + // Reconnect with this test's import handlers rather than a previous app's live lease. + appAtomRegistry.reset(); + using _registry = { [Symbol.dispose]: () => appAtomRegistry.reset() }; + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); + const router = getRouter(createMemoryHistory({ initialEntries: ["/welcome"] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + container: appContainer(), + router: false, + }); + await expect + .element(page.getByRole("button", { name: "Review all 1", exact: true })) + .toBeVisible(); + await page.getByRole("button", { name: "Review all 1", exact: true }).click(); + await expect.element(page.getByRole("img", { name: "Pi", exact: true })).toBeVisible(); + await expect.element(page.getByRole("img", { name: "Claude Code", exact: true })).toBeVisible(); + await expect.element(page.getByRole("img", { name: "Codex", exact: true })).toBeVisible(); + await page.getByText("Import projects and their threads", { exact: true }).click(); + await page.getByRole("button", { name: "Import threads and start", exact: true }).click(); + await expect + .element(page.getByText("Could not import thread history.", { exact: true })) + .toBeVisible(); + await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(2)); + + expect(importSessions).toHaveBeenCalledTimes(1); + await expect + .element(page.getByRole("heading", { name: "Welcome to T3 Code" })) + .not.toBeInTheDocument(); + changed = false; + await router.navigate({ to: "/welcome" }); + await expect.element(page.getByRole("heading", { name: "Welcome to T3 Code" })).toBeVisible(); + await page.getByText("Import projects and their threads", { exact: true }).click(); + await page.getByRole("button", { name: "Import threads and start", exact: true }).click(); + await expect + .element(page.getByText("Imported 2 threads. 1 thread could not be imported.", { exact: true })) + .toBeVisible(); + expect(importSessions).toHaveBeenCalledTimes(2); + expect(importSessions).toHaveBeenLastCalledWith({ + projectId: PROJECT_ID, + expectedWorkspaceRoot: "/workspace/render", + }); + await expect + .element(page.getByRole("heading", { name: "Welcome to T3 Code" })) + .not.toBeInTheDocument(); +}); + it("a new draft does not load its reserved thread before the server lists it", async () => { await using _viewport = await appViewport(); using _environment = installFakeEnvironment([]); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 807ee0d06c2c0..33a090d5b379d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -315,6 +315,8 @@ import { ChatHeader } from "./chat/ChatHeader"; import { useSideChat } from "./chat/useSideChat"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; +import { PaneHeaderActions } from "./chat/PaneHeaderActions"; +import GitActionsControl from "./GitActionsControl"; import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; import { PanelLayoutControls, @@ -3479,6 +3481,13 @@ export default function ChatView(props: ChatViewProps) { const threadPanelHeaderControl = ; const panelLayoutControls = ( + {panelToggleControls} ); @@ -3491,7 +3500,7 @@ export default function ChatView(props: ChatViewProps) { const content = (
{embedded ? null : ( - {embedded ? null : } + {embedded ? null : } + {gitCwd && isGitRepo ? ( + + ) : null}
); return ( diff --git a/apps/web/src/components/ClientSettingsRecoveryGate.browser.test.tsx b/apps/web/src/components/ClientSettingsRecoveryGate.browser.test.tsx new file mode 100644 index 0000000000000..3a774b743e38c --- /dev/null +++ b/apps/web/src/components/ClientSettingsRecoveryGate.browser.test.tsx @@ -0,0 +1,38 @@ +import type { ClientSettings } from "@t3tools/contracts/settings"; +import { userEvent } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { render } from "../../test/render"; + +const persistence = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence }), +})); + +import { ClientSettingsRecoveryGate } from "./ClientSettingsRecoveryGate"; + +describe("ClientSettingsRecoveryGate", () => { + it("holds the app on a failed settings read until Retry reads them", async () => { + using _error = vi.spyOn(console, "error").mockImplementation(() => undefined); + persistence.getClientSettings + .mockReset() + .mockRejectedValueOnce(new Error("storage unavailable")) + .mockResolvedValue(null); + await using screen = await render( + +

App

+
, + ); + + await expect.element(screen.getByText("Couldn't load your settings")).toBeVisible(); + await expect.element(screen.getByText("App")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + await expect.element(screen.getByText("App")).toBeVisible(); + expect(persistence.getClientSettings).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/components/ClientSettingsRecoveryGate.tsx b/apps/web/src/components/ClientSettingsRecoveryGate.tsx new file mode 100644 index 0000000000000..c1d50955346cf --- /dev/null +++ b/apps/web/src/components/ClientSettingsRecoveryGate.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; + +import { + ensureClientSettingsHydrated, + useClientSettingsHydrationStatus, +} from "../hooks/useSettings"; +import { Button } from "./ui/button"; +import { Empty, EmptyContent, EmptyHeader, EmptyTitle } from "./ui/empty"; +import { RefreshIcon } from "./ui/refresh-icon"; +import { StandalonePage } from "./ui/standalone-page"; + +/** + * Edits made on defaults after a failed read are never saved, so the app waits for a good read. + * Until the first read finishes it keeps the boot splash, so no frame paints on defaults. + */ +export function ClientSettingsRecoveryGate({ children }: { readonly children: ReactNode }) { + const status = useClientSettingsHydrationStatus(); + if (status === "pending") return ; + if (status !== "failed" && status !== "retrying") return children; + const retrying = status === "retrying"; + return ( + + + + Couldn't load your settings + + + + + + + ); +} + +/** The splash from index.html, which the first React commit replaces. */ +function BootSplash() { + return ( +
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/CommandBlock.tsx b/apps/web/src/components/CommandBlock.tsx index 5f98539ef4eb4..906d09ca838bd 100644 --- a/apps/web/src/components/CommandBlock.tsx +++ b/apps/web/src/components/CommandBlock.tsx @@ -9,10 +9,12 @@ export function CommandBlock({ command, className, prominent = false, + compact = false, }: { readonly command: string; readonly className?: string; readonly prominent?: boolean; + readonly compact?: boolean; }) { const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 1500, @@ -22,12 +24,16 @@ export function CommandBlock({
- $ + {compact ? null : $} {command} )} diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index 792e1abdc369b..f6af0447d8993 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useId, useState } from "react"; import { useNavigate } from "@tanstack/react-router"; import { create } from "zustand"; +import { useNowMinute } from "../hooks/useNowMinute"; import { localSnoozeDate, localSnoozeTime, @@ -8,15 +9,14 @@ import { type CustomSnoozeInput, } from "@t3tools/client-runtime/state/thread-settled"; import { Button } from "./ui/button"; -import { CalendarIcon, Settings2Icon } from "lucide-react"; +import { AlarmClockIcon, CalendarIcon, Settings2Icon } from "lucide-react"; import { Calendar } from "./ui/calendar"; import { weekStartsOn } from "../timestampFormat"; -import { useNowMinute } from "../hooks/useNowMinute"; import { Popover, PopoverTrigger, PopoverPopup } from "./ui/popover"; import { Input } from "./ui/input"; +import { SubmitKbd } from "./SubmitKbd"; import { Label } from "./ui/label"; import { Toggle, ToggleGroup } from "./ui/toggle-group"; -import { Select, SelectTrigger, SelectValue, SelectPopup, SelectItem } from "./ui/select"; import { NumberField, NumberFieldGroup, @@ -62,6 +62,41 @@ export function CustomSnoozeDialogHost() { return request ? : null; } +type DurationParts = { readonly days: number; readonly hours: number; readonly minutes: number }; + +const DURATION_FIELDS = [ + { key: "days", label: "Days" }, + { key: "hours", label: "Hours" }, + { key: "minutes", label: "Minutes" }, +] as const; + +function durationMinutes(parts: DurationParts): number { + return parts.days * 24 * 60 + parts.hours * 60 + parts.minutes; +} + +/** "today at 6:12 PM", "tomorrow at 9:00 AM", or "on Mon, Sep 29 at 9:00 AM". */ +function formatReturnTime(wake: Date, now: Date): string { + const time = wake.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }); + const dayOffset = Math.round( + (new Date(wake).setHours(0, 0, 0, 0) - new Date(now).setHours(0, 0, 0, 0)) / 86_400_000, + ); + if (dayOffset === 0) return `today at ${time}`; + if (dayOffset === 1) return `tomorrow at ${time}`; + const day = wake.toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + ...(wake.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }), + }); + return `on ${day} at ${time}`; +} + +/** The current time, refreshed each minute while mounted so a live label never goes stale. */ +function useMinuteClock(): Date { + const now = useNowMinute(); + return new Date(`${now}:00Z`); +} + function CustomSnoozeDialog() { const id = useId(); const navigate = useNavigate(); @@ -69,15 +104,19 @@ function CustomSnoozeDialog() { const nowMinute = useNowMinute(); // Follows the clock so a dialog left open past midnight stops offering yesterday. const today = startOfLocalDay(Date.parse(`${nowMinute}Z`)); - const [mode, setMode] = useState("date"); + const [mode, setMode] = useState("duration"); const [date, setDate] = useState(initial); const [calendarOpen, setCalendarOpen] = useState(false); const [time, setTime] = useState(localSnoozeTime(initial)); - const [amount, setAmount] = useState("2"); - const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); + const [duration, setDuration] = useState({ days: 0, hours: 2, minutes: 0 }); const [error, setError] = useState(null); + const now = useMinuteClock(); + const totalMinutes = durationMinutes(duration); const input: CustomSnoozeInput = - mode === "date" ? { mode, date: localSnoozeDate(date), time } : { mode, amount, unit }; + mode === "date" + ? { mode, date: localSnoozeDate(date), time } + : { mode, amount: String(totalMinutes), unit: "minutes" }; + const wakeAt = mode === "duration" ? resolveCustomSnooze(input, now) : null; return ( + Snooze for Date and time - Duration
{mode === "date" ? ( @@ -130,6 +169,7 @@ function CustomSnoozeDialog() {
) : ( -
- { - setAmount(value === null ? "" : String(value)); - setError(null); - }} - > - - - - - - - - +
+
+ Duration +
+ {DURATION_FIELDS.map((field) => ( + { + setDuration((current) => ({ + ...current, + [field.key]: Math.max(0, Math.floor(value ?? 0)), + })); + setError(null); + }} + > + + + + + + + + ))} +
+
+

+ + {wakeAt + ? `Returns ${formatReturnTime(new Date(wakeAt), now)}` + : "Set a duration longer than zero."} +

)}
@@ -228,6 +277,7 @@ function CustomSnoozeDialog() { - - + diff --git a/apps/web/src/components/DialogSubmitShortcut.tsx b/apps/web/src/components/DialogSubmitShortcut.tsx new file mode 100644 index 0000000000000..075a87b613060 --- /dev/null +++ b/apps/web/src/components/DialogSubmitShortcut.tsx @@ -0,0 +1,17 @@ +import type { KeyboardEvent } from "react"; + +import { isMacPlatform } from "~/lib/utils"; +import { SubmitKbd } from "./SubmitKbd"; + +/** The mod+Enter key cap a dialog's primary button carries. */ +export function DialogSubmitKbd() { + return ; +} + +/** True for mod+Enter, the shortcut that runs a dialog's primary action from any field. */ +export function isDialogSubmitShortcut(event: KeyboardEvent): boolean { + if (event.key !== "Enter" || event.shiftKey || event.altKey || event.nativeEvent.isComposing) { + return false; + } + return isMacPlatform(navigator.platform) ? event.metaKey : event.ctrlKey; +} diff --git a/apps/web/src/components/GitActionsControl.composition.browser.test.tsx b/apps/web/src/components/GitActionsControl.composition.browser.test.tsx index 82e522045add8..ccb71cf07207d 100644 --- a/apps/web/src/components/GitActionsControl.composition.browser.test.tsx +++ b/apps/web/src/components/GitActionsControl.composition.browser.test.tsx @@ -4,7 +4,7 @@ import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { act, render } from "../../test/render"; -import { PublishRepositoryDialog } from "./GitActionsControl"; +import { PublishRepositoryDialog } from "./PublishRepositoryDialog"; const publish = vi.hoisted(() => ({ run: vi.fn() })); @@ -61,9 +61,10 @@ it("keeps composition Enter in the repository path from publishing", async () => offeredProviders={undefined} />, ); - await page.getByRole("button", { name: "Next" }).click(); + // The redesigned dialog asks for the owner and the name on one page. + await page.getByPlaceholder("owner").fill("octo"); const path = page.getByRole("textbox", { name: "Repository" }); - await path.fill("octo/repo"); + await path.fill("repo"); for (const nativeEvent of [{ isComposing: true }, { keyCode: 229 }]) { const event = new KeyboardEvent("keydown", { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 95867142bc469..7ccf4b1b23522 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -5,7 +5,10 @@ import type { VcsStatusResult, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; -import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; +import { + sourceControlClients, + type ChangeRequestTerminology, +} from "@t3tools/client-runtime/source-control-clients"; import { changeRequestTerminologyFor, type DefaultBranchActionDialogCopy, @@ -544,3 +547,107 @@ export function resolveChangesTotals( baseRef: selected.baseRef, }; } + +/** Title of the running action's progress popover (pen F8fwH). */ +export function gitStackedActionTitle( + action: GitStackedAction, + terminology: ChangeRequestTerminology = sourceControlClients.get(undefined).changeRequest, +): string { + switch (action) { + case "commit": + return "Commit"; + case "push": + return "Push"; + case "create_pr": + return `Create ${terminology.shortLabel}`; + case "commit_push": + return "Commit & push"; + case "commit_push_pr": + return `Commit, push & ${terminology.shortLabel}`; + } +} + +export interface GitSyncMenuItem { + readonly id: "commit_push" | "pull" | "sync"; + readonly label: string; + readonly disabled: boolean; + /** Shown under the item while it is disabled. */ + readonly disabledReason: string | null; +} + +function syncMenuItem( + id: GitSyncMenuItem["id"], + label: string, + disabledReason: string | null, +): GitSyncMenuItem { + return { id, label, disabled: disabledReason !== null, disabledReason }; +} + +/** + * The menu rows that move the branch against its upstream: commit & push, pull, and sync + * (pull, then push what is still ahead). Each is null when the repository cannot offer it at + * all, such as pull without an upstream. + */ +export function buildSyncMenuItems( + gitStatus: VcsStatusResult | null, + isBusy: boolean, + hasPrimaryRemote = true, +): { + readonly commitPush: GitSyncMenuItem | null; + readonly pull: GitSyncMenuItem | null; + readonly sync: GitSyncMenuItem | null; +} { + if (!gitStatus) return { commitPush: null, pull: null, sync: null }; + const busyReason = isBusy ? "Git action in progress." : null; + const detachedReason = + gitStatus.refName === null ? "Detached HEAD: check out a branch first." : null; + const hasChanges = gitStatus.hasWorkingTreeChanges; + const isAhead = gitStatus.aheadCount > 0; + const isBehind = gitStatus.behindCount > 0; + const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; + + const commitPush = + hasPrimaryRemote || gitStatus.hasUpstream + ? syncMenuItem( + "commit_push", + "Commit & push", + busyReason ?? + detachedReason ?? + (!hasChanges + ? "Worktree is clean. Make changes before committing." + : isBehind + ? "Branch is behind upstream. Pull first." + : !gitStatus.hasUpstream && !canPushWithoutUpstream + ? 'Add an "origin" remote before pushing.' + : null), + ) + : null; + + if (!gitStatus.hasUpstream) return { commitPush, pull: null, sync: null }; + + const pull = syncMenuItem( + "pull", + "Pull", + busyReason ?? + detachedReason ?? + (!isBehind + ? "Already up to date with upstream." + : hasChanges + ? "Commit or stash local changes before pulling." + : null), + ); + const sync = syncMenuItem( + "sync", + "Sync ref", + busyReason ?? + detachedReason ?? + (isAhead && isBehind + ? "Branch has diverged from upstream. Rebase/merge first." + : !isAhead && !isBehind + ? "Branch is up to date. No action needed." + : hasChanges + ? "Commit or stash local changes before syncing." + : null), + ); + return { commitPush, pull, sync }; +} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 9ee3ffe0cfeb9..994b06a793c97 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,58 +1,49 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { useAtomMount, useAtomValue } from "@effect/atom-react"; -import { - AuthOrchestrationOperateScope, - AuthSourceControlWriteScope, - SourceControlProviderKind, - type ScopedThreadRef, - type ResolvedKeybindingsConfig, -} from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import type { - GitRunStackedActionResult, - GitStackedAction, - SourceControlCloneProtocol, - SourceControlProviderDiscoveryItem, - SourceControlPublishRepositoryResult, - SourceControlRepositoryVisibility, - VcsStatusResult, -} from "@t3tools/contracts"; -import { useNavigate } from "@tanstack/react-router"; -import { shortcutLabelForCommand } from "~/keybindings"; -import * as Option from "effect/Option"; -import { useEffect, useEffectEvent, useId, useState } from "react"; + import { useDeadlinePassed } from "../hooks/useDeadline"; import { useNowSecond } from "../hooks/useNowSecond"; -import { flushSync } from "react-dom"; import { + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, + type GitRunStackedActionResult, + type GitStackedAction, + type ScopedThreadRef, + type ResolvedKeybindingsConfig, + type VcsStatusResult, +} from "@t3tools/contracts"; +import { type MouseEvent, type ReactNode, useEffect, useEffectEvent, useId, useState } from "react"; +import { + ArrowDownToLineIcon, ArrowUpFromLineIcon, EllipsisIcon, + BookUp2Icon, CheckIcon, - ChevronDownIcon, CloudDownloadIcon, CloudUploadIcon, FileDiffIcon, GitBranchPlusIcon, + GitCommitHorizontalIcon, GitCommitIcon, InfoIcon, - LockIcon, - GlobeIcon, + RefreshCwIcon, + UploadIcon, } from "lucide-react"; -import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; -import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; import { cn } from "~/lib/utils"; +import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { buildMenuItems, + buildSyncMenuItems, formatGitActionElapsed, GIT_ACTION_SUCCESS_VISIBLE_MS, hasUsableGitMenuContent, + gitStackedActionTitle, type GitActionProgressPresentation, - type GitActionIconName, type GitActionMenuItem, type GitQuickAction, type DefaultBranchConfirmableAction, @@ -68,38 +59,35 @@ import { resolveQuickAction, resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; -import { WizardPopup, WizardHeader, WizardSteps, WizardPanel, WizardFooter } from "./ui/wizard"; -import { StartTruncatedPath } from "./StartTruncatedPath"; +import { GitCommitDialog, type GitCommitDialogSubmission } from "./GitCommitDialog"; +import { PublishRepositoryDialog } from "./PublishRepositoryDialog"; import { Button } from "~/components/ui/button"; -import { Checkbox } from "~/components/ui/checkbox"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, - DialogPanel, DialogPopup, DialogTitle, } from "~/components/ui/dialog"; -import { Input } from "~/components/ui/input"; import { Menu, + MenuGroup, + MenuGroupLabel, MenuItem, MenuItemLabel, - MenuShortcut, MenuPopup, + MenuSeparator, + MenuShortcut, MenuTrigger, } from "~/components/ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; -import { ScrollArea } from "~/components/ui/scroll-area"; -import { Textarea } from "~/components/ui/textarea"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useOpenInPreferredEditor } from "~/editorPreferences"; import { useGitStackedAction, useSourceControlActionRunning, - useSourceControlPublishRepositoryAction, useVcsInitAction, useVcsPullAction, } from "../state/sourceControlActions"; @@ -116,8 +104,9 @@ import { useDiffPanelStore, } from "~/diffPanelStore"; import { useClientSettings } from "~/hooks/useSettings"; -import { serverEnvironment } from "~/state/server"; -import { sourceControlEnvironment } from "~/state/sourceControl"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "~/keybindings"; +import { isTerminalFocused } from "~/lib/terminalFocus"; import { threadEnvironment } from "~/state/threads"; import { useAtomCommand } from "~/state/use-atom-command"; import { vcsActionManager, vcsEnvironment, vcsStatusRevalidation } from "~/state/vcs"; @@ -125,22 +114,19 @@ import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { THREAD_DETAILS_PANEL_LABEL_CLASS } from "./chat/threadDetailsPanelStyles"; -import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation"; -import { - isSourceControlProviderOffered, - type OfferedSourceControlProviders, -} from "@t3tools/client-runtime/operations/projects"; -import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; -import { useOpenLink } from "~/browser/useOpenLink"; interface GitActionsControlProps { + /** "shortcut" keeps the commit and push shortcut and its dialogs live. */ + presentation?: "shortcut"; displayMode?: "panel"; gitCwd: string | null; activeThreadRef: ScopedThreadRef | null; draftId?: DraftId; compact?: boolean; + listenForShortcuts?: boolean; showActions?: boolean; showChanges?: boolean; onOpenChanges?: () => void; @@ -157,6 +143,8 @@ interface PendingDefaultBranchAction { filePaths?: string[]; } +type GitActionToastId = ReturnType; + interface RunGitActionWithToastInput { action: GitStackedAction; commitMessage?: string; @@ -193,65 +181,6 @@ function requestVcsStatusRefresh( } const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; -const PUBLISH_PROVIDER_OPTIONS = sourceControlClients.definitions.map((definition) => ({ - value: definition.kind, - label: definition.pickerLabel, - description: definition.publishDescription, - pathPlaceholder: definition.repositoryPathHint, - definition, - Icon: sourceControlIcon(definition), -})); - -function publishProviderOption(provider: SourceControlProviderKind) { - return ( - PUBLISH_PROVIDER_OPTIONS.find((option) => option.value === provider) ?? - PUBLISH_PROVIDER_OPTIONS[0]! - ); -} - -function getPublishProviderReadiness(input: { - provider: SourceControlProviderKind; - sourceControlProviders: ReadonlyArray; -}): { readonly ready: boolean; readonly hint: string | null } { - const discovered = input.sourceControlProviders.find( - (provider) => provider.kind === input.provider, - ); - if (!discovered) { - return { - ready: false, - hint: "Provider status unavailable. Open Settings -> Source control and rescan.", - }; - } - if (discovered.status !== "available") { - return { ready: false, hint: discovered.installHint }; - } - if (discovered.auth.status === "unauthenticated") { - return { - ready: false, - hint: - Option.getOrNull(discovered.auth.detail) ?? - `${discovered.label} is not authenticated. Open Settings -> Source control for setup guidance.`, - }; - } - return { ready: true, hint: null }; -} - -const COMMIT_DIALOG_TITLE = "Commit changes"; -const COMMIT_DIALOG_DESCRIPTION = - "Review and confirm your commit. Leave the message blank to auto-generate one."; - -function GitActionItemIcon({ - icon, - SourceControlIcon, -}: { - icon: GitActionIconName; - SourceControlIcon: ReturnType["Icon"]; -}) { - if (icon === "commit") return ; - if (icon === "push") return ; - return ; -} - function GitQuickActionIcon({ quickAction, SourceControlIcon, @@ -283,11 +212,11 @@ function GitActionElapsedTime({ className?: string; }) { return startedAtMs === null ? null : ( - + ); } -function RunningGitActionElapsedTime({ +function GitActionElapsedTimeLabel({ startedAtMs, className, }: { @@ -350,7 +279,7 @@ function GitActionProgressButtonContent({ +

} > {progress.output} @@ -388,7 +317,7 @@ function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSu +

} > {success.description} @@ -403,524 +332,9 @@ function GitActionSuccessButtonContent({ success }: { success: InlineGitActionSu ); } -interface PublishRepositoryDialogProps { - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; - readonly environmentId: ScopedThreadRef["environmentId"] | null; - readonly threadRef: ScopedThreadRef | null; - readonly gitCwd: string; - readonly offeredProviders: OfferedSourceControlProviders | undefined; -} - -export function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { - const openLink = useOpenLink(props.threadRef); - const navigate = useNavigate(); - const sourceControlDiscovery = useEnvironmentQuery( - props.environmentId === null - ? null - : sourceControlEnvironment.discovery({ - environmentId: props.environmentId, - input: {}, - }), - ); - const [selectedPublishProvider, setSelectedPublishProvider] = - useState(null); - const [publishRepositoryOverride, setPublishRepositoryOverride] = useState(null); - const [publishVisibility, setPublishVisibility] = - useState("private"); - const [publishRemoteName, setPublishRemoteName] = useState("origin"); - const [publishProtocol, setPublishProtocol] = useState("ssh"); - const [publishWizardStep, setPublishWizardStep] = useState(0); - const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); - const [publishError, setPublishError] = useState(null); - const [publishResult, setPublishResult] = useState( - null, - ); - const sourceControlScope = { - environmentId: props.environmentId, - cwd: props.gitCwd, - }; - const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope); - const publishAccountByProvider = new Map(); - for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { - publishAccountByProvider.set(provider.kind, Option.getOrNull(provider.auth.account)); - } - const publishSourceControlProviders = sourceControlDiscovery.data?.sourceControlProviders ?? []; - const publishReadinessByProvider = new Map( - PUBLISH_PROVIDER_OPTIONS.map((option) => [ - option.value, - getPublishProviderReadiness({ - provider: option.value, - sourceControlProviders: publishSourceControlProviders, - }), - ]), - ); - const publishProviderReadiness = (provider: SourceControlProviderKind) => - publishReadinessByProvider.get(provider) ?? { ready: false, hint: null }; - const offeredPublishProviderOptions = PUBLISH_PROVIDER_OPTIONS.filter((option) => - isSourceControlProviderOffered(props.offeredProviders, option.value), - ); - const hasReadyPublishProvider = offeredPublishProviderOptions.some( - (option) => publishProviderReadiness(option.value).ready, - ); - const sortedPublishProviderOptions = offeredPublishProviderOptions.toSorted((left, right) => { - const leftReady = publishProviderReadiness(left.value).ready; - const rightReady = publishProviderReadiness(right.value).ready; - if (leftReady !== rightReady) { - return leftReady ? -1 : 1; - } - return left.label.localeCompare(right.label); - }); - const firstReadyPublishProvider = sortedPublishProviderOptions.find( - (option) => publishProviderReadiness(option.value).ready, - )?.value; - // The dialog stays mounted, so a host turned off after it was picked must not stay selected. - const offeredSelection = - selectedPublishProvider !== null && - isSourceControlProviderOffered(props.offeredProviders, selectedPublishProvider) - ? selectedPublishProvider - : null; - const publishProvider = - offeredSelection !== null && publishProviderReadiness(offeredSelection).ready - ? offeredSelection - : (firstReadyPublishProvider ?? offeredSelection ?? sourceControlClients.get(undefined).kind); - const selectedPublishProviderReadiness = publishProviderReadiness(publishProvider); - const publishAccount = publishAccountByProvider.get(publishProvider) ?? null; - const publishRepositoryPrefill = publishAccount ? `${publishAccount}/` : ""; - const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; - const currentPublishProvider = publishProviderOption(publishProvider); - const publishHost = currentPublishProvider.definition.publishHost( - Option.getOrNull( - sourceControlDiscovery.data?.sourceControlProviders.find( - (provider) => provider.kind === publishProvider, - )?.auth.host ?? Option.none(), - ), - ); - const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; - const publishProviderLabel = currentPublishProvider.label; - const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; - const publishWizardStepSummaries = [ - publishProviderLabel, - publishResult?.repository.nameWithOwner ?? null, - null, - ] as const; - - const publishRepositoryParts = publishRepository.trim().split("/"); - const publishRepositoryOwner = publishRepositoryParts[0]?.trim() ?? ""; - const publishRepositoryName = publishRepositoryParts.slice(1).join("/").trim(); - const canSubmitPublishRepository = - publishRepositoryAction.isAllowed && - hasReadyPublishProvider && - selectedPublishProviderReadiness.ready && - !publishRepositoryAction.isPending && - publishRepositoryOwner.length > 0 && - publishRepositoryName.length > 0; - - const submitPublishRepository = () => { - if (!canSubmitPublishRepository) { - return; - } - - setPublishError(null); - - void (async () => { - const result = await publishRepositoryAction.run({ - provider: publishProvider, - repository: publishRepository.trim(), - visibility: publishVisibility, - remoteName: publishRemoteName.trim() || "origin", - protocol: publishProtocol, - }); - - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - setPublishError(error instanceof Error ? error.message : "An error occurred."); - } - return; - } - - flushSync(() => { - setPublishResult(result.value); - setPublishWizardStep(2); - }); - })(); - }; - - const resetState = () => { - setPublishRemoteName("origin"); - setPublishRepositoryOverride(null); - setPublishWizardStep(0); - setPublishAdvancedOpen(false); - setPublishError(null); - setPublishResult(null); - }; - - const handleOpenChange = (open: boolean) => { - props.onOpenChange(open); - if (!open) { - resetState(); - } - }; - - const openSourceControlSettings = () => { - handleOpenChange(false); - void navigate({ to: "/settings/source-control" }); - }; - - return ( -

- - - - publishWizardStep === 2 || - index >= publishWizardSteps.length - 1 || - index > publishWizardStep - } - onStepChange={setPublishWizardStep} - /> - - - -
- - Provider - - { - setSelectedPublishProvider(value as SourceControlProviderKind); - setPublishRepositoryOverride(null); - }} - aria-labelledby="publish-provider-cards-label" - className="grid grid-cols-2" - > - {sortedPublishProviderOptions.map((option) => { - const readiness = publishProviderReadiness(option.value); - const isSelected = publishProvider === option.value && readiness.ready; - if (!readiness.ready) { - return ( -
- - - {option.label} - - - { - event.preventDefault(); - event.stopPropagation(); - openSourceControlSettings(); - }} - > - Setup Required - - } - /> - - {readiness.hint ?? - "Open Settings -> Source control to configure this provider."} - - -
- ); - } - - return ( - - - - {option.label} - - - ); - })} -
-
- -
-
- -
- - - {publishHost}/ - - { - setPublishRepositoryOverride(event.target.value); - }} - onKeyDown={(event) => { - if (event.key !== "Enter") { - return; - } - if (event.nativeEvent.isComposing || event.keyCode === 229) { - return; - } - event.preventDefault(); - submitPublishRepository(); - }} - placeholder={publishPathPlaceholder} - disabled={publishRepositoryAction.isPending} - className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-placeholder focus:outline-none" - /> -
-
- -
- - Visibility - - - setPublishVisibility(value as SourceControlRepositoryVisibility) - } - aria-labelledby="publish-visibility-cards-label" - disabled={publishRepositoryAction.isPending} - className="grid grid-cols-2" - > - {[ - { - value: "private" as const, - label: "Private", - description: "Only invited people", - Icon: LockIcon, - }, - { - value: "public" as const, - label: "Public", - description: "Anyone on the web", - Icon: GlobeIcon, - }, - ].map((option) => { - const isSelected = publishVisibility === option.value; - return ( - - - - - {option.label} - - - {option.description} - - - - ); - })} - -
- -
- - {publishAdvancedOpen ? ( -
- -
- - Protocol - - { - const protocol = next[0]; - if (protocol === "ssh" || protocol === "https") { - setPublishProtocol(protocol); - } - }} - aria-labelledby="publish-protocol-label" - disabled={publishRepositoryAction.isPending} - > - SSH - HTTPS - -
-
- ) : null} -
- - {publishRepositoryAction.isPending ? ( -
- - Publishing repository to {publishProviderLabel}... -
- ) : null} - {publishError && !publishRepositoryAction.isPending ? ( -
-

Publish failed

-

{publishError}

-
- ) : null} -
- -
- {publishResult ? ( - <> -
- - - -

- {publishResult.status === "pushed" - ? "Repository published" - : "Repository created"} -

-

- {publishResult.status === "pushed" - ? `${publishResult.branch} is now live on ${publishProviderLabel}.` - : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`} -

-
-
- - - {publishResult.repository.nameWithOwner} - -
- - - ) : ( -
- Publish result unavailable. -
- )} -
-
- - - {publishWizardStep === 2 ? ( - - ) : ( - <> - - {publishWizardStep < 1 ? ( - - ) : ( - - )} - - )} - -
-
- ); -} - export default function GitActionsControl({ + presentation, + listenForShortcuts = true, displayMode, gitCwd, activeThreadRef, @@ -929,9 +343,10 @@ export default function GitActionsControl({ showChanges = true, onOpenChanges, workspaceMutationId = null, - keybindings, + keybindings: panelKeybindings, }: GitActionsControlProps) { const progressLabelId = useId(); + const isPanel = presentation !== "shortcut"; const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, "thread branch metadata update", @@ -944,12 +359,14 @@ export default function GitActionsControl({ ); const canOperateThread = useEnvironmentScope(activeEnvironmentId, AuthOrchestrationOperateScope); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(activeEnvironmentId)); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const openInPreferredEditor = useOpenInPreferredEditor( activeEnvironmentId, serverConfig?.availableEditors ?? [], ); const threadToastData = activeThreadRef ? { threadRef: activeThreadRef } : undefined; const activeServerThreadShell = useQuietThreadShell(activeThreadRef); + const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) @@ -966,9 +383,7 @@ export default function GitActionsControl({ const canChangeThreadBranch = canWriteSourceControl && (isLocalDraftThread || canOperateThread); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); - const [dialogCommitMessage, setDialogCommitMessage] = useState(""); - const [excludedFiles, setExcludedFiles] = useState>(new Set()); - const [isEditingFiles, setIsEditingFiles] = useState(false); + const [failedCommitMessage, setFailedCommitMessage] = useState(""); const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); const [inlineSuccess, setInlineSuccess] = useState(null); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = @@ -1101,14 +516,8 @@ export default function GitActionsControl({ : `vs ${resolvedChangesTotals.baseRef}` : null; - const allFiles = gitStatusForActions?.workingTree.files ?? []; - // Changed-file paths are repository-relative, so they must resolve against - // the worktree root from the same status snapshot, not the project cwd. + // Changed-file paths resolve against the worktree root from this status snapshot. const workingTreeRoot = gitStatusForActions?.workingTree.rootPath ?? null; - const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); - const allSelected = excludedFiles.size === 0; - const noneSelected = selectedFiles.length === 0; - const initAction = useVcsInitAction(sourceControlScope); const runImmediateGitAction = useGitStackedAction(sourceControlScope); const pullAction = useVcsPullAction(sourceControlScope); @@ -1160,6 +569,11 @@ export default function GitActionsControl({ isGitActionRunning, hasPrimaryRemote, ); + const syncMenuItems = buildSyncMenuItems( + gitStatusForActions, + isGitActionRunning, + hasPrimaryRemote, + ); const quickAction = resolveQuickAction( gitStatusForActions, isGitActionRunning, @@ -1279,7 +693,7 @@ export default function GitActionsControl({ toastManager.add( stackedThreadToast({ type: "error", - title: "Action failed", + title: `${gitStackedActionTitle(action, changeRequestTerminology)} failed`, description: error instanceof Error ? error.message : "An error occurred.", timeout: errorToastTiming.timeout, ...(scopedToastData !== undefined ? { data: scopedToastData } : {}), @@ -1290,15 +704,78 @@ export default function GitActionsControl({ const actionResult = result.value; syncThreadBranchAfterGitAction(actionResult); - const createdPullRequest = actionResult.pr.status === "created"; - setInlineSuccess({ - title: createdPullRequest - ? `Created ${changeRequestTerminology.shortLabel}` - : actionResult.toast.title, - description: createdPullRequest ? null : (actionResult.toast.description ?? null), - scopeKey: successScopeKey, - expiresAtMs: inlineSuccessExpiresAt(), - }); + if (isPanel) { + const createdPullRequest = actionResult.pr.status === "created"; + setInlineSuccess({ + title: createdPullRequest + ? `Created ${changeRequestTerminology.shortLabel}` + : actionResult.toast.title, + description: createdPullRequest ? null : (actionResult.toast.description ?? null), + scopeKey: successScopeKey, + expiresAtMs: inlineSuccessExpiresAt(), + }); + return true; + } + let resultToastId: GitActionToastId | null = null; + const closeResultToast = () => { + if (resultToastId !== null) { + toastManager.close(resultToastId); + } + }; + + const toastCta = actionResult.toast.cta; + let toastActionProps: { + children: string; + onClick: (event: MouseEvent) => void; + } | null = null; + if (toastCta.kind === "run_action") { + toastActionProps = { + children: toastCta.label, + onClick: () => { + closeResultToast(); + void runGitActionWithToast({ + action: toastCta.action.kind, + }); + }, + }; + } else if (toastCta.kind === "open_pr") { + toastActionProps = { + children: toastCta.label, + onClick: (event) => { + closeResultToast(); + openPrLink(event, toastCta.url); + }, + }; + } + + const successToastTiming = resolveGitActionResultToastTiming("success"); + const successToastData = { + ...scopedToastData, + ...(successToastTiming.dismissAfterVisibleMs !== null + ? { dismissAfterVisibleMs: successToastTiming.dismissAfterVisibleMs } + : {}), + }; + + if (toastActionProps) { + resultToastId = toastManager.add( + stackedThreadToast({ + type: "success", + title: actionResult.toast.title, + description: actionResult.toast.description, + timeout: successToastTiming.timeout, + actionProps: toastActionProps, + data: successToastData, + }), + ); + } else { + resultToastId = toastManager.add({ + type: "success", + title: actionResult.toast.title, + description: actionResult.toast.description, + timeout: successToastTiming.timeout, + data: successToastData, + }); + } return true; } @@ -1318,70 +795,67 @@ export default function GitActionsControl({ }); }; - // Keep a typed message for the retry unless the user already started a new one. - const restoreDialogCommitMessageOnFailure = (commitMessage: string) => (committed: boolean) => { - if (committed || !commitMessage) return; - setDialogCommitMessage((current) => current || commitMessage); - }; - - const runDialogActionOnNewBranch = () => { - if (!isCommitDialogOpen) return; - const commitMessage = dialogCommitMessage.trim(); - - setIsCommitDialogOpen(false); - setDialogCommitMessage(""); - setExcludedFiles(new Set()); - setIsEditingFiles(false); - - void runGitActionWithToast({ - action: "commit", - ...(commitMessage ? { commitMessage } : {}), - ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), - featureBranch: true, - skipDefaultBranchPrompt: true, - }).then(restoreDialogCommitMessageOnFailure(commitMessage)); - }; - - const runQuickAction = () => { - if (quickAction.kind === "open_publish") { - setIsPublishDialogOpen(true); - return; - } - if (quickAction.kind === "run_pull") { - void (async () => { - setInlineSuccess(null); - const result = await pullAction.run(); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - const errorToastTiming = resolveGitActionResultToastTiming("error"); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Pull failed", - description: error instanceof Error ? error.message : "An error occurred.", - timeout: errorToastTiming.timeout, - ...(threadToastData !== undefined ? { data: threadToastData } : {}), - }), - ); + const runPull = () => { + if (!canWriteSourceControl) return; + void (async () => { + setInlineSuccess(null); + const result = await pullAction.run(); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { return; } + const error = squashAtomCommandFailure(result); + const errorToastTiming = resolveGitActionResultToastTiming("error"); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Pull failed", + description: error instanceof Error ? error.message : "An error occurred.", + timeout: errorToastTiming.timeout, + ...(threadToastData !== undefined ? { data: threadToastData } : {}), + }), + ); + return; + } - const pullResult = result.value; - const title = pullResult.status === "pulled" ? "Pulled" : "Already up to date"; - const description = - pullResult.status === "pulled" - ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}` - : `${pullResult.refName} is already synchronized.`; + const pullResult = result.value; + const title = pullResult.status === "pulled" ? "Pulled" : "Already up to date"; + const description = + pullResult.status === "pulled" + ? `Updated ${pullResult.refName} from ${pullResult.upstreamRef ?? "upstream"}` + : `${pullResult.refName} is already synchronized.`; + if (isPanel) { setInlineSuccess({ title, description, scopeKey: successScopeKey, expiresAtMs: inlineSuccessExpiresAt(), }); - })(); + return; + } + const successToastTiming = resolveGitActionResultToastTiming("success"); + toastManager.add({ + type: "success", + title, + description, + timeout: successToastTiming.timeout, + data: { + ...threadToastData, + ...(successToastTiming.dismissAfterVisibleMs !== null + ? { dismissAfterVisibleMs: successToastTiming.dismissAfterVisibleMs } + : {}), + }, + }); + })(); + }; + + const runQuickAction = () => { + if (quickAction.kind === "open_publish") { + setIsPublishDialogOpen(true); + return; + } + if (quickAction.kind === "run_pull") { + runPull(); return; } if (quickAction.kind === "show_hint") { @@ -1398,8 +872,41 @@ export default function GitActionsControl({ } }; + const runCommitPush = () => { + if (!syncMenuItems.commitPush || syncMenuItems.commitPush.disabled) return; + void runGitActionWithToast({ action: "commit_push" }); + }; + + // Sync ref pulls what upstream has, then pushes what the branch still has ahead. + const runSync = () => { + if (!syncMenuItems.sync || syncMenuItems.sync.disabled || !gitStatusForActions) return; + if (gitStatusForActions.behindCount > 0) { + runPull(); + return; + } + void runGitActionWithToast({ action: "push" }); + }; + + const runCommitPushFromShortcut = useEffectEvent(runCommitPush); + const handlesCommitPushShortcut = presentation === "shortcut" && isRepo; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown for the mounted git control's commit and push shortcuts + useEffect(() => { + if (!listenForShortcuts || !handlesCommitPushShortcut) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.repeat) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { terminalFocus: isTerminalFocused() }, + }); + if (command !== "git.commitPush") return; + event.preventDefault(); + runCommitPushFromShortcut(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handlesCommitPushShortcut, keybindings, listenForShortcuts]); + const openDialogForMenuItem = (item: GitActionMenuItem) => { - if (item.disabled) return; + if (!canWriteSourceControl || item.disabled) return; if (item.dialogAction === "push") { void runGitActionWithToast({ action: "push" }); return; @@ -1408,23 +915,23 @@ export default function GitActionsControl({ void runGitActionWithToast({ action: "create_pr" }); return; } - setExcludedFiles(new Set()); - setIsEditingFiles(false); setIsCommitDialogOpen(true); }; - const runDialogAction = () => { - if (!isCommitDialogOpen) return; - const commitMessage = dialogCommitMessage.trim(); + const runCommitDialog = (submission: GitCommitDialogSubmission) => { setIsCommitDialogOpen(false); - setDialogCommitMessage(""); - setExcludedFiles(new Set()); - setIsEditingFiles(false); + setFailedCommitMessage(""); + const commitMessage = submission.commitMessage ?? ""; void runGitActionWithToast({ action: "commit", - ...(commitMessage ? { commitMessage } : {}), - ...(!allSelected ? { filePaths: selectedFiles.map((f) => f.path) } : {}), - }).then(restoreDialogCommitMessageOnFailure(commitMessage)); + ...(submission.commitMessage ? { commitMessage: submission.commitMessage } : {}), + ...(submission.filePaths ? { filePaths: [...submission.filePaths] } : {}), + ...(submission.featureBranch ? { featureBranch: true, skipDefaultBranchPrompt: true } : {}), + }).then((committed) => { + // Keep a typed message for the retry unless the user already started a new one. + if (committed || !commitMessage) return; + setFailedCommitMessage((current) => current || commitMessage); + }); }; const openChangedFileInEditor = (filePath: string) => { @@ -1464,12 +971,15 @@ export default function GitActionsControl({ !gitStatusForActions.hasWorkingTreeChanges && gitStatusForActions.behindCount > 0 && gitStatusForActions.aheadCount === 0; - const hasGitMenuContent = hasUsableGitMenuContent({ - menuItems: gitActionMenuItems, - canWriteSourceControl, - canPublishRepository, - hasNotice: isDetachedHead || isCleanAndBehind || Boolean(gitStatusError), - }); + const hasGitMenuContent = + hasUsableGitMenuContent({ + menuItems: gitActionMenuItems, + canWriteSourceControl, + canPublishRepository, + hasNotice: isDetachedHead || isCleanAndBehind || Boolean(gitStatusError), + }) || + (canWriteSourceControl && + Object.values(syncMenuItems).some((item) => item !== null && !item.disabled)); // A disabled menu cannot refresh on open, and terminal edits or commits publish no status. const refreshStatusForEmptyMenu = () => @@ -1492,74 +1002,138 @@ export default function GitActionsControl({ ); })(); }; + const commitPushShortcutLabel = shortcutLabelForCommand(keybindings, "git.commitPush"); + const changedFileCount = gitStatusForActions?.workingTree.files.length ?? 0; + const menuDensity = "default"; + const renderMenuRow = (row: { + readonly key: string; + readonly icon: ReactNode; + readonly label: string; + readonly disabled: boolean; + readonly disabledReason: string | null; + readonly trailing?: ReactNode; + readonly onClick: () => void; + }) => ( +
+ + {row.icon} + {row.label} + {row.trailing} + + {row.disabled && row.disabledReason ? ( +

+ {row.disabledReason} +

+ ) : null} +
+ ); + const renderActionMenuItem = (item: GitActionMenuItem) => + renderMenuRow({ + key: `${item.id}-${item.label}`, + icon: + item.icon === "commit" ? ( + + ) : item.icon === "push" ? ( + + ) : ( + + ), + label: item.label, + disabled: !canWriteSourceControl || item.disabled, + disabledReason: getMenuActionDisabledReason({ + item, + gitStatus: gitStatusForActions, + isBusy: isGitActionRunning, + hasPrimaryRemote, + }), + ...(item.id === "commit" && changedFileCount > 0 + ? { + trailing: ( + + {changedFileCount} {changedFileCount === 1 ? "file" : "files"} + + ), + } + : {}), + onClick: () => openDialogForMenuItem(item), + }); + const commitItem = gitActionMenuItems.find((item) => item.id === "commit"); const pushItem = gitActionMenuItems.find((item) => item.id === "push"); + const prItem = gitActionMenuItems.find((item) => item.id === "pr"); const gitItems = ( <> - {gitActionMenuItems.map((item) => { - const disabledReason = getMenuActionDisabledReason({ - item, - gitStatus: gitStatusForActions, - isBusy: isGitActionRunning, - hasPrimaryRemote, - }); - if (item.disabled && disabledReason) { - return ( - - } - > - - - {item.label} - - - - {disabledReason} - - - ); - } - - return ( - { - openDialogForMenuItem(item); - }} - > - - {item.label} - - ); - })} - {canPublishRepository ? ( - { - setIsPublishDialogOpen(true); - }} - > - - Publish repository... - - ) : null} + + Git actions + {commitItem ? renderActionMenuItem(commitItem) : null} + {syncMenuItems.commitPush + ? renderMenuRow({ + key: "commit_push", + icon: , + label: syncMenuItems.commitPush.label, + disabled: syncMenuItems.commitPush.disabled, + disabledReason: syncMenuItems.commitPush.disabledReason, + ...(commitPushShortcutLabel + ? { trailing: {commitPushShortcutLabel} } + : {}), + onClick: runCommitPush, + }) + : null} + {pushItem ? renderActionMenuItem(pushItem) : null} + {syncMenuItems.pull + ? renderMenuRow({ + key: "pull", + icon: , + label: syncMenuItems.pull.label, + disabled: syncMenuItems.pull.disabled, + disabledReason: syncMenuItems.pull.disabledReason, + onClick: () => runPull(), + }) + : null} + {prItem ? renderActionMenuItem(prItem) : null} + {syncMenuItems.sync + ? renderMenuRow({ + key: "sync", + icon: , + label: syncMenuItems.sync.label, + disabled: syncMenuItems.sync.disabled, + disabledReason: syncMenuItems.sync.disabledReason, + onClick: runSync, + }) + : null} + {isDetachedHead && ( -

+

Detached HEAD: create and check out a branch to enable push and{" "} {changeRequestTerminology.singular} actions.

)} {isCleanAndBehind && ( -

+

Behind upstream. Pull/rebase first.

)} {gitStatusError && ( -

{gitStatusError}

+

{gitStatusError}

)} + {canPublishRepository ? ( + <> + + { + setIsPublishDialogOpen(true); + }} + > + + Publish repository... + + + ) : null} ); @@ -1591,7 +1165,11 @@ export default function GitActionsControl({ } onClick={quickActionDisabledReason ? undefined : runQuickAction} > - + {quickAction.label} {showChanges ? changesSummary : null} @@ -1601,7 +1179,7 @@ export default function GitActionsControl({ return ( <> - {displayMode === "panel" && isRepo ? ( + {presentation === "shortcut" ? null : displayMode === "panel" && isRepo ? (
{showActions ? ( @@ -1692,7 +1270,9 @@ export default function GitActionsControl({ View changes {!showActions ? changesSummary : null} {keybindings && ( - {shortcutLabelForCommand(keybindings, "diff.toggle")} + + {shortcutLabelForCommand(panelKeybindings ?? keybindings, "diff.toggle")} + )} )} @@ -1717,183 +1297,18 @@ export default function GitActionsControl({
)} - { - if (!open) { - setIsCommitDialogOpen(false); - setDialogCommitMessage(""); - setExcludedFiles(new Set()); - setIsEditingFiles(false); - } - }} - > - - - {COMMIT_DIALOG_TITLE} - {COMMIT_DIALOG_DESCRIPTION} - - -
-
- Branch - - - {gitStatusForActions?.refName ?? "(detached HEAD)"} - - {isDefaultRef && ( - Default branch - )} - -
-
-
-
- {isEditingFiles && allFiles.length > 0 && ( - { - setExcludedFiles( - allSelected ? new Set(allFiles.map((f) => f.path)) : new Set(), - ); - }} - /> - )} - Files - {!allSelected && !isEditingFiles && ( - - ({selectedFiles.length} of {allFiles.length}) - - )} -
- {allFiles.length > 0 && ( - - )} -
- {!gitStatusForActions || allFiles.length === 0 ? ( -

none

- ) : ( -
-
- -
- {allFiles.map((file) => { - const isExcluded = excludedFiles.has(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; - }); - }} - /> - )} - -
- ); - })} -
-
-
-
- - +{formatDiffCount(selectedFiles.reduce((sum, f) => sum + f.insertions, 0))} - - / - - -{formatDiffCount(selectedFiles.reduce((sum, f) => sum + f.deletions, 0))} - -
-
- )} -
-
-
-

Commit message (optional)

-