diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 36b754bc77543..64b8148534992 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -143,7 +143,7 @@ function groupLabel( ): string | null { switch (triggerKind) { case "pull-request": - return "Pull requests"; + return "Pull requests and threads"; case "slash-command": return "Commands"; case "skill": @@ -161,7 +161,7 @@ function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): } switch (triggerKind) { case "pull-request": - return "No matching pull requests."; + return "No matching pull requests or threads."; case "path": return "No matching mentions."; case "skill": diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index 545611da4eff6..4ac266774b9d5 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -43,7 +43,26 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { {props.approval.appName ?? props.approval.requestKind} - {props.approval.detail ? ( + {props.approval.files && props.approval.files.length > 0 ? ( + + {props.approval.files.map((file) => ( + + + {file.path} + + {file.additions !== undefined || file.deletions !== undefined ? ( + + +{file.additions ?? 0} −{file.deletions ?? 0} + + ) : null} + + ))} + + ) : props.approval.detail ? ( {props.approval.detail} diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 1e66bf4ee2c8e..676f6c73e075e 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -339,10 +339,11 @@ export function useComposerCommandMenu({ cwd: trigger?.kind === "path" ? projectCwd : null, query: trigger?.kind === "path" ? trigger.query : null, }); + const isThreadTrigger = trigger?.kind === "path" || trigger?.kind === "pull-request"; // Mounts older settled threads matching the mention, which shells may leave out. useThreadSearch( - trigger?.kind === "path" && environmentId ? [environmentId] : [], - trigger?.kind === "path" ? trigger.query : "", + isThreadTrigger && environmentId ? [environmentId, ...linkedEnvironments.keys()] : [], + trigger?.query ?? "", { titlesOnly: true }, ); const pullRequestSearch = useComposerPullRequestSearch({ @@ -357,9 +358,21 @@ export function useComposerCommandMenu({ if (!trigger) return []; if (trigger.kind === "pull-request") { - return pullRequestSearch.entries.map((entry) => ({ + // `#` lists pull requests, then threads; a bare `#` offers the most recent threads. + const threadItems = environmentId + ? matchComposerThreadItems({ + shells: threadShells, + environmentId, + linkedEnvironments, + excludeThreadId: currentThreadId, + query: trigger.query, + allowEmptyQuery: true, + now, + }) + : []; + const pullRequestItems = pullRequestSearch.entries.map((entry) => ({ id: `pr:${entry.projectId}:${entry.repository}:${entry.number}`, - type: "pull-request", + type: "pull-request" as const, pullRequest: { number: entry.number, title: entry.title, @@ -376,6 +389,7 @@ export function useComposerCommandMenu({ : `${entry.repository} · ` }${entry.title}`, })); + return [...pullRequestItems, ...threadItems]; } if (trigger.kind === "slash-command") { @@ -564,7 +578,7 @@ export function useComposerCommandMenu({ return; } if (item.type === "thread") { - if (!ownerKey || trigger.kind !== "path") return; + if (!ownerKey || (trigger.kind !== "path" && trigger.kind !== "pull-request")) return; const shell = threadShells.find( (candidate) => candidate.environmentId === item.thread.environmentId && diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index 36be1d1e21c27..a9625f03e43ba 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -22,6 +22,11 @@ import { } from "@t3tools/contracts"; import { chatMarkdownImageSources } from "@t3tools/shared/markdownPipeline"; import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; +import { + projectTurnItemForDetail, + projectTurnItemForWire, + threadStreamEventItem, +} from "@t3tools/shared/wireProjection"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as DateTime from "effect/DateTime"; @@ -50,11 +55,6 @@ import { GUEST_FAILURE } from "../orchestration-v2/GuestShareDecider.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; import { coalesceThreadLiveStream } from "../orchestration-v2/ThreadLiveEventCoalescer.ts"; import { buildBoundedThreadStreamSnapshot } from "../orchestration-v2/ThreadStream.ts"; -import { - projectTurnItemForDetail, - projectTurnItemForWire, - threadStreamEventItem, -} from "../orchestration-v2/WireProjection.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 6e5fd3516e0b1..ce253c2a5178c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2830,89 +2830,140 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - it.effect("compacts Codex with the native RPC and completes the compaction turn", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "compact-thread"; - const nativeTurnId = "compact-turn"; - const item = { type: "contextCompaction", id: "compact-item" }; - const transcript = makeCodexReplayTranscript({ - scenario: "native-compaction", - entries: [ - ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "unused" }).slice(0, 5), - { - type: "expect_outbound", - label: "compact", - frame: { - id: 3, - method: "thread/compact/start", - params: { threadId: nativeThreadId }, + it.effect.each(["reset", "billable-only", "missing", "other-turn-reset", "late-reset"] as const)( + "compacts Codex with %s token telemetry", + (usageKind) => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "compact-thread"; + const nativeTurnId = "compact-turn"; + const item = { type: "contextCompaction", id: "compact-item" }; + const usageFrame = (tokens: number, inputTokens: number, turnId = nativeTurnId) => ({ + type: "emit_inbound" as const, + label: "usage", + frame: { + method: "thread/tokenUsage/updated", + params: { + threadId: nativeThreadId, + turnId, + tokenUsage: { + total: { + totalTokens: 9437579, + inputTokens: 9408294, + cachedInputTokens: 8982400, + outputTokens: 29285, + reasoningOutputTokens: 8756, + }, + last: { + totalTokens: tokens, + inputTokens, + cachedInputTokens: 0, + outputTokens: 0, + reasoningOutputTokens: 0, + }, + modelContextWindow: 258400, + }, }, }, - { type: "emit_inbound", label: "compact", frame: { id: 3, result: {} } }, - { - type: "emit_inbound", - label: "start", - frame: { - method: "turn/started", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), + }); + const transcript = makeCodexReplayTranscript({ + scenario: "native-compaction", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "unused" }).slice( + 0, + 5, + ), + { + type: "expect_outbound", + label: "compact", + frame: { + id: 3, + method: "thread/compact/start", + params: { threadId: nativeThreadId }, }, }, - }, - ...(["item/started", "item/completed"] as const).map((method) => ({ - type: "emit_inbound" as const, - label: method, - frame: { method, params: { threadId: nativeThreadId, turnId: nativeTurnId, item } }, - })), - { - type: "emit_inbound", - label: "complete", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + { type: "emit_inbound", label: "compact", frame: { id: 3, result: {} } }, + { + type: "emit_inbound", + label: "start", + frame: { + method: "turn/started", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "inProgress" }), + }, }, }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - assert.isDefined(harness.runtime.compactThread); - yield* harness.runtime.compactThread!( - makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("compact-attempt"), - text: "/compact", - }), - ); - yield* harness.firstTerminal; - const items = harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "compaction" - ? [event.turnItem] - : [], - ); - assert.deepEqual( - items.map((entry) => entry.status), - ["running", "completed"], - ); - assert.equal(items[0]?.id, items[1]?.id); - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, + ...(usageKind === "missing" ? [] : [usageFrame(223313, 222435)]), + { + type: "emit_inbound", + label: "compaction-start", + frame: { + method: "item/started", + params: { threadId: nativeThreadId, turnId: nativeTurnId, item }, + }, + }, + ...(usageKind === "missing" ? [] : [usageFrame(226619, 223200)]), + ...(usageKind === "reset" ? [usageFrame(21607, 0)] : []), + ...(usageKind === "other-turn-reset" ? [usageFrame(21607, 0, "other-turn")] : []), + { + type: "emit_inbound", + label: "compaction-complete", + frame: { + method: "item/completed", + params: { threadId: nativeThreadId, turnId: nativeTurnId, item }, + }, + }, + ...(usageKind === "late-reset" ? [usageFrame(21607, 0)] : []), + { + type: "emit_inbound", + label: "complete", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + assert.isDefined(harness.runtime.compactThread); + yield* harness.runtime.compactThread!( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("compact-attempt"), + text: "/compact", + }), + ); + yield* harness.firstTerminal; + const items = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "compaction" + ? [event.turnItem] + : [], + ); + assert.deepEqual( + items.map((entry) => entry.status), + ["running", "completed"], + ); + assert.equal(items[0]?.id, items[1]?.id); + assert.equal(items[1]?.beforeTokenCount, usageKind === "missing" ? undefined : 223313); + assert.equal(items[1]?.afterTokenCount, usageKind === "reset" ? 21607 : undefined); + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), - ), ); it.effect.each(["failed", "interrupted", "Stop"] as const)( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 4e8fd1ac5f30b..a69c1ae9ea124 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1913,6 +1913,18 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( // Codex reports MCP startup on thread start, usually before the turn context exists. const mcpStartupFailures = new Map>(); const turnTokenUsageByThread = new Map(); + const contextUsageByThread = new Map< + string, + { + tokens: number | undefined; + compaction?: { + nativeItemId: string; + nativeTurnId: string; + before?: number; + after?: number; + }; + } + >(); const usageStateForThread = (nativeThreadId: string) => { let state = turnTokenUsageByThread.get(nativeThreadId); if (!state) { @@ -4764,6 +4776,28 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("thread/tokenUsage/updated", (payload) => Effect.gen(function* () { + const last = payload.tokenUsage.last; + const usage = contextUsageByThread.get(payload.threadId) ?? { tokens: undefined }; + usage.tokens = + Number.isSafeInteger(last.totalTokens) && last.totalTokens >= 0 + ? last.totalTokens + : undefined; + // Codex resets `last` to context size with zero usage breakdown + // after compaction. The preceding summarization request is billable + // usage, not the reduced context, so never use it as the after count. + if ( + usage.compaction && + usage.tokens !== undefined && + usage.compaction.nativeTurnId === payload.turnId && + last.inputTokens === 0 && + last.cachedInputTokens === 0 && + (last.cacheWriteInputTokens ?? 0) === 0 && + last.outputTokens === 0 && + last.reasoningOutputTokens === 0 + ) { + usage.compaction.after = usage.tokens; + } + contextUsageByThread.set(payload.threadId, usage); accumulateCodexTurnTokenUsage( usageStateForThread(payload.threadId), payload.turnId, @@ -5043,6 +5077,24 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( status: "running" | "completed" | "failed" | "interrupted" | "cancelled", updatedAt: DateTime.Utc, ) { + const nativeThreadId = context.providerThread.nativeThreadRef?.nativeId; + if (nativeThreadId && status === "running") { + const tokens = contextUsageByThread.get(nativeThreadId)?.tokens; + contextUsageByThread.set(nativeThreadId, { + tokens, + compaction: { + nativeItemId, + nativeTurnId: context.nativeTurnId, + ...(tokens === undefined ? {} : { before: tokens }), + }, + }); + } + const usage = nativeThreadId ? contextUsageByThread.get(nativeThreadId) : undefined; + const compaction = + usage?.compaction?.nativeItemId === nativeItemId ? usage.compaction : undefined; + if (status !== "running" && compaction && usage) { + delete usage.compaction; + } const { ordinal, startedAt } = yield* resolveItemPosition( context, nativeItemId, @@ -5077,6 +5129,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( : status === "failed" ? "Context compaction failed" : "Context compaction stopped", + ...(compaction?.before === undefined ? {} : { beforeTokenCount: compaction.before }), + ...(status === "completed" && compaction?.after !== undefined + ? { afterTokenCount: compaction.after } + : {}), startedAt, completedAt: status === "running" ? null : updatedAt, updatedAt, diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index d263a146a50fc..b98a33d9e5cc0 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -25,7 +25,7 @@ import { selectHistory, historicalMessage, } from "@t3tools/provider-core/server/handoffBudget"; -import { projectContextHandoffForWire } from "./WireProjection.ts"; +import { projectContextHandoffForWire } from "@t3tools/shared/wireProjection"; import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 81ec4019f2c0f..1a7af26ba985e 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -28,7 +28,7 @@ import * as SqlError from "effect/sql/SqlError"; import { replayAndBufferProjectedLiveEvents } from "./LiveStreamBudget.ts"; import type { UnsequencedProjectEvent } from "../persistence/OrchestrationEventStore.ts"; -import { projectStoredEventForWire } from "./WireProjection.ts"; +import { projectStoredEventForWire } from "@t3tools/shared/wireProjection"; import { makeRunOutcomeRecorder, type RunOutcome } from "./RunMetrics.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4ccb6bbf85ad8..250d89f775a25 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1,7 +1,7 @@ import type { ProjectThreadAwarenessV2Input } from "@t3tools/shared/agentAwareness"; import { pendingGuestMessageCount } from "@t3tools/contracts"; import { applyThreadPullRequestLinkUpdate } from "../sourceControl/threadPullRequests.ts"; -import { projectTurnItemForWire } from "./WireProjection.ts"; +import { projectTurnItemForWire } from "@t3tools/shared/wireProjection"; import * as Stream from "effect/Stream"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { makeThreadFind } from "./ThreadFind.ts"; diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 623fddb491108..5c9cf806694b3 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -40,7 +40,7 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter" import { makeProviderFailureTurnItem } from "@t3tools/provider-core/server/failure"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { stripUnservedToolOutputImageBytes } from "./toolOutputImageBytes.ts"; -import { boundFileChangeForStorage } from "./WireProjection.ts"; +import { boundFileChangeForStorage } from "@t3tools/shared/wireProjection"; export class ProviderEventNormalizeError extends Schema.TaggedError()( "ProviderEventNormalizeError", diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts index 7ea61c01f9872..5bff1e52c02d0 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -32,7 +32,7 @@ import { selectHistoryPageFromCursor, THREAD_HISTORY_PAGE_POLICY, } from "./threadHistoryPaging.ts"; -import { projectTurnItemForWire } from "./WireProjection.ts"; +import { projectTurnItemForWire } from "@t3tools/shared/wireProjection"; vi.mock("@t3tools/shared/threadFindText", { spy: true }); diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index e1a1abdc1819b..cdaf31acb81bb 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -66,7 +66,7 @@ import { threadPullRequestsOf } from "../sourceControl/threadPullRequests.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import { DispatchModeLimit, exceededDispatchModeLimit } from "./DispatchModeLimit.ts"; -import { projectTurnItemForDetail } from "./WireProjection.ts"; +import { projectTurnItemForDetail } from "@t3tools/shared/wireProjection"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as ServerSettings from "../serverSettings.ts"; diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index 5c9fd310e93ae..9c5a74e23e861 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -26,7 +26,7 @@ import { THREAD_RESUME_MAX_REPLAY_ENCODED_BYTES, THREAD_RESUME_MAX_REPLAY_EVENTS, } from "./ThreadStream.ts"; -import { projectDomainEventForWire } from "./WireProjection.ts"; +import { projectDomainEventForWire } from "@t3tools/shared/wireProjection"; const NOW = DateTime.makeUnsafe("2026-09-08T00:00:00.000Z"); const THREAD_ID = ThreadId.make("thread-stream-test"); diff --git a/apps/server/src/orchestration-v2/ThreadStream.ts b/apps/server/src/orchestration-v2/ThreadStream.ts index c16a1b9e095aa..f0a7393c7e9da 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.ts @@ -5,15 +5,15 @@ import type { import { omitLocalVisibleTurnItems } from "@t3tools/shared/orchestrationV2BoundedSnapshot"; import { dedupeSnapshotBodies } from "@t3tools/shared/orchestrationV2DedupedBodies"; +import { + capProjectionCheckpointFilesForWire, + projectThreadProjectionForWire, +} from "@t3tools/shared/wireProjection"; import { buildBoundedThreadProjection, type BoundedProjectionResult, } from "./threadHistoryPaging.ts"; -import { - capProjectionCheckpointFilesForWire, - projectThreadProjectionForWire, -} from "./WireProjection.ts"; export const THREAD_RESUME_MAX_REPLAY_EVENTS = 128; diff --git a/apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts b/apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts index 7eb434176a137..db14316a60515 100644 --- a/apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts @@ -12,7 +12,7 @@ import * as Schema from "effect/Schema"; import type * as RpcMessage from "effect/rpc/RpcMessage"; import { describe, expect, it } from "vite-plus/test"; -import { projectDomainEventForWire } from "./WireProjection.ts"; +import { projectDomainEventForWire } from "@t3tools/shared/wireProjection"; const OUTPUT_BYTES_PER_ROW = 8_192; const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index 471697f10dab6..a033cdf199e8f 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -31,7 +31,7 @@ import { projectTurnItemForDetail, projectDomainEventForWire, projectStoredEventForWire, -} from "./WireProjection.ts"; +} from "@t3tools/shared/wireProjection"; import { threadShellFromProjection } from "./ProjectionStore.ts"; import { MAX_TOOL_OUTPUT_IMAGES, toolOutputImages } from "@t3tools/shared/toolOutput"; @@ -294,6 +294,89 @@ describe("orchestration V2 wire projection", () => { expect(projected).toMatchObject({ exitCode: 0, outputOmitted: true }); }); + it("keeps the failed test count from a test runner summary", () => { + const item = { + ...base, + type: "command_execution" as const, + input: "pnpm vitest run ChatComposer", + output: `${"x".repeat(40_000)}\n Tests 2 failed | 12 passed (14)`, + exitCode: 1, + }; + const projected = projectTurnItemForWire(item); + expect(projected).not.toHaveProperty("output"); + expect(projected).toMatchObject({ outputIndicatesFailure: true, failedTestCount: 2 }); + expect( + projectTurnItemForWire({ ...item, input: "bun typecheck", output: "Tests 2 failed" }), + ).not.toHaveProperty("failedTestCount"); + }); + + it("counts header-like changed lines inside each file's hunks", () => { + const diffStr = [ + "diff --git a/a.sql b/a.sql", + "--- a/a.sql", + "+++ b/a.sql", + "@@ -1,2 +1 @@", + "--- comment", + "----", + "+++counter", + "diff --git a/b.txt b/b.txt", + "--- a/b.txt", + "+++ b/b.txt", + "@@ -1 +1 @@", + "--- old", + "+++new", + ].join("\n"); + expect( + projectTurnItemForWire({ ...base, type: "file_change", fileName: "a.sql", diffStr }), + ).toMatchObject({ additions: 2, deletions: 3 }); + }); + + it("derives change counts from the diffs providers report", () => { + const fileChange = { ...base, type: "file_change" as const, fileName: "src/main.ts" }; + expect( + projectTurnItemForWire({ + ...fileChange, + diffStr: "@@ -1,3 +1,3 @@\n a\n-b\n+c\n+d\n@@ -9 +10 @@\n-e", + }), + ).toMatchObject({ additions: 2, deletions: 2 }); + const claudeEdit = JSON.stringify({ + filePath: "src/main.ts", + oldString: '-"not counted"', + originalFile: '+ "[\\"-x\\"]"', + structuredPatch: [{ oldStart: 1, lines: [" keep", "-old", "+new", '+"quoted"'] }], + }); + const projected = projectTurnItemForWire({ ...fileChange, diffStr: claudeEdit }); + expect(projected).toMatchObject({ additions: 2, deletions: 1 }); + expect(projected).not.toHaveProperty("diffStr"); + expect( + projectTurnItemForWire({ + ...fileChange, + diffStr: JSON.stringify({ type: "create", content: "a\nb\nc\n", structuredPatch: [] }), + }), + ).toMatchObject({ additions: 3, deletions: 0 }); + expect( + projectTurnItemForWire({ ...fileChange, oldStr: "a\nb\nc", newStr: "a\nx\ny\nc" }), + ).toMatchObject({ additions: 2, deletions: 1 }); + expect(projectTurnItemForWire({ ...fileChange, diffStr: "not a diff" })).not.toHaveProperty( + "additions", + ); + for (const operation of ["add", "delete"] as const) { + for (const content of ["hi\n", "\nhi\n\n", '{"hello": true}\n', ""]) { + const lines = content === "" ? 0 : content.split("\n").length - 1; + expect( + projectTurnItemForWire({ + ...fileChange, + diffStr: content, + changes: [{ operation, path: fileChange.fileName }], + }), + ).toMatchObject({ + additions: operation === "add" ? lines : 0, + deletions: operation === "delete" ? lines : 0, + }); + } + } + }); + it("omits inline file bodies but preserves file identity and change counts", () => { const item = { ...base, diff --git a/apps/server/src/orchestration-v2/assistantStreaming.test.ts b/apps/server/src/orchestration-v2/assistantStreaming.test.ts index 0f46d467fa60c..871faced1cf94 100644 --- a/apps/server/src/orchestration-v2/assistantStreaming.test.ts +++ b/apps/server/src/orchestration-v2/assistantStreaming.test.ts @@ -2,7 +2,8 @@ import { MessageId, ProviderDriverKind, ThreadId } from "@t3tools/contracts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as DateTime from "effect/DateTime"; import { assert, describe, expect, it, vi } from "vite-plus/test"; -import { makeAssistantStreamingFilter, splitBufferedAssistantText } from "./assistantStreaming.ts"; +import { makeAssistantStreamingFilter } from "./assistantStreaming.ts"; +import { splitBufferedAssistantText } from "@t3tools/shared/assistantStreaming"; describe("V2 assistant streaming", () => { it("keeps code fences intact", () => { diff --git a/apps/server/src/orchestration-v2/assistantStreaming.ts b/apps/server/src/orchestration-v2/assistantStreaming.ts index 6f0cee57b854b..4c65651bce9e0 100644 --- a/apps/server/src/orchestration-v2/assistantStreaming.ts +++ b/apps/server/src/orchestration-v2/assistantStreaming.ts @@ -1,113 +1,12 @@ import type { ResponseStreamingMode } from "@t3tools/contracts"; +import { + advanceScan, + EMPTY_SCAN, + readyBoundary, + type AssistantTextScan, +} from "@t3tools/shared/assistantStreaming"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -// List fences can open at any indentation; closers may be at most three spaces deeper. -const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/; -// CommonMark blank lines contain only spaces and tabs. -const BLANK_LINE_PATTERN = /^[ \t]*$/; -// Require trailing space so partial list markers cannot match during streaming. -const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; -const SECTION_TITLE_PATTERN = /^ {0,3}(?:#{1,6}(?:[ \t]|$)|\*\*(?:[^*]|\*(?!\*))+\*\*:?$)/; -// Unindented ATX headings end preceding paragraphs and lists without blank lines; bold lines do not. -const TOP_LEVEL_HEADING_PATTERN = /^#{1,6}(?:[ \t]|$)/; - -// Matches LIST_ITEM_START_PATTERN on the trimmed partial line without copying it. -const PARTIAL_LIST_ITEM_START_PATTERN = /[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t][ \t\r]*[^ \t\r]/y; - -interface AssistantTextScan { - readonly text: string; - // Start of the partial line; every line before it has been scanned. - readonly lineStart: number; - readonly openFence: { readonly marker: string; readonly indent: number } | null; - readonly boundary: number; - readonly titleAwaitingContent: boolean; -} - -const EMPTY_SCAN: AssistantTextScan = { - text: "", - lineStart: 0, - openFence: null, - boundary: -1, - titleAwaitingContent: false, -}; - -function trimLineEnd(text: string, start: number, end: number): string { - while (end > start) { - const char = text.charCodeAt(end - 1); - if (char !== 32 && char !== 9 && char !== 13) break; - end--; - } - return text.slice(start, end); -} - -/** Scans the terminated lines of `text`, resuming after `previous` when `text` extends it. */ -function advanceScan(previous: AssistantTextScan, text: string): AssistantTextScan { - const resumed = text.startsWith(previous.text); - let { openFence, boundary, lineStart, titleAwaitingContent } = resumed ? previous : EMPTY_SCAN; - let searchFrom = resumed ? previous.text.length : 0; - for (;;) { - const newline = text.indexOf("\n", searchFrom); - if (newline === -1) break; - const line = trimLineEnd(text, lineStart, newline); - if ( - openFence === null && - lineStart > 0 && - !titleAwaitingContent && - LIST_ITEM_START_PATTERN.test(line) - ) { - boundary = lineStart; - } - const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line); - if (fenceMatch) { - const indent = fenceMatch[1]!.length; - const marker = fenceMatch[2]!; - if (openFence === null) { - openFence = { marker, indent }; - titleAwaitingContent = false; - } else if ( - marker[0] === openFence.marker[0] && - marker.length >= openFence.marker.length && - indent <= openFence.indent + 3 && - line.length === indent + marker.length - ) { - // CommonMark: a closing fence carries no info string. - openFence = null; - boundary = newline + 1; - } - } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { - if (!titleAwaitingContent) { - boundary = newline + 1; - } - } else if (openFence === null) { - if (lineStart > 0 && !titleAwaitingContent && TOP_LEVEL_HEADING_PATTERN.test(line)) { - boundary = lineStart; - } - titleAwaitingContent = SECTION_TITLE_PATTERN.test(line); - } - lineStart = newline + 1; - searchFrom = lineStart; - } - return { text, lineStart, openFence, boundary, titleAwaitingContent }; -} - -// The partial line may still change, so its list item start is checked here, not kept in the scan. -function readyBoundary(scan: AssistantTextScan): number { - if (scan.openFence === null && scan.lineStart > 0 && !scan.titleAwaitingContent) { - PARTIAL_LIST_ITEM_START_PATTERN.lastIndex = scan.lineStart; - if (PARTIAL_LIST_ITEM_START_PATTERN.test(scan.text)) return scan.lineStart; - } - return scan.boundary; -} - -/** Emit terminated lines only, except partial list-start lookahead for tight lists. Hold section titles until content follows. */ -export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { - const boundary = readyBoundary(advanceScan(EMPTY_SCAN, text)); - if (boundary === -1) { - return { ready: "", rest: text }; - } - return { ready: text.slice(0, boundary), rest: text.slice(boundary) }; -} - // Long enough to ride out normal gaps between chunks; short enough that text written before a // long tool call or background wait does not look lost. const ASSISTANT_IDLE_FLUSH_MS = 800; diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index b58ce1b143119..f3405c7ee4673 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -37,7 +37,7 @@ import { projectIdentitiesDigest, } from "./ShellStream.ts"; import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; -import { projectThreadProjectionForWire } from "./WireProjection.ts"; +import { projectThreadProjectionForWire } from "@t3tools/shared/wireProjection"; const isInvalidThreadHistoryCursorError = Schema.is(InvalidThreadHistoryCursorError); diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 111cf0541358a..49f14b918523f 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -25,7 +25,7 @@ import { selectRecentTimelineWindow, THREAD_HISTORY_PAGE_POLICY, } from "./threadHistoryPaging.ts"; -import { projectThreadProjectionForWire } from "./WireProjection.ts"; +import { projectThreadProjectionForWire } from "@t3tools/shared/wireProjection"; const NOW = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); const THREAD = ThreadId.make("thread-1"); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 5c65b9d973baa..7b2a2dbeb2124 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -690,6 +690,39 @@ it.layer( fs.writeFileString(filePath, contents), ); + it.effect("reports the last command exit code from shell-integration marks", () => + Effect.gen(function* () { + const { manager, ptyAdapter, getEvents } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + const failed = yield* Deferred.make(); + const succeeded = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "activity" && event.lastCommandExitCode === 1 + ? Deferred.succeed(failed, undefined).pipe(Effect.asVoid) + : event.type === "activity" && + event.lastCommandExitCode === 0 && + event.hasRunningSubprocess === false + ? Deferred.succeed(succeeded, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + // Split across chunks: the mark must still be read once it completes. + process.emitData("fail\r\n\u001b]133;D;"); + process.emitData("1\u0007\u001b]133;A\u0007$ "); + yield* Deferred.await(failed); + + process.emitData("\u001b]633;D;0\u0007$ "); + yield* Deferred.await(succeeded); + const activityCount = (yield* getEvents).filter((event) => event.type === "activity").length; + expect(activityCount).toBe(2); + }), + ); + it.effect("handles an exit replayed during subscription after publishing startup", () => Effect.gen(function* () { const ptyAdapter = new FakePtyAdapter(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 625b678446e17..e9fc85389e140 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -306,6 +306,8 @@ interface TerminalSessionState { unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; childCommandLabel: string | null; + /** Exit code from the shell's last OSC 133/633 command-finished mark, if it reported one. */ + lastCommandExitCode: number | null; runtimeEnv: Record | null; stopOnSettle: boolean; } @@ -339,6 +341,12 @@ type DrainProcessEventAction = sequence: number; history: BoundedTerminalHistory | null; data: string; + activity: { + sequence: number; + hasRunningSubprocess: boolean; + lastCommandExitCode: number | null; + label: string; + } | null; } | { type: "exit"; @@ -417,6 +425,9 @@ function summary(session: TerminalSessionState): TerminalSummary { exitCode: session.exitCode, exitSignal: session.exitSignal, hasRunningSubprocess: session.hasRunningSubprocess, + ...(session.lastCommandExitCode !== null + ? { lastCommandExitCode: session.lastCommandExitCode } + : {}), label: terminalWireLabel(session), updatedAt: session.updatedAt, }; @@ -1186,12 +1197,30 @@ function findEscapeSequenceEndIndex(input: string, start: number): number | null const HISTORY_SEQUENCE_START = /\u001b/; const NEXT_HISTORY_SEQUENCE_START = new RegExp(HISTORY_SEQUENCE_START.source, "g"); +/** + * Reads a shell-integration "command finished" mark (OSC 133;D or VS Code's + * OSC 633;D). Returns the exit code, null for a mark without one, and + * undefined for any other OSC. + */ +function readCommandFinishedExitCode(content: string): number | null | undefined { + const match = /^(?:133|633);D(?:;(-?\d+))?(?:;|$)/.exec(content); + if (!match) return undefined; + if (match[1] === undefined) return null; + const exitCode = Number.parseInt(match[1], 10); + return Number.isSafeInteger(exitCode) ? exitCode : null; +} + function sanitizeTerminalHistoryChunk( pendingControlSequence: string, data: string, -): { visibleText: string; pendingControlSequence: string } { +): { + visibleText: string; + pendingControlSequence: string; + /** Last command-finished mark in this chunk; undefined when there was none. */ + commandExitCode: number | null | undefined; +} { if (pendingControlSequence.length === 0 && !HISTORY_SEQUENCE_START.test(data)) { - return { visibleText: data, pendingControlSequence: "" }; + return { visibleText: data, pendingControlSequence: "", commandExitCode: undefined }; } const input = `${pendingControlSequence}${data}`; @@ -1199,6 +1228,11 @@ function sanitizeTerminalHistoryChunk( const pendingScanStart = Math.max(0, pendingControlSequence.length - 1); let visibleText = ""; let index = 0; + let commandExitCode: number | null | undefined; + const readOsc = (content: string) => { + const exitCode = readCommandFinishedExitCode(content); + if (exitCode !== undefined) commandExitCode = exitCode; + }; const append = (value: string) => { visibleText += value; @@ -1210,7 +1244,7 @@ function sanitizeTerminalHistoryChunk( if (codePoint === 0x1b) { const nextCodePoint = input.charCodeAt(index + 1); if (Number.isNaN(nextCodePoint)) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return { visibleText, pendingControlSequence: input.slice(index), commandExitCode }; } if (nextCodePoint === 0x5b) { @@ -1229,7 +1263,7 @@ function sanitizeTerminalHistoryChunk( cursor += 1; } if (cursor >= input.length) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return { visibleText, pendingControlSequence: input.slice(index), commandExitCode }; } continue; } @@ -1245,10 +1279,11 @@ function sanitizeTerminalHistoryChunk( Math.max(index + 2, pendingScanStart), ); if (terminatorIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return { visibleText, pendingControlSequence: input.slice(index), commandExitCode }; } const sequence = input.slice(index, terminatorIndex); const content = stripStringTerminator(input.slice(index + 2, terminatorIndex)); + if (nextCodePoint === 0x5d) readOsc(content); const strip = (nextCodePoint === 0x5d && shouldStripOscSequence(content)) || (nextCodePoint === 0x50 && shouldStripDcsSequence(content)); @@ -1261,7 +1296,7 @@ function sanitizeTerminalHistoryChunk( const escapeSequenceEndIndex = findEscapeSequenceEndIndex(input, index + 1); if (escapeSequenceEndIndex === null) { - return { visibleText, pendingControlSequence: input.slice(index) }; + return { visibleText, pendingControlSequence: input.slice(index), commandExitCode }; } append(input.slice(index, escapeSequenceEndIndex)); index = escapeSequenceEndIndex; @@ -1275,7 +1310,7 @@ function sanitizeTerminalHistoryChunk( index = nextSequenceStart; } - return { visibleText, pendingControlSequence: "" }; + return { visibleText, pendingControlSequence: "", commandExitCode }; } function legacySafeThreadId(threadId: string): string { @@ -2246,6 +2281,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.history.append(sanitized.visibleText); } const eventStamp = advanceEventSequence(session); + let activity: Extract["activity"] = null; + if ( + sanitized.commandExitCode !== undefined && + sanitized.commandExitCode !== session.lastCommandExitCode + ) { + session.lastCommandExitCode = sanitized.commandExitCode; + activity = { + sequence: advanceEventSequence(session).sequence, + hasRunningSubprocess: session.hasRunningSubprocess, + lastCommandExitCode: session.lastCommandExitCode, + label: terminalWireLabel(session), + }; + } return { type: "output", @@ -2254,6 +2302,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func sequence: eventStamp.sequence, history: sanitized.visibleText.length > 0 ? session.history : null, data: nextEvent.data, + activity, } as const; } @@ -2263,6 +2312,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.pid = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; + session.lastCommandExitCode = null; session.status = "exited"; session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; @@ -2303,6 +2353,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func sequence: action.sequence, data: action.data, }); + if (action.activity !== null) { + yield* publishEvent({ + type: "activity", + threadId: action.threadId, + terminalId: action.terminalId, + sequence: action.activity.sequence, + hasRunningSubprocess: action.activity.hasRunningSubprocess, + ...(action.activity.lastCommandExitCode !== null + ? { lastCommandExitCode: action.activity.lastCommandExitCode } + : {}), + label: action.activity.label, + }); + } continue; } @@ -2335,6 +2398,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.pid = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; + session.lastCommandExitCode = null; session.status = "exited"; session.pendingHistoryControlSequence = ""; session.pendingProcessEvents = []; @@ -2441,6 +2505,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.exitSignal = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; + session.lastCommandExitCode = null; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; @@ -2559,6 +2624,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func session.process = null; session.hasRunningSubprocess = false; session.childCommandLabel = null; + session.lastCommandExitCode = null; session.pendingProcessEvents = []; session.pendingProcessEventIndex = 0; session.processEventDrainRunning = false; @@ -2747,6 +2813,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId: liveSession.value.terminalId, sequence: eventStamp.sequence, hasRunningSubprocess: next.hasRunningSubprocess, + ...(liveSession.value.lastCommandExitCode !== null + ? { lastCommandExitCode: liveSession.value.lastCommandExitCode } + : {}), label: terminalWireLabel(liveSession.value), }), state, @@ -2889,6 +2958,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func unsubscribeExit: null, hasRunningSubprocess: false, childCommandLabel: null, + lastCommandExitCode: null, runtimeEnv: normalizedRuntimeEnv(input.env), stopOnSettle: input.stopOnSettle ?? false, }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 02bcb51c127e3..35e1efaa35e5a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -167,7 +167,7 @@ import { projectDomainEventForWire, projectThreadProjectionForWire, threadStreamEventItem, -} from "./orchestration-v2/WireProjection.ts"; +} from "@t3tools/shared/wireProjection"; import { dedupeSnapshotBodies } from "@t3tools/shared/orchestrationV2DedupedBodies"; import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; import * as ThreadSearch from "./orchestration-v2/ThreadSearch.ts"; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 731b0194bc967..423960b44a85b 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { + lazy, + Suspense, useEffect, useState, useSyncExternalStore, @@ -51,6 +53,7 @@ import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; import ThreadSidebar from "./Sidebar"; import { isAppModalPage } from "./sidebar/mainAppLocation"; +import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import type { SidebarRouteReveal } from "./sidebar/routeReveal"; import { ChromeChannelArt, useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { @@ -473,6 +476,15 @@ function NavigationHistoryButton({ ); } +// Dev only, and loaded on demand so production bundles never carry it. +const DesignSystemSidebarNav = import.meta.env.DEV + ? lazy(() => + import("./design-system/DesignSystemSidebarNav").then((module) => ({ + default: module.DesignSystemSidebarNav, + })), + ) + : null; + export function AppSidebarLayout({ children, showSidebar = true, @@ -490,6 +502,9 @@ export function AppSidebarLayout({ const pathname = useLocation({ select: (location) => location.pathname }); const panelAnimationsSuppressed = usePanelNavigationSuppression(pathname); const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; + const isOnDesignSystem = + DesignSystemSidebarNav !== null && + (pathname === "/design-system" || pathname.startsWith("/design-system/")); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); // A clamped drag may not change width, so viewport changes need a separate subscription. @@ -572,7 +587,7 @@ export function AppSidebarLayout({ data-app-sidebar="" {...sidebarArea} role="navigation" - aria-label="Threads" + aria-label={isOnDesignSystem ? "Design system" : "Threads"} // Light, black chrome paints the sidebar with the dark tokens (index.css). data-chrome-scope="" resizable={{ @@ -586,7 +601,16 @@ export function AppSidebarLayout({ }} style={windowControlsInsetStyle} > - + {isOnDesignSystem && DesignSystemSidebarNav ? ( + <> + + + + + + ) : ( + + )} diff --git a/apps/web/src/components/BranchPicker.tsx b/apps/web/src/components/BranchPicker.tsx index 520a44844d9ff..0cbce6667469a 100644 --- a/apps/web/src/components/BranchPicker.tsx +++ b/apps/web/src/components/BranchPicker.tsx @@ -1,11 +1,12 @@ import { useLegendListRef } from "~/hooks/useLegendListRef"; import type { VcsRef } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; +import { CheckIcon, CloudIcon, FolderGit2Icon, GitBranchIcon } from "lucide-react"; import { useId, useRef, useState, type ComponentProps, type ReactNode } from "react"; import { MiddleTruncate } from "./ui/middle-truncate"; import { cn } from "../lib/utils"; -import { RefreshIcon } from "./ui/refresh-icon"; +import { Badge } from "./ui/badge"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { @@ -19,6 +20,10 @@ import { } from "./ui/combobox"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +function BranchPickerSeparator() { + return
; +} + export function BranchPicker({ items, filteredItems, @@ -37,6 +42,7 @@ export function BranchPicker({ popupProps, renderItem, getItemType, + getItemGroup, children, }: { items: string[]; @@ -56,6 +62,7 @@ export function BranchPicker({ popupProps: Omit, "children">; renderItem: (value: string, index: number) => ReactNode; getItemType?: ((value: string) => string) | undefined; + getItemGroup?: ((value: string) => string) | undefined; children: ReactNode; }) { const highlightedValueRef = useRef(null); @@ -103,8 +110,9 @@ export function BranchPicker({ value={value} > {children} - + { @@ -135,9 +143,23 @@ export function BranchPicker({ initialScrollOffset={0} keyExtractor={(item) => item} {...(getItemType ? { getItemType } : {})} - renderItem={({ item, index }) => renderItem(item, index)} + 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} - estimatedItemSize={28} + ItemSeparatorComponent={BranchPickerSeparator} + estimatedItemSize={33} drawDistance={336} onScroll={updateBranchListScrollFades} onLayout={updateBranchListScrollFades} @@ -145,7 +167,7 @@ export function BranchPicker({ onEndReached={fetchNextBranchPage} onEndReachedThreshold={0.5} className={cn( - "min-h-0 max-h-56 scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 py-2", + "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, @@ -160,12 +182,8 @@ export function BranchPicker({ render={ } /> @@ -211,15 +231,8 @@ export function BranchPickerRefItem({ const itemValue = refName.name; const hasSecondaryWorktree = refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd; - const badge = refName.current - ? "current" - : hasSecondaryWorktree - ? "worktree" - : refName.isRemote - ? "remote" - : refName.isDefault - ? "default" - : null; + const badge = hasSecondaryWorktree ? "worktree" : refName.isDefault ? "default" : null; + const Icon = refName.isRemote ? CloudIcon : hasSecondaryWorktree ? FolderGit2Icon : GitBranchIcon; return ( -
+
+ - {badge && {badge}} + {badge ? ( + + {badge} + + ) : null} + {refName.current ? ( + + ) : null}
); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 5dec87e3d73d7..3a3aa48407db5 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -81,14 +81,21 @@ export function shouldShowComposerContextStrip(input: { // Hysteresis prevents labels and icons from flapping at the width boundary. const CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX = 16; -export function resolveContextStripLabelsCompact(input: { - compact: boolean; +export type ContextStripCompaction = "none" | "labels" | "branch"; + +export function resolveContextStripCompaction(input: { + current: ContextStripCompaction; neededWidth: number; availableWidth: number; -}): boolean { - return input.compact - ? input.neededWidth > input.availableWidth - CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX - : input.neededWidth > input.availableWidth; + labelsSavedWidth: number; +}): ContextStripCompaction { + const fits = (width: number, expanding: boolean) => + width <= input.availableWidth - (expanding ? CONTEXT_STRIP_COMPACT_EXPAND_HYSTERESIS_PX : 0); + if (fits(input.neededWidth, input.current !== "none")) return "none"; + if (fits(input.neededWidth - input.labelsSavedWidth, input.current === "branch")) { + return "labels"; + } + return "branch"; } export function resolveEnvModeLabel(mode: EnvMode): string { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index db2fa2151b37d..6246c18a4c44a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -2,7 +2,6 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; import { readLocalApi } from "../localApi"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { ComposerContextLabel } from "./ComposerContextLabel"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { @@ -10,6 +9,7 @@ import { FolderGit2Icon, FolderGitIcon, FolderIcon, + GitBranchIcon, PlusIcon, ScaleIcon, } from "lucide-react"; @@ -31,7 +31,8 @@ import { ADD_TO_MACHINE_LABEL_CLASS, type EnvMode, type EnvironmentOption, - resolveContextStripLabelsCompact, + resolveContextStripCompaction, + type ContextStripCompaction, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, resolveLockedWorkspaceLabel, @@ -46,7 +47,7 @@ import { import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { openAddProjectToMachine } from "./AddProjectToMachineDialog"; import { type AddProjectTarget, useAddProjectTargets } from "../hooks/useAddProjectTargets"; -import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { ComposerContextLabel } from "./ComposerContextLabel"; import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { @@ -68,10 +69,14 @@ import { MenuSubTrigger, MenuTrigger, } from "./ui/menu"; -import { Separator } from "./ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; import { ComposerSurface } from "./chat/ComposerSurface"; +import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; +import { + resolveRestingComposerControlsMinimumWidth, + resolveRestingComposerControlsNaturalWidth, +} from "./composerFooterLayout"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { cn } from "~/lib/utils"; import { observeResize } from "~/lib/observeResize"; @@ -106,12 +111,255 @@ interface BranchToolbarProps { onComposerFocusRequest?: () => void; /** Shown at the end of the composer strip, such as the new thread's permissions. */ trailing?: ReactNode; + afterBranch?: ReactNode; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; + /** The thread's machine is offline or reconnecting. */ + environmentUnavailable?: boolean; /** Panel only: where the branch picker renders the thread's PR rows. */ panelPrRowsContainer?: HTMLElement | null; } +interface WorkspaceMenuProps { + workspaceRoot: string | undefined; + forceNewWorktree: boolean; + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; + envLocked: boolean; + envModeLocked: boolean; + environmentId: EnvironmentId; + availableEnvironments: readonly EnvironmentOption[] | undefined; + showEnvironmentPicker: boolean; + onEnvironmentChange: ((environmentId: EnvironmentId) => void) | undefined; + effectiveEnvMode: EnvMode; + activeWorktreePath: string | null; + onEnvModeChange: (mode: EnvMode) => void; + previousWorktreeLabel: string | null; + previousWorktreeBranch: string | null; + onUsePreviousWorktree: () => void; + addTargets: readonly AddProjectTarget[]; + onAddToMachine: (environmentId: EnvironmentId) => void; +} + +/** The workspace trigger stays separate from branch selection; only drafts expose workspace choices. */ +const WorkspaceMenu = memo(function WorkspaceMenu({ + workspaceRoot, + forceNewWorktree, + autoEnvironmentLabel, + onAutoEnvironment, + envLocked, + envModeLocked, + environmentId, + availableEnvironments, + showEnvironmentPicker, + onEnvironmentChange, + effectiveEnvMode, + activeWorktreePath, + onEnvModeChange, + previousWorktreeLabel, + previousWorktreeBranch, + onUsePreviousWorktree, + addTargets, + onAddToMachine, +}: WorkspaceMenuProps) { + const composerFloatingLayerProps = useComposerMenuProps(); + const inWorktree = forceNewWorktree || effectiveEnvMode === "worktree" || activeWorktreePath; + const WorkspaceIcon = inWorktree ? FolderGit2Icon : GitBranchIcon; + const workspaceLabel = forceNewWorktree + ? resolveEnvModeLabel("worktree") + : envModeLocked + ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode) + : effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree") + : resolveCurrentWorkspaceLabel(activeWorktreePath); + const canPickEnvironment = showEnvironmentPicker && !envLocked; + const icon = ; + + if (envModeLocked && !canPickEnvironment) { + return ( + + + } + > + {icon} + + {workspaceLabel} + + ); + } + + return ( + + + } + className={envModeLocked ? "ps-2 pe-0.5" : "ps-2"} + aria-label={`Workspace: ${workspaceLabel}`} + data-composer-context-control + data-composer-shortcut={[ + canPickEnvironment ? "composer.host" : "", + !envModeLocked ? "composer.workspace" : "", + ].join(" ")} + /> + } + > + {icon} + {envModeLocked ? null : ( + <> + {workspaceLabel} + + + )} + + {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 + + + + {addTargets.map((target) => ( + onAddToMachine(target.environmentId)} + > + + + {target.label} + + + ))} + + + ) : null} + + + + ) : null} + + Workspace + { + if (value === "previous-worktree") { + onUsePreviousWorktree(); + return; + } + onEnvModeChange(value as EnvMode); + }} + > + + + {activeWorktreePath ? ( + + ) : ( + + )} + + {workspaceRoot ? ( + + {workspaceRoot} + + ) : null} + + + + + + Worktree + + {forceNewWorktree + ? "Each model starts in its own worktree." + : "Start in a new worktree"} + + + + {previousWorktreeLabel ? ( + <> + Previous worktree + + + + + ) : null} + + + + + ); +}); + +/** The thread panel's run-context row: machine, workspace, and a copy-path context menu. */ function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) { const api = readLocalApi(); if (!api) return; @@ -285,7 +533,7 @@ const RunContextSelector = memo(function RunContextSelector({ className={cn( isPanel ? THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS - : "inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-1.75 font-normal text-dim-foreground text-xs sm:h-6", + : "inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-1.75 text-dim-foreground text-caption sm:h-6", )} onContextMenu={handleContextMenu} aria-label={ @@ -465,6 +713,12 @@ const RunContextSelector = memo(function RunContextSelector({ const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]"; +const RESTING_CONTROLS_SELECTOR = '[data-chat-composer-resting-controls="true"]'; + +function readStripCompaction(strip: HTMLElement): ContextStripCompaction { + const value = strip.dataset.compact; + return value === "labels" || value === "branch" ? value : "none"; +} /** A hidden or squeezed label's scrollWidth loses its tail, causing label/icon flapping. */ function labelTextWidth(label: HTMLElement, range: Range): number { @@ -515,15 +769,15 @@ function animateLabelWidths( } /** - * Whether the strip's labels must collapse to icons. The strip is measured when + * How far the strip collapses: labels to icons, then the branch. The strip is measured when * it, a control group or a hosted control resizes, when its text or children * change, and when fonts load, which covers everything the widths depend on. */ -function useLabelsOverflow(): readonly [ +function useStripCompaction(): readonly [ attach: (element: HTMLDivElement | null) => (() => void) | undefined, - overflows: boolean, + compaction: ContextStripCompaction, ] { - const [overflows, setOverflows] = useState(false); + const [compaction, setCompaction] = useState("none"); const attach = (current: HTMLDivElement | null) => { if (!current) return; @@ -531,7 +785,7 @@ function useLabelsOverflow(): readonly [ const measure = () => { // The committed attribute, not render state, so a stale closure cannot misread it. - const compact = current.hasAttribute("data-compact"); + const compact = readStripCompaction(current); const stripWidth = current.clientWidth; if (stripWidth === 0) return; const stripStyle = getComputedStyle(current); @@ -562,28 +816,39 @@ function useLabelsOverflow(): readonly [ let groups = 0; for (const child of current.children) { if (!(child instanceof HTMLElement)) continue; - const width = contentWidth(child); + const controls = child.querySelector(RESTING_CONTROLS_SELECTOR); + const controlsMeasurement = controls ? measureRestingComposerControls(controls) : null; + if (controlsMeasurement) { + child.style.minWidth = `${resolveRestingComposerControlsMinimumWidth(controlsMeasurement)}px`; + } + const width = controlsMeasurement + ? resolveRestingComposerControlsNaturalWidth(controlsMeasurement) + : contentWidth(child); if (width <= 1) continue; groups += 1; needed += width; } needed += stripGap * Math.max(0, groups - 1); const range = document.createRange(); - for (const label of current.querySelectorAll("[data-composer-label]")) { - needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width); + let labelsSavedWidth = 0; + for (const label of current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)) { + const textWidth = labelTextWidth(label, range); + needed += Math.max(0, textWidth - label.getBoundingClientRect().width); + if (label.dataset.composerLabel === "hide") labelsSavedWidth += textWidth; } - const nextOverflows = resolveContextStripLabelsCompact({ - compact, + const nextCompaction = resolveContextStripCompaction({ + current: compact, neededWidth: needed, availableWidth: available, + labelsSavedWidth, }); - if (nextOverflows === compact) return; + if (nextCompaction === compact) return; const previousRects = new Map( Array.from(current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)).map( (label) => [label, label.getBoundingClientRect()] as const, ), ); - flushSync(() => setOverflows(nextOverflows)); + flushSync(() => setCompaction(nextCompaction)); animateLabelWidths(previousRects, labelAnimations); }; @@ -620,7 +885,7 @@ function useLabelsOverflow(): readonly [ }; }; - return [attach, overflows] as const; + return [attach, compaction] as const; } export const BranchToolbar = memo(function BranchToolbar({ @@ -649,6 +914,8 @@ export const BranchToolbar = memo(function BranchToolbar({ availableEnvironments, onEnvironmentChange, trailing, + afterBranch, + environmentUnavailable = false, panelPrRowsContainer, }: BranchToolbarProps) { const branchSelectorRef = useRef(null); @@ -710,13 +977,12 @@ 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, - // matching the strip visibility ChatView computes from the same inputs. + // Started threads get no handler but still show which machine they run on. const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ activeEnvironment: activeEnvironmentOption, canPickEnvironment: hasMultipleEnvironments, }); - const [attachStrip, labelsOverflow] = useLabelsOverflow(); + const [attachStrip, stripCompaction] = useStripCompaction(); if (!activeProject) return null; @@ -771,84 +1037,59 @@ export const BranchToolbar = memo(function BranchToolbar({ ); } + const workspaceMenu = ( + + ); + // Local threads choose their machine from the workspace menu. + const showMachineChip = + availableEnvironments !== undefined && + showEnvironmentIndicator && + activeEnvironmentOption?.isPrimary === false; + return ( - {showGitControls ? ( -
- -
- ) : null} - {showGitControls || showEnvironmentIndicator ? ( -
- {showEnvironmentIndicator && availableEnvironments && ( - <> - 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})} - /> - {showGitControls ? ( - - ) : null} - - )} - {showGitControls ? ( - - ) : null} -
+ {showMachineChip ? ( + 0 ? { onAddToMachine: () => onAddToMachine(null) } : {})} + /> ) : null} - + {!showGitControls && !showMachineChip ? workspaceMenu : null} {showGitControls ? ( ) : null} + {afterBranch} {trailing}
); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 467cb46a9b910..52981134a28d8 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,6 +1,5 @@ import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; import { ComposerContextLabel } from "./ComposerContextLabel"; -import { useRightPanelStore } from "../rightPanelStore"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted, @@ -14,21 +13,22 @@ import { type VcsRef, type ThreadId, } from "@t3tools/contracts"; -import { GitBranchIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; import { useEffect, useImperativeHandle, useOptimistic, + useRef, useState, useTransition, type MouseEvent as ReactMouseEvent, + type ReactNode, type Ref, } from "react"; 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 { readThreadShell, useProject, useQuietThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -56,15 +56,12 @@ import { sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; -import { - ThreadPullRequestBadgeControl, - resolveDisplayedThreadPr, - useThreadDisplayedPullRequest, -} from "./ThreadStatusIndicators"; +import { resolveDisplayedThreadPr } from "./ThreadStatusIndicators"; 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 { stackedThreadToast, toastManager } from "./ui/toast"; @@ -77,6 +74,13 @@ interface BranchToolbarBranchSelectorProps { ref?: Ref; className?: string; displayMode?: "toolbar" | "panel"; + leading?: ReactNode; + /** Composer only: one chip for the branch, workspace mode, and machine. */ + renderWorkspaceMenu?: (branch: { + label: string; + onChooseRef: () => void; + disabled: boolean; + }) => ReactNode; environmentId: EnvironmentId; threadId: ThreadId; draftId?: DraftId; @@ -107,6 +111,8 @@ export function BranchToolbarBranchSelector({ ref, className, displayMode = "toolbar", + renderWorkspaceMenu, + leading, environmentId, threadId, draftId, @@ -121,6 +127,7 @@ export function BranchToolbarBranchSelector({ prRowsContainer, }: BranchToolbarBranchSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); + const toolbarAnchorRef = useRef(null); const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); @@ -589,6 +596,20 @@ export function BranchToolbarBranchSelector({ [handleOpenChange, isBranchActionPending, isInitialBranchesLoadPending], ); + // Until git reports the ref, a placeholder holds the label's place so the + // draft never flashes the "Select ref" prompt before its base ref, nor + // "From main" before learning the base starts from origin/main. + const isActiveBranchResolving = + branchCwd !== null && + (resolvedActiveBranch === null + ? isInitialBranchesLoadPending || + (branchStatusQuery.data === null && branchStatusQuery.error === null) + : startFromOrigin && + effectiveEnvMode === "worktree" && + !activeWorktreePath && + resolvedActiveBranchIsRemote === null && + (isInitialBranchesLoadPending || + (activeBranchRefQuery.data === null && activeBranchRefQuery.error === null))); const triggerLabel = resolveBranchTriggerLabel({ activeWorktreePath, effectiveEnvMode, @@ -605,18 +626,6 @@ export function BranchToolbarBranchSelector({ threadBranch: branchPrBranch, gitStatus: branchStatusQuery.data, }); - const { - badge: prBadge, - number: prNumber, - url: prUrl, - status: prStatus, - } = useThreadDisplayedPullRequest( - environmentId, - serverThread, - branchPr, - branchStatusQuery.data?.sourceControlProvider, - ); - const openPrLink = useOpenPrLink(threadRef); function selectPickerItem(itemValue: string) { if (itemValue === checkoutPullRequestItemValue && prReference && onCheckoutPullRequestRequest) { @@ -722,39 +731,46 @@ export function BranchToolbarBranchSelector({ ? "create-branch" : "branch" } + getItemGroup={(item) => + item === checkoutPullRequestItemValue + ? "Pull requests" + : item === createBranchItemValue + ? "Create" + : branchByName.get(item)?.isRemote + ? "Remote" + : "Local" + } originControl={ isSelectingWorktreeBase ? { checked: startFromOrigin, onCheckedChange: onStartFromOriginChange } : undefined } popupProps={{ - align: displayMode === "panel" ? "start" : "end", + ...(displayMode === "toolbar" + ? { + anchor: () => + toolbarAnchorRef.current?.closest('[data-slot="composer-shell"]') ?? + toolbarAnchorRef.current, + } + : {}), + align: "start", side: displayMode === "panel" ? "right" : "top", - className: cn("flex flex-col", displayMode === "panel" ? "w-(--anchor-width)" : "w-80"), + className: cn( + "flex flex-col", + displayMode === "panel" ? "w-(--anchor-width)" : "min-w-0 w-90", + ), ...(displayMode === "toolbar" ? composerFloatingLayerProps : {}), }} >
- {displayMode !== "panel" ? ( - } - badge={prBadge} - pullRequests={serverThread?.pullRequests ?? []} - number={prNumber} - url={prUrl} - status={prStatus} - onOpenList={() => useRightPanelStore.getState().open(threadRef, "pull-requests")} - onOpenPullRequest={(event, targetUrl = prUrl) => { - if (targetUrl) openPrLink(event, targetUrl); - }} - /> - ) : null} + {leading} { @@ -764,26 +780,49 @@ export function BranchToolbarBranchSelector({ }} onContextMenu={(event) => handleBranchContextMenu(event, resolvedActiveBranch)} > - - ) : ( - - ) - } - className="min-w-0 max-w-full active:scale-100" - disabled={isInitialBranchesLoadPending || isBranchActionPending} - > - handleOpenChange(true), + disabled: isInitialBranchesLoadPending || isBranchActionPending, + }) + ) : ( + + ) : ( + + ) + } + className="min-w-0 max-w-full active:scale-100" + disabled={isInitialBranchesLoadPending || isBranchActionPending} + > + {leading ? null : ( + )} - /> - - - - + + {isActiveBranchResolving ? ( + + ) : ( + + )} + + {displayMode === "toolbar" ? ( + + ) : null} + + )} {displayMode === "panel" && prRowsContainer !== null ? portalTo( diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx deleted file mode 100644 index 71d943c5cf5d7..0000000000000 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { ComposerSelectControl } from "./chat/ComposerControl"; -import { ComposerContextLabel } from "./ComposerContextLabel"; -import { FolderGit2Icon, FolderGitIcon, FolderIcon } from "lucide-react"; -import { memo, type MouseEvent as ReactMouseEvent } from "react"; - -import { - resolveCurrentWorkspaceLabel, - resolveEnvModeLabel, - resolveLockedWorkspaceLabel, - type EnvMode, -} from "./BranchToolbar.logic"; -import { useComposerMenuProps } from "./chat/composerEventScope"; -import { PreviousWorktreeItemContent } from "./PreviousWorktreeItemContent"; -import { - Select, - SelectGroup, - SelectGroupLabel, - SelectItem, - SelectPopup, - SelectValue, -} from "./ui/select"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { useAtomValue } from "@effect/atom-react"; -import { shortcutLabelForCommand } from "../keybindings"; -import { primaryServerKeybindingsAtom } from "../state/server"; - -const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; - -interface BranchToolbarEnvModeSelectorProps { - forceNewWorktree?: boolean; - envLocked: boolean; - effectiveEnvMode: EnvMode; - activeWorktreePath: string | null; - onEnvModeChange: (mode: EnvMode) => void; - previousWorktreeLabel?: string | null; - previousWorktreeBranch?: string | null; - onUsePreviousWorktree?: () => void; -} - -export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSelector({ - forceNewWorktree = false, - envLocked, - effectiveEnvMode, - activeWorktreePath, - onEnvModeChange, - previousWorktreeLabel, - previousWorktreeBranch = null, - onUsePreviousWorktree, -}: BranchToolbarEnvModeSelectorProps) { - const composerFloatingLayerProps = useComposerMenuProps(); - const shortcutLabel = shortcutLabelForCommand( - useAtomValue(primaryServerKeybindingsAtom), - "composer.workspace", - ); - const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); - const envModeItems = [ - { - value: "local", - label: resolveCurrentWorkspaceLabel(activeWorktreePath), - }, - { value: "worktree", label: resolveEnvModeLabel("worktree") }, - ...(showPreviousWorktree && previousWorktreeLabel - ? [{ value: PREVIOUS_WORKTREE_SELECT_VALUE, label: previousWorktreeLabel }] - : []), - ]; - - const stopContextMenuMouseDown = (event: ReactMouseEvent) => { - if (event.button !== 0 || event.ctrlKey) { - event.stopPropagation(); - } - }; - - if (envLocked || forceNewWorktree) { - const lockedRow = ( - - {activeWorktreePath ? ( - - ) : effectiveEnvMode === "worktree" ? ( - - ) : ( - - )} - - {forceNewWorktree - ? resolveEnvModeLabel("worktree") - : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)} - - - ); - - return ( - - - - {forceNewWorktree - ? "Each model starts in its own worktree." - : (activeWorktreePath ?? - resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode))} - - - ); - } - - return ( - - ); -}); diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index dccff4185c90e..a3160cb8a9045 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -6,11 +6,12 @@ import { useAtomValue } from "@effect/atom-react"; import { shortcutLabelForCommand } from "../keybindings"; import { primaryServerKeybindingsAtom } from "../state/server"; import type { EnvironmentId } from "@t3tools/contracts"; -import { PlusIcon, ScaleIcon } from "lucide-react"; +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, @@ -29,6 +30,8 @@ interface BranchToolbarEnvironmentSelectorProps { 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. @@ -44,7 +47,9 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir availableEnvironments, onEnvironmentChange, onAddToMachine, + unavailable = false, }: BranchToolbarEnvironmentSelectorProps) { + const unavailableTone = unavailable; const composerFloatingLayerProps = useComposerMenuProps(); const shortcutLabel = shortcutLabelForCommand( useAtomValue(primaryServerKeybindingsAtom), @@ -64,12 +69,13 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ...(onAddToMachine ? [{ value: ADD_MACHINE_VALUE, label: "Add to another machine" }] : []), ]; - // Match the xs control's height and padding: the composer strip has no min-height, - // and its glass seam assumes that height even when this label is the only control. if (envLocked || (onEnvironmentChange === undefined && onAddToMachine === undefined)) { const lockedRow = ( } > - {autoEnvironmentLabel ? ( -
)} - {threadStatusLine} {pendingGuestMessageCount > 0 && isServerThread && activeThread ? ( ) : null} - {hasQueuedTurns ? queuedRunsControl : null} ); @@ -3368,25 +3538,6 @@ export default function ChatView(props: ChatViewProps) { onDrop={workspaceFileDropHandlers.onDrop} > - {isWorkspaceFileDragActive ? : null} -
- -
- - + +
+ {hasThreadNow ? ( +
+ +
+ ) : null} + {hasQueuedTurns ? queuedRunsControl : null} + {composerTasks?.type === "todo_list" && + (isWorking || + composerTasks.steps.some((step) => step.status !== "completed")) ? ( + + ) : null} + +
+ {showProviderSubagentBar ? ( + + ) : null + } + /> + ) : null} + {!composerMounted ? null : ( + + )} +
+
+
+
-
- {showProviderSubagentBar ? ( - - ) : null - } - /> - ) : null} - {!composerMounted ? null : ( - + } - threadPullRequests={ - (activeThreadShell ?? activeThread)?.pullRequests - } - onComposerOverlayHeightChange={publishComposerOverlayHeightStable} - permissionsShownInStrip={ - showComposerContextStrip && !isServerThread - } - promptRef={promptRef} - composerImagesRef={composerImagesRef} - composerFilesRef={composerFilesRef} - composerTerminalContextsRef={composerTerminalContextsRef} - onPageScrollKeyDown={onComposerPageScrollKeyDownStable} - onPageScrollKeyUp={onComposerPageScrollKeyUpStable} - onPageScrollRelease={onComposerPageScrollReleaseStable} - onCompactContext={onComposerCompactContextStable} - onSend={onComposerSendStable} - onResume={onComposerResumeStable} - onInterrupt={onInterruptStable} - onSelectActivePendingUserInputOption={ - onSelectActivePendingUserInputOptionStable - } - onAdvanceActivePendingUserInput={ - onAdvanceActivePendingUserInputStable - } - onDismissActivePendingUserInput={onDismissUserInputStable} - onPreviousActivePendingUserInputQuestion={ - onPreviousActivePendingUserInputQuestionStable - } - onChangeActivePendingUserInputCustomAnswer={ - onChangeActivePendingUserInputCustomAnswer - } - onProviderModelSelect={onProviderModelSelectStable} - onOpenProviderSetup={openProviderSetupStable} - getModelDisabledReason={getModelDisabledReason} - handleRuntimeModeChange={handleRuntimeModeChangeStable} - focusComposer={focusComposerStable} - scheduleComposerFocus={scheduleComposerFocusStable} - setThreadError={setThreadError} - onExpandImage={onExpandTimelineImageStable} - onFileOpen={openFileAttachmentStable} - editingQueuedAttachments={queuedRunEdit.editingAttachments} - onRemoveEditingQueuedAttachment={ - queuedRunEdit.removeEditingAttachment + trailing={ +
} /> - )} -
- -
-
- {showComposerContextStrip && ( -
- - ), - })} - /> -
- )} -
+
+ )}
- - {isDraftHeroState && composerBannerItems.length > 0 ? ( - // Out of flow, like the headline, so a notice never moves the centered composer. - // Short windows pin the composer to the bottom, where an out-of-flow notice - // would fall off-screen, so there it takes space and lifts the composer. -
- -
- ) : null} -
+
+
diff --git a/apps/web/src/components/ComposerContextLabel.tsx b/apps/web/src/components/ComposerContextLabel.tsx index 89b54d5419821..0dbcb574a6e11 100644 --- a/apps/web/src/components/ComposerContextLabel.tsx +++ b/apps/web/src/components/ComposerContextLabel.tsx @@ -6,18 +6,24 @@ import { THREAD_DETAILS_PANEL_LABEL_CLASS } from "./chat/threadDetailsPanelStyle export function ComposerContextLabel({ children, displayMode = "toolbar", + compact = "hide", }: { children: ReactNode; displayMode?: "toolbar" | "panel"; + /** "truncate" keeps a short label until the row compacts its branch too. */ + compact?: "hide" | "truncate"; }) { + const compactable = compact === "hide"; return ( diff --git a/apps/web/src/components/ComposerContextPermissions.tsx b/apps/web/src/components/ComposerContextPermissions.tsx deleted file mode 100644 index f0494b1bf3a09..0000000000000 --- a/apps/web/src/components/ComposerContextPermissions.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import type { EnvironmentId, RuntimeMode } from "@t3tools/contracts"; -import { useAtomValue } from "@effect/atom-react"; - -import { shortcutLabelForCommand } from "../keybindings"; -import { useManagedPolicy } from "../state/managedPolicy"; -import { primaryServerKeybindingsAtom } from "../state/server"; -import { ComposerControl } from "./chat/ComposerControl"; -import { ComposerPermissionsSection } from "./chat/ComposerFooterControls"; -import { useComposerMenuProps } from "./chat/composerEventScope"; -import { - resolveCompatibleRuntimeMode, - resolveRuntimeModeOptions, - runtimeModeConfig, -} from "./chat/runtimeModeConfig"; -import { ComposerContextLabel } from "./ComposerContextLabel"; -import { Menu, MenuPopup, MenuTrigger } from "./ui/menu"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; - -export function ComposerContextPermissions(props: { - environmentId: EnvironmentId | null; - runtimeMode: RuntimeMode; - supportedRuntimeModes: ReadonlyArray | undefined; - onRuntimeModeChange: (mode: RuntimeMode) => void; -}) { - const shortcutLabel = shortcutLabelForCommand( - useAtomValue(primaryServerKeybindingsAtom), - "composer.mode", - ); - const { mode, modes } = resolveCompatibleRuntimeMode( - props.runtimeMode, - props.supportedRuntimeModes, - ); - const options = resolveRuntimeModeOptions(modes, useManagedPolicy(props.environmentId)); - const composerMenuProps = useComposerMenuProps(); - const { icon: Icon, label } = runtimeModeConfig[mode]; - return ( - - - - } - /> - } - > - - - - Permissions - {shortcutLabel ? {shortcutLabel} : null} - - - - - - - - ); -} diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5895b63cbb9a0..b7fb0d77fa2b3 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -168,6 +168,7 @@ export interface ComposerPromptEditorProps { skills: ReadonlyArray; disabled: boolean; placeholder: string; + narrowPlaceholder?: string | undefined; /** Rendered after the placeholder text, such as a key hint. */ placeholderSuffix?: ReactNode; ariaLabel?: string | undefined; @@ -928,6 +929,7 @@ function ComposerPromptEditorTiptapInner( skills, disabled, placeholder, + narrowPlaceholder, ariaLabel, suggestionListId, activeSuggestionId, @@ -1074,7 +1076,7 @@ function ComposerPromptEditorTiptapInner( const editorAttributes = { class: cn( - "composer-tiptap -m-1 block max-h-52 min-h-[calc(2lh+--spacing(2))] overflow-y-auto p-1 whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", + "composer-tiptap -m-1 block max-h-42 min-h-5 overflow-y-auto p-1 whitespace-pre-wrap wrap-break-word bg-transparent leading-5 text-foreground focus:outline-none", className, ), "data-testid": "composer-editor", @@ -1566,77 +1568,88 @@ function ComposerPromptEditorTiptapInner( // The one place the controlled value reaches the editor. Imperative handlers compare against // the committed value, so it is recorded here even before the editor exists. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes the controlled value and cursor into the Tiptap document before paint + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reconciles the controlled Tiptap document before paint, outside React's commit useLayoutEffect(() => { latestValueRef.current = value; if (!editor) return; - const initialSelection = !hasAppliedControlledSelectionRef.current; - hasAppliedControlledSelectionRef.current = true; - const normalizedCursor = clampEditorCursor(value, cursor); - const previousSnapshot = snapshotRef.current; - if ( - !initialSelection && - previousSnapshot.value === value && - previousSnapshot.cursor === normalizedCursor - ) { - return; - } - const normalizedExpandedCursor = initialSelection - ? initialExpandedCursor - : expandEditorCursor(value, normalizedCursor); - snapshotRef.current = { - value, - cursor: normalizedCursor, - expandedCursor: normalizedExpandedCursor, - contextIds: collectInlineContextIds(value), - }; - selectionRangeRef.current = { - start: normalizedExpandedCursor, - end: normalizedExpandedCursor, - }; - setIsEmpty(value.length === 0); - const rootElement = editor.view.dom; - const isFocused = Boolean(rootElement && document.activeElement === rootElement); - if (!initialSelection && previousSnapshot.value === value && !isFocused) return; - - isApplyingControlledUpdateRef.current = true; - const pendingCitation = - citationRequestRef.current?.value === value ? citationRequestRef.current : null; - if (previousSnapshot.value !== value) { - saveCitationDraft(editor, previousSnapshot.value); - editor.commands.setContent( - buildDocJson(value, skillLabelFor, { styling: richText, literalText, isKnownModel }), - { - emitUpdate: false, - }, - ); - } - selectMarkdownOffset(editor, normalizedExpandedCursor); - const map = serializeEditorDoc(editor.state.doc); - if (isFocused) scrollTiptapCaretIntoView(editor); - if (pendingCitation) { - citationRequestRef.current = null; - const target = map.runs.find( - (run) => - run.kind === "token" && - run.nodeName === "composer-citation" && - run.mdStart === pendingCitation.citationStart, - ); - if (target) { - const node = editor.state.doc.nodeAt(target.pmPos); - const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey; - if (citeKey) { - setOpenCitation({ - key: citeKey, - sourceAnchor: pendingCitation.sourceAnchor, - removeOnCancel: pendingCitation.insertedSpaces, - }); + // Tiptap mounts React node views with flushSync; leave React's commit before updating them. + let active = true; + const applyControlledUpdate = () => { + if (!active || editor.isDestroyed) return; + const initialSelection = !hasAppliedControlledSelectionRef.current; + hasAppliedControlledSelectionRef.current = true; + const normalizedCursor = clampEditorCursor(value, cursor); + const previousSnapshot = snapshotRef.current; + if ( + !initialSelection && + previousSnapshot.value === value && + previousSnapshot.cursor === normalizedCursor + ) { + return; + } + const normalizedExpandedCursor = initialSelection + ? initialExpandedCursor + : expandEditorCursor(value, normalizedCursor); + snapshotRef.current = { + value, + cursor: normalizedCursor, + expandedCursor: normalizedExpandedCursor, + contextIds: collectInlineContextIds(value), + }; + selectionRangeRef.current = { + start: normalizedExpandedCursor, + end: normalizedExpandedCursor, + }; + setIsEmpty(value.length === 0); + const rootElement = editor.view.dom; + const isFocused = Boolean(rootElement && document.activeElement === rootElement); + if (!initialSelection && previousSnapshot.value === value && !isFocused) return; + + isApplyingControlledUpdateRef.current = true; + const pendingCitation = + citationRequestRef.current?.value === value ? citationRequestRef.current : null; + if (previousSnapshot.value !== value) { + saveCitationDraft(editor, previousSnapshot.value); + editor.commands.setContent( + buildDocJson(value, skillLabelFor, { styling: richText, literalText, isKnownModel }), + { + emitUpdate: false, + }, + ); + } + selectMarkdownOffset(editor, normalizedExpandedCursor); + const map = serializeEditorDoc(editor.state.doc); + if (isFocused) scrollTiptapCaretIntoView(editor); + if (pendingCitation) { + citationRequestRef.current = null; + const target = map.runs.find( + (run) => + run.kind === "token" && + run.nodeName === "composer-citation" && + run.mdStart === pendingCitation.citationStart, + ); + if (target) { + const node = editor.state.doc.nodeAt(target.pmPos); + const citeKey = (node?.attrs as { citeKey?: string } | undefined)?.citeKey; + if (citeKey) { + setOpenCitation({ + key: citeKey, + sourceAnchor: pendingCitation.sourceAnchor, + removeOnCancel: pendingCitation.insertedSpaces, + }); + } } } - } - queueMicrotask(() => { - isApplyingControlledUpdateRef.current = false; - }); + queueMicrotask(() => { + isApplyingControlledUpdateRef.current = false; + }); + }; + // Cursor-only updates must precede focus restoration on a rich/plain remount. + if (snapshotRef.current.value === value) applyControlledUpdate(); + else queueMicrotask(applyControlledUpdate); + return () => { + active = false; + }; }, [ clampEditorCursor, cursor, @@ -1870,12 +1883,22 @@ function ComposerPromptEditorTiptapInner( /> {isEmpty && contextRecords.size === 0 && placeholder ? (
- {placeholder} + {narrowPlaceholder ? ( + <> + {placeholder} + + {narrowPlaceholder} + + + ) : ( + placeholder + )} {placeholderSuffix}
) : null} diff --git a/apps/web/src/components/ContextChip.browser.test.tsx b/apps/web/src/components/ContextChip.browser.test.tsx index 154bc1b76af88..ed13d4996afbd 100644 --- a/apps/web/src/components/ContextChip.browser.test.tsx +++ b/apps/web/src/components/ContextChip.browser.test.tsx @@ -5,24 +5,35 @@ import "~/index.css"; import { render } from "../../test/render"; import { ContextChip, ContextChipLabel } from "./ContextChip"; +// An empty inline-block's bottom edge sits on the baseline of the line it is in. +function BaselineMarker(props: { id: string }) { + return ; +} + describe("ContextChip", () => { - it("scales its label with the sentence without growing the line", async () => { + it("sets its label on the sentence baseline at the sentence size without growing the line", async () => { await using screen = await render(

