diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 53044dcf7e5d..39abd5687201 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -36,7 +36,7 @@ const clientSettings: ClientSettings = { contextWindowMeterEnabled: false, composerCollapseOnScroll: true, dismissedProviderUpdateNotificationKeys: [], - diffFilesCollapsed: true, + diffFilesCollapsed: "viewed", diffIgnoreWhitespace: true, diffLayout: "stacked", environmentIdentificationMode: "artwork", @@ -51,6 +51,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + openLinksInPreviewPattern: "", onboardingCompletedAt: null, panelAnimationDurationMs: 0, planModeEnabled: false, diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index e209ab0b6ce5..58af254bb291 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -44,6 +44,7 @@ export function SettingsThreadsRouteScreen() { contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} > + @@ -215,6 +216,33 @@ function AutoSettleSettingsRows() { ); } +/** + * Device-local counterpart of web's "Sent message position" client setting. + */ +function TimelineSettingsSection() { + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferences = useAtomValue(mobilePreferencesAtom); + const anchorsAtTop = + AsyncResult.isSuccess(preferences) && preferences.value.chatTurnAnchor === "top"; + + return ( + + + savePreferences({ chatTurnAnchor: value ? "top" : "bottom" })} + /> + + + Holds each message you send at the top of the screen so the reply reads downward. Off keeps + the conversation pinned to the latest message. + + + ); +} + /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 14beefd6d178..25c97a4c6eb9 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -9,6 +9,8 @@ import type { CodexFeedbackSubmission, EnvironmentThreadStatus, } from "@t3tools/client-runtime/state/threads"; +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { LegendListRef } from "@legendapp/list/react-native"; @@ -75,6 +77,7 @@ import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; import { deviceEnvironment } from "../../state/device"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { useEnvironmentQuery } from "../../state/query"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; @@ -337,6 +340,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ); const [anchorMessageId, setAnchorMessageId] = useState(null); const [submittedMessageId, setSubmittedMessageId] = useState(null); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const chatTurnAnchor = + (AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.chatTurnAnchor : null) ?? + "bottom"; const [endFollowEnabled, setEndFollowEnabled] = useState(true); // Android keys the safe-area padding on keyboard visibility (#5988): the // back gesture closes the keyboard while the editor stays focused, and a @@ -790,12 +797,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread hasStartedTurn: props.selectedThread.latestTurn !== null, hasUserMessage, queuedMessageCount: props.selectedThreadQueueCount, + turnAnchor: chatTurnAnchor, }), ); composerEditorRef.current?.blur(); return messageId; }, [ anchorMessageId, + chatTurnAnchor, clearUsageLimitsFor, props.onSendMessage, props.selectedThread.latestTurn, diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 13e81130823a..dcbbc10c479d 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -91,6 +91,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => { hasStartedTurn: false, hasUserMessage: false, queuedMessageCount: 0, + turnAnchor: "bottom", }), ).toBe("first-message"); }); @@ -103,6 +104,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => { hasStartedTurn: false, hasUserMessage: false, queuedMessageCount: 1, + turnAnchor: "bottom", }), ).toBe("first-message"); }); @@ -115,6 +117,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => { hasStartedTurn: false, hasUserMessage: false, queuedMessageCount: 0, + turnAnchor: "bottom", }), ).toBe("first-message"); }); @@ -127,6 +130,7 @@ describe("resolveThreadFeedSubmissionAnchor", () => { hasStartedTurn: false, hasUserMessage: true, queuedMessageCount: 0, + turnAnchor: "bottom", }), ).toBeNull(); }); @@ -139,9 +143,36 @@ describe("resolveThreadFeedSubmissionAnchor", () => { hasStartedTurn: true, hasUserMessage: false, queuedMessageCount: 0, + turnAnchor: "bottom", }), ).toBeNull(); }); + + it("re-anchors every direct send when messages anchor at the top", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: true, + hasUserMessage: true, + queuedMessageCount: 0, + turnAnchor: "top", + }), + ).toBe("second-message"); + }); + + it("keeps the current framing for a send that queues behind others", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: true, + hasUserMessage: true, + queuedMessageCount: 1, + turnAnchor: "top", + }), + ).toBe("first-message"); + }); }); describe("resolveThreadFeedLiveFollow", () => { diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index 431dda7550d1..69cd7e209ea4 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -1,3 +1,5 @@ +import type { ChatTurnAnchor } from "@t3tools/contracts"; + export type ThreadFeedLiveFollowEvent = | { readonly type: "reset" } | { readonly type: "user-scroll-begin" } @@ -47,13 +49,24 @@ export function shouldFollowThreadWorkGroupAppend(input: { ); } +/** + * Which sent message the feed should hold near the top while its turn streams. + * `bottom` anchors only the first message of a thread; `top` re-anchors every + * direct send. A message that queues behind others is not the live turn, so it + * leaves the current framing alone either way. + */ export function resolveThreadFeedSubmissionAnchor(input: { readonly currentAnchorMessageId: AnchorId | null; readonly submittedMessageId: AnchorId; readonly hasStartedTurn: boolean; readonly hasUserMessage: boolean; readonly queuedMessageCount: number; + readonly turnAnchor: ChatTurnAnchor; }): AnchorId | null { + if (input.turnAnchor === "top") { + return input.queuedMessageCount > 0 ? input.currentAnchorMessageId : input.submittedMessageId; + } + if (input.hasStartedTurn || input.hasUserMessage) { return null; } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 9514efdff424..95bc62f66331 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,7 +5,11 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { + ChatTurnAnchor, + ProviderInstanceId, + SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import type { ComposerEnterBehavior } from "../lib/composerEnterBehavior"; import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; import * as MobileDatabase from "./mobile-database"; @@ -30,6 +34,8 @@ export interface Preferences { readonly collapsedProjectGroups?: readonly string[]; /** What the Return key does in the composer on a hardware keyboard. iOS only. */ readonly composerEnterBehavior?: ComposerEnterBehavior; + /** Device-local counterpart of the web client's `chatTurnAnchor` setting. */ + readonly chatTurnAnchor?: ChatTurnAnchor; /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; readonly projectGroupingMode?: SidebarProjectGroupingMode; @@ -97,6 +103,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; composerEnterBehavior?: ComposerEnterBehavior; + chatTurnAnchor?: ChatTurnAnchor; projectGroupingEnabled?: boolean; projectGroupingMode?: SidebarProjectGroupingMode; planModeEnabled?: boolean; @@ -157,6 +164,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (parsed.composerEnterBehavior === "send" || parsed.composerEnterBehavior === "newline") { preferences.composerEnterBehavior = parsed.composerEnterBehavior; } + if (parsed.chatTurnAnchor === "bottom" || parsed.chatTurnAnchor === "top") { + preferences.chatTurnAnchor = parsed.chatTurnAnchor; + } if (typeof parsed.projectGroupingEnabled === "boolean") { preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 5e3b4c6e4afe..a8d3a5f271a7 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -86,6 +86,8 @@ import * as WorkspacePaths from "../src/workspace/WorkspacePaths.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; +import * as GitHubCli from "../src/sourceControl/GitHubCli.ts"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; import * as PullRequestService from "../src/pullRequest/PullRequestService.ts"; @@ -335,6 +337,16 @@ export const makeOrchestrationIntegrationHarness = ( generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), } as unknown as TextGeneration["Service"]); + const gitHubCliLayer = Layer.mock(GitHubCli.GitHubCli)({ + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + } satisfies Partial); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provide( Layer.mock(ProviderAuthService)({ @@ -345,6 +357,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), + Layer.provideMerge(gitHubCliLayer), Layer.provideMerge(serverSettingsLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 87f9bd03d46c..1f673b73b688 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -75,6 +75,8 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ServerActivation } from "../../serverActivation.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; +import * as GitHubCli from "../../sourceControl/GitHubCli.ts"; +import { ChildProcessSpawner } from "effect/unstable/process"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -347,6 +349,21 @@ describe("ProviderCommandReactor", () => { }), ), ); + const executeGitHubCli = vi.fn((_) => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: JSON.stringify({ + hosts: { + "github.com": [ + { state: "success", active: true, host: "github.com", login: "octocat" }, + ], + }, + }), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + ); const providerSnapshots = [ { instanceId: modelSelection.instanceId, @@ -475,6 +492,11 @@ describe("ProviderCommandReactor", () => { createWorktree, } satisfies Partial), ), + Layer.provideMerge( + Layer.mock(GitHubCli.GitHubCli)({ + execute: executeGitHubCli, + } satisfies Partial), + ), Layer.provideMerge( Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), @@ -628,6 +650,7 @@ describe("ProviderCommandReactor", () => { refreshStatus, generateBranchName, generateThreadTitle, + executeGitHubCli, runtimeSessions, stateDir, drain, @@ -2682,6 +2705,9 @@ describe("ProviderCommandReactor", () => { `Add a safer reconnect backoff. ${assistantQuoteText}`, ); expect(harness.generateBranchName.mock.calls[0]?.[0].message).not.toContain("t3-citation://"); + expect(harness.renameBranch.mock.calls[0]?.[0]).toMatchObject({ + newBranch: expect.stringMatching(/^octocat\//), + }); expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); const readModel = await harness.readModel(); expect( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 77022a8518d7..062c7b0c02a5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -15,7 +15,11 @@ import { } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; -import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { + isTemporaryWorktreeBranch, + sanitizeBranchFragment, + WORKTREE_BRANCH_PREFIX, +} from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -65,6 +69,11 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import * as GitHubCli from "../../sourceControl/GitHubCli.ts"; +import { + findAuthenticatedGitHubAccount, + parseGitHubAuthStatus, +} from "../../sourceControl/gitHubAuthStatus.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); @@ -187,16 +196,17 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } -function buildGeneratedWorktreeBranchName(raw: string): string { +function buildGeneratedWorktreeBranchName(raw: string, prefix: string): string { const normalized = raw .trim() .toLowerCase() .replace(/^refs\/heads\//, "") .replace(/['"`]/g, ""); - const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) - ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) - : normalized; + const strippablePrefix = [`${WORKTREE_BRANCH_PREFIX}/`, `${prefix}/`].find((entry) => + normalized.startsWith(entry), + ); + const withoutPrefix = strippablePrefix ? normalized.slice(strippablePrefix.length) : normalized; const branchFragment = withoutPrefix .replace(/[^a-z0-9/_-]+/g, "-") @@ -207,7 +217,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { .replace(/[./_-]+$/g, ""); const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; + return `${prefix}/${safeFragment}`; } const make = Effect.gen(function* () { @@ -218,6 +228,7 @@ const make = Effect.gen(function* () { const providerService = yield* ProviderService; const providerRegistry = yield* ProviderRegistry; const gitWorkflow = yield* GitWorkflowService; + const githubCli = yield* GitHubCli.GitHubCli; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; @@ -906,6 +917,20 @@ const make = Effect.gen(function* () { }; }); + // Generated branches are namespaced under the authenticated GitHub login + // (e.g. `octocat/fix-reconnect`); `t3code/` remains the fallback when `gh` + // is unavailable or signed out. + const resolveGeneratedBranchPrefix = (cwd: string) => + githubCli.execute({ cwd, args: ["auth", "status", "--json", "hosts"] }).pipe( + Effect.map((result) => { + const account = findAuthenticatedGitHubAccount( + parseGitHubAuthStatus(result.stdout).accounts, + ); + return account ? sanitizeBranchFragment(account.account) : WORKTREE_BRANCH_PREFIX; + }), + Effect.orElseSucceed(() => WORKTREE_BRANCH_PREFIX), + ); + const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fn( "maybeGenerateAndRenameWorktreeBranchForFirstTurn", )(function* (input: { @@ -935,15 +960,21 @@ const make = Effect.gen(function* () { yield* providerRegistry.getProviders, ); - const generated = yield* textGeneration.generateBranchName({ - cwd, - message: input.messageText, - ...(attachments.length > 0 ? { attachments } : {}), - modelSelection, - }); + const [generated, branchPrefix] = yield* Effect.all( + [ + textGeneration.generateBranchName({ + cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + modelSelection, + }), + resolveGeneratedBranchPrefix(cwd), + ] as const, + { concurrency: 2 }, + ); if (!generated) return; - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch, branchPrefix); if (targetBranch === oldBranch) return; const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts new file mode 100644 index 000000000000..7bee3db20e58 --- /dev/null +++ b/apps/server/src/persistence/Migrations.test.ts @@ -0,0 +1,30 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "./Migrations.ts"; + +it.layer(NodeSqliteClient.layer({ filename: ":memory:" }))("runMigrations", (it) => { + it.effect("replaces the fork's stale WorktreeArchives slot 54 with upstream's migration", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 53 }); + // A fork database that ran the removed archive feature. + yield* sql`CREATE TABLE worktree_archives (archive_id TEXT PRIMARY KEY)`; + yield* sql` + INSERT INTO effect_sql_migrations (migration_id, name, created_at) + VALUES (54, 'WorktreeArchives', '2026-09-01T00:00:00.000Z') + `; + + yield* runMigrations(); + + const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + assert.ok(columns.some((column) => column.name === "auto_settle_disabled_at")); + const ledger = yield* sql<{ readonly name: string }>` + SELECT name FROM effect_sql_migrations WHERE migration_id = 54 + `; + assert.deepEqual(ledger, [{ name: "ProjectionThreadsAutoSettleDisabledAt" }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c837ae3f4c50..fb35c1ddc5fa 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -10,6 +10,7 @@ import * as Migrator from "effect/unstable/sql/Migrator"; import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; // Import all migrations statically import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; @@ -165,9 +166,28 @@ export interface RunMigrationsOptions { * * @returns Effect containing array of executed migrations */ +/** + * Fork repair: databases that ran the fork's removed WorktreeArchives + * migration carry it in the ledger as slot 54, which upstream later used for + * ProjectionThreadsAutoSettleDisabledAt. The Migrator skips by id alone, so the + * stale row is dropped first and upstream's 54 (idempotent DDL) runs in its + * place. The empty worktree_archives table is left alone. + */ +const releaseForkMigrationSlots = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const ledger = yield* sql<{ readonly name: string }>` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_sql_migrations' + `; + if (ledger.length === 0) return; + yield* sql` + DELETE FROM effect_sql_migrations WHERE migration_id = 54 AND name = 'WorktreeArchives' + `; +}); + export const runMigrations = Effect.fn("runMigrations")(function* ({ toMigrationInclusive, }: RunMigrationsOptions = {}) { + yield* releaseForkMigrationSlots; const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`); yield* migrations.length === 0 diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 2438edca8b1b..11303b3e67bd 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -26,6 +26,7 @@ import * as TestClock from "effect/testing/TestClock"; import { makeTestProviderAdapterHarness } from "../../integration/TestProviderAdapter.integration.ts"; import { ServerConfig } from "../config.ts"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import { GitWorkflowService } from "../git/GitWorkflowService.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; @@ -906,6 +907,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { Layer.provide(AnalyticsService.layerTest), ); const reactorLayer = ProviderCommandReactorLive.pipe( + Layer.provide(Layer.mock(GitHubCli.GitHubCli)({})), Layer.provideMerge(providerLayer), Layer.provide( Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index e134ebfc68cb..0974cab73845 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -184,6 +184,13 @@ Use the \`request_user_input\` tool only when it is listed in the available tool In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. `; +// `additionalContext` entry sent when the session cwd carries the +// t3code-provisioned `.context/` scratch directory (worktrees get one at +// creation). Callers pass it in Default mode only: Plan Mode forbids writing +// files, and plans there flow through blocks instead. +const T3_CODE_CONTEXT_DIRECTORY_INSTRUCTIONS = + "If the user asks you to build a plan, or you want somewhere to hand the user files that should not be committed to git, put them in the `.context` directory at the repository root. t3code automatically gitignores it."; + export interface CodexRuntimeInfo { readonly model: string; readonly modelName?: string | undefined; @@ -214,6 +221,7 @@ export function buildCodexAdditionalContext( * setting, so the prompt cannot claim tools the turn doesn't have. */ toolsAvailable: boolean | T3CodeToolAvailability = true, + options?: { readonly hasContextDirectory?: boolean }, ): Record { const tools = toolInstructions(toolsAvailable); // Separate keys keep each value under Codex's per-entry token cap. @@ -223,5 +231,13 @@ export function buildCodexAdditionalContext( value: buildRuntimeInstructions({ harness: "Codex", ...runtime }), }, ...(tools ? { t3_code_tools: { kind: "application", value: tools } } : {}), + ...(options?.hasContextDirectory === true + ? { + t3_code_scratch_dir: { + kind: "application", + value: T3_CODE_CONTEXT_DIRECTORY_INSTRUCTIONS, + }, + } + : {}), }; } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 7917b8360946..070f73ee367d 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -529,6 +529,62 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("appends the .context note to the system prompt when the cwd carries one", () => { + const cwd = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-context-dir-")); + NodeFS.mkdirSync(NodePath.join(cwd, ".context")); + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + cwd, + }); + + const systemPrompt = harness.getLastCreateQueryInput()?.options.systemPrompt; + if ( + typeof systemPrompt !== "object" || + Array.isArray(systemPrompt) || + systemPrompt.type !== "preset" + ) { + return assert.fail("expected a preset system prompt object"); + } + assert.include(systemPrompt.append ?? "", "`.context` directory"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("omits the .context note when the cwd has no .context directory", () => { + const cwd = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-no-context-dir-")); + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + cwd, + }); + + const systemPrompt = harness.getLastCreateQueryInput()?.options.systemPrompt; + if ( + typeof systemPrompt !== "object" || + Array.isArray(systemPrompt) || + systemPrompt.type !== "preset" + ) { + return assert.fail("expected a preset system prompt object"); + } + assert.ok(!systemPrompt.append?.includes(".context")); + assert.ok(systemPrompt.append?.includes("")); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("uses bypass permissions for full-access claude sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d50b3cbb9248..c1ca187a4c1f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -26,6 +26,7 @@ import { type ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; +import { WORKTREE_CONTEXT_DIRECTORY_NAME } from "@t3tools/shared/git"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { type ClaudeScopedLimitNames, claudeRateLimitEventToUpdate } from "./claudeUsageLimits.ts"; import { @@ -225,6 +226,13 @@ const remapClaudeForkTurnBoundaries = ( }; const PROVIDER = ProviderDriverKind.make("claudeAgent"); + +// Appended to the system prompt when the session's cwd carries the +// t3code-provisioned `.context/` scratch directory (worktrees get one at +// creation; see GitVcsDriverCore.provisionContextDirectory). Only claimed when +// the directory actually exists so the prompt never points at a missing path. +const CONTEXT_DIRECTORY_PROMPT = + "If the user asks you to build a plan, or you want somewhere to hand the user files that should not be committed to git, put them in the `.context` directory at the repository root. t3code automatically gitignores it."; type ClaudeTextStreamKind = Extract< RuntimeContentStreamKind, "assistant_text" | "reasoning_text" | "reasoning_summary_text" @@ -4899,6 +4907,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( extraArgs["thinking-display"] = "summarized"; } const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const hasContextDirectory = + input.cwd !== undefined && + (yield* fileSystem + .exists(path.join(input.cwd, WORKTREE_CONTEXT_DIRECTORY_NAME)) + .pipe(Effect.orElseSucceed(() => false))); // The attachments dir grant lets the agent Read/copy pasted images at // the paths ProviderService injects into the turn text, without an // approval prompt. It is a leaf directory holding only attachment @@ -4914,8 +4927,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( systemPrompt: { type: "preset", preset: "claude_code", - // Model and effort can change after this session-level prompt is set. - append: buildRuntimeInstructions({ harness: "Claude Code" }), + append: [ + buildRuntimeInstructions({ harness: "Claude Code" }), + ...(hasContextDirectory ? [CONTEXT_DIRECTORY_PROMPT] : []), + ].join("\n\n"), }, settingSources: [...CLAUDE_SETTING_SOURCES], // `ultracode` is a Claude Code setting, not an API effort level. It is diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index baa8d846f7c6..b24a920c9414 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -37,6 +37,8 @@ import * as NodeCrypto from "node:crypto"; import * as Crypto from "effect/Crypto"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -45,6 +47,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; +import { WORKTREE_CONTEXT_DIRECTORY_NAME } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; @@ -2239,6 +2242,8 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( options?: CodexAdapterLiveOptions, ) { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const crypto = yield* Crypto.Crypto; const serverConfig = yield* Effect.service(ServerConfig); @@ -2275,10 +2280,14 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const sessionCwd = input.cwd ?? process.cwd(); + const hasContextDirectory = yield* fileSystem + .exists(path.join(sessionCwd, WORKTREE_CONTEXT_DIRECTORY_NAME)) + .pipe(Effect.orElseSucceed(() => false)); const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, - cwd: input.cwd ?? process.cwd(), + cwd: sessionCwd, binaryPath: codexConfig.binaryPath, ...(options?.models ? { models: options.models } : {}), launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), @@ -2288,6 +2297,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: input.runtimeMode, + ...(hasContextDirectory ? { hasContextDirectory: true } : {}), ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } : {}), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index e315c9ab20fe..ccf6786c7ba0 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -295,6 +295,31 @@ describe("buildTurnStartParams", () => { }); }); + it.effect("threads hasContextDirectory into additional context in default mode only", () => + Effect.gen(function* () { + const defaultParams = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Go", + interactionMode: "default", + hasContextDirectory: true, + }); + const planParams = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Go", + interactionMode: "plan", + hasContextDirectory: true, + }); + + NodeAssert.match( + defaultParams.additionalContext?.t3_code_scratch_dir?.value ?? "", + /`\.context` directory/, + ); + NodeAssert.equal(planParams.additionalContext?.t3_code_scratch_dir, undefined); + }), + ); + it("reports the same fallback model and effort in settings and instructions", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -627,6 +652,14 @@ describe("buildCodexAdditionalContext", () => { NodeAssert.doesNotMatch(value, /[^<]*\n/); }); + it("adds the .context scratch dir entry only when the cwd carries one", () => { + const withDirectory = buildCodexAdditionalContext(runtime, true, { hasContextDirectory: true }); + const withoutDirectory = buildCodexAdditionalContext(runtime, true); + + NodeAssert.match(withDirectory.t3_code_scratch_dir?.value ?? "", /`\.context` directory/); + NodeAssert.equal(withoutDirectory.t3_code_scratch_dir, undefined); + }); + it("keeps every entry under Codex's 1,000 token cap per entry", () => { const context = buildCodexAdditionalContext(runtime, { browser: true, device: true }); for (const entry of Object.values(context)) { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index f30baf011e8a..30eaeae75e93 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -185,6 +185,8 @@ export interface CodexSessionRuntimeOptions { readonly appServerArgs?: ReadonlyArray; /** The provider's model list; supplies the display name for runtime info. */ readonly models?: Effect.Effect>; + /** True when the cwd carries the t3code-provisioned `.context/` scratch dir. */ + readonly hasContextDirectory?: boolean; /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */ readonly mcpCapabilities?: ReadonlySet; } @@ -592,6 +594,8 @@ function buildCodexTurnInstructions(input: { readonly modelName?: string; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; + /** True when the cwd carries the t3code-provisioned `.context/` scratch dir. */ + readonly hasContextDirectory?: boolean; }): Pick { if (input.interactionMode === undefined) { return {}; @@ -610,6 +614,10 @@ function buildCodexTurnInstructions(input: { additionalContext: buildCodexAdditionalContext( { model, modelName: input.modelName, reasoningEffort }, input.browserToolsAvailable ?? true, + // Plan Mode forbids writing files, so the scratch dir note is default-mode only. + { + hasContextDirectory: input.interactionMode !== "plan" && input.hasContextDirectory === true, + }, ), }; } @@ -634,6 +642,7 @@ export function buildTurnStartParams(input: { readonly interactionMode?: ProviderInteractionMode; /** Defaults to true so callers that predate the agent-access gate are unchanged. */ readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; + readonly hasContextDirectory?: boolean; }): Effect.Effect< CodexTurnStartParamsWithCollaborationMode, CodexErrors.CodexAppServerProtocolParseError @@ -656,6 +665,7 @@ export function buildTurnStartParams(input: { ...(input.modelName ? { modelName: input.modelName } : {}), ...(input.effort ? { effort: input.effort } : {}), browserToolsAvailable: input.browserToolsAvailable ?? true, + ...(input.hasContextDirectory ? { hasContextDirectory: true } : {}), }); return decodeCodexTurnStartParamsWithCollaborationMode({ @@ -2581,6 +2591,7 @@ export const makeCodexSessionRuntime = ( options.appServerArgs, options.mcpCapabilities, ), + ...(options.hasContextDirectory ? { hasContextDirectory: true } : {}), }); yield* Ref.set(lastAdditionalContextRef, params.additionalContext); const rawResponse = yield* client.raw.request("turn/start", params); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 88c08fd3bdee..0c160e3d9587 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -264,7 +264,7 @@ const PlatformServicesLive = NodeServices.layer; const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(OrchestrationReactorLive), Layer.provideMerge(ProviderRuntimeIngestionLive), - Layer.provideMerge(ProviderCommandReactorLive), + Layer.provideMerge(ProviderCommandReactorLive.pipe(Layer.provide(GitHubCli.layer))), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(StorageCleanup.layer), Layer.provideMerge(ThreadDeletionReactorLive), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 85c4d1a59d1d..8cb0828d97ef 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2635,6 +2635,51 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("provisions a git-ignored .context directory in new worktrees", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreesRoot = yield* makeTmpDir("git-worktrees-"); + const worktreePath = pathService.join(worktreesRoot, "feature-worktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/context-dir", + }); + + const fileSystem = yield* FileSystem.FileSystem; + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, ".context", "_.txt")), + true, + ); + // The info/exclude entry keeps the scratch directory out of git + // status, so the fresh worktree reads as clean and can be removed + // without --force. + assert.equal(yield* git(worktreePath, ["status", "--porcelain"]), ""); + yield* driver.removeWorktree({ cwd, path: worktreePath }); + assert.equal(yield* fileSystem.exists(worktreePath), false); + + // Creating another worktree must not duplicate the exclude entry. + yield* driver.createWorktree({ + cwd, + path: pathService.join(worktreesRoot, "second-worktree"), + refName: initialBranch, + newRefName: "feature/context-dir-second", + }); + const excludeContents = yield* fileSystem.readFileString( + pathService.join(cwd, ".git", "info", "exclude"), + ); + const entryCount = excludeContents + .split(/\r?\n/) + .filter((line) => line.trim() === ".context/").length; + assert.equal(entryCount, 1); + }), + ); + it.effect("allows worktree removal to run longer than the default command timeout", () => Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0dd73af687f9..0e26764a4c48 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -27,7 +27,11 @@ import { type ReviewDiffPreviewSource, type VcsRef, } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { + dedupeRemoteBranchesWithLocalMatches, + normalizeGitRemoteUrl, + WORKTREE_CONTEXT_DIRECTORY_NAME, +} from "@t3tools/shared/git"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -43,6 +47,10 @@ import { import { ServerConfig } from "../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000; +// The workspace file tree only surfaces directories that contain files, so the +// `.context` scratch directory is seeded with an empty placeholder to keep it +// visible. +const WORKTREE_CONTEXT_PLACEHOLDER_FILE = "_.txt"; const gitProcesses = Semaphore.makeUnsafe(8); // `git worktree add` checks out the full tree, so on large repositories it can // take well beyond the default 30s (e.g. a 375k-file repo takes ~40s on an idle @@ -3062,6 +3070,42 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + // Gives every new worktree a `.context/` scratch directory without touching + // the project's tracked .gitignore: the ignore entry goes into the repo's + // `info/exclude`, which lives in the shared common dir, so one entry covers + // the main checkout and all worktrees. The exclude entry is written before + // the placeholder file so the placeholder is born ignored (untracked files + // would dirty status and block non-forced worktree removal; ignored files + // do neither). + const provisionContextDirectory = Effect.fn("provisionContextDirectory")(function* ( + cwd: string, + worktreePath: string, + ) { + const gitCommonDir = yield* resolveGitCommonDir(cwd); + const excludePath = path.join(gitCommonDir, "info", "exclude"); + const excludeEntry = `${WORKTREE_CONTEXT_DIRECTORY_NAME}/`; + const existingExcludes = yield* fileSystem + .readFileString(excludePath) + .pipe(Effect.orElseSucceed(() => "")); + const hasEntry = existingExcludes.split(/\r?\n/).some((line) => line.trim() === excludeEntry); + if (!hasEntry) { + yield* fileSystem.makeDirectory(path.dirname(excludePath), { recursive: true }); + const separator = + existingExcludes.length === 0 || existingExcludes.endsWith("\n") ? "" : "\n"; + yield* fileSystem.writeFileString( + excludePath, + `${existingExcludes}${separator}${excludeEntry}\n`, + ); + } + + const contextDirPath = path.join(worktreePath, WORKTREE_CONTEXT_DIRECTORY_NAME); + yield* fileSystem.makeDirectory(contextDirPath, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(contextDirPath, WORKTREE_CONTEXT_PLACEHOLDER_FILE), + "", + ); + }); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input, options) { @@ -3189,6 +3233,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ]); } + // Best-effort: a missing scratch directory should not fail worktree + // creation. + yield* provisionContextDirectory(input.cwd, worktreePath).pipe( + Effect.catch((error) => + Effect.logWarning("GitVcsDriver.createWorktree: failed to provision .context", { + worktreePath, + error, + }), + ), + ); + return { worktree: { path: worktreePath, diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts index a60362c43bd5..c73c58e789fa 100644 --- a/apps/web/src/browser/browserLinkTarget.test.ts +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -5,7 +5,10 @@ import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget"; -const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget })); +const settings = vi.hoisted(() => ({ + browserLinkTarget: "system" as BrowserLinkTarget, + openLinksInPreviewPattern: "", +})); vi.mock("~/hooks/useSettings", () => ({ ensureClientSettingsHydrated: vi.fn(async () => undefined), @@ -15,6 +18,22 @@ vi.mock("~/hooks/useSettings", () => ({ const click = { metaKey: false, ctrlKey: false }; describe("resolveLinkTarget", () => { + it("lets matching patterns override the default while retaining modifier and runtime guards", () => { + const input = { + url: "https://example.com/docs", + event: click, + preference: "system" as const, + canOpenInApp: true, + previewPattern: "example\\.com", + }; + expect(resolveLinkTarget(input)).toBe("app"); + expect(resolveLinkTarget({ ...input, url: "https://other.com" })).toBe("system"); + expect(resolveLinkTarget({ ...input, event: { metaKey: true, ctrlKey: false } })).toBe( + "system", + ); + expect(resolveLinkTarget({ ...input, canOpenInApp: false })).toBe("system"); + expect(resolveLinkTarget({ ...input, previewPattern: "[" })).toBe("system"); + }); it("keeps the system browser unless the user asked for in-app", () => { expect( resolveLinkTarget({ diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index 7ecffb4593d5..06112006258a 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -11,6 +11,7 @@ import type { BrowserLinkTarget } from "@t3tools/contracts"; import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings"; +import { urlMatchesPreviewLinkPattern } from "./previewLinkPattern"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; export interface ResolveLinkTargetInput { @@ -18,6 +19,7 @@ export interface ResolveLinkTargetInput { /** Cmd/Ctrl-click always goes to the system browser, whatever the default. */ readonly event: { readonly metaKey: boolean; readonly ctrlKey: boolean }; readonly preference: BrowserLinkTarget; + readonly previewPattern?: string; /** Whether this client has an in-app browser and a thread to open it beside. */ readonly canOpenInApp: boolean; } @@ -31,7 +33,11 @@ export interface ResolveLinkTargetInput { */ export function resolveLinkTarget(input: ResolveLinkTargetInput): BrowserLinkTarget { if (input.event.metaKey || input.event.ctrlKey) return "system"; - if (input.preference !== "app") return "system"; + if ( + input.preference !== "app" && + !urlMatchesPreviewLinkPattern(input.previewPattern ?? "", input.url) + ) + return "system"; if (!input.canOpenInApp) return "system"; if (!isWebUrl(input.url)) return "system"; return "app"; @@ -57,9 +63,12 @@ export function isWebUrl(url: string): boolean { * is asynchronous anyway, so waiting costs nothing the user can see. * Read failures reject rather than choosing a browser without the saved preference. */ -export async function resolveBrowserLinkTargetPreference(): Promise { +export async function resolveBrowserLinkTargetPreference(url?: string): Promise { await ensureClientSettingsHydrated(); - return getClientSettings().browserLinkTarget; + const settings = getClientSettings(); + return url && urlMatchesPreviewLinkPattern(settings.openLinksInPreviewPattern, url) + ? "app" + : settings.browserLinkTarget; } /** Whether the in-app target is available at all in this client. */ diff --git a/apps/web/src/browser/focusBrowserSurfaceForUrl.ts b/apps/web/src/browser/focusBrowserSurfaceForUrl.ts new file mode 100644 index 000000000000..caa4b7ef674c --- /dev/null +++ b/apps/web/src/browser/focusBrowserSurfaceForUrl.ts @@ -0,0 +1,25 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { readThreadPreviewState, setActivePreviewTab } from "~/previewStateStore"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; + +/** Reuses a browser surface at its current URL without navigating or reloading it. */ +export function focusBrowserSurfaceForUrl(threadRef: ScopedThreadRef, url: string): boolean { + const target = URL.parse(url); + if (!target) return false; + + const panel = useRightPanelStore.getState(); + const { surfaces } = selectThreadRightPanelState(panel.byThreadKey, threadRef); + const { sessions } = readThreadPreviewState(threadRef); + for (const surface of surfaces) { + if (surface.kind !== "preview" || surface.resourceId === null) continue; + const status = sessions[surface.resourceId]?.navStatus; + if (!status || status._tag === "Idle") continue; + if (URL.parse(status.url)?.href !== target.href) continue; + + panel.activateSurface(threadRef, surface.id); + setActivePreviewTab(threadRef, surface.resourceId); + return true; + } + return false; +} diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index a320e3ba34da..95d9526924eb 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -22,6 +22,7 @@ import { rememberPreviewUrl, } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; +import { focusBrowserSurfaceForUrl } from "./focusBrowserSurfaceForUrl"; import { browserDefaultOpenProfileId, @@ -56,6 +57,9 @@ export async function openUrlInPreview(input: { readonly url: string; readonly openPreview: OpenPreviewMutation; }): Promise> { + if (focusBrowserSurfaceForUrl(input.threadRef, input.url)) { + return AsyncResult.success(undefined); + } const defaults = await resolveBrowserDefaults().catch( (cause: unknown) => new BrowserSettingsReadError({ cause }), ); diff --git a/apps/web/src/browser/openUrlInPreview.test.ts b/apps/web/src/browser/openUrlInPreview.test.ts new file mode 100644 index 000000000000..61864426ff02 --- /dev/null +++ b/apps/web/src/browser/openUrlInPreview.test.ts @@ -0,0 +1,149 @@ +import { + DEFAULT_CLIENT_SETTINGS, + type PreviewSessionSnapshot, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { openDiscoveredPort } from "~/components/preview/openDiscoveredPort"; +import { openTerminalLinkInPreview } from "~/components/preview/openTerminalLinkInPreview"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { + applyPreviewServerSnapshot, + readThreadPreviewState, + resetPreviewStateForTests, +} from "~/previewStateStore"; +import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; + +import { openUrlInPreview, type OpenPreviewMutation } from "./openFileInPreview"; + +vi.mock("~/previewStateStore", async (importOriginal) => ({ + ...(await importOriginal()), + isPreviewSupportedInRuntime: () => true, +})); + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; +const url = "https://example.com/"; + +function snapshot(tabId: string, currentUrl = url): PreviewSessionSnapshot { + return { + threadId: threadRef.threadId, + tabId, + navStatus: { _tag: "Success", url: currentUrl, title: "Example" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-09-17T00:00:00.000Z", + }; +} + +function addTab(tab: PreviewSessionSnapshot, ref = threadRef) { + applyPreviewServerSnapshot(ref, tab); + useRightPanelStore.getState().openBrowser(ref, tab.tabId); +} + +function panelState() { + return selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); +} + +const entryPoints = { + link: (target: string, openPreview: OpenPreviewMutation) => + openUrlInPreview({ threadRef, url: target, openPreview }), + terminal: (target: string, openPreview: OpenPreviewMutation) => + openTerminalLinkInPreview({ + threadRef, + url: target, + openPreview, + forceBrowser: false, + fallbackToBrowser: () => { + throw new Error("Unexpected system browser fallback"); + }, + }), + port: (target: string, openPreview: OpenPreviewMutation) => + openDiscoveredPort({ + threadRef, + openPreview, + port: { + host: "example.com", + port: 443, + url: target, + processName: null, + pid: null, + terminal: null, + }, + }), +}; + +beforeEach(() => { + resetPreviewStateForTests(); + useRightPanelStore.setState({ byThreadKey: {} }); + __setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, browserLinkTarget: "app" }); +}); + +describe.each(Object.entries(entryPoints))("%s links", (_name, open) => { + it.each(["Success", "Loading"] as const)( + "focuses an existing %s tab and reveals the panel without opening another session", + async (status) => { + const existing = snapshot("existing"); + addTab({ ...existing, navStatus: { _tag: status, url, title: "Example" } }); + addTab(snapshot("other", "https://other.example/")); + useRightPanelStore.getState().close(threadRef); + const surfaces = panelState().surfaces; + const openPreview = vi.fn(async () => AsyncResult.success(snapshot("created"))); + + // Browsers canonicalize an origin-only URL with a trailing slash. + await open("https://example.com", openPreview); + + expect(openPreview).not.toHaveBeenCalled(); + expect(panelState()).toMatchObject({ isOpen: true, activeSurfaceId: "browser:existing" }); + expect(panelState().surfaces).toEqual(surfaces); + expect(readThreadPreviewState(threadRef).activeTabId).toBe("existing"); + }, + ); + + it.each(["https://example.com/docs", "https://example.com/?q=1", "https://example.com/#docs"])( + "opens a new tab for a different URL: %s", + async (target) => { + addTab(snapshot("existing")); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot("created", target))); + + await open(target, openPreview); + + expect(openPreview).toHaveBeenCalledOnce(); + expect(panelState().activeSurfaceId).toBe("browser:created"); + expect(panelState().surfaces).toHaveLength(2); + }, + ); + + it.each(["thread", "environment"] as const)( + "does not reuse a tab from another %s", + async (scope) => { + const otherRef = { + environmentId: (scope === "environment" + ? "remote" + : "local") as ScopedThreadRef["environmentId"], + threadId: (scope === "thread" ? "thread-2" : "thread-1") as ScopedThreadRef["threadId"], + }; + addTab({ ...snapshot("elsewhere"), threadId: otherRef.threadId }, otherRef); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot("created"))); + + await open(url, openPreview); + + expect(openPreview).toHaveBeenCalledOnce(); + expect(panelState().activeSurfaceId).toBe("browser:created"); + }, + ); + + it("does not reuse a session missing from the surface list", async () => { + applyPreviewServerSnapshot(threadRef, snapshot("hidden")); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot("created"))); + + await open(url, openPreview); + + expect(openPreview).toHaveBeenCalledOnce(); + expect(panelState().surfaces.map((surface) => surface.id)).toEqual(["browser:created"]); + }); +}); diff --git a/apps/web/src/browser/previewLinkPattern.test.ts b/apps/web/src/browser/previewLinkPattern.test.ts new file mode 100644 index 000000000000..986971c161eb --- /dev/null +++ b/apps/web/src/browser/previewLinkPattern.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { urlMatchesPreviewLinkPattern } from "./previewLinkPattern"; + +describe("urlMatchesPreviewLinkPattern", () => { + it("matches URLs against the pattern", () => { + expect( + urlMatchesPreviewLinkPattern(String.raw`^https://github\.com/`, "https://github.com/t3/t3"), + ).toBe(true); + expect( + urlMatchesPreviewLinkPattern(String.raw`^https://github\.com/`, "https://example.com/"), + ).toBe(false); + }); + + it("matches nothing for an empty or whitespace pattern", () => { + expect(urlMatchesPreviewLinkPattern("", "https://example.com/")).toBe(false); + expect(urlMatchesPreviewLinkPattern(" ", "https://example.com/")).toBe(false); + }); + + it("matches nothing for an invalid pattern", () => { + expect(urlMatchesPreviewLinkPattern("(unclosed", "https://example.com/")).toBe(false); + }); + + it("recompiles when the pattern changes", () => { + expect(urlMatchesPreviewLinkPattern("github", "https://github.com/")).toBe(true); + expect(urlMatchesPreviewLinkPattern("gitlab", "https://github.com/")).toBe(false); + }); +}); diff --git a/apps/web/src/browser/previewLinkPattern.ts b/apps/web/src/browser/previewLinkPattern.ts new file mode 100644 index 000000000000..06f219a5eba9 --- /dev/null +++ b/apps/web/src/browser/previewLinkPattern.ts @@ -0,0 +1,23 @@ +/** + * Matches URLs against the "open matching links in the integrated + * browser" setting (`openLinksInPreviewPattern`). The pattern is a + * user-supplied RegExp source; an empty or invalid pattern matches nothing. + * The compiled RegExp is cached per pattern since every link click consults + * this. + */ +let compiled: { readonly pattern: string; readonly regex: RegExp | null } | null = null; + +export function urlMatchesPreviewLinkPattern(pattern: string, url: string): boolean { + const source = pattern.trim(); + if (source.length === 0) return false; + if (compiled?.pattern !== source) { + let regex: RegExp | null = null; + try { + regex = new RegExp(source); + } catch { + regex = null; + } + compiled = { pattern: source, regex }; + } + return compiled.regex?.test(url) ?? false; +} diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index 2a1d122eedbf..5a7a3b1c0a77 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -45,7 +45,7 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( const target = resolveLinkTarget({ url, event: options.event ?? NO_MODIFIER, - preference: await resolveBrowserLinkTargetPreference(), + preference: await resolveBrowserLinkTargetPreference(url), canOpenInApp: canOpenLinksInApp(Boolean(targetThreadRef)), }); if (target === "app" && targetThreadRef) { diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index 5154fe47e035..f335b9208176 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -114,19 +114,19 @@ describe("clientPersistenceStorage", () => { expect(settings).not.toHaveProperty("diffWordWrap"); }); - it("keeps the default diff file state across reloads and defaults it to collapsed", async () => { + it("keeps the default diff file state across reloads and defaults it to all folded", async () => { const testWindow = getTestWindow(); const { readBrowserClientSettings, writeBrowserClientSettings } = await import("./clientPersistenceStorage"); testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); - expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true); + expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe("all"); - writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: true }); - expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true); + writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: "viewed" }); + expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe("viewed"); - writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: false }); - expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(false); + writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: "none" }); + expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe("none"); }); it("keeps the diff layout across reloads and defaults it to stacked", async () => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4a1f616aed3b..5394f836610a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2471,6 +2471,7 @@ function useChatMarkdownState({ // Subscribed rather than read at click time: the anchor has to decide // synchronously whether to intercept its `_blank`, and a subscription is what // makes a persisted "app" apply once settings hydrate after launch. + const previewPattern = useClientSettings((settings) => settings.openLinksInPreviewPattern); const linkTargetPreference = useClientSettings((settings) => settings.browserLinkTarget); const resolveThreadPullRequest = useCallback( (href: string): (ThreadPullRequestKey & { readonly url: string }) | null => { @@ -2700,6 +2701,7 @@ function useChatMarkdownState({ inlineCodeFileLinkMetaByText, isStreaming, linkTargetPreference, + previewPattern, markdownFileLinkMetaByHref, onTaskListChange, onUseArtifactTemplate, @@ -2731,6 +2733,7 @@ function useChatMarkdownState({ inlineCodeFileLinkMetaByText, isStreaming, linkTargetPreference, + previewPattern, markdownFileLinkMetaByHref, onTaskListChange, onUseArtifactTemplate, @@ -2882,6 +2885,7 @@ const CHAT_MARKDOWN_COMPONENTS = { openChangeRequestLink, openDeferredMarkdownLink, linkTargetPreference, + previewPattern, openExternalLinkInPreview, projects, linkedThreadPullRequestFor, @@ -2988,6 +2992,7 @@ const CHAT_MARKDOWN_COMPONENTS = { url: href, event, preference: linkTargetPreference, + previewPattern, canOpenInApp: canOpenInPreview, }) !== "app" ) { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 61f1770c4bef..5b1b833eebf1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -271,7 +271,6 @@ import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations" import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; -import { useThreadActions } from "../hooks/useThreadActions"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getComposerPromptInjectionState, @@ -279,6 +278,7 @@ import { } from "./chat/composerProviderState"; import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { isPreviewFocused } from "../lib/previewFocus"; +import { useThreadActions } from "../hooks/useThreadActions"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { preventRepeatedTerminalCloseShortcut, @@ -332,6 +332,7 @@ import { } from "../queuedMessageStore"; import { sendQueuedMessage } from "./chat/sendQueuedMessage"; import { type ReviewCommentContext } from "../reviewCommentContext"; +import { appendThreadTranscriptPrompt } from "../lib/threadTranscript"; import { environmentCatalog } from "../connection/catalog"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; @@ -1486,7 +1487,8 @@ export default function ChatView(props: ChatViewProps) { const threadSyncPhase = routeKind === "server" ? (props.threadSyncPhase ?? null) : null; const threadDetailLoading = threadSyncPhase === "loading"; const handleNewThread = useNewThreadHandler(); - const { settleThread, pinThread, confirmAndUnpinThread } = useThreadActions(); + const { settleThread, pinThread, confirmAndUnpinThread, isWorktreeCardPinned } = + useThreadActions(); const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -1644,6 +1646,7 @@ export default function ChatView(props: ChatViewProps) { const setComposerDraftPreviewAnnotations = useComposerDraftStore( (store) => store.setPreviewAnnotations, ); + const setComposerDraftTranscripts = useComposerDraftStore((store) => store.setTranscripts); const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); @@ -5092,6 +5095,13 @@ export default function ChatView(props: ChatViewProps) { setActivePreviewTab(activeThreadRef, nextActiveSurface.resourceId); } }, [activeThreadRef]); + const reorderRightPanelSurfaces = useCallback( + (surfaceId: string, targetSurfaceId: string) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().moveSurface(activeThreadRef, surfaceId, targetSurfaceId); + }, + [activeThreadRef], + ); const finishRightPanelSurfaceClose = useCallback( (surfaces: readonly RightPanelSurface[]) => { if (!activeThreadRef) return; @@ -5408,6 +5418,20 @@ export default function ChatView(props: ChatViewProps) { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); + // Holds a message the user just sent near the top of the viewport so its turn + // reads downward from it. ChatView drives the streaming scrolls itself while + // this "anchoring-new-turn" mode lasts; see releaseChatTimelineAnchor. + const anchorSentMessage = useCallback((threadRef: ScopedThreadRef, messageId: MessageId) => { + isAtEndRef.current = true; + timelineScrollModeRef.current = "anchoring-new-turn"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); + pendingTimelineAnchorRef.current = messageId; + activeTimelineAnchorIndexRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + setTimelineAnchor({ threadKey: scopedThreadKey(threadRef), messageId }); + }, []); useLayoutEffect(() => { if (timelineScrollModeRef.current !== "anchoring-new-turn") { return; @@ -6757,7 +6781,7 @@ export default function ChatView(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (!isServerThread || !activeThreadRef || !supportsPinning) return; - const pinned = activeThreadPinned; + const pinned = activeThreadPinned || isWorktreeCardPinned(activeThreadRef); void (pinned ? confirmAndUnpinThread(activeThreadRef) : pinThread(activeThreadRef)).then( (result) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; @@ -6943,10 +6967,12 @@ export default function ChatView(props: ChatViewProps) { terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, + activeThreadRef, closeRightPanelSurface, requestCloseTerminal, requestClosePanelTerminal, createNewTerminal, + isServerThread, setTerminalOpen, runProjectScript, splitTerminal, @@ -6961,6 +6987,7 @@ export default function ChatView(props: ChatViewProps) { supportsPinning, supportsSettlement, confirmAndUnpinThread, + isWorktreeCardPinned, copyActiveThreadReference, getShortcutContext, toggleRightPanel, @@ -7277,6 +7304,7 @@ export default function ChatView(props: ChatViewProps) { files: overflow.filter((attachment) => attachment.type === "file"), terminalContexts: [], previewAnnotations: [], + transcripts: [], reviewComments: [], sendSettings: sendCtx ? readComposerSendSettings(sendCtx) : firstMessage.sendSettings, queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), @@ -7303,6 +7331,10 @@ export default function ChatView(props: ChatViewProps) { ...(draft?.previewAnnotations ?? []), ...messages.flatMap((message) => message.previewAnnotations), ]); + setComposerDraftTranscripts(composerDraftTarget, [ + ...(draft?.transcripts ?? []), + ...messages.flatMap((message) => message.transcripts), + ]); setComposerDraftReviewComments(composerDraftTarget, [ ...(draft?.reviewComments ?? []), ...messages.flatMap((message) => message.reviewComments), @@ -7432,6 +7464,7 @@ export default function ChatView(props: ChatViewProps) { files: composerFiles, terminalContexts: composerTerminalContexts, previewAnnotations: sendContextPreviewAnnotations, + transcripts: composerTranscripts, reviewComments: composerReviewComments, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, @@ -7489,7 +7522,10 @@ export default function ChatView(props: ChatViewProps) { prompt: promptForSend, imageCount: composerImages.length + composerFiles.length, terminalContexts: composerTerminalContexts, - elementContextCount: composerPreviewAnnotations.length + composerReviewComments.length, + elementContextCount: + composerTranscripts.length + + composerPreviewAnnotations.length + + composerReviewComments.length, }); const feedbackCommand = ctxSelectedProvider === "codex" && @@ -7619,6 +7655,7 @@ export default function ChatView(props: ChatViewProps) { composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && composerPreviewAnnotations.length === 0 && + composerTranscripts.length === 0 && composerReviewComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; @@ -7683,6 +7720,7 @@ export default function ChatView(props: ChatViewProps) { files: [...composerFiles], terminalContexts: [...composerTerminalContexts], previewAnnotations: [...composerPreviewAnnotations], + transcripts: [...composerTranscripts], reviewComments: [...composerReviewComments], sendSettings, queuedAfterToolActivityId: latestCompletedToolActivityId(threadActivities), @@ -7720,9 +7758,10 @@ export default function ChatView(props: ChatViewProps) { const composerAttachmentsSnapshot = [...composerImagesSnapshot, ...composerFilesSnapshot]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const composerTranscriptsSnapshot = [...composerTranscripts]; const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; // Expired terminal excerpts are not sent; their chips leave the text with them. - const messageTextForSend = composerTerminalContexts + const messageTextWithoutTranscripts = composerTerminalContexts .filter((context) => !composerTerminalContextsSnapshot.includes(context)) .reduce( (text, context) => @@ -7730,6 +7769,10 @@ export default function ChatView(props: ChatViewProps) { promptForSend, ) .trim(); + const messageTextForSend = composerTranscriptsSnapshot.reduce( + (text, transcript) => appendThreadTranscriptPrompt(text, transcript), + messageTextWithoutTranscripts, + ); // Records bind attachments by the id each side knows: the local id for the optimistic // row, the upload id (or local id on the data-URL path) on the wire; the server // rebinds them to the persisted id. @@ -8206,22 +8249,16 @@ export default function ChatView(props: ChatViewProps) { ...(attachment.source ? { source: attachment.source } : {}), }, ); + // The first message of a thread always anchors so the opening turn reads + // from the top; later sends only do when the user opted in. const shouldAnchorFirstMessage = activeThread.latestTurn === null && !timelineMessages.some((message) => message.role === "user"); - if (shouldAnchorFirstMessage) { - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); + if (shouldAnchorFirstMessage || settings.chatTurnAnchor === "top") { + anchorSentMessage( + scopeThreadRef(activeThread.environmentId, threadIdForSend), + messageIdForSend, + ); } else { scrollToEnd(); } @@ -8498,6 +8535,8 @@ export default function ChatView(props: ChatViewProps) { : promptRef.current.length === 0 && composerImagesRef.current.length === 0 && composerFilesRef.current.length === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.transcripts + .length ?? 0) === 0 && composerTerminalContextsRef.current.length === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) ?.previewAnnotations.length ?? 0) === 0 && @@ -8522,6 +8561,7 @@ export default function ChatView(props: ChatViewProps) { addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); + setComposerDraftTranscripts(composerDraftTarget, composerTranscriptsSnapshot); setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(messageTextForSend, messageTextForSend.length), @@ -8925,7 +8965,11 @@ export default function ChatView(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - scrollToEnd(); + if (settings.chatTurnAnchor === "top") { + anchorSentMessage(scopeThreadRef(environmentId, threadIdForSend), messageIdForSend); + } else { + scrollToEnd(); + } setOptimisticUserMessages((existing) => [ ...existing, @@ -9031,9 +9075,11 @@ export default function ChatView(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, + anchorSentMessage, scrollToEnd, setComposerDraftInteractionMode, setThreadError, + settings.chatTurnAnchor, startThreadTurn, environmentId, composerRef, @@ -9729,6 +9775,7 @@ export default function ChatView(props: ChatViewProps) { "flex min-h-0 min-w-0 flex-col overflow-x-hidden", rightPanelMaximized ? "w-0 flex-none" : "flex-1", )} + data-chat-column data-chat-column-maximized-away={rightPanelMaximized ? "true" : "false"} > {/* Top bar */} @@ -10268,6 +10315,7 @@ export default function ChatView(props: ChatViewProps) { previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} + onReorderSurface={reorderRightPanelSurfaces} onCloseSurface={closeRightPanelSurface} onRenameDevice={(surfaceId, title) => { if (activeThreadRef) @@ -10325,6 +10373,7 @@ export default function ChatView(props: ChatViewProps) { previewRuntimeTabId={resolvePreviewRuntimeTabId} terminalLabelsById={activeTerminalLabelsById} onActivate={activateRightPanelSurface} + onReorderSurface={reorderRightPanelSurfaces} onCloseSurface={closeRightPanelSurface} onRenameDevice={(surfaceId, title) => { if (activeThreadRef) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 2a5fe2ba0588..4dd76bfcef7d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -75,6 +75,7 @@ import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { importPastedComposerText } from "./composerInlineTokenPaste"; +import { extractPastedThreadTranscripts, type ThreadTranscriptDraft } from "~/lib/threadTranscript"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; import type { ComposerDraftContextRecords } from "./composerContextPresentation"; @@ -142,6 +143,11 @@ export interface ComposerPromptEditorProps { onPageScrollRelease?: () => void; onCitationSubmitAndSend?: () => void; onPaste: React.ClipboardEventHandler; + /** + * When set, pasted `` blocks are pulled out of the paste + * before the editor sees it and handed here instead of landing as raw text. + */ + onPasteTranscripts?: (transcripts: ThreadTranscriptDraft[]) => void; editorRef: React.RefObject; } @@ -603,6 +609,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { onPageScrollRelease, onCitationSubmitAndSend, onPaste, + onPasteTranscripts, editorRef, } = props; // The setting toggles styling, not the engine: both modes are Tiptap. @@ -614,6 +621,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const onCommandKeyDownRef = useRef(onCommandKeyDown); const buildFragmentRef = useRef(buildContextClipboardFragment); const importFragmentRef = useRef(importContextFragment); + const onPasteTranscriptsRef = useRef(onPasteTranscripts); const skillsRef = useRef(skills); const latestValueRef = useRef(value); // The editor instance for callbacks created before it exists (paste). @@ -635,6 +643,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { useEffect(() => { importFragmentRef.current = importContextFragment; }, [importContextFragment]); + useEffect(() => { + onPasteTranscriptsRef.current = onPasteTranscripts; + }, [onPasteTranscripts]); useEffect(() => { skillsRef.current = skills; }, [skills]); @@ -1004,6 +1015,18 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { let text = importFragment ? importPastedComposerText(clipboardData, importFragment) : pastedText; + // Transcript blocks become composer cards, not prompt text. Pulled + // out before tokenizing: a transcript body can carry mention-shaped + // tokens that must not turn into chips. + const onTranscriptsPasted = onPasteTranscriptsRef.current; + if (onTranscriptsPasted) { + const extracted = extractPastedThreadTranscripts(text); + if (extracted.transcripts.length > 0) { + onTranscriptsPasted(extracted.transcripts); + text = extracted.remainingText; + if (text.length === 0) return true; + } + } // Complete chips at paste boundaries just as autocomplete does. const tokens = collectComposerPromptInlineTokens(`${text}\n`); const lastToken = tokens.at(-1); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ea3f38f2fff0..16ef50cbc042 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -458,9 +458,10 @@ export default function DiffPanel({ () => renderableFiles.map(getCachedFileEntry), [renderableFiles], ); + // Thread diffs carry no viewed marks, so the `viewed` mode folds nothing here. const defaultCollapsedDiffFileKeys = useMemo( () => - settings.diffFilesCollapsed + settings.diffFilesCollapsed === "all" ? new Set(renderableFileEntries.map((file) => file.fileKey)) : EMPTY_COLLAPSED_DIFF_FILE_KEYS, [renderableFileEntries, settings.diffFilesCollapsed], diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f302e976ca70..880478dae8ff 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -92,6 +92,37 @@ describe("when: ref is clean and has an open PR", () => { }); }); +describe("when: ref has a merged PR", () => { + const mergedPr = { + number: 15, + title: "Merged PR", + url: "https://example.com/pr/15", + baseRef: "main", + headRef: "feature/test", + state: "merged", + } as const; + + it("resolveQuickAction opens the merged PR when the ref is idle", () => { + const quick = resolveQuickAction(status({ pr: mergedPr }), false); + assert.deepInclude(quick, { kind: "open_pr", label: "Merged", disabled: false }); + }); + + it("resolveQuickAction prefers viewing the merged PR over re-creating one after a squash merge", () => { + const quick = resolveQuickAction(status({ pr: mergedPr, aheadOfDefaultCount: 2 }), false); + assert.deepInclude(quick, { kind: "open_pr", label: "Merged", disabled: false }); + }); + + it("resolveQuickAction opens the merged PR when the upstream ref is gone", () => { + const quick = resolveQuickAction(status({ pr: mergedPr, hasUpstream: false }), false); + assert.deepInclude(quick, { kind: "open_pr", label: "Merged", disabled: false }); + }); + + it("resolveQuickAction still offers the commit flow for new changes", () => { + const quick = resolveQuickAction(status({ pr: mergedPr, hasWorkingTreeChanges: true }), false); + assert.deepInclude(quick, { kind: "run_action", action: "commit_push_pr" }); + }); +}); + describe("when: actions are busy", () => { it("resolveQuickAction returns running disabled state", () => { const quick = resolveQuickAction(status(), true); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 96f7af794ace..f2355697409d 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -186,11 +186,15 @@ export function resolveQuickAction( const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; + const hasMergedPr = gitStatus.pr?.state === "merged"; const isAhead = gitStatus.aheadCount > 0; const hasDefaultBranchDelta = (gitStatus.aheadOfDefaultCount ?? gitStatus.aheadCount) > 0; const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; const terminology = resolveChangeRequestTerminology(gitStatus); + // A merged PR still opens on click, but the label reports the state rather + // than the action - the purple tint and "Merged" read as one status chip. + const viewPrLabel = hasMergedPr ? "Merged" : `View ${terminology.shortLabel}`; if (!hasBranch) { return { @@ -218,8 +222,8 @@ export function resolveQuickAction( if (!gitStatus.hasUpstream) { if (!hasPrimaryRemote) { - if (hasOpenPr && !isAhead) { - return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; + if ((hasOpenPr || hasMergedPr) && !isAhead) { + return { label: viewPrLabel, disabled: false, kind: "open_pr" }; } return { label: "Publish repository", @@ -228,8 +232,8 @@ export function resolveQuickAction( }; } if (!isAhead) { - if (hasOpenPr) { - return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; + if (hasOpenPr || hasMergedPr) { + return { label: viewPrLabel, disabled: false, kind: "open_pr" }; } return { label: "Push", @@ -288,8 +292,11 @@ export function resolveQuickAction( }; } - if (hasOpenPr && gitStatus.hasUpstream) { - return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; + // A merged PR wins over the create-PR fallback: after a squash merge the + // branch still reads as ahead of default, but re-creating a PR for commits + // that already landed is never the right offer. + if (hasOpenPr || hasMergedPr) { + return { label: viewPrLabel, disabled: false, kind: "open_pr" }; } if (hasDefaultBranchDelta && !isDefaultRef) { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2c26f1e69b1d..17701ea23d09 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1138,6 +1138,15 @@ export default function GitActionsControl({ const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") : null; + // The "View PR" quick action reads as a state chip: green while open, purple once merged. + const quickActionPrState = + quickAction.kind === "open_pr" ? gitStatusForActions?.pr?.state : undefined; + const quickActionVariant = + quickActionPrState === "open" + ? "success" + : quickActionPrState === "merged" + ? "merged" + : "outline"; const pendingDefaultBranchActionCopy = pendingDefaultBranchAction ? resolveDefaultBranchActionDialogCopy({ action: pendingDefaultBranchAction.action, @@ -1201,11 +1210,13 @@ export default function GitActionsControl({ onOpenPullRequest(openPr.number); return; } - const prUrl = openPr?.url ?? null; + const prUrl = + openPr?.url ?? + (gitStatusForActions?.pr?.state === "merged" ? gitStatusForActions.pr.url : null); if (!prUrl) { toastManager.add({ type: "error", - title: "No open pull request found.", + title: "No pull request found.", data: threadToastData, }); return; @@ -1401,6 +1412,7 @@ export default function GitActionsControl({ const actionResult = result.value; syncThreadBranchAfterGitAction(actionResult); + const closeResultToast = () => { toastManager.close(resolvedProgressToastId); }; @@ -1434,7 +1446,6 @@ export default function GitActionsControl({ ...scopedToastData, dismissAfterVisibleMs: 10_000, }; - if (toastActionProps) { toastManager.update( resolvedProgressToastId, @@ -1814,7 +1825,7 @@ export default function GitActionsControl({ ) : ( + } + /> + + + {tooltip.label} + {tooltip.shortcut ? {tooltip.shortcut} : null} + + + + ); +} - const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => { - const Icon = action.icon; - return ( - - - {action.badgeCount > 0 ? ( - + - {action.badgeCount} - - ) : null} - - ); - }; + + + } + /> + + {action.available ? action.addLabel : action.disabledReason} + + + ); +} +/** Preserves each tab's content and actions while adding drag reordering. */ +function SortableTab({ + surfaceId, + className, + children, + ...props +}: ComponentProps<"div"> & { surfaceId: string }) { + const { setNodeRef, listeners, transform, transition, isDragging } = useSortable({ + id: surfaceId, + }); return (
action.shortcut).join("")} + {...props} + {...listeners} + ref={setNodeRef} + style={{ transform: CSS.Translate.toString(transform), transition }} className={cn( - "flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none", - // The panel topbar sits above this container; matching bottom padding - // keeps the list centered against the full panel, not the leftover. - "pb-(--workspace-topbar-height)", + className, + "[-webkit-app-region:no-drag]", + isDragging && "relative z-10 opacity-80", )} > -
-

Open a surface

-
- {actions.map((action) => - action.available ? ( - // The row is itself a button, so the profile chooser sits beside - // it in a wrapper rather than inside it. Hover lives on the - // wrapper: the chooser overlays the row, and a pointer moving - // onto it must not read as leaving the row. -
setHighlight(availableActions.indexOf(action))} - onMouseLeave={() => - setHighlight((current) => - current === availableActions.indexOf(action) ? -1 : current, - ) - } - > - - {/* - Same choice the tab bar's "+" menu offers: the row opens the - default profile, the chevron picks another. Only worth showing - once there is something to choose between. - */} - {action.label === "Browser" && props.browserProfiles.length > 1 ? ( - - - } - > - - - - {props.browserProfiles.map((profile) => ( - props.onAddBrowserInProfile(profile.id)} - > - {profile.name} - - ))} - - - ) : null} -
- ) : ( - - {actionIcon(action, "size-4")} - {action.label} - {action.shortcut} -
- } - /> - ), - )} -
-
+ {children} ); } @@ -868,16 +683,13 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }); }, []); - const addSurfaceActions = [ - { - label: "Browser", - icon: Globe2, - shortcut: "B", - available: props.browserAvailable, - disabledReason: SURFACE_DISABLED_REASONS.browser, - onClick: props.onAddBrowser, - }, + const addSurfaceActions: SurfaceAction[] = [ { + surfaceKind: "terminal", + addLabel: "New terminal", + description: "New terminal", + multiInstance: true, + badgeCount: 0, label: "Terminal", icon: TerminalSquare, shortcut: "T", @@ -886,6 +698,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onClick: props.onAddTerminal, }, { + surfaceKind: "files", + addLabel: "Open files", + description: "Open files", + multiInstance: false, + badgeCount: 0, label: "Files", icon: Files, shortcut: "F", @@ -894,6 +711,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onClick: props.onAddFiles, }, { + surfaceKind: "diff", + addLabel: "Open diff", + description: "Open diff", + multiInstance: false, + badgeCount: 0, label: "Diff", icon: FileDiff, shortcut: "D", @@ -902,6 +724,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onClick: props.onAddDiff, }, { + surfaceKind: "pull-request", + addLabel: "Open pull request", + description: "Open pull request", + multiInstance: false, + badgeCount: 0, label: "Pull request", icon: PullRequestGlyph.pullRequest, shortcut: "P", @@ -910,6 +737,24 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onClick: props.onAddPullRequest, }, { + surfaceKind: "preview", + addLabel: "New browser", + description: "New browser", + multiInstance: true, + badgeCount: 0, + label: "Browser", + icon: Globe2, + shortcut: "B", + available: props.browserAvailable, + disabledReason: SURFACE_DISABLED_REASONS.browser, + onClick: props.onAddBrowser, + }, + { + surfaceKind: "pull-requests", + addLabel: "Open linked pull requests", + description: "Open linked pull requests", + multiInstance: false, + badgeCount: 0, label: "Linked pull requests", icon: PullRequestGlyph.link, shortcut: "L", @@ -918,6 +763,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onClick: props.onAddPullRequests, }, { + surfaceKind: "agents", + addLabel: "Open agents", + description: "Open agents", + multiInstance: false, + badgeCount: props.liveAgentCount, label: "Agents", icon: Bot, shortcut: "A", @@ -926,6 +776,11 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onClick: props.onAddAgents, }, { + surfaceKind: "device", + addLabel: "Open device", + description: "Open device", + multiInstance: true, + badgeCount: 0, label: "Device", icon: Smartphone, shortcut: "M", @@ -933,7 +788,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.device, onClick: props.onAddDevice, }, - ] as const; + ]; + + const tabDragSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + ); + const isLauncherVisible = props.surfaces.length === 0; + const ghostIconActions = iconGhostsForOpenSurfaces( + addSurfaceActions, + props.surfaces.map((surface) => surface.kind), + ); + const launcherActionsRef = useRef(addSurfaceActions); + launcherActionsRef.current = addSurfaceActions; + useEffect(() => { + if (!isLauncherVisible) return; + const handler = (event: KeyboardEvent) => { + if (!shouldClaimSurfaceLauncherKey(event)) return; + const action = surfaceShortcutActionForKey( + launcherActionsRef.current.filter((entry) => entry.available), + event, + ); + if (!action) return; + event.preventDefault(); + event.stopPropagation(); + action.onClick(); + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, [isLauncherVisible]); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1128,137 +1010,158 @@ export function RightPanelTabs(props: RightPanelTabsProps) { data-right-panel-tab-list >
- {props.surfaces.map((surface) => { - const active = surface.id === props.activeSurfaceId; - const pending = props.pendingSurfaceIds.has(surface.id); - const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); - const previewTabId = previewTabIdOf(surface, props.previewSessions); - // Desktop state is keyed by the session id, but desktop actions - // must be addressed with the runtime id. - const audio = tabAudioState( - previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, - ); - const audioRuntimeTabId = previewTabId - ? (props.previewRuntimeTabId?.(previewTabId) ?? null) - : null; - return ( -
handleTabAuxClick(event, surface)} - onContextMenu={(event) => void handleTabContextMenu(event, surface)} - className={cn( - "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", - ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - active - ? "bg-accent text-foreground" - : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", - )} - > - props.onCloseSurface(surface)} - > - - {pending ? ( - - ) : null} - - {audio === "none" || !audioRuntimeTabId ? null : ( - - { - // Sibling of the close button, inside a tab that - // activates on click: keep this to the toggle. - event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); - }} - > - {audio === "muted" ? ( - - ) : ( - - )} - - } - /> - {audio === "muted" ? "Unmute tab" : "Mute tab"} - - )} - {renamingDevice === surface.id ? ( - { - element?.focus(); - element?.select(); - }} - onBlur={(event) => { - props.onRenameDevice?.(surface.id, event.currentTarget.value); - setRenamingDevice(null); - }} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = title; - event.currentTarget.blur(); - } - }} - /> - ) : ( - - { - if (surface.kind === "device" && props.onRenameDevice) - setRenamingDevice(surface.id); - }} - className="cursor-pointer flex min-w-0 items-center" - onClick={() => props.onActivate(surface)} - > - {title} - - } - /> - - {surface.kind === "device" ? ( - ( + + )) + : null} + { + if (over && active.id !== over.id) + props.onReorderSurface(String(active.id), String(over.id)); + }} + > + surface.id)} + strategy={horizontalListSortingStrategy} + > + {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const pending = props.pendingSurfaceIds.has(surface.id); + const title = surfaceTitle( + surface, + props.previewSessions, + props.terminalLabelsById, + ); + const previewTabId = previewTabIdOf(surface, props.previewSessions); + // Desktop state is keyed by the session id, but desktop actions + // must be addressed with the runtime id. + const audio = tabAudioState( + previewTabId ? (props.desktopByTabId[previewTabId] ?? null) : null, + ); + const audioRuntimeTabId = previewTabId + ? (props.previewRuntimeTabId?.(previewTabId) ?? null) + : null; + return ( + handleTabAuxClick(event, surface)} + onContextMenu={(event) => void handleTabContextMenu(event, surface)} + className={cn( + "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", + active + ? "bg-accent text-foreground" + : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", + )} + > + props.onCloseSurface(surface)} + > + + {pending ? ( + - ) : ( - title - )} - - - )} -
- ); - })} - {props.surfaces.length > 0 ? ( + ) : null} + + {audio === "none" || !audioRuntimeTabId ? null : ( + + { + // Sibling of the close button, inside a tab that + // activates on click: keep this to the toggle. + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + {audio === "muted" ? ( + + ) : ( + + )} + + } + /> + + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + + )} + {renamingDevice === surface.id ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => { + props.onRenameDevice?.(surface.id, event.currentTarget.value); + setRenamingDevice(null); + }} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} + /> + ) : ( + + { + if (surface.kind === "device" && props.onRenameDevice) + setRenamingDevice(surface.id); + }} + className="cursor-pointer flex min-w-0 items-center" + onClick={() => props.onActivate(surface)} + > + {title} + + } + /> + {title} + + )} + + ); + })} + + + {!isLauncherVisible + ? ghostIconActions.map((action) => ( + + )) + : null} + {props.surfaces.length > 0 || browserProfiles.length > 1 ? ( @@ -1405,27 +1310,9 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.activeSurfaceId === null ? ( - +
+

No surface open

+
) : ( props.children )} diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts index c757eca87aa4..a347e165548f 100644 --- a/apps/web/src/components/Sidebar.drag.test.ts +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -835,3 +835,42 @@ describe("lifted card clearance", () => { expect(511 + apply(511, 36, -500, 136, 114).y).toBe(250); }); }); + +describe("lifted worktree card projection", () => { + const items = [ + pinnedHeader, + divider, + thread("a1", "active"), + thread("a2", "active"), + thread("a3", "active"), + settledHeader, + thread("s1", "settled"), + ]; + + it("hides the siblings and opens a gap the height of the whole card", () => { + const result = preview( + { items, settledOrder: [], settledExpanded: true, cardKeys: ["a1", "a2"] }, + "a1", + "a3", + ); + // a2 travels inside the lifted card, so its own row leaves the flow. + expect(result.get("a2")?.scaleY).toBe(0); + // a3 slides up into the card's old slot (two rows plus the 1px gap). + expect(result.get("a3")).toEqual({ ...stationary, y: -166 }); + // The shelf below keeps its place: the gap below a3 is the card's height. + expect(result.get(sidebarMarkerId("settled-header"))).toEqual(stationary); + }); + + it("carries the siblings across the pinned divider with the dragged row", () => { + const result = preview( + { items, settledOrder: [], settledExpanded: true, cardKeys: ["a1", "a2"] }, + "a1", + sidebarMarkerId("pinned-header"), + ); + expect(result.get("a2")?.scaleY).toBe(0); + // The divider drops below the card; a3 stays put since the card left + // above it and re-entered above it. + expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual({ ...stationary, y: 166 }); + expect(result.get("a3")).toEqual(stationary); + }); +}); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index bfd112d18998..e759367c5912 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -8,6 +8,7 @@ import { type SidebarListMarker, type SidebarSection, } from "./Sidebar.logic"; +import { applyWorktreeCardToDropTarget } from "./Sidebar.worktree"; const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; const hidden = { ...stationary, scaleY: 0 }; @@ -106,9 +107,14 @@ export function createSidebarSortingStrategy(input: { /** Space each pinned boundary opens for its label while dragging. The * markers stay zero height at rest, so nothing is reserved until pickup. */ boundaryLabelHeight?: number; + /** Every member of the lifted worktree card, dragged row included. The + * lifted row renders the whole card, so its siblings leave the flow and the + * gap opens to the card's full height. */ + cardKeys?: readonly string[]; }): SortingStrategy { const { items } = input; const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index])); + const cardKeys = input.cardKeys ?? []; let previous: Pick | undefined; let transforms: ReturnType[] | null = []; @@ -116,8 +122,10 @@ export function createSidebarSortingStrategy(input: { const active = items[activeIndex]; const over = items[overIndex] ?? active; if (active?.kind !== "thread" || !over || !rects[0]) return []; - const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); - if (!target) return []; + const plainTarget = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); + if (!plainTarget) return []; + const target = applyWorktreeCardToDropTarget(plainTarget, active.key, cardKeys); + const siblings = new Set(cardKeys.filter((key) => key !== active.key)); const groups: Record = { pinned: [], active: [], @@ -135,10 +143,13 @@ export function createSidebarSortingStrategy(input: { } continue; } + // The lifted node may have been remeasured as a whole worktree card, + // so row heights only ever come from rows still in the flow. + if (item.key === active.key) continue; if (item.section === "pinned" || item.section === "active") cardHeight ??= rects[index]?.height; else slimHeight ??= rects[index]?.height; - if (item.key !== active.key) groups[item.section].push(item); + if (item.key !== active.key && !siblings.has(item.key)) groups[item.section].push(item); } // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9. const scale = @@ -198,13 +209,24 @@ export function createSidebarSortingStrategy(input: { ? cardHeight : slimHeight; const moved = item.kind === "thread" && item.key === active.key; + // The lifted card is as tall as its members' rows plus the 1px list + // gaps between them: the siblings as measured at pickup, the dragged + // row as a plain row (its own node now holds the whole card). + const cardHeightTotal = + siblings.size === 0 + ? fallback + : cardKeys.reduce((sum, key) => { + const memberIndex = key === active.key ? undefined : indices.get(key); + const memberRect = memberIndex === undefined ? undefined : rects[memberIndex]; + return sum + (memberRect?.height ?? fallback) + 1; + }, -1); return item.kind === "marker" && (item.marker === "pinned-header" || item.marker === "pinned-divider") ? labelHeight : item.kind === "marker" && item.marker.endsWith("placeholder") ? slimHeight : moved - ? fallback + ? cardHeightTotal : (rect?.height ?? fallback); }); const firstShelf = items.findIndex( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 2796b2f7f885..7decaf9887e2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -264,6 +264,9 @@ export function planSidebarThreadDrop(input: { readonly activeOrder: readonly string[]; readonly activeKeysById: ReadonlyMap; readonly activeReorderableKeys?: ReadonlySet; + /** The lifted worktree card's members in card order; keys are assigned to + the whole block so the members stay adjacent on every client. */ + readonly movedIds?: readonly string[]; }): SidebarThreadDropPlan { const { activeKey, @@ -295,6 +298,7 @@ export function planSidebarThreadDrop(input: { orderedIds: order, keysById: activeKeysById, movedId: activeKey, + ...(input.movedIds === undefined ? {} : { movedIds: input.movedIds }), }); if (activeReorderableKeys && assignments.some(({ id }) => !activeReorderableKeys.has(id))) { return { kind: "none" }; @@ -324,6 +328,7 @@ export function planSidebarThreadDrop(input: { orderedIds: order, keysById: pinnedKeysById, movedId: activeKey, + ...(input.movedIds === undefined ? {} : { movedIds: input.movedIds }), }); if (reorderableKeys && assignments.some(({ id }) => !reorderableKeys.has(id))) { return { kind: "none" }; @@ -1247,3 +1252,31 @@ export function sortScopedProjectsForSidebar< left.id.localeCompare(right.id), ); } + +export function getWorktreeSiblingThreadIdAfterArchive< + T extends Pick & + ThreadSortInput & { readonly worktreePath: string | null; readonly archivedAt: string | null }, +>(input: { + threads: readonly T[]; + archivedThreadId: T["id"]; + sortOrder: SidebarThreadSortOrder; +}): T["id"] | null { + const { archivedThreadId, sortOrder, threads } = input; + const archivedThread = threads.find((thread) => thread.id === archivedThreadId); + const worktreePath = archivedThread?.worktreePath?.trim(); + if (!worktreePath) { + return null; + } + + return ( + sortThreads( + threads.filter( + (thread) => + thread.id !== archivedThreadId && + thread.archivedAt === null && + thread.worktreePath?.trim() === worktreePath, + ), + sortOrder, + )[0]?.id ?? null + ); +} diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index f79bc06c64be..d11efb401dcb 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -10,10 +10,15 @@ export function SidebarDragLifecycle({ onUnmount }: { onUnmount: () => void }) { type Options = { distance: number; + /** A press held still this long (or Option at press) targets the row + * alone inside its worktree card; any earlier movement lifts the card. */ + holdMs: number; onAttach: (sensor: SidebarPointerSensor) => void; onFinish: (started: boolean) => void; }; +export type SidebarDragMode = "card" | "row"; + /** A sidebar gesture ends on release, cancellation, or loss of its window. * Own the listeners so unmounting the list can cancel the sensor too. */ export class SidebarPointerSensor { @@ -25,7 +30,10 @@ export class SidebarPointerSensor { }, ]; autoScrollEnabled = true; + /** Read at drag start; Sidebar decides whether the row has a card at all. */ + mode: SidebarDragMode = "card"; private phase: "pending" | "dragging" | "finished" = "pending"; + private holdTimer: ReturnType | null = null; private readonly pointer: PointerEvent; private readonly document: Document; private readonly window: Window; @@ -44,10 +52,23 @@ export class SidebarPointerSensor { this.window.addEventListener("resize", this.cancel); this.document.addEventListener("dragstart", this.preventDefault); this.document.addEventListener("contextmenu", this.preventDefault); + if (this.pointer.altKey) this.mode = "row"; + else { + this.holdTimer = setTimeout(() => { + this.holdTimer = null; + if (this.phase === "pending") this.mode = "row"; + }, props.options.holdMs); + } props.options.onAttach(this); props.onPending(props.active, { distance: props.options.distance }, this.coordinates()); } + private clearHoldTimer() { + if (this.holdTimer === null) return; + clearTimeout(this.holdTimer); + this.holdTimer = null; + } + private coordinates = () => ({ x: this.pointer.clientX, y: this.pointer.clientY }); private preventDefault = (event: Event) => event.preventDefault(); private clearClickSuppression = () => { @@ -81,6 +102,7 @@ export class SidebarPointerSensor { return; } this.phase = "dragging"; + this.clearHoldTimer(); this.document.addEventListener("click", this.suppressClick, { capture: true }); this.document.addEventListener("selectionchange", this.clearSelection); this.clearSelection(); @@ -111,6 +133,7 @@ export class SidebarPointerSensor { if (this.phase === "finished") return; const aborted = this.phase === "pending"; this.phase = "finished"; + this.clearHoldTimer(); this.document.removeEventListener("pointermove", this.move, { capture: true }); this.document.removeEventListener("pointerup", this.end, { capture: true }); this.document.removeEventListener("pointercancel", this.pointerCancel, { capture: true }); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 90f65ea2b941..45184c1c3974 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -126,13 +126,27 @@ import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useSidebarWorktreeCardsEnabled } from "../hooks/useSettings"; +import { + applyWorktreeCardToDropTarget, + dropSplitsForeignWorktreeCard, + gatherWorktreeCards, + resolveWorktreeCardPositions, + worktreeCardAccent, + worktreeCardKey, + worktreeCardMembers, + worktreeCardOrderWithin, + worktreeCardSiblings, + worktreeCardStaysContiguous, + type WorktreeCardPosition, +} from "./Sidebar.worktree"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { readThreadShell, + readThreadDetail, useAllEnvironmentProjectSnapshotsReady, useProjects, useThreadShells, @@ -155,6 +169,7 @@ import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; +import { buildThreadTranscriptBlock } from "../lib/threadTranscript"; import { animateSidebarLayoutChanges, applySidebarThreadDrop, @@ -199,7 +214,11 @@ import { createSidebarSortingStrategy, restrictBelowSidebarLabel, } from "./Sidebar.drag"; -import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; +import { + SidebarDragLifecycle, + SidebarPointerSensor, + type SidebarDragMode, +} from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { ThreadPullRequestBadgeControl, @@ -602,6 +621,10 @@ function SidebarSectionPlaceholder(props: { // sorting strategy opens 24px for a 16px label with 4px clearance on each side. const SIDEBAR_DRAG_LABEL_HEIGHT = 24; +// A press held still this long before moving drags the row alone inside its +// worktree card instead of the whole card (Option at press does the same). +const SIDEBAR_ROW_DRAG_HOLD_MS = 300; + function SidebarDragBoundary(props: { marker: "pinned-header" | "pinned-divider"; label: string; @@ -932,6 +955,28 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { ); }); +// Worktree card paint (Sidebar.worktree.ts): one well behind flush member +// rows, with the list's 1px gap as the hairline between them. Members drop +// the vertical padding at their shared edges; the well keeps the row's usual +// 2px inset at the card's top and bottom and adds one at the sides. The 1px +// card outline eats one of those inset pixels, so the row body sits exactly +// where it does outside a card; its color comes from worktreeCardAccent. +const worktreeCardRowClassName: Record = { + first: "rounded-t-lg border-x border-t bg-sidebar-foreground/[0.04] px-px pt-px pb-0", + middle: "border-x bg-sidebar-foreground/[0.04] px-px py-0", + last: "rounded-b-lg border-x border-b bg-sidebar-foreground/[0.04] px-px pt-0 pb-px", +}; + +// The rows inside a lifted worktree card: not sortable themselves (the card's +// wrapper carries the dnd-kit node), but styled as dragging. +const liftedCardRowBag: SortableThreadRowBag = { + listeners: undefined, + setNodeRef: () => {}, + transform: null, + transition: undefined, + isDragging: true, +}; + // Verb and icon on the lifted row while it hovers over another section. Uses // the same icons as the row actions and context menu so the drop reads as the // action it performs. @@ -992,6 +1037,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // the pinned section. Any other position shows the verb badge instead, and // the badge carries its own icon. dragOverPinned: boolean; + // Members of the lifted worktree card; the verb badge counts them past one. + dropCount: number; + // Paint for a row inside a worktree card (Sidebar.worktree.ts); null + // outside a card. Applied to the list item only, the row body is untouched. + cardPosition: WorktreeCardPosition | null; + // This row opens a card that sits directly under another card, so it pays + // the list's row gap a second time to keep the two outlines apart. + cardFollowsCard: boolean; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -1433,22 +1486,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // dnd-kit props for the row root. Same bag on both variants: every row in // the list translates around the gap as the drag passes it. const sortable = props.sortable; - const sortableRootProps = sortable + const sortableRootStyle = sortable ? { - ref: sortable.setNodeRef, - style: { - transform: CSS.Translate.toString(sortable.transform), - transition: sortable.transition, - // A zero-height boundary also makes dnd-kit scale the source to - // zero. Only projected peers use scaleY as a visibility sentinel. - visibility: - !sortable.isDragging && sortable.transform?.scaleY === 0 - ? ("hidden" as const) - : undefined, - }, - ...sortable.listeners, + transform: CSS.Translate.toString(sortable.transform), + transition: sortable.transition, + // A zero-height boundary also makes dnd-kit scale the source to + // zero. Only projected peers use scaleY as a visibility sentinel. + visibility: + !sortable.isDragging && sortable.transform?.scaleY === 0 + ? ("hidden" as const) + : undefined, } + : undefined; + const sortableRootProps = sortable + ? { ref: sortable.setNodeRef, style: sortableRootStyle, ...sortable.listeners } : {}; + // Every member of one worktree card draws the same hairline color, so the + // card reads as one block and its neighbors read as other worktrees. + const rowWorktreeCardKey = worktreeCardKey(thread); + const worktreeCardBorderColor = + props.cardPosition === null || rowWorktreeCardKey === null + ? null + : worktreeCardAccent(rowWorktreeCardKey); const dragDestination = sortable?.isDragging && props.dropVerb !== null ? ( {dropVerbBadge[props.dropVerb]} + {props.dropCount > 1 ? {props.dropCount} : null} ) : null; @@ -1757,9 +1817,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { data-thread-item {...sortableRootProps} {...(fileDropHandlers ?? {})} + style={ + worktreeCardBorderColor === null + ? sortableRootStyle + : { ...sortableRootStyle, borderColor: worktreeCardBorderColor } + } className={cn( // Matches the h-[4.875rem] content box; the py-0.5 padding is added on top. "list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]", + props.cardPosition !== null && worktreeCardRowClassName[props.cardPosition], + props.cardFollowsCard && "mt-0.5", sortable?.isDragging && "relative z-20", )} > @@ -2183,6 +2250,7 @@ export default function Sidebar() { const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); + const worktreeCardsEnabled = useSidebarWorktreeCardsEnabled(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, @@ -2256,6 +2324,25 @@ export default function Sidebar() { ); }, }); + const { copyToClipboard: copyTranscriptToClipboard } = useCopyToClipboard<{ title: string }>({ + target: "thread transcript", + onCopy: ({ title }) => { + toastManager.add({ + type: "success", + title: "Transcript copied", + description: `Paste it into another thread's composer to attach "${title}".`, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy transcript", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + }); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), @@ -2542,7 +2629,8 @@ export default function Sidebar() { // lifecycle command and any order-key writes. The next pickup waits for // this hold so a second drop cannot replace an unconfirmed placement. const [optimisticDrop, setOptimisticDrop] = useState<{ - readonly key: string; + /** Every thread the drop moved: one row, or a worktree card's members. */ + readonly keys: readonly string[]; readonly sourceSection: SidebarSection; readonly section: "pinned" | "active" | "settled"; readonly occurredAt: string; @@ -2597,7 +2685,7 @@ export default function Sidebar() { if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) { draggable.add(threadKey); } - if (optimisticDrop?.key === threadKey) { + if (optimisticDrop?.keys.includes(threadKey)) { const projected = applySidebarThreadDrop( thread, optimisticDrop.section, @@ -2632,25 +2720,32 @@ export default function Sidebar() { // web and mobile from the same data. const sortedPinned = sortPinnedThreadsForSidebar(pinned); const sortedActive = sortThreadsForSidebar(active); + const orderedPinned = + optimisticDrop?.section !== "pinned" || optimisticDrop.order === null + ? sortedPinned + : orderItemsByPreferredIds({ + items: sortedPinned, + preferredIds: optimisticDrop.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); + const orderedActive = + optimisticDrop?.section !== "active" || optimisticDrop.order === null + ? sortedActive + : orderItemsByPreferredIds({ + items: sortedActive, + preferredIds: optimisticDrop.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }); + // Worktree cards gather behind their highest-ranking member on top of the + // flat order; a card with any pinned member lives in the pinned block. + const gathered = worktreeCardsEnabled + ? gatherWorktreeCards({ pinned: orderedPinned, active: orderedActive }) + : { pinned: orderedPinned, active: orderedActive }; return { - pinnedThreads: - optimisticDrop?.section !== "pinned" || optimisticDrop.order === null - ? sortedPinned - : orderItemsByPreferredIds({ - items: sortedPinned, - preferredIds: optimisticDrop.order, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }), + pinnedThreads: gathered.pinned, draggableThreadKeys: draggable, activeReorderableThreadKeys: activeReorderable, - activeThreads: - optimisticDrop?.section !== "active" || optimisticDrop.order === null - ? sortedActive - : orderItemsByPreferredIds({ - items: sortedActive, - preferredIds: optimisticDrop.order, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }), + activeThreads: gathered.active, // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => @@ -2660,7 +2755,15 @@ export default function Sidebar() { settledThreads: sortSettledThreads(settled), snoozeNow: preciseNow, }; - }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); + }, [ + nowMinute, + optimisticDrop, + scopedProjectKeys, + serverConfigs, + snoozeWakeTick, + threads, + worktreeCardsEnabled, + ]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -3197,7 +3300,18 @@ export default function Sidebar() { readonly occurredAt: string; readonly activationY: number | null; readonly targetSection: SidebarSection | null; + /** "card" lifts the whole worktree card; "row" (Option, or a still + hold before moving) lifts the row alone, kept inside its card. */ + readonly mode: SidebarDragMode; + /** The dragged row's worktree card in card order; just the row when it + has no card. */ + readonly cardKeys: readonly string[]; } | null>(null); + // The card being carried as one, or undefined for a single-row drag. + const liftedCardKeys = + dragState !== null && dragState.mode === "card" && dragState.cardKeys.length > 1 + ? dragState.cardKeys + : undefined; const dragTargetSection = dragState?.targetSection ?? null; const dragSensorRef = useRef(null); const finishThreadDrag = useCallback((started: boolean) => { @@ -3216,6 +3330,7 @@ export default function Sidebar() { const dndSensors = useSensors( useSensor(SidebarPointerSensor, { distance: 6, + holdMs: SIDEBAR_ROW_DRAG_HOLD_MS, onAttach: attachDragSensor, onFinish: finishThreadDrag, }), @@ -3255,21 +3370,35 @@ export default function Sidebar() { thread, ]), ); - const thread = canonicalByKey.get(optimisticDrop.key); - if (thread === undefined || thread.archivedAt !== null) { + const members = optimisticDrop.keys.flatMap((key) => { + const thread = canonicalByKey.get(key); + return thread === undefined || thread.archivedAt !== null ? [] : [thread]; + }); + if (members.length !== optimisticDrop.keys.length) { setOptimisticDrop(null); return; } - const canonicalSection = effectiveSnoozed(thread, { now: new Date().toISOString() }) - ? "snoozed" - : thread.settledOverride === "settled" - ? "settled" - : thread.pinnedAt != null - ? "pinned" - : "active"; + const now = new Date().toISOString(); + const sections = members.map((thread): SidebarSection => + effectiveSnoozed(thread, { now }) + ? "snoozed" + : thread.settledOverride === "settled" + ? "settled" + : thread.pinnedAt != null + ? "pinned" + : "active", + ); + // A worktree card's unpinned members render in the pinned block, so + // between the two live sections any state is still "on its way". + const isLive = (section: SidebarSection) => section === "pinned" || section === "active"; + const liveMove = isLive(optimisticDrop.sourceSection) && isLive(optimisticDrop.section); if ( - canonicalSection !== optimisticDrop.sourceSection && - canonicalSection !== optimisticDrop.section + sections.some( + (section) => + section !== optimisticDrop.sourceSection && + section !== optimisticDrop.section && + !(liveMove && isLive(section)), + ) ) { setOptimisticDrop(null); return; @@ -3278,16 +3407,20 @@ export default function Sidebar() { // Settle also emits unpin/unsnooze events. Wait for the entire move // before releasing the projected fields and sort timestamps. if ( - canonicalSection === optimisticDrop.section && - thread.pinnedAt == null && - (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null) + members.every( + (thread, index) => + sections[index] === optimisticDrop.section && + thread.pinnedAt == null && + (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null), + ) ) { setOptimisticDrop(null); } return; } - if (canonicalSection !== optimisticDrop.section) return; - if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return; + if (sections.some((section) => section !== optimisticDrop.section)) return; + if (optimisticDrop.clearsSnooze && members.some((thread) => thread.snoozedUntil != null)) + return; const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; const canonicalDestination = destinationKeys.flatMap((key) => { const canonical = canonicalByKey.get(key); @@ -3374,6 +3507,19 @@ export default function Sidebar() { } else { dragLabelOffsetRef.current = 0; } + const activeThread = threadByKey.get(activeKey); + const sectionList = + activeSection === "pinned" + ? pinnedThreads + : activeSection === "active" + ? activeThreads + : []; + const cardKeys = + worktreeCardsEnabled && activeThread !== undefined + ? worktreeCardMembers(sectionList, activeThread).map((member) => + scopedThreadKey(scopeThreadRef(member.environmentId, member.id)), + ) + : []; setDragState({ activeKey, activeSection, @@ -3381,9 +3527,14 @@ export default function Sidebar() { occurredAt: new Date().toISOString(), activationY: event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, + mode: + event.activatorEvent instanceof PointerEvent && event.activatorEvent.altKey + ? "row" + : (dragSensorRef.current?.mode ?? "card"), + cardKeys: cardKeys.length > 0 ? cardKeys : [activeKey], }); }, - [sectionByThreadKey], + [activeThreads, pinnedThreads, sectionByThreadKey, threadByKey, worktreeCardsEnabled], ); // Include every visible row in the measured order. Older servers disable // pickup on their rows without changing where those rows render. @@ -3478,6 +3629,27 @@ export default function Sidebar() { [sidebarListItems], ); const sortableIds = useMemo(() => sidebarListItems.map(sidebarListItemId), [sidebarListItems]); + // Card paint per row, from the gathered pinned and active lists. Cards + // stacked back to back are tracked too: their outlines would otherwise sit + // a pixel apart and read as one block. + const worktreeCardPaint = useMemo(() => { + const positionByKey = new Map(); + const stackedStartKeys = new Set(); + if (!worktreeCardsEnabled) return { positionByKey, stackedStartKeys }; + for (const list of [pinnedThreads, activeThreads]) { + const resolved = resolveWorktreeCardPositions(list); + list.forEach((thread, index) => { + const position = resolved[index]; + if (!position) return; + const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + positionByKey.set(key, position); + // A "first" right after a "last" always opens a different card: a run + // only ends where the next worktree begins. + if (position === "first" && resolved[index - 1] === "last") stackedStartKeys.add(key); + }); + } + return { positionByKey, stackedStartKeys }; + }, [activeThreads, pinnedThreads, worktreeCardsEnabled]); const draggedSettledOrder = useMemo(() => { const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey); if (dragState === null || thread === undefined) return []; @@ -3498,9 +3670,11 @@ export default function Sidebar() { settledVisibleCount, routeThreadKey, snoozedThreadCount: snoozedThreads.length, + ...(liftedCardKeys === undefined ? {} : { cardKeys: liftedCardKeys }), }), [ draggedSettledOrder, + liftedCardKeys, routeThreadKey, settledShelfExpanded, settledVisibleCount, @@ -3528,74 +3702,69 @@ export default function Sidebar() { [threads], ); const draggedThreadKey = dragState?.activeKey; - const draggedFromSection = dragState?.activeSection; + const draggedMode = dragState?.mode; + const draggedCardKeys = dragState?.cardKeys; const dragActivationY = dragState?.activationY; - const dndCollisionDetection = useMemo(() => { - if (draggedThreadKey === undefined || draggedFromSection === undefined) - return createSidebarCollisionDetection(() => true); - const source = threadByKey.get(draggedThreadKey); - if (source === undefined) return createSidebarCollisionDetection(() => false); - return createSidebarCollisionDetection( - (id) => { - const target = resolveSidebarDropTarget(sidebarListItems, draggedThreadKey, id); - if (target === null) return false; - return ( - planSidebarThreadDrop({ - activeKey: draggedThreadKey, - activeSection: draggedFromSection, - activePinned: source.pinnedAt != null, - activeSettled: source.settledOverride === "settled", - supportsSettlement: - serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement === - true, - target, - pinnedOrder: pinnedKeys, - pinnedKeysById, - reorderableKeys: draggableThreadKeys, - activeOrder: activeKeys, - activeKeysById, - activeReorderableKeys: activeReorderableThreadKeys, - }).kind !== "none" - ); - }, - { - items: sidebarListItems, - activationY: dragActivationY ?? null, - }, - ); - }, [ - activeKeysById, - pinnedKeysById, - serverConfigs, - activeKeys, - activeReorderableThreadKeys, - draggedThreadKey, - draggedFromSection, - dragActivationY, - draggableThreadKeys, - pinnedKeys, - sidebarListItems, - threadByKey, - ]); - const handleThreadDragEnd = useCallback( - (event: DragEndEvent) => { - const activeKey = String(event.active.id); + const cardKeyByThreadKey = useMemo( + () => + new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + worktreeCardKey(thread), + ]), + ), + [threads], + ); + // The drop a slot would perform for the lifted row, or null when the slot + // is not offered. Shared by collision detection (which slots light up) and + // the drop itself. A lifted card moves as one block; a row lifted alone + // inside its card stays there; nothing lands between another card's rows. + const resolveThreadDrop = useCallback( + (activeKey: string, overId: string) => { const activeSection = sectionByThreadKey.get(activeKey); - const target = - event.over === null - ? null - : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id)); - const activeThread = threadByKey.get(activeKey); - if (activeSection === undefined || target === null || activeThread === undefined) return; - const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + const source = threadByKey.get(activeKey); + if (activeSection === undefined || source === undefined) return null; + const plain = resolveSidebarDropTarget(sidebarListItems, activeKey, overId); + if (plain === null) return null; + const lifted = draggedThreadKey === activeKey; + const cardKeys = lifted && draggedCardKeys !== undefined ? draggedCardKeys : [activeKey]; + const cardDrag = lifted && draggedMode === "card" && cardKeys.length > 1; + const target = cardDrag ? applyWorktreeCardToDropTarget(plain, activeKey, cardKeys) : plain; + const sectionOrder = + target.section === "pinned" + ? target.pinnedOrder + : target.section === "active" + ? target.activeOrder + : null; + const memberKeys = cardDrag ? cardKeys : [activeKey]; + const rowInCard = !cardDrag && cardKeys.length > 1; + if (rowInCard) { + if ( + target.section !== activeSection || + sectionOrder === null || + !worktreeCardStaysContiguous(sectionOrder, cardKeys) + ) { + return null; + } + } else if ( + worktreeCardsEnabled && + sectionOrder !== null && + dropSplitsForeignWorktreeCard( + sectionOrder, + memberKeys, + (key) => cardKeyByThreadKey.get(key) ?? null, + ) + ) { + return null; + } const plan = planSidebarThreadDrop({ activeKey, activeSection, - activePinned: activeThread.pinnedAt != null, - activeSettled: activeThread.settledOverride === "settled", + activePinned: source.pinnedAt != null, + activeSettled: source.settledOverride === "settled", supportsSettlement: - serverConfigs.get(activeThread.environmentId)?.environment.capabilities - .threadSettlement === true, + serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement === + true, target, pinnedOrder: pinnedKeys, pinnedKeysById, @@ -3603,9 +3772,60 @@ export default function Sidebar() { activeOrder: activeKeys, activeKeysById, activeReorderableKeys: activeReorderableThreadKeys, + // Both gestures key the whole card: a card drag to land it as a + // block, a row drag so the card keeps its anchor (see + // worktreeCardOrderWithin). + ...(cardDrag + ? { movedIds: cardKeys } + : rowInCard && sectionOrder !== null + ? { movedIds: worktreeCardOrderWithin(sectionOrder, cardKeys) } + : {}), }); - if (plan.kind === "none") return; - if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; + return plan.kind === "none" ? null : { target, plan, memberKeys }; + }, + [ + activeKeys, + activeKeysById, + activeReorderableThreadKeys, + cardKeyByThreadKey, + draggableThreadKeys, + draggedCardKeys, + draggedMode, + draggedThreadKey, + pinnedKeys, + pinnedKeysById, + sectionByThreadKey, + serverConfigs, + sidebarListItems, + threadByKey, + worktreeCardsEnabled, + ], + ); + const dndCollisionDetection = useMemo(() => { + if (draggedThreadKey === undefined) return createSidebarCollisionDetection(() => true); + return createSidebarCollisionDetection( + (id) => resolveThreadDrop(draggedThreadKey, id) !== null, + { + items: sidebarListItems, + activationY: dragActivationY ?? null, + }, + ); + }, [draggedThreadKey, dragActivationY, resolveThreadDrop, sidebarListItems]); + const handleThreadDragEnd = useCallback( + (event: DragEndEvent) => { + const activeKey = String(event.active.id); + const activeSection = sectionByThreadKey.get(activeKey); + const resolved = + event.over === null ? null : resolveThreadDrop(activeKey, String(event.over.id)); + const activeThread = threadByKey.get(activeKey); + if (activeSection === undefined || resolved === null || activeThread === undefined) return; + const { target, plan, memberKeys } = resolved; + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + if ( + plan.kind === "settle" && + memberKeys.some((key) => settlingThreadKeysRef.current.has(key)) + ) + return; const assignments = plan.kind === "pin" ? [ @@ -3616,7 +3836,7 @@ export default function Sidebar() { ? plan.assignments : []; const drop = { - key: activeKey, + keys: memberKeys, sourceSection: activeSection, section: target.section, occurredAt: new Date().toISOString(), @@ -3650,21 +3870,40 @@ export default function Sidebar() { } return false; }; + // A worktree card's members share the drop: every lifecycle command + // below runs per member, the dragged row first. The sidebar writes + // its own keys, so pin and unpin run per thread here (scope + // "thread"), never fanning out a second time. + const members = memberKeys.flatMap((key) => { + const thread = threadByKey.get(key); + return thread === undefined + ? [] + : [{ key, thread, ref: scopeThreadRef(thread.environmentId, thread.id) }]; + }); switch (plan.kind) { case "settle": { - settlingThreadKeysRef.current.add(activeKey); - const navigateAfterSettle = planForwardNavigation(activeKey); - const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( - () => settlingThreadKeysRef.current.delete(activeKey), - ); + // The member that owns the route decides where to go afterwards; + // the rest of the card is parking too, so it is skipped over. + const parkedKey = + memberKeys.find((key) => key === routeThreadKeyRef.current) ?? activeKey; + const navigateAfterSettle = planForwardNavigation(parkedKey, new Set(memberKeys)); + for (const member of members) { + settlingThreadKeysRef.current.add(member.key); + const settled = await run( + settleThread(member.ref), + "Failed to settle thread", + ).finally(() => settlingThreadKeysRef.current.delete(member.key)); + if (!settled) return; + } + const parked = members.find((member) => member.key === parkedKey); if ( - settled && + parked !== undefined && shouldNavigateAfterThreadPark({ - threadKey: activeKey, + threadKey: parkedKey, currentThreadKey: routeThreadKeyRef.current, action: "settle", now: new Date().toISOString(), - thread: readThreadShell(threadRef), + thread: readThreadShell(parked.ref), }) ) navigateAfterSettle?.(); @@ -3672,8 +3911,13 @@ export default function Sidebar() { } case "move-active": // The drag expresses unpin intent; button/menu confirmation is unchanged. - if (plan.unpin && !(await run(unpinThread(threadRef), "Failed to unpin thread"))) - return; + for (const member of members) { + if ( + member.thread.pinnedAt != null && + !(await run(unpinThread(member.ref, { scope: "thread" }), "Failed to unpin thread")) + ) + return; + } if ( plan.unsettle && !(await run(unsettleThread(threadRef), "Failed to un-settle thread")) @@ -3685,10 +3929,10 @@ export default function Sidebar() { case "pin": if ( !(await run( - pinThread( - threadRef, - plan.orderKey === undefined ? {} : { orderKey: plan.orderKey }, - ), + pinThread(threadRef, { + scope: "thread", + ...(plan.orderKey === undefined ? {} : { orderKey: plan.orderKey }), + }), "Failed to pin thread", )) ) @@ -3698,40 +3942,44 @@ export default function Sidebar() { break; } // Stop on failure; each successful key write remains a valid placement. + // In the pinned block, a thread without a pin of its own (a card + // member gathered behind a pinned sibling, or arriving with the + // dragged row) takes its key on the pin command instead: the server + // rejects reorder for unpinned threads. const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments; for (const assignment of keyWrites) { const thread = threadByKey.get(assignment.id); if (thread === undefined) continue; - if ( - !(await run( - (plan.kind === "move-active" ? reorderActiveThread : reorderPinnedThread)( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ), - plan.kind === "move-active" - ? "Failed to reorder active threads" - : "Failed to reorder pinned threads", - )) - ) - return; + const ref = scopeThreadRef(thread.environmentId, thread.id); + const write = + target.section === "active" + ? run( + reorderActiveThread(ref, assignment.orderKey), + "Failed to reorder active threads", + ) + : thread.pinnedAt == null + ? run( + pinThread(ref, { scope: "thread", orderKey: assignment.orderKey }), + "Failed to pin thread", + ) + : run( + reorderPinnedThread(ref, assignment.orderKey), + "Failed to reorder pinned threads", + ); + if (!(await write)) return; } })(); }, [ activeKeysById, pinnedKeysById, - serverConfigs, - activeKeys, - activeReorderableThreadKeys, - draggableThreadKeys, pinThread, - pinnedKeys, planForwardNavigation, reorderPinnedThread, reorderActiveThread, + resolveThreadDrop, sectionByThreadKey, settleThread, - sidebarListItems, threadByKey, unpinThread, unsettleThread, @@ -4070,9 +4318,21 @@ export default function Sidebar() { const isRegeneratingTitle = thread.titleRegeneration != null; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); - const isPinned = thread.pinnedAt != null; + // A card is pinned while any member is: an unpinned member of a + // pinned card offers "Unpin worktree", which unpins its siblings. + const worktreeSiblings = worktreeCardsEnabled + ? worktreeCardSiblings(threads, thread, { now: new Date().toISOString() }) + : []; + const isPinned = + thread.pinnedAt != null || worktreeSiblings.some((sibling) => sibling.pinnedAt != null); // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + // Sidebar rows hold shells; the transcript needs the full detail, + // which is only loaded for threads that have been opened. + const transcriptMessages = + readThreadDetail(threadRef)?.messages.filter( + (message) => !message.streaming && message.text.trim().length > 0, + ) ?? []; const threadProjectGroup = projectGroupsRef.current.find((project) => project.memberProjectRefs.some( @@ -4085,6 +4345,7 @@ export default function Sidebar() { api.contextMenu.show( buildThreadActionMenuItems({ branch: thread.branch ?? null, + hasTranscript: transcriptMessages.length > 0, projectFilter: threadProjectGroup ? { label: threadProjectGroup.displayName, @@ -4092,6 +4353,7 @@ export default function Sidebar() { } : null, isPinned, + worktreeSiblingCount: worktreeSiblings.length, isSettled, autoSettleEnabled: thread.autoSettleDisabledAt == null, isSnoozed, @@ -4236,6 +4498,16 @@ export default function Sidebar() { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "copy-transcript": { + if (transcriptMessages.length === 0) return; + const block = buildThreadTranscriptBlock({ + title: thread.title, + branch: thread.branch ?? null, + messages: transcriptMessages, + }); + copyTranscriptToClipboard(block, { title: thread.title }); + return; + } case "archive": { if (confirmThreadArchive) { const confirmed = await settlePromise(() => @@ -4309,6 +4581,7 @@ export default function Sidebar() { copyBranchToClipboard, copyPathToClipboard, copyThreadIdToClipboard, + copyTranscriptToClipboard, deleteThread, handleMultiSelectContextMenu, markThreadUnread, @@ -4320,7 +4593,9 @@ export default function Sidebar() { setThreadAutoSettle, startThreadRename, updateThreadMetadata, + threads, timestampFormat, + worktreeCardsEnabled, ], ); @@ -4708,10 +4983,14 @@ export default function Sidebar() { thread: EnvironmentThreadShell, section: SidebarSection, sortable?: SortableThreadRowBag, + // Rows inside the lifted worktree card: the first one + // carries the verb badge for the whole card. + lifted?: { verbRow: boolean; cardPosition: WorktreeCardPosition }, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); + const verbRow = lifted?.verbRow ?? dragState?.activeKey === threadKey; // Settled and snoozed are the ONLY things that collapse a // row: every other thread is a full card. Density comes // from users (or the auto rules) actually parking work, @@ -4748,13 +5027,23 @@ export default function Sidebar() { isPinned={thread.pinnedAt != null} sortable={sortable} dropVerb={ - dragState?.activeKey === threadKey + verbRow && dragState !== null ? resolveSidebarDropVerb(dragState.activeSection, dragTargetSection) : null } - dragOverPinned={ - dragState?.activeKey === threadKey && dragTargetSection === "pinned" + dropCount={ + verbRow && dragState !== null ? dragState.cardKeys.length : 1 + } + cardPosition={ + lifted?.cardPosition ?? + worktreeCardPaint.positionByKey.get(threadKey) ?? + null + } + cardFollowsCard={ + lifted === undefined && + worktreeCardPaint.stackedStartKeys.has(threadKey) } + dragOverPinned={verbRow && dragTargetSection === "pinned"} snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null ? snoozeWakeLabel(thread.snoozedUntil, { @@ -4819,6 +5108,40 @@ export default function Sidebar() { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); + // The lifted worktree card: the dragged row's sortable + // node wraps every member, painted as one card, while + // the members' own rows sit hidden in the flow. + const renderLiftedCard = (bag: SortableThreadRowBag) => ( +
  • +
      + {(liftedCardKeys ?? []).map((memberKey, index, keys) => { + const member = threadByKey.get(memberKey); + if (member === undefined) return null; + return renderThreadRowInner(member, section, liftedCardRowBag, { + verbRow: index === 0, + cardPosition: + index === 0 + ? "first" + : index === keys.length - 1 + ? "last" + : "middle", + }); + })} +
    +
  • + ); return ( - {(bag) => renderThreadRowInner(thread, section, bag)} + {(bag) => + liftedCardKeys !== undefined && dragState?.activeKey === threadKey + ? renderLiftedCard(bag) + : renderThreadRowInner(thread, section, bag) + } ); }; diff --git a/apps/web/src/components/Sidebar.worktree.test.ts b/apps/web/src/components/Sidebar.worktree.test.ts new file mode 100644 index 000000000000..42b758f7b638 --- /dev/null +++ b/apps/web/src/components/Sidebar.worktree.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + applyWorktreeCardToDropTarget, + dropSplitsForeignWorktreeCard, + gatherWorktreeCards, + resolveWorktreeCardPositions, + worktreeCardAccent, + worktreeCardKey, + worktreeCardMembers, + worktreeCardOrderWithin, + worktreeCardSiblings, + worktreeCardStaysContiguous, +} from "./Sidebar.worktree"; + +const thread = (id: string, worktreePath: string | null, environmentId = "env") => ({ + id, + environmentId, + worktreePath, +}); + +describe("gatherWorktreeCards", () => { + it("pulls later members up behind the highest-ranking one, keeping flat order", () => { + const { pinned, active } = gatherWorktreeCards({ + pinned: [], + active: [thread("a", "/wt/x"), thread("b", null), thread("c", "/wt/x"), thread("d", "/wt/y")], + }); + expect(pinned).toEqual([]); + expect(active.map((t) => t.id)).toEqual(["a", "c", "b", "d"]); + }); + + it("moves unpinned members into the pinned list when any member is pinned", () => { + const { pinned, active } = gatherWorktreeCards({ + pinned: [thread("p", null), thread("a", "/wt/x")], + active: [thread("b", null), thread("c", "/wt/x")], + }); + expect(pinned.map((t) => t.id)).toEqual(["p", "a", "c"]); + expect(active.map((t) => t.id)).toEqual(["b"]); + }); + + it("keeps the same path on different environments apart", () => { + const { active } = gatherWorktreeCards({ + pinned: [], + active: [thread("a", "/wt/x", "one"), thread("b", null), thread("c", "/wt/x", "two")], + }); + expect(active.map((t) => t.id)).toEqual(["a", "b", "c"]); + }); +}); + +describe("resolveWorktreeCardPositions", () => { + it("paints only runs of two or more", () => { + const positions = resolveWorktreeCardPositions([ + thread("a", "/wt/x"), + thread("b", "/wt/x"), + thread("c", "/wt/x"), + thread("d", "/wt/y"), + thread("e", null), + thread("f", null), + ]); + expect(positions).toEqual(["first", "middle", "last", null, null, null]); + }); +}); + +describe("worktreeCardMembers", () => { + it("returns the contiguous run around the thread", () => { + const list = [thread("a", "/wt/x"), thread("b", "/wt/x"), thread("c", null)]; + expect(worktreeCardMembers(list, list[1]!).map((t) => t.id)).toEqual(["a", "b"]); + expect(worktreeCardMembers(list, list[2]!).map((t) => t.id)).toEqual(["c"]); + }); +}); + +describe("applyWorktreeCardToDropTarget", () => { + it("moves the siblings with the dragged row as one block in card order", () => { + const target = applyWorktreeCardToDropTarget( + { section: "active", pinnedOrder: [], activeOrder: ["b", "x", "a", "c"] }, + "x", + ["x", "a", "c"], + ); + expect(target.activeOrder).toEqual(["b", "x", "a", "c"]); + const moved = applyWorktreeCardToDropTarget( + { section: "active", pinnedOrder: [], activeOrder: ["a", "c", "b", "x"] }, + "x", + ["x", "a", "c"], + ); + expect(moved.activeOrder).toEqual(["b", "x", "a", "c"]); + }); + + it("carries siblings across the pinned divider with the dragged row", () => { + const target = applyWorktreeCardToDropTarget( + { section: "pinned", pinnedOrder: ["p", "x"], activeOrder: ["a", "b"] }, + "x", + ["x", "a"], + ); + expect(target.pinnedOrder).toEqual(["p", "x", "a"]); + expect(target.activeOrder).toEqual(["b"]); + }); +}); + +describe("worktreeCardOrderWithin", () => { + it("lists the members in their section order", () => { + expect(worktreeCardOrderWithin(["b", "y", "x", "c"], ["x", "y"])).toEqual(["y", "x"]); + }); +}); + +describe("worktreeCardStaysContiguous", () => { + it("accepts reorders inside the card and rejects leaving it", () => { + expect(worktreeCardStaysContiguous(["b", "y", "x", "c"], ["x", "y"])).toBe(true); + expect(worktreeCardStaysContiguous(["x", "b", "y", "c"], ["x", "y"])).toBe(false); + }); +}); + +describe("dropSplitsForeignWorktreeCard", () => { + const cardKeyOf = (key: string) => (key.startsWith("w") ? "card" : null); + it("flags a slot between two members of another card", () => { + expect(dropSplitsForeignWorktreeCard(["w1", "x", "w2"], ["x"], cardKeyOf)).toBe(true); + expect(dropSplitsForeignWorktreeCard(["x", "w1", "w2"], ["x"], cardKeyOf)).toBe(false); + expect(dropSplitsForeignWorktreeCard(["w1", "w2", "w3"], ["w2"], cardKeyOf)).toBe(false); + }); +}); + +describe("worktreeCardSiblings", () => { + type Shell = Parameters[0][number]; + const shell = ( + id: string, + overrides: { + worktreePath?: string | null; + archivedAt?: string | null; + settledOverride?: Shell["settledOverride"]; + snoozedUntil?: string | null; + } = {}, + ): Shell => ({ + id, + environmentId: "env", + worktreePath: overrides.worktreePath === undefined ? "/wt/x" : overrides.worktreePath, + archivedAt: overrides.archivedAt ?? null, + settledOverride: overrides.settledOverride ?? null, + snoozedUntil: overrides.snoozedUntil ?? null, + snoozedAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + session: null, + latestTurn: null, + }); + it("excludes the thread itself and members that left the card", () => { + const now = "2026-09-17T12:00:00.000Z"; + const siblings = worktreeCardSiblings( + [ + shell("self"), + shell("live"), + shell("settled", { settledOverride: "settled" }), + shell("snoozed", { snoozedUntil: "2026-09-18T12:00:00.000Z" }), + shell("archived", { archivedAt: now }), + shell("elsewhere", { worktreePath: "/wt/y" }), + shell("root", { worktreePath: null }), + ], + shell("self"), + { now }, + ); + expect(siblings.map((s) => s.id)).toEqual(["live"]); + expect( + worktreeCardSiblings([shell("a")], shell("root", { worktreePath: null }), { now }), + ).toEqual([]); + expect(worktreeCardKey(shell("root", { worktreePath: " " }))).toBeNull(); + }); + + it("gives a parked thread no siblings, so it inherits no card state", () => { + const now = "2026-09-17T12:00:00.000Z"; + const live = [shell("live"), shell("other")]; + expect( + worktreeCardSiblings(live, shell("settled", { settledOverride: "settled" }), { now }), + ).toEqual([]); + expect( + worktreeCardSiblings(live, shell("snoozed", { snoozedUntil: "2026-09-18T12:00:00.000Z" }), { + now, + }), + ).toEqual([]); + }); +}); + +describe("worktreeCardAccent", () => { + it("is stable per card key and spreads worktrees across the palette", () => { + const keys = Array.from({ length: 40 }, (_, index) => `env\u0000/wt/feature-${index}`); + const colors = keys.map(worktreeCardAccent); + + expect(worktreeCardAccent(keys[0]!)).toBe(colors[0]); + expect(worktreeCardAccent("env\u0000/wt/other")).not.toBe(colors[0]); + // Worktree paths that differ only in a trailing character are the common + // case; neighbors in that series must not land on the same hue. + expect(colors.every((color, index) => index === 0 || color !== colors[index - 1])).toBe(true); + expect(new Set(colors).size).toBeGreaterThan(20); + }); +}); diff --git a/apps/web/src/components/Sidebar.worktree.ts b/apps/web/src/components/Sidebar.worktree.ts new file mode 100644 index 000000000000..d64f8c5bcd4d --- /dev/null +++ b/apps/web/src/components/Sidebar.worktree.ts @@ -0,0 +1,238 @@ +import { + effectiveSnoozed, + type ThreadSnoozeShell, +} from "@t3tools/client-runtime/state/thread-settled"; +import type { SidebarThreadSummary } from "../types"; +import type { SidebarDropTarget } from "./Sidebar.logic"; + +// ── Worktree cards ──────────────────────────────────────────────────── +// The default sidebar keeps a flat, server-ordered thread list. A worktree +// card is a read-time view over that list: the pinned and active members of +// one worktree are gathered to the slot of their highest-ranking member and +// painted as one card. Nothing is stored for the card itself. Writes happen +// only behind user gestures (a card drag fans key writes out to every +// member), so another client splitting the flat order can never leave a +// half card here, and a partial write still renders one whole card. + +export type WorktreeCardThread = { + readonly environmentId: string; + readonly id: string; + readonly worktreePath: string | null; +}; + +/** Card identity: a worktree path is only unique within an environment. + Null for root-checkout threads, which never join a card. */ +export function worktreeCardKey(thread: { + readonly environmentId: string; + readonly worktreePath: string | null; +}): string | null { + const path = thread.worktreePath?.trim(); + return path ? `${thread.environmentId}\u0000${path}` : null; +} + +/** + * Gather each worktree's members behind its highest-ranking member. Pinned + * ranks above active, so a card with any pinned member lands in the pinned + * list, unpinned members included (they carry no pin of their own until the + * card is touched again). Members keep their flat relative order. + */ +export function gatherWorktreeCards(input: { + readonly pinned: readonly T[]; + readonly active: readonly T[]; +}): { pinned: T[]; active: T[] } { + const membersByCard = new Map(); + const flat: { thread: T; section: "pinned" | "active" }[] = []; + for (const section of ["pinned", "active"] as const) { + for (const thread of input[section]) { + flat.push({ thread, section }); + const key = worktreeCardKey(thread); + if (key === null) continue; + const members = membersByCard.get(key); + if (members) members.push(thread); + else membersByCard.set(key, [thread]); + } + } + const emitted = new Set(); + const out = { pinned: [] as T[], active: [] as T[] }; + for (const { thread, section } of flat) { + const key = worktreeCardKey(thread); + if (key === null) { + out[section].push(thread); + continue; + } + if (emitted.has(key)) continue; + emitted.add(key); + out[section].push(...membersByCard.get(key)!); + } + return out; +} + +export type WorktreeCardPosition = "first" | "middle" | "last"; + +/** Card paint per row of a gathered section list. Null for rows outside any + card, including a worktree's lone thread, which renders as today. */ +export function resolveWorktreeCardPositions( + list: readonly T[], +): ReadonlyArray { + const positions: (WorktreeCardPosition | null)[] = list.map(() => null); + let start = 0; + while (start < list.length) { + const key = worktreeCardKey(list[start]!); + let end = start; + while (key !== null && end + 1 < list.length && worktreeCardKey(list[end + 1]!) === key) { + end += 1; + } + if (end > start) { + for (let index = start; index <= end; index += 1) { + positions[index] = index === start ? "first" : index === end ? "last" : "middle"; + } + } + start = end + 1; + } + return positions; +} + +/** The contiguous run of the same worktree around `thread` in a gathered + section list; just the thread itself outside a card. */ +export function worktreeCardMembers( + list: readonly T[], + thread: WorktreeCardThread, +): T[] { + const key = worktreeCardKey(thread); + const index = list.findIndex( + (candidate) => candidate.environmentId === thread.environmentId && candidate.id === thread.id, + ); + if (index === -1) return []; + if (key === null) return [list[index]!]; + let start = index; + while (start > 0 && worktreeCardKey(list[start - 1]!) === key) start -= 1; + let end = index; + while (end + 1 < list.length && worktreeCardKey(list[end + 1]!) === key) end += 1; + return list.slice(start, end + 1); +} + +/** Re-form the dragged card at the drop slot: the dragged row's siblings leave + wherever the plain drop target left them and follow it as one block, in + card order. */ +export function applyWorktreeCardToDropTarget( + target: SidebarDropTarget, + activeKey: string, + cardKeys: readonly string[], +): SidebarDropTarget { + if (cardKeys.length < 2 || !cardKeys.includes(activeKey)) return target; + const siblings = new Set(cardKeys.filter((key) => key !== activeKey)); + const place = (order: readonly string[]) => { + const stripped = order.filter((key) => !siblings.has(key)); + const at = stripped.indexOf(activeKey); + if (at === -1) return stripped; + stripped.splice(at, 1, ...cardKeys); + return stripped; + }; + return { + section: target.section, + pinnedOrder: place(target.pinnedOrder), + activeOrder: place(target.activeOrder), + }; +} + +/** The card's members in the order a section arranges them after a drop. + A row moved inside its card re-keys the whole card against the card's + outside neighbors: keying only the moved row would let the card's anchor + (its best key) change and gather the card somewhere else. */ +export function worktreeCardOrderWithin( + sectionOrder: readonly string[], + cardKeys: readonly string[], +): string[] { + const members = new Set(cardKeys); + return sectionOrder.filter((key) => members.has(key)); +} + +/** Row drags inside a card must leave the card whole: every member still + adjacent in the section order. */ +export function worktreeCardStaysContiguous( + order: readonly string[], + cardKeys: readonly string[], +): boolean { + const indices = cardKeys.map((key) => order.indexOf(key)); + if (indices.some((index) => index === -1)) return false; + return Math.max(...indices) - Math.min(...indices) === cardKeys.length - 1; +} + +/** A drop slot strictly inside another worktree's card would preview a split + that gather-at-read undoes on commit; such slots are not offered. */ +export function dropSplitsForeignWorktreeCard( + order: readonly string[], + blockKeys: readonly string[], + cardKeyOf: (key: string) => string | null, +): boolean { + const first = order.indexOf(blockKeys[0] ?? ""); + const last = order.indexOf(blockKeys.at(-1) ?? ""); + if (first === -1 || last === -1) return false; + const before = order[first - 1]; + const after = order[last + 1]; + if (before === undefined || after === undefined) return false; + const neighborCard = cardKeyOf(before); + return ( + neighborCard !== null && + neighborCard === cardKeyOf(after) && + neighborCard !== cardKeyOf(blockKeys[0] ?? "") + ); +} + +type WorktreeSiblingShell = WorktreeCardThread & + ThreadSnoozeShell & { + readonly archivedAt: string | null; + readonly settledOverride: SidebarThreadSummary["settledOverride"]; + readonly pinnedAt?: string | null | undefined; + }; + +function isLiveCardMember(shell: WorktreeSiblingShell, options: { readonly now: string }) { + return ( + shell.archivedAt === null && + shell.settledOverride !== "settled" && + !effectiveSnoozed(shell, options) + ); +} + +/** + * The other live members of a thread's worktree card, for actions that fan + * out (pin, unpin). Settled and snoozed threads have left the card, so they + * are never touched by a card gesture, and a parked thread has no card of + * its own to act on. + */ +export function worktreeCardSiblings( + shells: ReadonlyArray, + thread: WorktreeSiblingShell, + options: { readonly now: string }, +): T[] { + const key = worktreeCardKey(thread); + if (key === null || !isLiveCardMember(thread, options)) return []; + return shells.filter( + (shell) => + shell.id !== thread.id && worktreeCardKey(shell) === key && isLiveCardMember(shell, options), + ); +} + +// ── Card accent ─────────────────────────────────────────────────────── +// A card draws a hairline in its own color so two stacked cards read as +// separate worktrees without a label. The color is derived from the card +// key, so nothing is stored and every client paints a worktree the same +// way. Lightness and chroma are fixed, so no worktree shouts louder than +// another, and only the hue tells them apart. + +const WORKTREE_CARD_HUE_COUNT = 36; +// Walking the wheel with a stride coprime to the hue count still visits +// every hue, but puts keys that hash to neighboring slots (worktree paths +// often differ only in a trailing character) a third of the wheel apart. +const WORKTREE_CARD_HUE_STEP = 110; + +/** Hairline color for a worktree card, keyed by `worktreeCardKey`. */ +export function worktreeCardAccent(cardKey: string): string { + // FNV-1a: cheap, and it scatters keys that share a long common prefix. + let hash = 0x811c9dc5; + for (let index = 0; index < cardKey.length; index += 1) { + hash = Math.imul(hash ^ cardKey.charCodeAt(index), 0x01000193) >>> 0; + } + const hue = ((hash % WORKTREE_CARD_HUE_COUNT) * WORKTREE_CARD_HUE_STEP) % 360; + return `oklch(0.68 0.17 ${hue} / 0.45)`; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d168c6db4e5f..808c8b13fed4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -185,6 +185,8 @@ import { } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { ComposerTranscriptCards } from "./ComposerTranscriptCards"; +import { type ThreadTranscriptDraft } from "../../lib/threadTranscript"; import { getRestingComposerImagePreviewCounts, resolveRestingComposerControlsLayout, @@ -1296,6 +1298,7 @@ export interface ChatComposerHandle { files: ComposerFileAttachment[]; terminalContexts: TerminalContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; + transcripts: ThreadTranscriptDraft[]; reviewComments: ReviewCommentContext[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; @@ -1635,6 +1638,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const composerTerminalContexts = composerDraft.terminalContexts; const composerPreviewAnnotations = composerDraft.previewAnnotations; + const composerTranscripts = composerDraft.transcripts; const composerReviewComments = composerDraft.reviewComments; const pendingSnapShotAnimations = useSyncExternalStore( subscribeToPendingSnapShotAnimations, @@ -1759,6 +1763,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const removeComposerDraftPreviewAnnotation = useComposerDraftStore( (store) => store.removePreviewAnnotation, ); + const addComposerDraftTranscripts = useComposerDraftStore((store) => store.addTranscripts); + const removeComposerDraftTranscript = useComposerDraftStore((store) => store.removeTranscript); const removeComposerDraftReviewComment = useComposerDraftStore( (store) => store.removeReviewComment, ); @@ -2236,12 +2242,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt, imageCount: composerImages.length + composerFiles.length, terminalContexts: composerTerminalContexts, - elementContextCount: composerPreviewAnnotations.length + composerReviewComments.length, + elementContextCount: + composerTranscripts.length + + composerPreviewAnnotations.length + + composerReviewComments.length, }), [ composerFiles.length, composerImages.length, composerPreviewAnnotations.length, + composerTranscripts.length, composerReviewComments.length, composerTerminalContexts, prompt, @@ -5650,6 +5660,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) event.stopPropagation(); }; + // Text pastes containing transcript blocks are intercepted inside the + // Lexical editor (see registerComposerTranscriptPaste); this callback + // receives the extracted blocks as draft cards. + const onPasteTranscripts = (transcripts: ThreadTranscriptDraft[]) => { + addComposerDraftTranscripts(composerDraftTarget, transcripts); + }; + const insertComposerText = useCallback( ( text: string, @@ -6015,6 +6032,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) files: composerFilesRef.current, terminalContexts: composerTerminalContextsRef.current, previewAnnotations: composerPreviewAnnotations, + transcripts: composerTranscripts, reviewComments: composerReviewComments, selectedPromptEffort, selectedModelOptionsForDispatch, @@ -6064,6 +6082,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerFilesRef, composerTerminalContextsRef, composerPreviewAnnotations, + composerTranscripts, composerReviewComments, focusComposer, environmentId, @@ -6793,6 +6812,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
    )} + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerTranscripts.length > 0 ? ( + removeComposerDraftTranscript(composerDraftTarget, id)} + className="mb-3" + /> + ) : null}
    ; + onRemove: (transcriptId: string) => void; + className?: string; +} + +/** Pasted thread transcripts pending send, shown as removable cards. */ +export function ComposerTranscriptCards({ + transcripts, + onRemove, + className, +}: ComposerTranscriptCardsProps) { + if (transcripts.length === 0) return null; + + return ( +
    + {transcripts.map((transcript) => ( +
    + + + +
    +

    + {transcript.title} +

    +

    + Transcript · {transcript.messageCount}{" "} + {transcript.messageCount === 1 ? "message" : "messages"} +

    +
    + +
    + ))} +
    + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index d95e22517630..41acfd0519a5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1099,6 +1099,7 @@ describe("deriveMessagesTimelineRows", () => { files: [], terminalContexts: [], previewAnnotations: [], + transcripts: [], reviewComments: [], sendSettings: { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bf7faa735fed..56b104865854 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -121,6 +121,7 @@ import { MessageCircleIcon, Minimize2Icon, MousePointerClickIcon, + MessagesSquareIcon, PaintbrushIcon, SearchIcon, SmartphoneIcon, @@ -243,6 +244,10 @@ import { useOpenPrLink } from "~/lib/openPullRequestLink"; import { useClientSettings } from "~/hooks/useSettings"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { + extractTrailingThreadTranscript, + type ParsedThreadTranscript, +} from "~/lib/threadTranscript"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -1998,6 +2003,14 @@ function UserTimelineRow({ row }: { row: Extract !image.name.startsWith("preview-annotation-")); const unchippedFiles = otherUserFiles.filter((file) => !chippedAttachmentIds.has(file.id)); const annotationRecordIds = useMemo( @@ -2213,9 +2226,12 @@ function UserTimelineRow({ row }: { row: Extract ) : null} + {threadTranscripts.map((transcript) => ( + + ))}
    + + + +
    +
    + {props.transcript.title} +
    +
    + Transcript · {props.transcript.messageCount}{" "} + {props.transcript.messageCount === 1 ? "message" : "messages"} +
    +
    +
    + ); +} + function UserMessageContextChip(props: { icon: ReactNode; label: string; diff --git a/apps/web/src/components/chat/sendQueuedMessage.ts b/apps/web/src/components/chat/sendQueuedMessage.ts index ac10dfef48c8..90a98a6337b9 100644 --- a/apps/web/src/components/chat/sendQueuedMessage.ts +++ b/apps/web/src/components/chat/sendQueuedMessage.ts @@ -10,6 +10,7 @@ import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; import { buildMessageContext, terminalContextReference } from "../../lib/composerContextRecords"; import { removeInlineContextReference } from "../../lib/composerContextReferences"; +import { appendThreadTranscriptPrompt } from "../../lib/threadTranscript"; import { awaitAttachmentUploads, getUploadedAttachments, @@ -86,7 +87,7 @@ export async function sendQueuedMessage( return; } // Expired terminal excerpts are not sent; their chips leave the text with them. - const prompt = message.terminalContexts + const promptWithoutTranscripts = message.terminalContexts .filter((context) => !sendableTerminalContexts.includes(context)) .reduce( (text, context) => @@ -94,6 +95,11 @@ export async function sendQueuedMessage( message.prompt, ) .trim(); + // Pasted thread transcripts ride along as trailing blocks, as the composer sends them. + const prompt = message.transcripts.reduce( + (text, transcript) => appendThreadTranscriptPrompt(text, transcript), + promptWithoutTranscripts, + ); const text = applyClaudePromptEffortPrefix( prompt || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, sendSettings.promptEffort, diff --git a/apps/web/src/components/preview/PreviewEmptyState.test.tsx b/apps/web/src/components/preview/PreviewEmptyState.test.tsx index 0f9da0fdb3bd..5231b64553d8 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.test.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.test.tsx @@ -18,6 +18,10 @@ const mocks = vi.hoisted(() => ({ vi.mock("./useDiscoveredLocalServers", () => ({ useDiscoveredLocalServers: () => mocks.servers, })); +vi.mock("~/state/entities", () => ({ + useThreadShell: () => null, + useThreadShellsForProjectRefs: () => [], +})); vi.mock("./PreviewFaviconIcon", () => ({ PreviewFaviconIcon: () => , })); diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 35b4b2f13d64..047b8f982a24 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -1,14 +1,20 @@ import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { Globe, History, RadioTower } from "lucide-react"; +import { useMemo, useState } from "react"; import type { BrowserHistoryEntry } from "~/browserHistoryStore"; import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; +import { Toggle, ToggleGroup } from "~/components/ui/toggle-group"; +import { useThreadShell, useThreadShellsForProjectRefs } from "~/state/entities"; import { DiscoveryList } from "../ui/discovery-list"; import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; import { PreviewRecentUrlCard } from "./PreviewRecentUrlCard"; +import { selectWorktreeServers } from "./previewEmptyStateLogic"; import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; +type ServerScope = "worktree" | "all"; + interface Props { threadRef: ScopedThreadRef; environmentId: EnvironmentId; @@ -30,6 +36,21 @@ export function PreviewEmptyState({ environmentId, configuredUrls, }); + const activeThread = useThreadShell(threadRef); + const activeProjectId = activeThread?.projectId ?? null; + const projectRefs = useMemo( + () => (activeProjectId ? [{ environmentId, projectId: activeProjectId }] : []), + [activeProjectId, environmentId], + ); + const projectThreads = useThreadShellsForProjectRefs(projectRefs); + const worktreeServers = useMemo( + () => selectWorktreeServers({ servers, activeThread, projectThreads }), + [servers, activeThread, projectThreads], + ); + const [scope, setScope] = useState("worktree"); + // The toggle only appears when it filters something out. + const canScope = worktreeServers.length > 0 && worktreeServers.length < servers.length; + const visibleServers = canScope && scope === "worktree" ? worktreeServers : servers; const recents = recentEntries.filter((entry) => URL.canParse(entry.url)).slice(0, 8); if (servers.length === 0 && recents.length === 0) { @@ -74,9 +95,23 @@ export function PreviewEmptyState({

    Local servers

    + {canScope ? ( + { + const next = value[0]; + if (next === "worktree" || next === "all") setScope(next); + }} + > + This worktree {worktreeServers.length} + All {servers.length} + + ) : null}
    - {servers.map((server) => ( + {visibleServers.map((server) => ( void; } +/** + * A live local server row. The title is the address a click opens, so a + * remote client sees the environment host rather than localhost. The + * description names the thread whose terminal started the server. + */ export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) { - const subtitle = describeServer(server); + const ownerThread = useThreadShell( + server.terminal + ? { environmentId: threadRef.environmentId, threadId: server.terminal.threadId } + : null, + ); return ( } - title={subtitle} - description={`${server.host}:${server.port}`} + title={describeAddress(server)} + description={ownerThread?.title ?? server.processName ?? "Listening"} /> ); } -function describeServer(server: PreviewableServer): string { - if (server.processName) return server.processName; - return "Listening"; +function describeAddress(server: PreviewableServer): string { + try { + return new URL(server.url).host; + } catch { + return `${server.host}:${server.port}`; + } } diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index 288db101e7a5..0fc0f5e2a65f 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -3,8 +3,10 @@ import { mapAtomCommandResult, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult } from "effect/unstable/reactivity"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { focusBrowserSurfaceForUrl } from "~/browser/focusBrowserSurfaceForUrl"; import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -16,6 +18,10 @@ export async function openDiscoveredPort(input: { readonly openPreview: OpenPreviewMutation; }): Promise> { const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); + if (focusBrowserSurfaceForUrl(input.threadRef, resolvedUrl)) { + recordVisitForThread(input.threadRef, input.port.url); + return AsyncResult.success(undefined); + } const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 96629b5c9b02..380c2bf6711e 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -3,22 +3,19 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { resetPreviewStateForTests } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + import { openTerminalLinkInPreview, TerminalLinkPreviewOpenError, } from "./openTerminalLinkInPreview"; -vi.mock("~/previewStateStore", () => ({ - applyPreviewServerSnapshot: vi.fn(), +vi.mock("~/previewStateStore", async (importOriginal) => ({ + ...(await importOriginal()), isPreviewSupportedInRuntime: () => true, })); -vi.mock("~/rightPanelStore", () => ({ - useRightPanelStore: { - getState: () => ({ openBrowser: vi.fn() }), - }, -})); - const browserDefaultsMocks = vi.hoisted(() => ({ resolve: vi.fn(), })); @@ -58,6 +55,8 @@ const snapshot: PreviewSessionSnapshot = { }; beforeEach(() => { + resetPreviewStateForTests(); + useRightPanelStore.setState({ byThreadKey: {} }); browserDefaultsMocks.resolve.mockReset(); browserDefaultsMocks.resolve.mockResolvedValue(hydratedDefaults); linkTargetMocks.preference.mockReturnValue("app"); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index 23ef35a87829..10b3fcc1c377 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -9,6 +9,7 @@ import { } from "~/browser/browserDefaults"; import { isWebUrl, resolveBrowserLinkTargetPreference } from "~/browser/browserLinkTarget"; import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { focusBrowserSurfaceForUrl } from "~/browser/focusBrowserSurfaceForUrl"; import { recordVisitForThread } from "~/browserHistoryStore"; import { applyPreviewServerSnapshot, isPreviewSupportedInRuntime } from "~/previewStateStore"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -50,13 +51,18 @@ export async function openTerminalLinkInPreview( isWebUrl(input.url) && isPreviewSupportedInRuntime() && input.threadRef.threadId.length > 0 && - (await resolveBrowserLinkTargetPreference()) === "app"; + (await resolveBrowserLinkTargetPreference(input.url)) === "app"; if (!supportsPreview) { input.fallbackToBrowser(); return; } + if (focusBrowserSurfaceForUrl(input.threadRef, input.url)) { + recordVisitForThread(input.threadRef, input.url); + return; + } + const errorContext = { environmentId: input.threadRef.environmentId, threadId: input.threadRef.threadId, diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts index 3759173d3cc2..5fad92c85893 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts @@ -1,7 +1,11 @@ -import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; +import { ThreadId, type PreviewSessionSnapshot, type ProjectScript } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { getConfiguredPreviewUrls, shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; +import { + getConfiguredPreviewUrls, + selectWorktreeServers, + shouldShowPreviewEmptyState, +} from "./previewEmptyStateLogic"; const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ threadId: "thread-1", @@ -40,3 +44,41 @@ describe("getConfiguredPreviewUrls", () => { ]); }); }); + +describe("selectWorktreeServers", () => { + const thread = (id: string, worktreePath: string | null) => ({ + id: ThreadId.make(id), + worktreePath, + }); + const server = (port: number, threadId: string | null) => ({ + port, + terminal: threadId === null ? null : { threadId: ThreadId.make(threadId) }, + }); + const servers = [ + server(5173, "active"), + server(5174, "sibling"), + server(5175, "other-worktree"), + server(5176, "root-thread"), + server(8080, null), + ]; + const projectThreads = [ + thread("active", "/wt/a"), + thread("sibling", "/wt/a"), + thread("other-worktree", "/wt/b"), + thread("root-thread", null), + ]; + const ports = (active: ReturnType | null) => + selectWorktreeServers({ servers, activeThread: active, projectThreads }).map((s) => s.port); + + it("keeps servers started by any thread in the active worktree", () => { + expect(ports(thread("active", "/wt/a"))).toEqual([5173, 5174]); + }); + + it("treats threads without a worktree as sharing the project root", () => { + expect(ports(thread("root-active", null))).toEqual([5176]); + }); + + it("matches nothing without an active thread", () => { + expect(ports(null)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.ts b/apps/web/src/components/preview/previewEmptyStateLogic.ts index 1ebd074032b0..3bc130175248 100644 --- a/apps/web/src/components/preview/previewEmptyStateLogic.ts +++ b/apps/web/src/components/preview/previewEmptyStateLogic.ts @@ -1,4 +1,9 @@ -import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; +import type { + OrchestrationThreadShell, + PreviewSessionSnapshot, + ProjectScript, + ThreadId, +} from "@t3tools/contracts"; export function shouldShowPreviewEmptyState(snapshot: PreviewSessionSnapshot | null): boolean { return snapshot === null || snapshot.navStatus._tag === "Idle"; @@ -9,3 +14,28 @@ export function getConfiguredPreviewUrls( ): ReadonlyArray { return scripts?.flatMap((script) => (script.previewUrl ? [script.previewUrl] : [])) ?? []; } + +/** + * Picks the servers started from terminals of threads that share the active + * thread's worktree. Threads without a worktree share the project root, so + * they count as one worktree. `projectThreads` must be the active thread's + * project threads; servers not started from a T3 terminal never match. + */ +export function selectWorktreeServers< + S extends { readonly terminal: { readonly threadId: ThreadId } | null }, +>(input: { + readonly servers: ReadonlyArray; + readonly activeThread: Pick | null; + readonly projectThreads: ReadonlyArray>; +}): ReadonlyArray { + const { activeThread } = input; + if (activeThread === null) return []; + const threadIds = new Set( + input.projectThreads + .filter((thread) => thread.worktreePath === activeThread.worktreePath) + .map((thread) => thread.id), + ); + return input.servers.filter( + (server) => server.terminal !== null && threadIds.has(server.terminal.threadId), + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 251f3cd49f95..835840f2e79c 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -228,8 +228,7 @@ function PullRequestCodeTab({ const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); /** Set once the reader has asked for every file at once, until they pick a file apart again. */ const [foldOverride, setFoldOverride] = useState(null); - const effectiveFoldOverride = - foldOverride ?? (settings.diffFilesCollapsed ? "folded" : "expanded"); + const effectiveFold = foldOverride ?? settings.diffFilesCollapsed; const diffLayout = settings.diffLayout; const updateClientSettings = useUpdateClientSettings(); const [wordWrap, setWordWrap] = useState(settings.wordWrap); @@ -561,7 +560,12 @@ function PullRequestCodeTab({ const items = useMemo[]>( () => annotatedFiles.map(({ fileKey, path, fileDiff, annotations, annotationsVersion }) => { - const collapsed = isFileDiffCollapsed(fileKey, effectiveFoldOverride, toggledFiles); + const collapsed = isFileDiffCollapsed( + fileKey, + effectiveFold, + isFileViewed(path), + toggledFiles, + ); // Ticking a file that is already folded changes no fold, so without this the box on // screen would keep saying the opposite of what the count says. const viewedMark = filesViewedEnabled @@ -579,7 +583,7 @@ function PullRequestCodeTab({ [ annotatedFiles, filesViewedEnabled, - effectiveFoldOverride, + effectiveFold, isFileViewed, isFileViewedStale, toggledFiles, @@ -653,10 +657,10 @@ function PullRequestCodeTab({ (fileKey: string, path: string, viewed: boolean) => { setViewed(path, viewed); setToggledFiles((current) => - toggleFileDiffFoldForViewed(fileKey, viewed, effectiveFoldOverride, current), + toggleFileDiffFoldForViewed(fileKey, viewed, effectiveFold, current), ); }, - [effectiveFoldOverride, setViewed], + [effectiveFold, setViewed], ); const requestTreeReveal = useCodeViewFileReveal(viewer, scopeKey); @@ -674,7 +678,7 @@ function PullRequestCodeTab({ // Held as an override of the default rather than as the file keys on screen: a diff that is // still paging would otherwise bring its next slice in folded, moments after the reader // asked for everything to be open. - setFoldOverride(areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys) ? "expanded" : "folded"); + setFoldOverride(areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys) ? "none" : "all"); setToggledFiles(new Set()); }; diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index 181ebe6cfec1..65029d016bfd 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts @@ -54,59 +54,69 @@ describe("isLineInFileDiff", () => { describe("isFileDiffCollapsed", () => { const NO_TOGGLES: ReadonlySet = new Set(); - it("opens every file before the reader has touched anything", () => { - expect(isFileDiffCollapsed("a.ts", null, NO_TOGGLES)).toBe(false); - expect(isFileDiffCollapsed("b.ts", null, NO_TOGGLES)).toBe(false); + it("opens every file when nothing is folded by default", () => { + expect(isFileDiffCollapsed("a.ts", "none", false, NO_TOGGLES)).toBe(false); + expect(isFileDiffCollapsed("b.ts", "none", true, NO_TOGGLES)).toBe(false); }); - it("opens every file once the toolbar has asked for it", () => { - // Pressing the toolbar clears the reader's own toggles, which is why the set is empty here. - expect(isFileDiffCollapsed("a.ts", "expanded", NO_TOGGLES)).toBe(false); - expect(isFileDiffCollapsed("b.ts", "expanded", NO_TOGGLES)).toBe(false); + it("folds every file when everything is folded by default", () => { + expect(isFileDiffCollapsed("a.ts", "all", false, NO_TOGGLES)).toBe(true); + expect(isFileDiffCollapsed("b.ts", "all", true, NO_TOGGLES)).toBe(true); }); - it("folds every file again on the second press", () => { - expect(isFileDiffCollapsed("a.ts", "folded", NO_TOGGLES)).toBe(true); - expect(isFileDiffCollapsed("b.ts", "folded", NO_TOGGLES)).toBe(true); + it("folds only the ticked-off files under the viewed default", () => { + expect(isFileDiffCollapsed("a.ts", "viewed", true, NO_TOGGLES)).toBe(true); + expect(isFileDiffCollapsed("b.ts", "viewed", false, NO_TOGGLES)).toBe(false); }); it("keeps a file the reader folded closed as the next slice arrives", () => { // The file keys grow with every slice, so the answer for one already folded must not depend // on how many of them there are by then. const toggled = new Set(["b.ts"]); - expect(isFileDiffCollapsed("b.ts", null, toggled)).toBe(true); - expect(isFileDiffCollapsed("c.ts", null, toggled)).toBe(false); + expect(isFileDiffCollapsed("b.ts", "none", false, toggled)).toBe(true); + expect(isFileDiffCollapsed("c.ts", "none", false, toggled)).toBe(false); }); - it("still answers to a toggle after either toolbar press", () => { - expect(isFileDiffCollapsed("a.ts", "expanded", new Set(["a.ts"]))).toBe(true); - expect(isFileDiffCollapsed("a.ts", "folded", new Set(["a.ts"]))).toBe(false); + it("still answers to a toggle under every default", () => { + const toggled = new Set(["a.ts"]); + expect(isFileDiffCollapsed("a.ts", "none", false, toggled)).toBe(true); + expect(isFileDiffCollapsed("a.ts", "all", false, toggled)).toBe(false); + expect(isFileDiffCollapsed("a.ts", "viewed", true, toggled)).toBe(false); + expect(isFileDiffCollapsed("a.ts", "viewed", false, toggled)).toBe(true); }); }); describe("toggleFileDiffFoldForViewed", () => { it("puts a file away when it is ticked off", () => { // Files start expanded, so ticking one off is the case that has somewhere to go. - expect([...toggleFileDiffFoldForViewed("a.ts", true, null, new Set())]).toEqual(["a.ts"]); + expect([...toggleFileDiffFoldForViewed("a.ts", true, "none", new Set())]).toEqual(["a.ts"]); }); it("brings a file back when the tick is taken off", () => { - expect([...toggleFileDiffFoldForViewed("a.ts", false, null, new Set(["a.ts"]))]).toEqual([]); + expect([...toggleFileDiffFoldForViewed("a.ts", false, "none", new Set(["a.ts"]))]).toEqual([]); }); it("leaves the fold alone when it already says what the tick does", () => { const folded = new Set(["a.ts"]); - expect(toggleFileDiffFoldForViewed("a.ts", true, null, folded)).toBe(folded); + expect(toggleFileDiffFoldForViewed("a.ts", true, "none", folded)).toBe(folded); }); it("moves against whatever the toolbar last asked for", () => { - // Everything is open, so ticking a file off has to fold that one against the default. - expect([...toggleFileDiffFoldForViewed("a.ts", true, "expanded", new Set())]).toEqual(["a.ts"]); - expect(toggleFileDiffFoldForViewed("a.ts", false, "expanded", new Set()).size).toBe(0); + // Everything is folded, so taking the tick off has to open that one against the default. + expect([...toggleFileDiffFoldForViewed("a.ts", false, "all", new Set())]).toEqual(["a.ts"]); + expect(toggleFileDiffFoldForViewed("a.ts", true, "all", new Set()).size).toBe(0); + }); + + it("drops the file's own toggle under the viewed default, where the tick is the fold", () => { + // The reader had opened this viewed file by hand; ticking it again must not flip it back + // against its new default and leave it open. + expect(toggleFileDiffFoldForViewed("a.ts", true, "viewed", new Set(["a.ts"])).size).toBe(0); + expect(toggleFileDiffFoldForViewed("a.ts", false, "viewed", new Set(["a.ts"])).size).toBe(0); + expect(toggleFileDiffFoldForViewed("a.ts", true, "viewed", new Set()).size).toBe(0); }); it("touches only the file that was ticked", () => { const toggled = new Set(["a.ts", "b.ts"]); - expect([...toggleFileDiffFoldForViewed("a.ts", false, null, toggled)]).toEqual(["b.ts"]); + expect([...toggleFileDiffFoldForViewed("a.ts", false, "none", toggled)]).toEqual(["b.ts"]); }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index b7b836cb3b5b..6aeaa8750fd9 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -1,5 +1,5 @@ import type { FileDiffMetadata } from "@pierre/diffs"; -import type { PullRequestDiffSide } from "@t3tools/contracts"; +import type { DiffFilesCollapsed, PullRequestDiffSide } from "@t3tools/contracts"; /** * Whether a conversation's line is really in this file's hunks. @@ -22,23 +22,26 @@ export function isLineInFileDiff( } /** What the toolbar last asked of every file at once, null being the reader asking nothing yet. */ -export type DiffFoldOverride = "expanded" | "folded" | null; +export type DiffFoldOverride = "all" | "none" | null; + +const NO_TOGGLES: ReadonlySet = new Set(); /** * Whether a file is drawn folded. * * A diff arrives a slice at a time, so the reader's own choices are kept as the difference from - * what the toolbar last said rather than as the set of folded files: a file that has not loaded - * yet cannot be in a set, and would otherwise land expanded moments after the reader folded - * everything. The caller supplies the saved default until the toolbar overrides it; individual - * files can still be toggled independently. + * the default rather than as the set of folded files: a file that has not loaded yet cannot be in + * a set, and would otherwise land expanded moments after the reader folded everything. The + * default is the saved setting until the toolbar overrides it, and under `viewed` it is the + * file's own tick; individual files can still be toggled independently. */ export function isFileDiffCollapsed( fileKey: string, - foldOverride: DiffFoldOverride, + fold: DiffFilesCollapsed, + viewed: boolean, toggledFileKeys: ReadonlySet, ): boolean { - const foldedByDefault = foldOverride === "folded"; + const foldedByDefault = fold === "all" || (fold === "viewed" && viewed); return toggledFileKeys.has(fileKey) ? !foldedByDefault : foldedByDefault; } @@ -46,19 +49,20 @@ export function isFileDiffCollapsed( * The reader's fold choices after a file was ticked off, or put back. * * Clearing a file puts it away and un-clearing brings it back, so the tick moves the fold as if - * the reader had pressed the chevron themselves, which keeps folding a difference from what the - * toolbar last asked, and so keeps "collapse all" from ticking anything off. + * the reader had pressed the chevron themselves, which keeps folding a difference from the + * default, and so keeps "collapse all" from ticking anything off. Under `viewed` the tick is the + * default, so the file's own toggle is dropped. */ export function toggleFileDiffFoldForViewed( fileKey: string, viewed: boolean, - foldOverride: DiffFoldOverride, + fold: DiffFilesCollapsed, toggledFileKeys: ReadonlySet, ): ReadonlySet { - if (isFileDiffCollapsed(fileKey, foldOverride, toggledFileKeys) === viewed) - return toggledFileKeys; + const toggled = isFileDiffCollapsed(fileKey, fold, viewed, NO_TOGGLES) !== viewed; + if (toggledFileKeys.has(fileKey) === toggled) return toggledFileKeys; const next = new Set(toggledFileKeys); - if (next.has(fileKey)) next.delete(fileKey); - else next.add(fileKey); + if (toggled) next.add(fileKey); + else next.delete(fileKey); return next; } diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index f77cfcc25768..9ca9a7cb2f3f 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -40,7 +40,7 @@ import { } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; import { MoreVertical, Plus as PlusIcon } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { AnimatedHeight } from "~/components/AnimatedHeight"; @@ -83,6 +83,7 @@ import { AlertDialogTitle, } from "../ui/alert-dialog"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { DraftInput } from "../ui/draft-input"; import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; import { @@ -888,6 +889,82 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled ); } +// Mirrors the schema bound on OpenLinksInPreviewPattern in contracts. +const PREVIEW_LINK_PATTERN_MAX_LENGTH = 500; + +function isValidPreviewLinkPattern(source: string): boolean { + if (source.length === 0) return true; + try { + return new RegExp(source) instanceof RegExp; + } catch { + return false; + } +} + +function PreviewLinkPatternInput({ + value, + disabled, + onCommit, +}: { + value: string; + disabled: boolean; + onCommit: (pattern: string) => void; +}) { + // Local draft so a regex can be invalid mid-edit; only valid patterns (or + // empty, meaning off) are persisted, and the field snaps back on blur. + const [draft, setDraft] = useState(value); + useEffect(() => { + setDraft(value); + }, [value]); + const isInvalid = useMemo(() => !isValidPreviewLinkPattern(draft.trim()), [draft]); + + return ( +
    + { + const next = event.target.value; + setDraft(next); + const source = next.trim(); + if (isValidPreviewLinkPattern(source)) { + onCommit(source); + } + }} + onBlur={() => setDraft(value)} + aria-invalid={isInvalid || undefined} + aria-label="URL pattern for links opened in the integrated browser" + /> + {isInvalid ? ( + Invalid regular expression + ) : null} +
    + ); +} + +function OpenLinksInPreviewSetting({ disabled }: { readonly disabled: boolean }) { + const pattern = useClientSettings((settings) => settings.openLinksInPreviewPattern); + const updateSettings = useUpdatePrimarySettings(); + + return ( + updateSettings({ openLinksInPreviewPattern: next })} + /> + } + /> + ); +} + /** * Profile list, its header menu, and the import flow. * @@ -1438,6 +1515,7 @@ export function IntegrationsSettingsPanel() { + ); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fc5082fcf1a4..bb1eb4954b9d 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type BackgroundActivityProfile, type DesktopUpdateChannel, + DiffFilesCollapsed, ProviderDriverKind, type ProviderInstanceId, type ScopedThreadRef, @@ -209,6 +210,13 @@ const DIFF_LAYOUT_LABELS: Record = { split: "Split", }; +const DIFF_FILES_COLLAPSED_LABELS: Record = { + none: "Expanded", + all: "Collapsed", + viewed: "Collapse viewed (pull requests)", +}; +const isDiffFilesCollapsed = Schema.is(DiffFilesCollapsed); + const QUIT_CONFIRMATION_MODE_LABELS: Record = { direct: "Direct", hold: "Hold", @@ -595,6 +603,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? ["Follow-up behavior"] : []), + ...(settings.chatTurnAnchor !== DEFAULT_UNIFIED_SETTINGS.chatTurnAnchor + ? ["Sent message position"] + : []), ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), @@ -659,6 +670,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.composerRichTextEnabled, settings.sendShortcut, settings.followUpBehavior, + settings.chatTurnAnchor, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, @@ -775,6 +787,7 @@ export function useSettingsRestore(onRestored?: () => void) { composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled, sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut, followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior, + chatTurnAnchor: DEFAULT_UNIFIED_SETTINGS.chatTurnAnchor, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -2055,6 +2068,7 @@ const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ "legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar", + "sidebar-worktree-cards", ]); /** @@ -2134,6 +2148,21 @@ function LegacyFeaturesSection() { /> } /> + {!settings.legacySidebarEnabled ? ( + + updateSettings({ sidebarGroupWorktreeThreads: Boolean(checked) }) + } + aria-label="Worktree cards" + /> + } + /> + ) : null} @@ -2502,7 +2531,7 @@ export function GeneralSettingsPanel() { /> { - if (value === "expanded" || value === "collapsed") { - updateSettings({ diffFilesCollapsed: value === "collapsed" }); + if (isDiffFilesCollapsed(value)) { + updateSettings({ diffFilesCollapsed: value }); } }} > @@ -2529,15 +2558,16 @@ export function GeneralSettingsPanel() { className="w-full sm:w-40" aria-label="Default diff file state" > - {settings.diffFilesCollapsed ? "Collapsed" : "Expanded"} + + {DIFF_FILES_COLLAPSED_LABELS[settings.diffFilesCollapsed]} + - - Expanded - - - Collapsed - + {DiffFilesCollapsed.literals.map((mode) => ( + + {DIFF_FILES_COLLAPSED_LABELS[mode]} + + ))} } @@ -2772,6 +2802,43 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ chatTurnAnchor: DEFAULT_UNIFIED_SETTINGS.chatTurnAnchor }) + } + /> + ) : null + } + control={ + + } + /> + { const streaming = getSettingsSearchTargetScope("response-streaming")!; expect(streaming.scope).toBe("project-defaults"); expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true); - for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) { + for (const id of [ + "legacy-plan-mode", + "legacy-context-window-indicator", + "legacy-sidebar", + "sidebar-worktree-cards", + ]) { expect(getSettingsSearchTargetScope(id)!.scope).toBeNull(); } }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index c9cb76401bd6..a26a51f866d6 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -333,7 +333,7 @@ export const SETTINGS_SEARCH_ITEMS = [ id: "default-diff-file-state", title: "Default diff file state", to: "/settings/general", - searchTerms: ["collapsed expanded collapse expand files pull request pr code tab"], + searchTerms: ["collapsed expanded collapse expand viewed files pull request pr code tab"], }, { id: "diff-layout", @@ -377,6 +377,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["queue steer running turn send default behavior composer"], }, + { + id: "sent-message-position", + title: "Sent message position", + to: "/settings/general", + searchTerms: ["scroll anchor top bottom new message turn timeline reading position"], + }, { id: "provider-update-checks", title: "Provider update checks", @@ -491,6 +497,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["project thread tree old flat list"], }, + { + id: "sidebar-worktree-cards", + title: "Worktree cards", + to: "/settings/general", + searchTerms: ["group worktree threads together card sidebar branch"], + }, { id: "keybindings", title: "Keybindings", @@ -819,6 +831,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/connections", searchTerms: ["pull request trusted environments shared credentials permissions read actions"], }, + { + id: "open-links-in-integrated-browser", + title: "Open matching links in the integrated browser", + to: "/settings/integrations", + targetId: "browser", + }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index c5ebeda55627..18e99dcbba2d 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -4,8 +4,10 @@ import { buildThreadActionMenuItems, type ThreadActionMenuState } from "./thread const baseState: ThreadActionMenuState = { branch: null, + hasTranscript: false, projectFilter: null, isPinned: false, + worktreeSiblingCount: 0, isSettled: false, autoSettleEnabled: true, isSnoozed: false, @@ -61,6 +63,15 @@ describe("buildThreadActionMenuItems", () => { expect(items[copyIndex + 2]?.id).toBe("archive"); }); + it("disables copy-transcript until the thread detail is loaded", () => { + const findTranscript = (state: ThreadActionMenuState) => + buildThreadActionMenuItems(state) + .find((item) => item.id === "copy") + ?.children?.find((child) => child.id === "copy-transcript"); + expect(findTranscript(baseState)?.disabled).toBe(true); + expect(findTranscript({ ...baseState, hasTranscript: true })?.disabled).toBe(false); + }); + it("offers project filtering only for surfaces with a scoped thread list", () => { expect(ids(baseState)).not.toContain("filter-by-project"); expect( @@ -167,3 +178,17 @@ describe("buildThreadActionMenuItems", () => { expect(archiveItem?.disabled).toBe(true); }); }); + +describe("worktree card labels", () => { + it("names the worktree when the pin fans out to siblings", () => { + const labels = (state: ThreadActionMenuState) => + buildThreadActionMenuItems(state) + .filter((item) => item.id === "pin" || item.id === "unpin") + .map((item) => item.label); + expect(labels({ ...baseState, worktreeSiblingCount: 2 })).toEqual(["Pin worktree"]); + expect(labels({ ...baseState, worktreeSiblingCount: 2, isPinned: true })).toEqual([ + "Unpin worktree", + ]); + expect(labels(baseState)).toEqual(["Pin thread"]); + }); +}); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 838fc91a636c..48a7c9c4d7ed 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -27,11 +27,18 @@ export type ThreadActionMenuId = | "copy-path" | "copy-branch" | "copy-thread-id" + | "copy-transcript" | "archive" | "delete"; export interface ThreadActionMenuState { readonly branch: string | null; + /** + * Whether the thread's full detail (messages) is loaded locally. Sidebar + * rows only hold shells, so "Copy transcript" stays disabled there until + * the thread has been opened. + */ + readonly hasTranscript: boolean; /** * Project scoping for the thread list. Null on surfaces with no scoped * list behind the menu (the chat header), where the item must not show. @@ -42,6 +49,9 @@ export interface ThreadActionMenuState { readonly isActive: boolean; } | null; readonly isPinned: boolean; + /** Live threads sharing this thread's worktree card. Pin and unpin fan out + to them, so the labels say "worktree" whenever there are any. */ + readonly worktreeSiblingCount: number; readonly isSettled: boolean; /** False while the user has turned automatic settlement off for this thread. */ readonly autoSettleEnabled: boolean; @@ -82,8 +92,16 @@ export function buildThreadActionMenuItems( ...(state.supports.pinning ? [ state.isPinned - ? { id: "unpin" as const, label: "Unpin thread", icon: "pin-off" } - : { id: "pin" as const, label: "Pin thread", icon: "pin" }, + ? { + id: "unpin" as const, + label: state.worktreeSiblingCount > 0 ? "Unpin worktree" : "Unpin thread", + icon: "pin-off", + } + : { + id: "pin" as const, + label: state.worktreeSiblingCount > 0 ? "Pin worktree" : "Pin thread", + icon: "pin", + }, ] : []), // Both lifecycle actions stay available on pinned threads: settling @@ -174,6 +192,12 @@ export function buildThreadActionMenuItems( ? [{ id: "copy-branch" as const, label: "Branch", icon: "git-branch" }] : []), { id: "copy-thread-id", label: "Thread ID", icon: "hash" }, + { + id: "copy-transcript", + label: "Transcript", + icon: "file-text", + disabled: !state.hasTranscript, + }, ], }, { id: "project-settings", label: "Project settings", icon: "settings" }, diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 1a101f107114..83565f90e22b 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -65,6 +65,11 @@ const buttonVariants = cva( "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90", "warning-outline": "border-warning/32 bg-warning-surface text-warning-foreground shadow-xs/5 [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-warning/40 [:hover,[data-pressed]]:bg-warning/16 dark:[:hover,[data-pressed]]:bg-warning/24", + // GitHub-style change request state chips: green while open, purple once merged. + success: + "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-success-strong bg-success-strong text-white shadow-success-strong/24 shadow-xs [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-success-strong/90", + merged: + "not-disabled:inset-shadow-[0_1px_--theme(--color-white/16%)] border-merged bg-merged text-white shadow-merged/24 shadow-xs [:active,[data-pressed]]:inset-shadow-[0_1px_--theme(--color-black/8%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-merged/90", }, }, }, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ce2c51fe5247..5659ea7f37f0 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -67,6 +67,7 @@ import { reviewCommentContextReference, terminalContextReference, } from "./lib/composerContextRecords"; +import { type ThreadTranscriptDraft } from "./lib/threadTranscript"; import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; @@ -226,12 +227,20 @@ const PersistedTerminalContextDraft = Schema.Struct({ }); type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; +const PersistedThreadTranscriptDraft = Schema.Struct({ + id: Schema.String, + title: Schema.String, + messageCount: Schema.Number, + block: Schema.String, +}); + const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), files: Schema.optionalKey(Schema.Array(PersistedComposerDraftFileAttachment)), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), + transcripts: Schema.optionalKey(Schema.Array(PersistedThreadTranscriptDraft)), reviewComments: Schema.optionalKey(Schema.Array(ReviewCommentContextSchema)), // Keyed by `ProviderInstanceId` (open branded slug) so custom provider // instances (e.g. `codex_personal`) round-trip alongside the built-in @@ -383,6 +392,12 @@ export interface ComposerThreadDraftState { persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; + /** + * Pasted `` blocks held as cards. Like preview + * annotations they are plain prompt text at send time; the card is only a + * composer affordance. + */ + transcripts: ThreadTranscriptDraft[]; reviewComments: ReviewCommentContext[]; /** * Per-instance model selection. Keyed by `ProviderInstanceId` (open @@ -426,6 +441,7 @@ export function composerDraftHasUserContent( draft.persistedAttachments.length > 0 || draft.terminalContexts.length > 0 || draft.previewAnnotations.length > 0 || + draft.transcripts.length > 0 || draft.reviewComments.length > 0 ); } @@ -664,6 +680,15 @@ interface ComposerDraftStoreState { annotations: ReadonlyArray, ) => void; removePreviewAnnotation: (threadRef: ComposerThreadTarget, annotationId: string) => void; + addTranscripts: ( + threadRef: ComposerThreadTarget, + transcripts: ReadonlyArray, + ) => void; + setTranscripts: ( + threadRef: ComposerThreadTarget, + transcripts: ReadonlyArray, + ) => void; + removeTranscript: (threadRef: ComposerThreadTarget, transcriptId: string) => void; addReviewComment: ( threadRef: ComposerThreadTarget, comment: ReviewCommentContext, @@ -762,12 +787,14 @@ const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = []; +const EMPTY_TRANSCRIPTS: ThreadTranscriptDraft[] = []; const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_FILES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); Object.freeze(EMPTY_PREVIEW_ANNOTATIONS); +Object.freeze(EMPTY_TRANSCRIPTS); Object.freeze(EMPTY_REVIEW_COMMENTS); const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = Object.freeze({}); @@ -784,6 +811,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, previewAnnotations: EMPTY_PREVIEW_ANNOTATIONS, + transcripts: EMPTY_TRANSCRIPTS, reviewComments: EMPTY_REVIEW_COMMENTS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, @@ -806,6 +834,7 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { persistedAttachments: [], terminalContexts: [], previewAnnotations: [], + transcripts: [], reviewComments: [], modelSelectionByProvider: {}, activeProvider: null, @@ -900,6 +929,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && draft.previewAnnotations.length === 0 && + draft.transcripts.length === 0 && draft.reviewComments.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && @@ -2129,6 +2159,7 @@ export function partializeComposerDraftStoreState( draft.files.length === 0 && draft.terminalContexts.length === 0 && draft.previewAnnotations.length === 0 && + draft.transcripts.length === 0 && draft.reviewComments.length === 0 && !hasModelData && draft.runtimeMode === null && @@ -2180,6 +2211,11 @@ export function partializeComposerDraftStoreState( ), } : {}), + ...(draft.transcripts.length > 0 + ? { + transcripts: draft.transcripts.map((transcript) => ({ ...transcript })), + } + : {}), ...(draft.reviewComments.length > 0 ? { reviewComments: draft.reviewComments.map((comment) => ({ ...comment })), @@ -2456,6 +2492,7 @@ function toHydratedThreadDraft( })) ?? [], previewAnnotations: persistedDraft.previewAnnotations?.map((annotation) => ({ ...annotation })) ?? [], + transcripts: persistedDraft.transcripts?.map((transcript) => ({ ...transcript })) ?? [], reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [], modelSelectionByProvider, activeProvider, @@ -3831,6 +3868,53 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + addTranscripts: (threadRef, transcripts) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey || transcripts.length === 0) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const incomingIds = new Set(transcripts.map((transcript) => transcript.id)); + const kept = existing.transcripts.filter( + (transcript) => !incomingIds.has(transcript.id), + ); + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { ...existing, transcripts: [...kept, ...transcripts] }, + }, + }; + }); + }, + setTranscripts: (threadRef, transcripts) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { ...existing, transcripts: [...transcripts] }, + }, + }; + }); + }, + removeTranscript: (threadRef, transcriptId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey || !transcriptId) return; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) return state; + const transcripts = current.transcripts.filter( + (transcript) => transcript.id !== transcriptId, + ); + if (transcripts.length === current.transcripts.length) return state; + const nextDraft = { ...current, transcripts }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey]; + else nextDraftsByThreadKey[threadKey] = nextDraft; + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, addReviewComment: (threadRef, comment, options) => { const threadKey = resolveComposerDraftKey(get(), threadRef); if (!threadKey || !isReviewCommentContext(comment)) return; @@ -4001,6 +4085,7 @@ const composerDraftStore = create()( persistedAttachments: [], terminalContexts: [], previewAnnotations: [], + transcripts: [], reviewComments: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index ddc5da05f806..e538acd69ae5 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -196,6 +196,12 @@ beforeEach(() => { callback(0); return 0; }); + // The pointer-selection guard arms via setTimeout; run it synchronously so + // menus are immediately interactive unless a test overrides this stub. + vi.stubGlobal("setTimeout", (callback: () => void) => { + callback(); + return 0; + }); vi.stubGlobal( "MouseEvent", class extends FakeDomEvent { @@ -248,8 +254,8 @@ describe("showContextMenuFallback", () => { }); it("ignores a click from the gesture that opened the menu", async () => { - let enablePointerSelection: ((time: number) => void) | undefined; - vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + let enablePointerSelection: (() => void) | undefined; + vi.stubGlobal("setTimeout", (callback: () => void) => { enablePointerSelection = callback; return 0; }); @@ -258,7 +264,7 @@ describe("showContextMenuFallback", () => { const renameButton = findButton("Rename"); renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - enablePointerSelection?.(0); + enablePointerSelection?.(); renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await expect(selectionPromise).resolves.toBe("rename"); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index db14ed8c860c..d94e6193b9e9 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -500,8 +500,12 @@ export function showContextMenuFallback( } activeContextMenuDismiss = dismiss; - requestAnimationFrame(() => { + // Deliberately not requestAnimationFrame: rAF never fires in hidden tabs + // (e.g. agent-driven QA through the collaborative browser), which would + // leave the menu permanently un-clickable there. A short timer keeps the + // same-gesture click protection on a clock that always runs. + setTimeout(() => { canDismissFromPointer = true; - }); + }, 50); }); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 194cc36c55f4..c92a0f815186 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -379,6 +379,17 @@ export function useLegacySidebarEnabled(): boolean { return settingsHydrated && legacySidebarEnabled; } +/** + * Whether the default sidebar gathers a worktree's threads into one card + * (see docs/user/thread-sidebar.md "Worktree cards"). Off in the legacy + * sidebar, so pin fan-out never fires where no card is drawn. + */ +export function useSidebarWorktreeCardsEnabled(): boolean { + const settingsHydrated = useClientSettingsHydrated(); + const settings = useClientSettingsValue(); + return settingsHydrated && !settings.legacySidebarEnabled && settings.sidebarGroupWorktreeThreads; +} + /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 4a99fa8f5c73..ade42f5ce0c5 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -25,9 +25,12 @@ import { readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentSupportsTitleRegeneration, + readThreadDetail, readThreadShell, + readThreadShells, useProjects, } from "../state/entities"; +import { buildThreadTranscriptBlock } from "../lib/threadTranscript"; import { usePrimaryEnvironmentId } from "../state/environments"; import { readLocalApi } from "../localApi"; import { @@ -37,6 +40,8 @@ import { } from "../logicalProject"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; import { useUiStateStore } from "../uiStateStore"; +import { worktreeCardSiblings } from "../components/Sidebar.worktree"; +import { useSidebarWorktreeCardsEnabled } from "./useSettings"; import { useCopyToClipboard } from "./useCopyToClipboard"; import { useNewThreadHandler } from "./useHandleNewThread"; import { useClientSettings } from "./useSettings"; @@ -93,6 +98,7 @@ export function useThreadActionMenu(input: { archiveThread, deleteThread, } = useThreadActions(); + const worktreeCardsEnabled = useSidebarWorktreeCardsEnabled(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -120,6 +126,17 @@ export function useThreadActionMenu(input: { }, onError: (error) => failureToast("Failed to copy thread ID", error), }); + const { copyToClipboard: copyTranscriptToClipboard } = useCopyToClipboard<{ title: string }>({ + target: "thread transcript", + onCopy: ({ title }) => { + toastManager.add({ + type: "success", + title: "Transcript copied", + description: `Paste it into another thread's composer to attach "${title}".`, + }); + }, + onError: (error) => failureToast("Failed to copy transcript", error), + }); const openMenu = useCallback( (position: { x: number; y: number }) => { @@ -141,12 +158,25 @@ export function useThreadActionMenu(input: { }; const isRegeneratingTitle = thread.titleRegeneration != null; const snoozePresets = resolveSnoozePresets(now, timestampFormat); + const threadDetail = readThreadDetail(threadRef); + const transcriptMessages = + threadDetail?.messages.filter( + (message) => !message.streaming && message.text.trim().length > 0, + ) ?? []; + // A card is pinned while any member is: the menu offers "Unpin + // worktree" on an unpinned member of a pinned card. + const worktreeSiblings = worktreeCardsEnabled + ? worktreeCardSiblings(readThreadShells(), thread, { now: now.toISOString() }) + : []; const items = buildThreadActionMenuItems({ branch: thread.branch ?? null, + hasTranscript: transcriptMessages.length > 0, + isPinned: + thread.pinnedAt != null || worktreeSiblings.some((sibling) => sibling.pinnedAt != null), + worktreeSiblingCount: worktreeSiblings.length, // The chat header has no project-scoped thread list behind the // menu, so the "Filter by project" affordance is sidebar-only. projectFilter: null, - isPinned: thread.pinnedAt != null, isSettled: supports.settlement && thread.settledOverride === "settled", autoSettleEnabled: thread.autoSettleDisabledAt == null, isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), @@ -273,6 +303,16 @@ export function useThreadActionMenu(input: { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "copy-transcript": { + if (transcriptMessages.length === 0) return; + const block = buildThreadTranscriptBlock({ + title: thread.title, + branch: thread.branch ?? null, + messages: transcriptMessages, + }); + copyTranscriptToClipboard(block, { title: thread.title }); + return; + } case "archive": { if (confirmThreadArchive) { const confirmed = await settlePromise(() => @@ -333,6 +373,7 @@ export function useThreadActionMenu(input: { copyBranchToClipboard, copyPathToClipboard, copyThreadIdToClipboard, + copyTranscriptToClipboard, deleteThread, handleNewThread, logicalProjectKeyByPhysicalKey, @@ -351,6 +392,7 @@ export function useThreadActionMenu(input: { unsettleThread, unsnoozeThread, updateThreadMetadata, + worktreeCardsEnabled, ], ); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 6a82b920ab31..88760bb30936 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -14,7 +14,11 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useMemo, useRef } from "react"; -import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; +import { + getFallbackThreadIdAfterDelete, + getWorktreeSiblingThreadIdAfterArchive, + pinOrderKeyBetween, +} from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -42,7 +46,8 @@ import { useUiStateStore } from "../uiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { useClientSettings } from "./useSettings"; +import { useClientSettings, useSidebarWorktreeCardsEnabled } from "./useSettings"; +import { worktreeCardSiblings } from "../components/Sidebar.worktree"; import * as ThreadUndo from "./threadUndo"; import { showThreadUndoNotice } from "./showThreadUndoNotice"; import { useAtomCommand } from "../state/use-atom-command"; @@ -98,15 +103,24 @@ export class ThreadSnoozeBlockedError extends Schema.TaggedError()( "ThreadAutoSettleOptOutUnsupportedError", { @@ -158,6 +172,8 @@ export class ThreadActiveReorderUnsupportedError extends Schema.TaggedError Promise) | null; }) { const { confirm } = input; @@ -165,12 +181,18 @@ export async function requestThreadUnpinConfirmation(input: { return AsyncResult.success(true); } + const siblingCount = input.siblingCount ?? 0; return settlePromise(() => confirm( - [ - `Unpin thread "${input.title}"?`, - "This will move the thread out of your pinned section.", - ].join("\n"), + siblingCount > 0 + ? [ + `Unpin thread "${input.title}" and ${siblingCount} more in its worktree?`, + "This will move the whole worktree out of your pinned section.", + ].join("\n") + : [ + `Unpin thread "${input.title}"?`, + "This will move the thread out of your pinned section.", + ].join("\n"), ), ); } @@ -238,6 +260,20 @@ export function useThreadActions() { const sidebarThreadSortOrder = useClientSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); const confirmThreadUnpin = useClientSettings((settings) => settings.confirmThreadUnpin); + const worktreeCardsEnabled = useSidebarWorktreeCardsEnabled(); + // The other live threads in the target's worktree card, or none when cards + // are off. Read at call time: the card is a view over current shells. + const readWorktreeSiblings = useCallback( + (target: ScopedThreadRef) => { + if (!worktreeCardsEnabled) return []; + const shell = readThreadShell(target); + if (!shell) return []; + return worktreeCardSiblings(readThreadShells(), shell, { + now: new Date().toISOString(), + }).filter((sibling) => sibling.environmentId === target.environmentId); + }, + [worktreeCardsEnabled], + ); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); const clearProjectDraftThreadById = useComposerDraftStore( (store) => store.clearProjectDraftThreadById, @@ -309,9 +345,21 @@ export function useThreadActions() { } const currentRouteThreadRef = getCurrentRouteThreadRef(); - const shouldNavigateToDraft = + const shouldNavigateAway = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; + // Resolved before the mutation lands so the archived thread is still in + // the store to read its worktree from. + const siblingThreadId = shouldNavigateAway + ? getWorktreeSiblingThreadIdAfterArchive({ + threads: readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }), + archivedThreadId: threadRef.threadId, + sortOrder: sidebarThreadSortOrder, + }) + : null; const action = ThreadUndo.begin("archive", scopedThreadKey(threadRef)); const archiveResult = await archiveThreadMutation({ environmentId: threadRef.environmentId, @@ -331,14 +379,27 @@ export function useThreadActions() { action: "Archived", claim: action, // Undo also brings the reader back when archiving moved them to a draft. - undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateToDraft }), + undo: () => unarchiveThread(threadRef, { navigate: shouldNavigateAway }), failureTitle: "Failed to undo archive", }); - if (shouldNavigateToDraft) { - const navigationResult = await settlePromise(() => - handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)), - ); + if (shouldNavigateAway) { + // Both branches are awaited for their side effect only; `handleNewThread` + // resolves with the created thread, which would otherwise widen the union + // settlePromise infers. + const navigationResult = await settlePromise(async () => { + if (siblingThreadId) { + await router.navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(threadRef.environmentId, siblingThreadId), + ), + replace: true, + }); + return; + } + await handleNewThreadRef.current(scopeProjectRef(thread.environmentId, thread.projectId)); + }); if (navigationResult._tag === "Failure") { return navigationResult; } @@ -352,12 +413,21 @@ export function useThreadActions() { getCurrentRouteThreadRef, markThreadVisited, resolveThreadTarget, + router, + sidebarThreadSortOrder, unarchiveThread, ], ); const deleteThread = useCallback( - async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { + async ( + target: ScopedThreadRef, + opts: { + deletedThreadKeys?: ReadonlySet; + /** Remove an orphaned worktree without asking - the caller already confirmed. */ + deleteOrphanedWorktree?: boolean; + } = {}, + ) => { const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. @@ -408,7 +478,9 @@ export function useThreadActions() { const automaticWorktreeCleanup = environmentSettings ? resolveWorktreeCleanup(environmentSettings, thread.projectId).worktreeOnDelete : false; - if (canDeleteWorktree && localApi && !automaticWorktreeCleanup) { + if (canDeleteWorktree && opts.deleteOrphanedWorktree) { + shouldDeleteWorktree = true; + } else if (canDeleteWorktree && localApi && !automaticWorktreeCleanup) { const confirmationResult = await settlePromise(() => localApi.dialogs.confirm( [ @@ -572,6 +644,15 @@ export function useThreadActions() { [unsettleThreadMutation], ); + // A worktree card is pinned while any live member is, so a keyboard or + // header toggle on an unpinned member of a pinned card must unpin. + const isWorktreeCardPinned = useCallback( + (target: ScopedThreadRef) => + readThreadShell(target)?.pinnedAt != null || + readWorktreeSiblings(target).some((sibling) => sibling.pinnedAt != null), + [readWorktreeSiblings], + ); + /** Turns automatic settlement (inactivity, merged PR) on or off for one thread. */ const setThreadAutoSettle = useCallback( async (target: ScopedThreadRef, enabled: boolean) => { @@ -594,7 +675,7 @@ export function useThreadActions() { ); const pinThread = useCallback( - async (target: ScopedThreadRef, opts: { orderKey?: string } = {}) => { + async (target: ScopedThreadRef, opts: { orderKey?: string; scope?: ThreadPinScope } = {}) => { // Version skew: never send the command to a server that predates it. if (!readEnvironmentSupportsPinning(target.environmentId)) { return AsyncResult.failure( @@ -612,23 +693,46 @@ export function useThreadActions() { // gets the default so the same action never places differently. // orderKey rides only to servers that decode it; pre-reorder servers // get the bare pin they understand and the thread stays keyless. - const orderKey = readEnvironmentSupportsPinReorder(target.environmentId) - ? (opts.orderKey ?? topOfPinnedRunOrderKey()) - : undefined; + const supportsReorder = readEnvironmentSupportsPinReorder(target.environmentId); + const runTop = smallestPinnedOrderKey(); + const orderKey = supportsReorder ? (opts.orderKey ?? topOfPinnedRunOrderKey()) : undefined; ThreadUndo.invalidate("pin", scopedThreadKey(target)); - return pinThreadMutation({ + const result = await pinThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId, ...(orderKey !== undefined ? { orderKey } : {}), }, }); + if (result._tag === "Failure" || (opts.scope ?? "worktree") === "thread") return result; + // Unpinned siblings follow the thread into the pinned block, keyed in + // sequence between it and the run's previous top so the card stays + // whole on every client. Stop on the first failure; each landed pin + // is a complete placement on its own. + let previous = orderKey ?? null; + for (const sibling of readWorktreeSiblings(target)) { + if (sibling.pinnedAt != null) continue; + const siblingKey = + supportsReorder && orderKey !== undefined + ? (pinOrderKeyBetween(previous, runTop) ?? undefined) + : undefined; + const siblingResult = await pinThreadMutation({ + environmentId: sibling.environmentId, + input: { + threadId: sibling.id, + ...(siblingKey !== undefined ? { orderKey: siblingKey } : {}), + }, + }); + if (siblingResult._tag === "Failure") return siblingResult; + previous = siblingKey ?? previous; + } + return result; }, - [pinThreadMutation], + [pinThreadMutation, readWorktreeSiblings], ); const unpinThread = useCallback( - async (target: ScopedThreadRef) => { + async (target: ScopedThreadRef, opts: { scope?: ThreadPinScope } = {}) => { if (!readEnvironmentSupportsPinning(target.environmentId)) { return AsyncResult.failure( Cause.fail( @@ -639,18 +743,44 @@ export function useThreadActions() { ), ); } - const thread = readThreadShell(target); - const orderKey = thread?.pinOrderKey ?? undefined; + // A card is pinned while any member is, so an unpinned member can be + // asked to unpin its worktree: the thread itself is skipped, its + // pinned siblings are not. + const scope = opts.scope ?? "worktree"; + const shell = readThreadShell(target); + const orderKey = shell?.pinOrderKey ?? undefined; const action = ThreadUndo.begin("pin", scopedThreadKey(target)); - const result = await unpinThreadMutation({ - environmentId: target.environmentId, - input: { threadId: target.threadId }, - }); - if (result._tag === "Success" && action.isCurrent()) { + const result = + shell?.pinnedAt == null && scope === "worktree" + ? AsyncResult.success(undefined) + : await unpinThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + if (result._tag === "Failure") { + action.finish(); + return result; + } + if (scope === "worktree") { + for (const sibling of readWorktreeSiblings(target)) { + if (sibling.pinnedAt == null) continue; + const siblingResult = await unpinThreadMutation({ + environmentId: sibling.environmentId, + input: { threadId: sibling.id }, + }); + if (siblingResult._tag === "Failure") { + action.finish(); + return siblingResult; + } + } + } + // Undo re-pins with the same scope, so a worktree unpin brings the + // whole card back and a single-thread unpin only that thread. + if (action.isCurrent()) { showThreadUndoNotice({ action: "Unpinned", claim: action, - undo: () => pinThread(target, orderKey === undefined ? {} : { orderKey }), + undo: () => pinThread(target, { ...(orderKey === undefined ? {} : { orderKey }), scope }), failureTitle: "Failed to undo unpin", }); } else { @@ -658,7 +788,7 @@ export function useThreadActions() { } return result; }, - [pinThread, unpinThreadMutation], + [pinThread, readWorktreeSiblings, unpinThreadMutation], ); const settleThread = useCallback( @@ -742,6 +872,8 @@ export function useThreadActions() { const confirmationResult = await requestThreadUnpinConfirmation({ enabled: confirmThreadUnpin, title: resolved?.thread.title ?? "this thread", + siblingCount: readWorktreeSiblings(target).filter((sibling) => sibling.pinnedAt != null) + .length, confirm: localApi ? (message) => localApi.dialogs.confirm(message) : null, }); if (confirmationResult._tag === "Failure") { @@ -752,7 +884,7 @@ export function useThreadActions() { } return unpinThread(target); }, - [confirmThreadUnpin, resolveThreadTarget, unpinThread], + [confirmThreadUnpin, readWorktreeSiblings, resolveThreadTarget, unpinThread], ); const reorderPinnedThread = useCallback( @@ -910,6 +1042,7 @@ export function useThreadActions() { pinThread, unpinThread, confirmAndUnpinThread, + isWorktreeCardPinned, reorderPinnedThread, reorderActiveThread, setThreadAutoSettle, @@ -918,6 +1051,7 @@ export function useThreadActions() { archiveThread, confirmAndDeleteThread, confirmAndUnpinThread, + isWorktreeCardPinned, deleteThread, pinThread, reorderPinnedThread, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d1ca0f4cf60e..08c821d2f38a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -217,6 +217,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-update-surface: var(--update-surface); --color-success-foreground: var(--success-foreground); --color-success: var(--success); + --color-success-strong: var(--success-strong); + --color-merged-foreground: var(--merged-foreground); + --color-merged: var(--merged); --color-diff-addition: var(--diff-addition); --color-diff-deletion: var(--diff-deletion); --color-diff-addition-foreground: var(--diff-addition-foreground); @@ -1070,6 +1073,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --info-foreground: var(--color-blue-700); --success: var(--color-emerald-500); --success-foreground: var(--color-emerald-700); + /* Solid success surfaces (e.g. the open-PR button); green-700 tracks + GitHub's open green (#1f883d), where emerald-500 reads too bright. */ + --success-strong: var(--color-green-700); + /* Merged change requests; violet-500 tracks GitHub's merged purple. */ + --merged: var(--color-violet-500); + --merged-foreground: var(--color-violet-700); --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-700); --warning-surface: color-mix(in srgb, var(--warning) 8%, transparent); @@ -1120,6 +1129,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --input: --alpha(var(--color-white) / 8%); --info-foreground: var(--color-blue-400); --success-foreground: var(--color-emerald-400); + --merged-foreground: var(--color-violet-400); --warning-foreground: var(--color-amber-400); --warning-surface: color-mix(in srgb, var(--warning) 16%, transparent); --update-foreground: var(--color-blue-400); diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index c145404a74e2..bfb705d3b58f 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -12,6 +12,7 @@ import { resolveNewDraftStartFromOrigin, resolveNewThreadModelSelectionOverride, startNewThreadFromContext, + startNewThreadInCurrentWorkspace, type ChatThreadActionContext, } from "./chatThreadActions"; @@ -168,4 +169,99 @@ describe("chatThreadActions", () => { expect(didStart).toBe(false); expect(handleNewThread).not.toHaveBeenCalled(); }); + + it("carries the active thread's worktree when starting in the current workspace", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewThreadInCurrentWorkspace( + createContext({ + activeThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/foo", + worktreePath: "/tmp/worktrees/foo", + }, + handleNewThread, + }), + ); + + expect(didStart).toBe(true); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: "feature/foo", + worktreePath: "/tmp/worktrees/foo", + envMode: "worktree", + startFromOrigin: false, + }); + }); + + it("preselects the current checkout when the active thread runs on the root checkout", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewThreadInCurrentWorkspace( + createContext({ + activeThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "main", + worktreePath: null, + }, + handleNewThread, + }), + ); + + expect(didStart).toBe(true); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + }); + }); + + it("carries an open draft's workspace selection verbatim", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewThreadInCurrentWorkspace( + createContext({ + activeDraftThread: { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + branch: "feature/bar", + worktreePath: null, + envMode: "worktree", + startFromOrigin: true, + }, + handleNewThread, + }), + ); + + expect(didStart).toBe(true); + expect(handleNewThread).toHaveBeenCalledWith(scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID), { + branch: "feature/bar", + worktreePath: null, + envMode: "worktree", + startFromOrigin: true, + }); + }); + + it("defaults to the current checkout when there is no active thread context", async () => { + const handleNewThread = vi.fn(async () => {}); + + const didStart = await startNewThreadInCurrentWorkspace( + createContext({ + handleNewThread, + }), + ); + + expect(didStart).toBe(true); + expect(handleNewThread).toHaveBeenCalledWith( + scopeProjectRef(ENVIRONMENT_ID, FALLBACK_PROJECT_ID), + { + branch: null, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + }, + ); + }); }); diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index c14a26d03d1c..694cda19245b 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -15,6 +15,10 @@ type ComposerModelSelectionState = Pick< interface ThreadContextLike { environmentId: EnvironmentId; projectId: ProjectId; + branch?: string | null; + worktreePath?: string | null; + envMode?: DraftThreadEnvMode; + startFromOrigin?: boolean; } interface NewThreadHandler { @@ -100,3 +104,46 @@ export async function startNewThreadFromContext( await context.handleNewThread(projectRef); return true; } + +// The explicit counterpart to the above: `chat.newInWorkspace` carries the +// viewed thread's workspace on purpose — its worktree when it has one, +// otherwise the current checkout (branch null = whatever the checkout is on). +// A viewed draft is itself a workspace selection, so it carries verbatim — +// handleNewThread reuses the open draft, and this keeps that a no-op. +export async function startNewThreadInCurrentWorkspace( + context: ChatThreadActionContext, +): Promise { + const projectRef = resolveThreadActionProjectRef(context); + if (!projectRef) { + return false; + } + + const currentCheckout = { + branch: null, + worktreePath: null, + envMode: "local" as const, + startFromOrigin: false, + }; + const thread = context.activeThread; + const draft = thread ? null : context.activeDraftThread; + const options = thread + ? thread.worktreePath + ? { + branch: thread.branch ?? null, + worktreePath: thread.worktreePath, + envMode: "worktree" as const, + startFromOrigin: false, + } + : currentCheckout + : draft + ? { + branch: draft.branch ?? null, + worktreePath: draft.worktreePath ?? null, + envMode: + draft.envMode ?? (draft.worktreePath ? ("worktree" as const) : ("local" as const)), + startFromOrigin: draft.startFromOrigin ?? false, + } + : currentCheckout; + await context.handleNewThread(projectRef, options); + return true; +} diff --git a/apps/web/src/lib/threadTranscript.test.ts b/apps/web/src/lib/threadTranscript.test.ts new file mode 100644 index 000000000000..78042b0c478a --- /dev/null +++ b/apps/web/src/lib/threadTranscript.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + appendThreadTranscriptPrompt, + buildThreadTranscriptBlock, + extractPastedThreadTranscripts, + extractTrailingThreadTranscript, +} from "./threadTranscript"; + +const block = buildThreadTranscriptBlock({ + title: "Fix login flow", + branch: "connorch/fix-login", + messages: [ + { role: "user", text: "The login button 404s." }, + { role: "assistant", text: "Found it - the route moved. Fixing now." }, + { role: "assistant", text: " " }, + ], +}); + +describe("thread transcripts", () => { + it("serializes title, branch, and non-empty messages", () => { + expect(block.startsWith("\n")).toBe(true); + expect(block.endsWith("\n")).toBe(true); + expect(block).toContain("Thread: Fix login flow"); + expect(block).toContain("Branch: connorch/fix-login"); + expect(block).toContain("Messages: 2"); + expect(block).toContain("## User\nThe login button 404s."); + expect(block).toContain("## Assistant\nFound it - the route moved. Fixing now."); + }); + + it("strips send-time context blocks from user messages", () => { + const sentUserText = [ + "Make the cards pop", + "", + "", + "Preview annotation:", + "Id: annotation_1", + "Page: Example", + "", + ].join("\n"); + const result = buildThreadTranscriptBlock({ + title: "Design pass", + branch: null, + messages: [{ role: "user", text: sentUserText }], + }); + expect(result).toContain("## User\nMake the cards pop"); + expect(result).not.toContain("preview_annotation"); + expect(result).not.toContain("Branch:"); + }); + + it("neutralizes nested transcript tags so the outer block survives a round trip", () => { + const nested = buildThreadTranscriptBlock({ + title: "Outer", + branch: null, + messages: [{ role: "assistant", text: `Quoting:\n${block}` }], + }); + const extracted = extractPastedThreadTranscripts(nested); + expect(extracted.transcripts).toHaveLength(1); + expect(extracted.transcripts[0]?.title).toBe("Outer"); + expect(extracted.remainingText).toBe(""); + }); + + it("extracts pasted blocks and preserves surrounding text", () => { + const pasted = `Continue where this left off:\n\n${block}\n\nThanks!`; + const extracted = extractPastedThreadTranscripts(pasted); + expect(extracted.transcripts).toHaveLength(1); + expect(extracted.transcripts[0]?.title).toBe("Fix login flow"); + expect(extracted.transcripts[0]?.messageCount).toBe(2); + expect(extracted.transcripts[0]?.block).toBe(block); + expect(extracted.remainingText).toBe("Continue where this left off:\n\nThanks!"); + }); + + it("passes plain text through untouched", () => { + const extracted = extractPastedThreadTranscripts("just some pasted text"); + expect(extracted.transcripts).toHaveLength(0); + expect(extracted.remainingText).toBe("just some pasted text"); + }); + + it("appends to the prompt and extracts back out for display", () => { + const drafts = extractPastedThreadTranscripts(block).transcripts; + const sent = appendThreadTranscriptPrompt("Pick this up", drafts[0]!); + expect(sent.startsWith("Pick this up\n\n")).toBe(true); + const extracted = extractTrailingThreadTranscript(sent); + expect(extracted.promptText).toBe("Pick this up"); + expect(extracted.transcript?.title).toBe("Fix login flow"); + expect(extracted.transcript?.messageCount).toBe(2); + }); +}); diff --git a/apps/web/src/lib/threadTranscript.ts b/apps/web/src/lib/threadTranscript.ts new file mode 100644 index 000000000000..f26f2b82b6a2 --- /dev/null +++ b/apps/web/src/lib/threadTranscript.ts @@ -0,0 +1,174 @@ +import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLegacy"; +import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; +import { randomUUID } from "./utils"; + +/** + * Thread transcripts use a trailing text block: "Copy transcript" puts a `` block on the + * clipboard, pasting one into the composer converts it into a draft card, + * and sending appends the block verbatim to the prompt text. No attachment + * type or wire schema is involved anywhere. + */ + +const THREAD_TRANSCRIPT_OPEN = ""; +const THREAD_TRANSCRIPT_CLOSE = ""; + +const THREAD_TRANSCRIPT_BLOCK_PATTERN = /\n([\s\S]*?)\n<\/thread_transcript>/g; + +const TRAILING_THREAD_TRANSCRIPT_BLOCK_PATTERN = + /\n*\n([\s\S]*?)\n<\/thread_transcript>\s*$/; + +/** A transcript sitting in the composer as a card, pending send. */ +export interface ThreadTranscriptDraft { + id: string; + title: string; + messageCount: number; + /** The full `` block, appended verbatim on send. */ + block: string; +} + +/** Card model parsed back out of a sent message for timeline display. */ +export interface ParsedThreadTranscript { + id: string; + title: string; + messageCount: number; +} + +export interface TranscriptSourceMessage { + role: "user" | "assistant" | "system" | "reasoning"; + text: string; +} + +// Reasoning messages are thinking traces, not conversation; they have no +// heading and are dropped from the transcript. +const ROLE_HEADINGS: Partial> = { + user: "## User", + assistant: "## Assistant", + system: "## System", +}; + +/** + * Neutralize transcript tags inside message content so a copied transcript + * that itself quotes a transcript cannot terminate the outer block early. + */ +function escapeTranscriptTags(text: string): string { + return text + .replaceAll(THREAD_TRANSCRIPT_OPEN, "<thread_transcript>") + .replaceAll(THREAD_TRANSCRIPT_CLOSE, "</thread_transcript>"); +} + +/** + * Normalize legacy appendices, then remove context references and nested + * transcripts so the copy carries the user's prose. + */ +function cleanUserMessageText(text: string): string { + let visibleText = replaceComposerContextReferences( + upgradeLegacyContextMessage(text).text, + () => "", + ).trim(); + while (true) { + const match = TRAILING_THREAD_TRANSCRIPT_BLOCK_PATTERN.exec(visibleText); + if (!match) break; + visibleText = visibleText.slice(0, match.index).replace(/\n+$/, ""); + } + return visibleText; +} + +export function buildThreadTranscriptBlock(source: { + title: string; + branch: string | null; + messages: ReadonlyArray; +}): string { + const entries = source.messages.flatMap((message) => { + const heading = ROLE_HEADINGS[message.role]; + if (heading === undefined) return []; + const text = message.role === "user" ? cleanUserMessageText(message.text) : message.text.trim(); + if (text.trim().length === 0) return []; + return [`${heading}\n${escapeTranscriptTags(text.trim())}`]; + }); + const header = [ + `Id: ${randomUUID()}`, + `Thread: ${source.title.trim() || "Untitled thread"}`, + ...(source.branch ? [`Branch: ${source.branch}`] : []), + `Messages: ${entries.length}`, + ]; + return [ + THREAD_TRANSCRIPT_OPEN, + ...header, + "", + entries.join("\n\n"), + THREAD_TRANSCRIPT_CLOSE, + ].join("\n"); +} + +function parseTranscriptBody(body: string, fallbackId: string): ParsedThreadTranscript { + const lines = body.split("\n"); + const idLine = lines.find((line) => line.startsWith("Id: ")); + const titleLine = lines.find((line) => line.startsWith("Thread: ")); + const countLine = lines.find((line) => line.startsWith("Messages: ")); + const parsedCount = countLine ? Number.parseInt(countLine.slice("Messages: ".length), 10) : NaN; + return { + id: idLine?.slice("Id: ".length).trim() || fallbackId, + title: titleLine?.slice("Thread: ".length).trim() || "Thread transcript", + messageCount: Number.isFinite(parsedCount) ? parsedCount : 0, + }; +} + +export interface ExtractedPastedTranscripts { + /** The pasted text with every transcript block removed. */ + remainingText: string; + transcripts: ThreadTranscriptDraft[]; +} + +/** + * Pull `` blocks out of pasted text so they can become + * composer cards instead of raw prompt text. Returns the transcripts in + * document order plus whatever text surrounded them. + */ +export function extractPastedThreadTranscripts(text: string): ExtractedPastedTranscripts { + if (!text.includes(THREAD_TRANSCRIPT_OPEN)) { + return { remainingText: text, transcripts: [] }; + } + const transcripts: ThreadTranscriptDraft[] = []; + const remainingText = text + .replace(THREAD_TRANSCRIPT_BLOCK_PATTERN, (block, body: string) => { + const parsed = parseTranscriptBody(body, randomUUID()); + transcripts.push({ + id: parsed.id, + title: parsed.title, + messageCount: parsed.messageCount, + block, + }); + return ""; + }) + .replace(/\n{3,}/g, "\n\n") + .trim(); + return { remainingText, transcripts }; +} + +/** + * Appended innermost - directly after the typed prompt, before terminal and + * element context blocks - so timeline extraction can unwind the appends in + * reverse order. + */ +export function appendThreadTranscriptPrompt( + prompt: string, + transcript: ThreadTranscriptDraft, +): string { + const trimmed = prompt.trim(); + return trimmed ? `${trimmed}\n\n${transcript.block}` : transcript.block; +} + +export interface ExtractedTrailingThreadTranscript { + promptText: string; + transcript: ParsedThreadTranscript | null; +} + +/** Timeline-side extraction of a sent message's trailing transcript block. */ +export function extractTrailingThreadTranscript(prompt: string): ExtractedTrailingThreadTranscript { + const match = TRAILING_THREAD_TRANSCRIPT_BLOCK_PATTERN.exec(prompt); + if (!match) return { promptText: prompt, transcript: null }; + return { + promptText: prompt.slice(0, match.index).replace(/\n+$/, ""), + transcript: parseTranscriptBody(match[1] ?? "", `${match.index}`), + }; +} diff --git a/apps/web/src/queuedMessageStore.test.ts b/apps/web/src/queuedMessageStore.test.ts index 8d72ab65054b..79011a380900 100644 --- a/apps/web/src/queuedMessageStore.test.ts +++ b/apps/web/src/queuedMessageStore.test.ts @@ -16,6 +16,7 @@ function makeMessage(prompt: string): Omit { files: [], terminalContexts: [], previewAnnotations: [], + transcripts: [], reviewComments: [], sendSettings: { modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, diff --git a/apps/web/src/queuedMessageStore.ts b/apps/web/src/queuedMessageStore.ts index 735cebf33b1c..e64619c929be 100644 --- a/apps/web/src/queuedMessageStore.ts +++ b/apps/web/src/queuedMessageStore.ts @@ -1,3 +1,4 @@ +import type { ThreadTranscriptDraft } from "./lib/threadTranscript"; import type { ModelSelection, PreviewAnnotationPayload, @@ -37,6 +38,7 @@ export interface QueuedComposerMessage { files: ComposerFileAttachment[]; terminalContexts: TerminalContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; + transcripts: ThreadTranscriptDraft[]; reviewComments: ReviewCommentContext[]; sendSettings: QueuedMessageSendSettings; /** diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 151c1699cbf7..fd1c5c0f8c5c 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -801,6 +801,32 @@ describe("rightPanelStore", () => { ).toEqual([]); }); + it("moveSurface reorders open surfaces without changing the active surface", () => { + useRightPanelStore.getState().open(refA, "diff"); + useRightPanelStore.getState().open(refA, "files"); + useRightPanelStore.getState().openTerminal(refA, "term-1"); + + useRightPanelStore.getState().moveSurface(refA, "terminal:term-1", "diff"); + + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces.map((surface) => surface.id)).toEqual([ + "terminal:term-1", + "diff", + "files", + ]); + expect(state.activeSurfaceId).toBe("terminal:term-1"); + }); + + it("moveSurface ignores unknown surface ids", () => { + useRightPanelStore.getState().open(refA, "diff"); + useRightPanelStore.getState().open(refA, "files"); + + useRightPanelStore.getState().moveSurface(refA, "diff", "agents"); + + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces.map((surface) => surface.id)).toEqual(["diff", "files"]); + }); + it("tracks one surface per terminal session", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().openTerminal(refA, "term-2"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index f020f14466b3..e71d170329f1 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -157,6 +157,7 @@ interface RightPanelStoreState { activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; + moveSurface: (ref: ScopedThreadRef, surfaceId: string, targetSurfaceId: string) => void; closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; @@ -705,6 +706,18 @@ export const useRightPanelStore = create()( : current, ), ), + moveSurface: (ref, surfaceId, targetSurfaceId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const from = current.surfaces.findIndex((surface) => surface.id === surfaceId); + const to = current.surfaces.findIndex((surface) => surface.id === targetSurfaceId); + if (from < 0 || to < 0 || from === to) return current; + const surfaces = [...current.surfaces]; + const [moved] = surfaces.splice(from, 1); + surfaces.splice(to, 0, moved!); + return { ...current, surfaces }; + }), + })), closeSurface: (ref, surfaceId) => set((state) => userAction(state, scopedThreadKey(ref), (current) => { diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 1b7239a0bef5..17b3b7d3b054 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -2021,6 +2021,10 @@ function PullRequestsRouteView() { useRightPanelStore.getState().closeAllSurfaces(rightPanelRef); selectSurfaceInUrl(null); }; + const reorderSurfaces = (surfaceId: string, targetSurfaceId: string) => { + if (rightPanelRef === null) return; + useRightPanelStore.getState().moveSurface(rightPanelRef, surfaceId, targetSurfaceId); + }; // This page has no ChatView, so it handles the shared panel shortcuts itself. const copyPullRequestFromShortcut = useEffectEvent((event: KeyboardEvent) => { @@ -2095,6 +2099,7 @@ function PullRequestsRouteView() { onActivate={(surface) => { if (surface.kind === "pull-request") activateSurface(surface); }} + onReorderSurface={reorderSurfaces} onCloseSurface={(surface) => { if (surface.kind === "pull-request") closeSurface(surface); }} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 5b5b4888269b..913f690a0669 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -13,7 +13,10 @@ import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; -import { startNewThreadFromContext } from "../lib/chatThreadActions"; +import { + startNewThreadFromContext, + startNewThreadInCurrentWorkspace, +} from "../lib/chatThreadActions"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isEditableFocused } from "../lib/editableFocus"; @@ -105,6 +108,25 @@ function ChatRouteGlobalShortcuts() { return; } + if (command === "chat.newInWorkspace") { + event.preventDefault(); + event.stopPropagation(); + // A viewed thread or draft pins the target project and workspace, so + // create directly. Without one there is nothing contextual about the + // command — fall back to the same routing as chat.new. + if (!activeThread && !activeDraftThread && !legacySidebarEnabled && projectGroupCount > 1) { + openCommandPalette({ open: "new-thread-in" }); + return; + } + void startNewThreadInCurrentWorkspace({ + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }); + return; + } + if (command === "chat.new") { event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index af977d567f2b..b6c450638159 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -179,6 +179,11 @@ export function waitForProject( }); } +/** Reads the loaded detail snapshot for actions such as copying a transcript. */ +export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { + return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); +} + export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null { return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); } diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index 019c9d53023a..4abea20c9749 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -5,17 +5,18 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain ## Workspace and conversation -| Term | Meaning | -| -------------- | ------------------------------------------------------------------------------------------------- | -| Environment | One running server and the machine, credentials, workspace access, and state it owns. | -| Client | A web, desktop, or mobile UI connected to an environment. The desktop app can also host a server. | -| Project | An environment-local workspace record rooted at a directory. | -| Workspace root | The project's base filesystem directory on the environment. | -| Worktree | A separate Git checkout a thread can use instead of the project's main checkout. | -| Thread | The durable conversation and work history for a project. It survives provider process exits. | -| Turn | One user-to-agent work cycle. Provider work can finish before checkpoint and diff work settles. | -| Activity | A non-message timeline item, such as a tool action, approval, or failure. | -| T3 home | The base data directory. Runtime state normally lives under its `userdata` directory. | +| Term | Meaning | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Environment | One running server and the machine, credentials, workspace access, and state it owns. | +| Client | A web, desktop, or mobile UI connected to an environment. The desktop app can also host a server. | +| Project | An environment-local workspace record rooted at a directory. | +| Workspace root | The project's base filesystem directory on the environment. | +| Worktree | A separate Git checkout a thread can use instead of the project's main checkout. | +| Worktree card | The sidebar's visual grouping of the active threads that share one worktree. A view over the flat thread order, not a stored object. | +| Thread | The durable conversation and work history for a project. It survives provider process exits. | +| Turn | One user-to-agent work cycle. Provider work can finish before checkpoint and diff work settles. | +| Activity | A non-message timeline item, such as a tool action, approval, or failure. | +| T3 home | The base data directory. Runtime state normally lives under its `userdata` directory. | ## Orchestration diff --git a/docs/user/composer.md b/docs/user/composer.md index 7ee8edcbb121..c9ad5cbe5a7a 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -49,6 +49,14 @@ the oldest queued message now. Change `thread.steerQueuedMessage` in **Settings → Keybindings** to use another shortcut. It leaves the current draft in the composer and waits if the agent needs an approval or an answer. +## Where a sent message lands + +By default the conversation stays pinned to the latest message. In +**Settings → General → Sent message position**, choose **Top** to hold each +message you send at the top of the view while the reply streams in below it. +Messages that queue behind a running turn keep the current position. On mobile, +the same choice is **Settings → Thread behavior → Sent messages at top**. + ## Queue messages offline on mobile Mobile keeps local copies of draft attachments, so you can preview them and queue diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 408810571632..9da862daad46 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -126,6 +126,8 @@ declined pull request. Tick a file off in the **Code** tab once you have read it and it collapses; the toolbar keeps a running count. A tick belongs to the pull request rather than to a commit, so scoping the tab to a single commit keeps them. A file pushed to after you cleared it comes back marked **Changed**. +To open a pull request with the files you have already ticked off folded and the rest open, set +**Default diff file state** to **Collapse viewed (pull requests)** in Settings > General. On GitHub these are GitHub's own viewed marks, so a review carries between T3 Code and github.com in either direction. Forgejo, GitLab, Bitbucket, and Azure DevOps expose no record T3 Code can read, so the diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4a19bac401b9..2d7e7cf82fbf 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -84,6 +84,20 @@ If dragging is unavailable for one environment, update the T3 Code server runnin environment. Pinned and active reordering require server support. Threads from older servers keep their default order until the server is updated. +## Worktree cards + +On web and desktop, the threads that share a worktree stay together in the sidebar as one card. +A card holds the worktree's pinned and active threads. Snoozed and settled threads leave the card +for their own shelves. Pinning a thread pins the rest of its worktree, and unpinning does the +same. Settle, snooze, and the other row actions still apply to one thread at a time. + +Drag any row to move the whole card. To reorder one thread inside its card, hold Option while +dragging, or hold the pointer still for a moment before you start to move. Drop a card on +**Settled** to settle every thread in it. + +Turn cards off in **Settings → General → Worktree cards**. The order is stored per thread on the +server, so mobile and your other devices show the same threads next to each other. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list @@ -150,3 +164,13 @@ Choose **Snooze → Custom…** from a thread's menu to pick a date and time in local time zone, or a duration in minutes, hours, or days. Durations start when you confirm; one day means 24 hours. On web and desktop, you can also snooze several selected threads together. Choose **Wake thread** to bring a thread back early. + +## Reusing a conversation in another thread + +Choose **Copy transcript** from a thread's context menu to copy the conversation to your +clipboard. Paste it into another thread's composer on web or desktop and it becomes a compact +transcript card instead of raw text; send the message and the agent receives the full +conversation as context. Remove the card before sending if you change your mind. + +The action is available once the thread's messages are loaded, so open a thread first if the menu +item is grayed out in the sidebar. diff --git a/package.json b/package.json index 7caf8bb08450..eb37559188b9 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dist:desktop:win": "node scripts/build-desktop-artifact.ts --platform win --target nsis", "dist:desktop:win:arm64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch arm64", "dist:desktop:win:x64": "node scripts/build-desktop-artifact.ts --platform win --target nsis --arch x64", + "install:desktop:local": "scripts/install-desktop-local.sh", "release:smoke": "node scripts/release-smoke.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", "sync:repos": "node scripts/sync-reference-repos.ts" diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index 9fe8df570499..65e1a50b1513 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -300,6 +300,43 @@ describe("planPinnedReorder with hidden rows", () => { }); }); +describe("planPinnedReorder with a moved block", () => { + it("keys every member between the block's neighbors, in block order", () => { + const keysById = new Map([ + ["a", "f"], + ["b", "t"], + ["x", "v"], + ["y", "w"], + ]); + const assignments = planPinnedReorder({ + orderedIds: ["a", "x", "y", "b"], + keysById, + movedId: "x", + movedIds: ["x", "y"], + }); + expect(assignments.map(({ id }) => id)).toEqual(["x", "y"]); + const [x, y] = assignments.map(({ orderKey }) => orderKey); + expect(x! > "f" && x! < y! && y! < "t").toBe(true); + }); + + it("rewrites the section when the block is not contiguous", () => { + const keysById = new Map([ + ["a", "f"], + ["x", "v"], + ["y", "w"], + ]); + const assignments = planPinnedReorder({ + orderedIds: ["x", "a", "y"], + keysById, + movedId: "x", + movedIds: ["x", "y"], + }); + expect(assignments.map(({ id }) => id)).toEqual(["x", "a", "y"]); + const keys = assignments.map(({ orderKey }) => orderKey); + expect(keys).toEqual([...keys].sort()); + }); +}); + describe("planPinnedMove", () => { it("moves a thread up with a single key write", () => { const assignments = planPinnedMove({ diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index b6a7264f3052..f43d79244222 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -258,6 +258,10 @@ export function generateSpreadPinOrderKeys(count: number): string[] { * reordering shipped), the whole section gets fresh spread keys — a * one-time materialization; every move after that is single-write. Active * reordering uses the same planner with activeOrderKey values. + * + * A contiguous block of moved threads (the web sidebar's worktree card) + * passes `movedIds`: every member gets its own key between the block's two + * neighbors, in block order, so the members stay adjacent on every client. */ export function planPinnedReorder(input: { /** Thread ids in the desired visual order (after the move). */ @@ -265,24 +269,36 @@ export function planPinnedReorder(input: { /** Include retained keys from hidden rows; only orderedIds receive writes. */ readonly keysById: ReadonlyMap; readonly movedId: string; + /** Contiguous run in orderedIds that moved together; must include movedId. */ + readonly movedIds?: readonly string[]; }): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { const { orderedIds, keysById, movedId } = input; + const movedIds = input.movedIds ?? [movedId]; const visibleIds = new Set(orderedIds); const reservedKeys = new Set( [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), ); - const movedIndex = orderedIds.indexOf(movedId); - if (movedIndex === -1) return []; - const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; - const afterId = movedIndex < orderedIds.length - 1 ? orderedIds[movedIndex + 1] : null; + const firstIndex = orderedIds.indexOf(movedIds[0] ?? movedId); + if (firstIndex === -1 || !movedIds.includes(movedId)) return []; + const lastIndex = firstIndex + movedIds.length - 1; + const contiguous = movedIds.every((id, offset) => orderedIds[firstIndex + offset] === id); + const beforeId = firstIndex > 0 ? orderedIds[firstIndex - 1] : null; + const afterId = lastIndex < orderedIds.length - 1 ? orderedIds[lastIndex + 1] : null; const beforeKey = beforeId != null ? (keysById.get(beforeId) ?? null) : null; const afterKey = afterId != null ? (keysById.get(afterId) ?? null) : null; const beforeUsable = beforeId === null || beforeKey != null; const afterUsable = afterId === null || afterKey != null; - if (beforeUsable && afterUsable) { - let key = pinOrderKeyBetween(beforeKey, afterKey); - while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); - if (key !== null) return [{ id: movedId, orderKey: key }]; + if (contiguous && beforeUsable && afterUsable) { + const assignments: { id: string; orderKey: string }[] = []; + let previous = beforeKey; + for (const id of movedIds) { + let key = pinOrderKeyBetween(previous, afterKey); + while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); + if (key === null) break; + assignments.push({ id, orderKey: key }); + previous = key; + } + if (assignments.length === movedIds.length) return assignments; } // Keyless neighbor (or corrupt keys): rewrite the section in the new order. const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 239f9905c534..97da8c473f53 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -91,6 +91,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "composer.branch", "chat.new", "chat.newLocal", + "chat.newInWorkspace", "editor.openFavorite", "usage.cost", "usage.tokens", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1331d2dcb30d..4a2b7c8ecbf7 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -271,16 +271,28 @@ describe("ClientSettings notifications", () => { }); describe("ClientSettings default diff file state", () => { - it("keeps files collapsed when existing settings omit the preference", () => { - expect(decodeClientSettings({}).diffFilesCollapsed).toBe(true); + it("folds every file when existing settings omit the preference", () => { + expect(decodeClientSettings({}).diffFilesCollapsed).toBe("all"); }); - it.each([true, false])("preserves a saved collapsed preference of %s", (diffFilesCollapsed) => { - const settings = decodeClientSettings({ diffFilesCollapsed }); - expect(encodeClientSettings(settings).diffFilesCollapsed).toBe(diffFilesCollapsed); - expect(decodeClientSettingsPatch({ diffFilesCollapsed }).diffFilesCollapsed).toBe( - diffFilesCollapsed, - ); + it.each(["all", "none", "viewed"] as const)( + "preserves a saved mode of %s", + (diffFilesCollapsed) => { + const settings = decodeClientSettings({ diffFilesCollapsed }); + expect(encodeClientSettings(settings).diffFilesCollapsed).toBe(diffFilesCollapsed); + expect(decodeClientSettingsPatch({ diffFilesCollapsed }).diffFilesCollapsed).toBe( + diffFilesCollapsed, + ); + }, + ); + + it.each([ + [true, "all"], + [false, "none"], + ] as const)("reads the legacy boolean %s as %s and saves it back as the mode", (legacy, mode) => { + const settings = decodeClientSettings({ diffFilesCollapsed: legacy }); + expect(settings.diffFilesCollapsed).toBe(mode); + expect(encodeClientSettings(settings).diffFilesCollapsed).toBe(mode); }); }); @@ -586,6 +598,16 @@ describe("ClientSettings sidebar", () => { expect(decoded).not.toHaveProperty("sidebarV2ConfiguredByUser"); }); + it("groups worktree threads by default and accepts the toggle off", () => { + expect(decodeClientSettings({}).sidebarGroupWorktreeThreads).toBe(true); + expect( + decodeClientSettings({ sidebarGroupWorktreeThreads: false }).sidebarGroupWorktreeThreads, + ).toBe(false); + expect( + decodeClientSettingsPatch({ sidebarGroupWorktreeThreads: false }).sidebarGroupWorktreeThreads, + ).toBe(false); + }); + it("drops the retired compact sidebar keys for users who opted in", () => { const stored = { compactSidebarEnabled: true, sidebarCompactThreadRows: true }; const decoded = decodeClientSettings(stored); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e8dc37bfc839..0275f1e5b8cd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -52,6 +52,25 @@ export const DiffLayout = Schema.Literals(["stacked", "split"]); export type DiffLayout = typeof DiffLayout.Type; const DEFAULT_DIFF_LAYOUT: DiffLayout = "stacked"; +/** Which files a diff opens folded: every file, none, or only those ticked off as viewed. */ +export const DiffFilesCollapsed = Schema.Literals(["all", "none", "viewed"]); +export type DiffFilesCollapsed = typeof DiffFilesCollapsed.Type; +const DEFAULT_DIFF_FILES_COLLAPSED: DiffFilesCollapsed = "all"; + +// Older settings files saved a boolean. It decodes to the equivalent mode and +// encodes back as the canonical string value. +const LegacyDiffFilesCollapsed = Schema.Boolean.pipe( + Schema.decodeTo( + DiffFilesCollapsed, + SchemaTransformation.transform({ + decode: (collapsed): DiffFilesCollapsed => (collapsed ? "all" : "none"), + encode: (mode) => mode === "all", + }), + ), +); + +const DiffFilesCollapsedSetting = Schema.Union([DiffFilesCollapsed, LegacyDiffFilesCollapsed]); + export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; @@ -154,6 +173,8 @@ export const TerminalFontSize = Schema.Int.check( export type TerminalFontSize = typeof TerminalFontSize.Type; const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 12; +export const DEFAULT_SIDEBAR_GROUP_WORKTREE_THREADS = true; + export const EnvironmentIdentificationMode = Schema.Literals(["artwork", "pill", "none"]); export type EnvironmentIdentificationMode = typeof EnvironmentIdentificationMode.Type; export const DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE: EnvironmentIdentificationMode = "artwork"; @@ -249,6 +270,14 @@ const QuitConfirmationModeSetting = Schema.Union([QuitConfirmationMode, LegacyCo export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)); export type FontFamilyPreference = typeof FontFamilyPreference.Type; +/** + * RegExp source (JavaScript syntax). External links whose full URL matches + * open in the integrated browser instead of the system browser. Empty means + * off. Only meaningful on desktop, where the integrated browser exists. + */ +export const OpenLinksInPreviewPattern = Schema.String.check(Schema.isMaxLength(500)); +export type OpenLinksInPreviewPattern = typeof OpenLinksInPreviewPattern.Type; + /** * The environment's theme, set with `t3 theme set `. Each client applies * it once per value — live when connected, on its next connect otherwise — so @@ -295,6 +324,14 @@ export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); export const ChatWidth = Schema.Literals(["comfortable", "wide", "full"]); export type ChatWidth = typeof ChatWidth.Type; +/** + * Where a message the user just sent settles in the timeline. `bottom` keeps + * classic chat behavior at the live edge; `top` holds the message near the top + * of the viewport so its turn reads downward from it. + */ +export const ChatTurnAnchor = Schema.Literals(["bottom", "top"]); +export type ChatTurnAnchor = typeof ChatTurnAnchor.Type; + export const ClientSettingsSchema = Schema.Struct({ notificationMode: NotificationMode.pipe( Schema.withDecodingDefault(Effect.succeed("off" as const)), @@ -304,6 +341,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed("red-green" as const)), ), chatWidth: ChatWidth.pipe(Schema.withDecodingDefault(Effect.succeed("comfortable" as const))), + chatTurnAnchor: ChatTurnAnchor.pipe( + Schema.withDecodingDefault(Effect.succeed("bottom" as const)), + ), loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), appearanceContrast: AppearanceContrast.pipe( @@ -371,7 +411,9 @@ export const ClientSettingsSchema = Schema.Struct({ dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), - diffFilesCollapsed: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + diffFilesCollapsed: DiffFilesCollapsedSetting.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_FILES_COLLAPSED)), + ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffLayout: DiffLayout.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_DIFF_LAYOUT))), environmentIdentificationMode: EnvironmentIdentificationMode.pipe( @@ -422,6 +464,9 @@ export const ClientSettingsSchema = Schema.Struct({ model: TrimmedNonEmptyString, }), ).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + openLinksInPreviewPattern: OpenLinksInPreviewPattern.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + ), providerModelPreferences: Schema.Record( ProviderInstanceId, Schema.Struct({ @@ -455,11 +500,6 @@ export const ClientSettingsSchema = Schema.Struct({ ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - // Legacy sidebar (the original per-project tree). Deliberately a fresh key - // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the - // old keys, so everyone, including prior beta opt-outs, resets to the new - // default sidebar. - legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -476,6 +516,17 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), ), + // Legacy sidebar (the original per-project tree). Deliberately a fresh key + // (was `sidebarV2Enabled` + `sidebarV2ConfiguredByUser`): decoding drops the + // old keys, so everyone, including prior beta opt-outs, resets to the new + // default sidebar. + legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Default sidebar only: threads that share a worktree render as one card + // (see docs/user/thread-sidebar.md "Worktree cards"). Fork-only; upstream + // behavior is the toggle off. + sidebarGroupWorktreeThreads: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_GROUP_WORKTREE_THREADS)), + ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -1572,6 +1623,7 @@ export const ClientSettingsPatch = Schema.Struct({ inAppNotificationsEnabled: Schema.optionalKey(Schema.Boolean), diffColorScheme: Schema.optionalKey(DiffColorScheme), chatWidth: Schema.optionalKey(ChatWidth), + chatTurnAnchor: Schema.optionalKey(ChatTurnAnchor), loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), appearanceContrast: Schema.optionalKey(AppearanceContrast), @@ -1590,7 +1642,7 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), - diffFilesCollapsed: Schema.optionalKey(Schema.Boolean), + diffFilesCollapsed: Schema.optionalKey(DiffFilesCollapsed), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), @@ -1613,6 +1665,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + openLinksInPreviewPattern: Schema.optionalKey(OpenLinksInPreviewPattern), providerModelPreferences: Schema.optionalKey( Schema.Record( ProviderInstanceId, @@ -1637,7 +1690,6 @@ export const ClientSettingsPatch = Schema.Struct({ followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), - legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), @@ -1645,6 +1697,8 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), + legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), + sidebarGroupWorktreeThreads: Schema.optionalKey(Schema.Boolean), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), diff --git a/packages/shared/src/chatList.test.ts b/packages/shared/src/chatList.test.ts index 78cf90f2aa88..6a0d582910e9 100644 --- a/packages/shared/src/chatList.test.ts +++ b/packages/shared/src/chatList.test.ts @@ -16,13 +16,20 @@ const rows: ReadonlyArray = [ const getAnchorId = (row: Row) => (row.anchorable ? row.id : null); describe("resolveChatListAnchoredEndSpace", () => { - it("anchors only the first eligible row", () => { + it("anchors the first eligible row", () => { expect(resolveChatListAnchoredEndSpace(rows, "first", getAnchorId)).toEqual({ anchorIndex: 0, anchorOffset: CHAT_LIST_ANCHOR_OFFSET, }); }); + it("anchors a later eligible row for follow-up sends", () => { + expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toEqual({ + anchorIndex: 2, + anchorOffset: CHAT_LIST_ANCHOR_OFFSET, + }); + }); + it("allows a surface to keep the anchor below its own header", () => { expect( resolveChatListAnchoredEndSpace(rows, "first", getAnchorId, { @@ -34,17 +41,6 @@ describe("resolveChatListAnchoredEndSpace", () => { }); }); - it("does not reserve end space for later eligible rows", () => { - expect(resolveChatListAnchoredEndSpace(rows, "latest", getAnchorId)).toBeUndefined(); - }); - - it("skips ineligible rows before the first anchor", () => { - expect(resolveChatListAnchoredEndSpace(rows.slice(1), "latest", getAnchorId)).toEqual({ - anchorIndex: 1, - anchorOffset: CHAT_LIST_ANCHOR_OFFSET, - }); - }); - it("ignores ineligible rows and missing anchors", () => { expect(resolveChatListAnchoredEndSpace(rows, "ignored", getAnchorId)).toBeUndefined(); expect(resolveChatListAnchoredEndSpace(rows, "missing", getAnchorId)).toBeUndefined(); diff --git a/packages/shared/src/chatList.ts b/packages/shared/src/chatList.ts index adfdcad4eaf2..32b143196a84 100644 --- a/packages/shared/src/chatList.ts +++ b/packages/shared/src/chatList.ts @@ -9,6 +9,11 @@ export interface ChatListAnchorOptions { readonly anchorOffset?: number; } +/** + * Locates the row a chat list should hold near the top of the viewport while + * its turn streams in below. Callers decide which sent message (if any) gets + * anchored; this only maps that id to the LegendList `anchoredEndSpace` config. + */ export function resolveChatListAnchoredEndSpace( items: ReadonlyArray, anchorId: AnchorId | null, @@ -19,24 +24,8 @@ export function resolveChatListAnchoredEndSpace( return undefined; } - for (let index = 0; index < items.length; index += 1) { - const item = items[index]; - if (item === undefined) { - continue; - } - - const itemAnchorId = getAnchorId(item); - if (itemAnchorId === null) { - continue; - } - - return itemAnchorId === anchorId - ? { - anchorIndex: index, - anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET, - } - : undefined; - } - - return undefined; + const anchorIndex = items.findIndex((item) => getAnchorId(item) === anchorId); + return anchorIndex === -1 + ? undefined + : { anchorIndex, anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET }; } diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 61d183141797..67bdd521c07d 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -11,6 +11,11 @@ import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; +// Scratch directory t3code provisions in every worktree it creates. It is +// git-ignored via the repo's info/exclude (see +// GitVcsDriverCore.provisionContextDirectory), so agents can hand the user +// plans and other files there without dirtying git status. +export const WORKTREE_CONTEXT_DIRECTORY_NAME = ".context"; // Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` // via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly // that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8029be96f939..6443b72f0f92 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -49,6 +49,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "mod+t", command: "chat.newInWorkspace", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, diff --git a/scripts/install-desktop-local.sh b/scripts/install-desktop-local.sh new file mode 100755 index 000000000000..e1210033cf34 --- /dev/null +++ b/scripts/install-desktop-local.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# Build the desktop app and install it locally, replacing any existing copy. +# Picks the install method from the host OS: macOS unpacks the .app into +# /Applications, Linux drops the AppImage in ~/Applications (override with +# T3_APPIMAGE_DIR) and registers a desktop entry under ~/.local/share. +# Skips the build with --skip-build to just reinstall the most recent +# release/ artifact. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# Set by the Linux path. The trap outlives the function that fills it in, so +# this cannot be a local. +WORK_DIR="" +cleanup() { + if [ -n "$WORK_DIR" ]; then + rm -rf "$WORK_DIR" + fi +} +trap cleanup EXIT + +SKIP_BUILD=false +for arg in "$@"; do + case "$arg" in + --skip-build) SKIP_BUILD=true ;; + *) + echo "Unknown argument: $arg" >&2 + exit 1 + ;; + esac +done + +install_macos() { + if [ "$SKIP_BUILD" = false ]; then + pnpm dist:desktop:dmg:arm64 + fi + + local zip_path + zip_path="$(ls -t release/T3-Code-*-arm64.zip 2>/dev/null | head -n 1 || true)" + if [ -z "$zip_path" ]; then + echo "No release/T3-Code-*-arm64.zip found. Run without --skip-build first." >&2 + exit 1 + fi + + set +o pipefail + local app_name + app_name="$(unzip -Z1 "$zip_path" | head -n 1 | cut -d/ -f1)" + set -o pipefail + local dest="/Applications/$app_name" + + echo "Installing $app_name from $(basename "$zip_path")..." + + if [ -d "$dest" ]; then + rm -rf "$dest" + fi + + ditto -xk "$zip_path" /Applications/ + xattr -cr "$dest" + + echo "Installed to $dest" +} + +install_linux() { + local arch + case "$(uname -m)" in + x86_64) arch=x64 ;; + aarch64 | arm64) arch=arm64 ;; + *) + echo "Unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; + esac + + if [ "$SKIP_BUILD" = false ]; then + # dist:desktop:linux pins x64, so drive the builder directly to cover arm64. + node scripts/build-desktop-artifact.ts --platform linux --target AppImage --arch "$arch" + fi + + # electron-builder renders ${arch} as the AppImage spelling (x86_64), which + # does not match the --arch flag, so match on extension and take the newest. + local appimage + appimage="$(ls -t release/T3-Code-*.AppImage 2>/dev/null | head -n 1 || true)" + if [ -z "$appimage" ]; then + echo "No release/T3-Code-*.AppImage found. Run without --skip-build first." >&2 + exit 1 + fi + + # ~/Applications is the AppImage convention on atomic Fedora desktops like + # Bluefin. Everything here lands under $HOME, which is /var/home, so it + # survives image updates and rebases -- unlike anything layered into /usr. + local app_dir="${T3_APPIMAGE_DIR:-$HOME/Applications}" + local data_dir="${XDG_DATA_HOME:-$HOME/.local/share}" + local desktop_dir="$data_dir/applications" + local icon_dir="$data_dir/icons" + # A version-free filename keeps the desktop entry and the t3code:// handler + # the app registers at startup pointing at a path that survives a rebuild. + local dest="$app_dir/T3-Code.AppImage" + + mkdir -p "$app_dir" "$desktop_dir" "$icon_dir" + + echo "Installing $(basename "$appimage") to $dest..." + + # Stage then rename so a running instance keeps its current inode rather + # than having the mounted image truncated underneath it. + cp "$appimage" "$dest.new" + chmod 755 "$dest.new" + mv -f "$dest.new" "$dest" + + WORK_DIR="$(mktemp -d)" + + # The AppImage already carries the entry electron-builder generated, with the + # channel-correct Name, StartupWMClass and the x-scheme-handler MimeTypes. + # Reuse it instead of restating that metadata here, where it would drift. + (cd "$WORK_DIR" && "$dest" --appimage-extract '*.desktop' >/dev/null && "$dest" --appimage-extract '*.png' >/dev/null) + + local src_desktop + src_desktop="$(ls "$WORK_DIR"/squashfs-root/*.desktop 2>/dev/null | head -n 1 || true)" + if [ -z "$src_desktop" ]; then + echo "Could not extract a desktop entry from $dest." >&2 + exit 1 + fi + + local icon_name + icon_name="$(sed -n 's/^Icon=//p' "$src_desktop" | head -n 1)" + + # The icon at the image root is a symlink into usr/share/icons and the + # extract patterns do not match across directories, so the first pass only + # produced a dangling link. Resolve it and pull the target out separately. + local src_icon="$WORK_DIR/squashfs-root/$icon_name.png" + if [ -L "$src_icon" ] && [ ! -f "$src_icon" ]; then + local icon_rel + icon_rel="$(readlink "$src_icon")" + (cd "$WORK_DIR" && "$dest" --appimage-extract "$icon_rel" >/dev/null) + fi + if [ ! -f "$src_icon" ]; then + echo "Could not extract an icon from $dest." >&2 + exit 1 + fi + + local icon_path="$icon_dir/$icon_name.png" + install -m 644 "$src_icon" "$icon_path" + + # Exec runs AppRun, which only exists inside the mounted image, and Icon + # names a theme entry we never install, so both become absolute paths here. + # Only the program token is replaced: the arguments carry --no-sandbox, + # which Electron needs because its SUID helper cannot work from squashfs. + local desktop_path="$desktop_dir/$icon_name.desktop" + sed -e "s|^Exec=[^ ]*|Exec=\"$dest\"|" \ + -e "s|^Icon=.*|Icon=$icon_path|" \ + "$src_desktop" >"$desktop_path" + + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$desktop_dir" || true + fi + + echo "Installed to $dest" + echo "Desktop entry: $desktop_path" +} + +case "$(uname -s)" in + Darwin) install_macos ;; + Linux) install_linux ;; + *) + echo "Unsupported platform: $(uname -s)" >&2 + exit 1 + ;; +esac