- Open the{" "} + Open + the{" "} }> - src/Typography.tsx + + src/ + + Typography.tsx + {" "} file

, ); const paragraph = screen.container.querySelector("p")!; const label = screen.container.querySelector('[data-slot="context-chip-label"]')!; - const sentenceSize = Number.parseFloat(getComputedStyle(paragraph).fontSize); - const chipSize = Number.parseFloat(getComputedStyle(label).fontSize); + const baseline = (id: string) => + screen.container.querySelector(`[data-baseline="${id}"]`)!.getBoundingClientRect().bottom; - expect(chipSize / sentenceSize).toBeCloseTo(0.86, 2); + expect(getComputedStyle(label).fontSize).toBe(getComputedStyle(paragraph).fontSize); + expect(Math.abs(baseline("chip") - baseline("text"))).toBeLessThanOrEqual(0.5); expect(paragraph.getBoundingClientRect().height).toBeCloseTo( Number.parseFloat(getComputedStyle(paragraph).lineHeight), 0, diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index 534fd4842db14..fb05f92837233 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -5,17 +5,16 @@ import type * as React from "react"; import { cn } from "~/lib/utils"; -// Each kind uses a theme tint role so its fill and label remain readable in both themes. const contextChipVariants = cva( - "context-chip inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] border align-middle font-medium leading-none [&_svg]:block [&_svg]:size-[1.17em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer [button&,a&]:transition-colors [button&,a&]:motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-foreground disabled:cursor-default", + "inline-flex max-w-full items-center gap-[0.2em] font-medium leading-[normal] text-(--context-chip-ink) [--context-chip-ink:var(--context-chip-accent)] [--document-icon-stroke:var(--context-chip-ink)] [&_svg]:block [&_svg]:size-[1em] [&_svg]:shrink-0 [&_svg]:self-center [button&,a&,[data-popup-open]&]:cursor-pointer focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current rounded-sm disabled:cursor-default", { defaultVariants: { kind: "neutral" }, variants: { kind: { - neutral: "border-transparent bg-muted text-foreground", - image: "[--context-chip-accent:var(--context-chip-image)]", + neutral: "[--context-chip-accent:currentColor]", + image: "[--context-chip-accent:var(--info-foreground)]", video: "[--context-chip-accent:var(--warning-foreground)]", - file: "rounded-row border-transparent bg-(--context-reference-fill) text-foreground [&_svg]:size-3 [&_svg]:text-muted-foreground [button:enabled&,a&]:hover:surface-ghost-hover", + file: "[--context-chip-accent:var(--info-foreground)]", mention: "[--context-chip-accent:var(--info-foreground)]", terminal: "[--context-chip-accent:var(--success-foreground)]", element: "[--context-chip-accent:var(--warning-foreground)]", @@ -27,56 +26,25 @@ const contextChipVariants = cva( "pr-merged": "[--context-chip-accent:var(--merged-foreground)]", "pr-closed": "[--context-chip-accent:var(--error-foreground)]", skill: "[--context-chip-accent:var(--merged-foreground)]", - model: "[--context-chip-accent:var(--context-chip-model)]", + model: "[--context-chip-accent:var(--warning-foreground)]", thread: "[--context-chip-accent:var(--info-foreground)]", - environment: "[--context-chip-accent:var(--info-foreground)]", - "doc-pdf": "[--context-chip-accent:var(--context-chip-doc-pdf)]", - "doc-word": "[--context-chip-accent:var(--context-chip-doc-word)]", - "doc-spreadsheet": "[--context-chip-accent:var(--context-chip-doc-spreadsheet)]", - "doc-slides": "[--context-chip-accent:var(--context-chip-doc-slides)]", - "doc-epub": "[--context-chip-accent:var(--context-chip-doc-epub)]", + environment: "[--context-chip-accent:var(--success-foreground)]", citation: "[--context-chip-accent:var(--info-foreground)]", + "doc-pdf": "[--context-chip-accent:var(--error-foreground)]", + "doc-word": "[--context-chip-accent:var(--info-foreground)]", + "doc-spreadsheet": "[--context-chip-accent:var(--success-foreground)]", + "doc-slides": "[--context-chip-accent:var(--warning-foreground)]", + "doc-epub": "[--context-chip-accent:var(--merged-foreground)]", }, shape: { inline: "", - bubble: "w-64 gap-2 rounded-2xl bg-message px-3 py-2 text-label text-message-foreground", + bubble: "w-64 gap-2 rounded-2xl surface-bubble px-3 py-2 text-label text-foreground", }, state: { - unresolved: "border-dashed", - invalid: "", + unresolved: "data-[state=unresolved]:[--context-chip-accent:currentColor]", + invalid: "data-[state=invalid]:[--context-chip-accent:var(--error-foreground)]", }, }, - compoundVariants: [ - { - kind: [ - "image", - "video", - "mention", - "terminal", - "element", - "preview-annotation", - "review-comment", - "pull-request", - "pr-open", - "pr-draft", - "pr-merged", - "pr-closed", - "skill", - "thread", - "citation", - "environment", - "doc-pdf", - "doc-word", - "doc-spreadsheet", - "doc-slides", - "doc-epub", - ], - className: - "[--context-chip-foreground:color-mix(in_oklab,var(--context-chip-accent)_22%,var(--foreground))] border-transparent bg-(--context-chip-accent)/10 text-(--context-chip-foreground) [button:enabled&,a&]:hover:bg-(--context-chip-accent)/17", - }, - { state: "unresolved", className: "border-border text-foreground" }, - { state: "invalid", className: "border-error/35 bg-error/10 text-error-foreground" }, - ], }, ); @@ -104,7 +72,10 @@ function ContextChip({ className, kind, state, shape, render, ...props }: Contex function ContextChipLabel({ className, ...props }: React.ComponentProps<"span">) { return ( &]:decoration-current", + className, + )} data-slot="context-chip-label" {...props} /> @@ -114,7 +85,7 @@ function ContextChipLabel({ className, ...props }: React.ComponentProps<"span">) function ContextChipAction({ className, render, ...props }: useRender.ComponentProps<"button">) { const defaultProps = { className: cn( - "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-current transition-colors hover:bg-(--context-chip-accent,var(--color-foreground))/17 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-[0.85em]", + "ml-[0.17em] inline-flex size-[1em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-current transition-colors hover:bg-(--context-chip-accent,var(--color-foreground))/17 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-[0.85em]", className, ), "data-slot": "context-chip-action", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 940b256aba333..769e637bae020 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -3,7 +3,15 @@ import { cn } from "~/lib/utils"; export type Icon = React.FC>; export const UltrafastIcon: Icon = (props) => ( - + diff --git a/apps/web/src/components/PersistentThreadTerminals.tsx b/apps/web/src/components/PersistentThreadTerminals.tsx index a387349b4c038..3d157b88e1ff8 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/PersistentThreadTerminals.tsx @@ -5,6 +5,10 @@ import { type ScopedThreadRef, type ThreadId, } from "@t3tools/contracts"; +import { + resolveTerminalActivityStatus, + type TerminalActivityStatus, +} from "@t3tools/client-runtime/state/terminal"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; @@ -134,6 +138,10 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), ); } + const terminalStatusById = new Map(); + for (const session of drawerTerminalSessions) { + terminalStatusById.set(session.target.terminalId, resolveTerminalActivityStatus(session.state)); + } const terminalLaunchLocationsById = new Map(); for (const session of drawerTerminalSessions) { const summary = session.state.summary; @@ -276,6 +284,7 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm onHeightChange={setTerminalHeight} onAddTerminalContext={handleAddTerminalContext} terminalLabelsById={terminalLabelsById} + terminalStatusById={terminalStatusById} terminalLaunchLocationsById={terminalLaunchLocationsById} />
diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index 61ad992510f57..e813928743dff 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -117,10 +117,6 @@ function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) return variant === "nightly" ? : ; } -export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) { - return variant === "nightly" ? : ; -} - const NIGHTLY_STARS: ReadonlyArray<{ cx: number; cy: number; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 017e9e6663bb6..4a60d9b70ce91 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -8,16 +8,18 @@ import { import { INITIAL_TERMINAL_OUTPUT_CURSOR, readTerminalOutputUpdate, + type TerminalActivityStatus, type TerminalOutputCursor, type TerminalOutputUpdate, type TerminalSessionState, } from "@t3tools/client-runtime/state/terminal"; import { + Copy, + MessageSquarePlus, Plus, - Square, SquareSplitHorizontal, SquareSplitVertical, - TerminalSquare, + SquareTerminal, Trash2, } from "lucide-react"; import { @@ -44,9 +46,12 @@ import { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; -import { Button } from "~/components/ui/button"; +import { Button, ContentButton } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; @@ -589,6 +594,10 @@ function TerminalSessionViewport({ const selectionActionRequestIdRef = useRef(0); const openSelectionMenuRequestIdRef = useRef(null); const readKeybindings = useEffectEvent(() => keybindings); + const [selectionToolbar, setSelectionToolbar] = useState( + null, + ); + const selectionToolbarRef = useRef(null); const handleSessionExited = useEffectEvent(() => { if (hasTerminalWriteAccess()) onSessionExited(); }); @@ -802,7 +811,8 @@ function TerminalSessionViewport({ const ownsMenu = openSelectionMenuRequestIdRef.current === selectionActionRequestIdRef.current; if (supersede || ownsMenu) selectionActionRequestIdRef.current += 1; - if (ownsMenu) void localApi?.contextMenu.close(); + openSelectionMenuRequestIdRef.current = null; + setSelectionToolbar(null); }; const clearSelectionAction = () => { selectionActions?.cancel(); @@ -947,11 +957,9 @@ function TerminalSessionViewport({ } }; - const showSelectionAction = async (pointer: SelectionActionPoint | null) => { - if (!localApi) { - clearSelectionAction(); - return; - } + // The floating selection toolbar: Add to chat, or Copy where the + // terminal has no chat to add to. Copy also stays in the context menu. + const showSelectionAction = (pointer: SelectionActionPoint | null) => { if (openSelectionMenuRequestIdRef.current === selectionActionRequestIdRef.current) { return; } @@ -962,27 +970,23 @@ function TerminalSessionViewport({ } const requestId = ++selectionActionRequestIdRef.current; openSelectionMenuRequestIdRef.current = requestId; - const clicked = await localApi.contextMenu - .show( - terminalSelectionMenuItems({ canAddToChat: canAddSelectionToChat() }), - nextAction.position, - ) - .finally(() => { - if (openSelectionMenuRequestIdRef.current === requestId) { - openSelectionMenuRequestIdRef.current = null; + const isCurrent = () => + requestId === selectionActionRequestIdRef.current && + openSelectionMenuRequestIdRef.current === requestId; + setSelectionToolbar({ + position: nextAction.position, + action: canAddSelectionToChat() ? "add-to-chat" : "copy", + run: () => { + if (!isCurrent()) return; + openSelectionMenuRequestIdRef.current = null; + setSelectionToolbar(null); + if (canAddSelectionToChat()) { + addSelectionToChat(nextAction.clipboardText); + } else { + void copySelection(nextAction.clipboardText, requestId); } - }); - if (requestId !== selectionActionRequestIdRef.current || clicked === null) { - return; - } - switch (clicked) { - case "add-to-chat": - if (canAddSelectionToChat()) addSelectionToChat(nextAction.clipboardText); - return; - case "copy": - await copySelection(nextAction.clipboardText, requestId); - return; - } + }, + }); }; const sendTerminalInput = async (data: string, fallbackError: string) => { @@ -1118,8 +1122,9 @@ function TerminalSessionViewport({ selectionActions = observeSelectionActions({ element: mount, + getActionElement: () => selectionToolbarRef.current, onSelection: (pointer) => { - void showSelectionAction(pointer); + showSelectionAction(pointer); }, onDismiss: (reason) => dismissSelectionAction(reason === "interaction"), }); @@ -1187,6 +1192,16 @@ function TerminalSessionViewport({ }; }, [cwd, environmentId, terminalId]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the mounted selection toolbar and keeps its DOM position inside the viewport before paint + useLayoutEffect(() => { + const toolbar = selectionToolbarRef.current; + if (!toolbar || !selectionToolbar) return; + const rect = toolbar.getBoundingClientRect(); + const { x, y } = selectionToolbar.position; + toolbar.style.left = `${Math.max(8, Math.min(x, window.innerWidth - rect.width - 8))}px`; + toolbar.style.top = `${Math.max(8, Math.min(y, window.innerHeight - rect.height - 8))}px`; + }, [selectionToolbar]); + const attachContainer = (mount: HTMLDivElement) => { containerRef.current = mount; const focus = () => @@ -1213,15 +1228,51 @@ function TerminalSessionViewport({ containerRef.current = null; }; }; + return ( -
+ <> +
+ {selectionToolbar && visible + ? createPortal( +
event.preventDefault()} + > + + {selectionToolbar.action === "add-to-chat" ? ( + <> + + Add to chat + + ) : ( + <> + + Copy + + )} + +
, + document.body, + ) + : null} + ); } +interface TerminalSelectionToolbarState { + readonly position: SelectionActionPoint; + readonly action: "add-to-chat" | "copy"; + readonly run: () => void; +} + interface ThreadTerminalDrawerProps { mode?: "drawer" | "panel"; threadRef: ScopedThreadRef; @@ -1250,35 +1301,39 @@ interface ThreadTerminalDrawerProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; keybindings: ResolvedKeybindingsConfig; terminalLabelsById?: ReadonlyMap; + /** Run status per terminal for the tab's tooltip; missing ids read as an idle shell. */ + terminalStatusById?: ReadonlyMap; terminalLaunchLocationsById?: ReadonlyMap; } interface TerminalActionButtonProps { label: string; - className: string; onClick: () => void; + /** Keeps the tooltip reachable (it explains the limit), unlike `disabled`. */ + unavailable?: boolean; children: ReactNode; disabled?: boolean; } function TerminalActionButton({ label, - className, onClick, + unavailable, children, - disabled, }: TerminalActionButtonProps) { return ( { + if (!unavailable) onClick(); + }} aria-label={label} - aria-disabled={disabled} + aria-disabled={unavailable || undefined} /> } > @@ -1382,103 +1437,6 @@ function TerminalSplitPanes({ ); } -function TerminalSidebarGroup({ - terminalGroup, - activeTerminalId, - showHeader, - terminalLabelById, - closeShortcutLabel, - canOperateTerminal, - onActiveTerminalChange, - onCloseTerminal, -}: { - terminalGroup: ThreadTerminalGroup; - activeTerminalId: string; - showHeader: boolean; - terminalLabelById: ReadonlyMap; - closeShortcutLabel: string | undefined; - canOperateTerminal: boolean; - onActiveTerminalChange: (terminalId: string) => void; - onCloseTerminal: (terminalId: string) => void; -}) { - const isGroupActive = terminalGroup.terminalIds.includes(activeTerminalId); - const groupActiveTerminalId = isGroupActive - ? activeTerminalId - : (terminalGroup.terminalIds[0] ?? activeTerminalId); - const terminalCount = terminalGroup.terminalIds.length; - const isSplitGroup = terminalCount > 1; - const groupLabel = !isSplitGroup - ? "Single" - : terminalGroup.splitDirection === "vertical" - ? "Stacked" - : "Side by side"; - const GroupIcon = !isSplitGroup - ? Square - : terminalGroup.splitDirection === "vertical" - ? SquareSplitVertical - : SquareSplitHorizontal; - - return ( -
- {showHeader && ( - - )} - -
- {terminalGroup.terminalIds.map((terminalId) => { - const isActive = terminalId === activeTerminalId; - const terminalLabel = terminalLabelById.get(terminalId) ?? "Terminal"; - const closeTerminalLabel = `Close ${terminalLabel}${ - isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : "" - }`; - return ( -
- {canOperateTerminal ? ( - onCloseTerminal(terminalId)} - tooltip={closeTerminalLabel} - > - - - ) : ( - - )} - -
- ); - })} -
-
- ); -} - export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, @@ -1506,6 +1464,7 @@ export default function ThreadTerminalDrawer({ onAddTerminalContext, keybindings, terminalLabelsById, + terminalStatusById, terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); @@ -1649,17 +1608,14 @@ export default function ThreadTerminalDrawer({ (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; - const hasTerminalSidebar = normalizedTerminalIds.length > 1; - const showGroupHeaders = - resolvedTerminalGroups.length > 1 || - resolvedTerminalGroups.some((terminalGroup) => terminalGroup.terminalIds.length > 1); - const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; + const isSplitView = visibleTerminalIds.length > 1; const resolveTerminalLaunchLocation = (terminalId: string): TerminalLaunchLocation => terminalLaunchLocationsById?.get(terminalId) ?? { cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { runtimeEnv } : {}), }; + const hasReachedSplitLimit = visibleTerminalIds.length >= MAX_TERMINALS_PER_GROUP; const splitTerminalActionLabel = hasReachedSplitLimit ? `Split Terminal Horizontally (max ${MAX_TERMINALS_PER_GROUP} per group)` : splitShortcutLabel @@ -1670,12 +1626,6 @@ export default function ThreadTerminalDrawer({ : splitVerticalShortcutLabel ? `Split Terminal Vertically (${splitVerticalShortcutLabel})` : "Split Terminal Vertically"; - const newTerminalActionLabel = newShortcutLabel - ? `New Terminal (${newShortcutLabel})` - : "New Terminal"; - const closeTerminalActionLabel = closeShortcutLabel - ? `Close Terminal (${closeShortcutLabel})` - : "Close Terminal"; const onSplitTerminalAction = () => { if (!canOperateTerminal || hasReachedSplitLimit) return; onSplitTerminal(); @@ -1684,6 +1634,12 @@ export default function ThreadTerminalDrawer({ if (!canOperateTerminal || hasReachedSplitLimit) return; onSplitTerminalVertical(); }; + const newTerminalActionLabel = newShortcutLabel + ? `New Terminal (${newShortcutLabel})` + : "New Terminal"; + const closeTerminalActionLabel = closeShortcutLabel + ? `Close Terminal (${closeShortcutLabel})` + : "Close Terminal"; const onNewTerminalAction = () => { if (!canOperateTerminal) return; onNewTerminal(); @@ -1800,6 +1756,13 @@ export default function ThreadTerminalDrawer({ onLostPointerCapture={handleResizePointerEnd} /> ); + const drawerClassName = cn( + "relative flex min-w-0 flex-col overflow-hidden bg-background", + isPanel ? "h-full flex-1" : "z-45 shrink-0 border-t border-border", + ); + const drawerStyle = isPanel + ? undefined + : { height: `var(${DRAG_HEIGHT_PROPERTY}, ${drawerHeight}px)` }; if (normalizedTerminalIds.length === 0) { return ( @@ -1808,11 +1771,8 @@ export default function ThreadTerminalDrawer({ {...(!isPanel && visible ? terminalArea : {})} data-thread-terminal-drawer data-terminal-owner={isPanel ? "right-panel" : "drawer"} - className={cn( - "relative flex min-w-0 flex-col overflow-hidden bg-background", - isPanel ? "h-full flex-1" : "z-45 shrink-0 border-t border-border", - )} - style={isPanel ? undefined : { height: `var(${DRAG_HEIGHT_PROPERTY}, ${drawerHeight}px)` }} + className={drawerClassName} + style={drawerStyle} > {resizeHandle}
@@ -1830,67 +1790,141 @@ export default function ThreadTerminalDrawer({ ); } + const newTerminalButton = ( + + + + ); + + // Keep every terminal action in the header so controls never cover shell output. + const terminalActions = ( +
+ + + + + + + {newTerminalButton} + confirmCloseTerminal(resolvedActiveTerminalId)} + unavailable={!canOperateTerminal} + > + + +
+ ); + return ( ); } + +function terminalActivityStatusLabel(status: TerminalActivityStatus): string | null { + if (status === "running") return "Process running"; + if (status === "failed") return "Last command failed"; + return null; +} diff --git a/apps/web/src/components/WorktreeBaseBranchPicker.tsx b/apps/web/src/components/WorktreeBaseBranchPicker.tsx index 2b374a89daca7..68a10fbdfba4a 100644 --- a/apps/web/src/components/WorktreeBaseBranchPicker.tsx +++ b/apps/web/src/components/WorktreeBaseBranchPicker.tsx @@ -89,7 +89,8 @@ export function WorktreeBaseBranchPicker({ onLoadNext={branches.loadNext} statusText={statusText} originControl={{ checked: startFromOrigin, onCheckedChange: onStartFromOriginChange }} - popupProps={{ align: "start", side: "bottom", className: "flex w-80 flex-col" }} + popupProps={{ align: "start", side: "bottom", className: "flex w-90 flex-col" }} + getItemGroup={(name) => (branchByName.get(name)?.isRemote ? "Remote" : "Local")} renderItem={(name, index) => { const branch = branchByName.get(name); return branch ? ( diff --git a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx index 667c19fe4718c..c39935621e43f 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.browser.test.tsx @@ -7,7 +7,7 @@ import { } from "@t3tools/contracts"; import { useState, type ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import "~/index.css"; @@ -88,11 +88,7 @@ async function removeSource() { } async function clickButton(label: string) { - const button = [...document.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === label, - ); - expect(button, `button ${label}`).toBeDefined(); - await act(async () => button!.click()); + await page.getByRole("button", { name: label, exact: true }).click(); } describe("citation comment source disappearance", () => { diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 9fa81d15e2d3b..3bc129d52918b 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -5,7 +5,7 @@ import { serializeAssistantCitation, } from "@t3tools/shared/assistantCitations"; import { Link, useNavigate } from "@tanstack/react-router"; -import { PencilIcon, QuoteIcon } from "lucide-react"; +import { MessageSquarePlusIcon, TextQuoteIcon } from "lucide-react"; import { useEffect, useEffectEvent, @@ -192,16 +192,20 @@ export function AssistantCitationChip({ onRestoreFocus?: () => void; }; }) { + const navigate = useNavigate(); + const sourceThread = useQuietThreadShell( + scopeThreadRef(citation.environmentId, citation.threadId), + ); const sourceLinkProps = useCitationSourceLinkProps(citation); const commentInputRef = useRef(null); const commentPopupRef = useRef(null); + const chipRef = useRef(null); const draftCommentRef = useRef(null); const [voiceSetupOpen, setVoiceSetupOpen] = useState(false); const [unavailableSourceAnchor, setUnavailableSourceAnchor] = useState(null); const commentOpen = commentEditor?.open ?? false; const sourceAnchor = commentEditor?.sourceAnchor; - const activeSourceAnchor = sourceAnchor === unavailableSourceAnchor ? undefined : sourceAnchor; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- drops the uncommitted comment draft held in a ref once the parent closes the comment editor useEffect(() => { if (!commentOpen) draftCommentRef.current = null; @@ -220,6 +224,7 @@ export function AssistantCitationChip({ if (settleDraftOnClose("none")) { commentEditor?.onOpenChange(false); } else { + // Keep the draft mounted and stop tracking the detached range. setUnavailableSourceAnchor(sourceAnchor); } }); @@ -234,31 +239,18 @@ export function AssistantCitationChip({ onUnavailable: onSourceUnavailable, }); }, [citation, commentOpen, sourceAnchor, unavailableSourceAnchor]); - const popupAnchor = activeSourceAnchor - ? { - contextElement: activeSourceAnchor.source, - getBoundingClientRect: () => { - const rects = activeSourceAnchor.range.getClientRects(); - const rect = - rects.item(rects.length - 1) ?? activeSourceAnchor.range.getBoundingClientRect(); - // Code blocks and tables scroll sideways inside the message, so the cited - // text can end in their hidden overflow. Base UI only shifts the popup while - // it still touches the anchor, so clamp the anchor to the message's width. - const bounds = activeSourceAnchor.source.getBoundingClientRect(); - const left = Math.min(Math.max(rect.left, bounds.left), bounds.right); - const right = Math.max(Math.min(rect.right, bounds.right), left); - return new DOMRect(left, rect.top, right - left, rect.height); - }, - } - : undefined; + // In the composer the editor sits above the tray, left-aligned with it like the composer's + // menus, so it reads as editing this chip rather than floating over the cited passage. + const popupAnchor = () => + chipRef.current?.closest('[data-slot="composer-shell"]') ?? chipRef.current; const label = assistantCitationLabel(citation); const composerSourceLink = ( -