From 444ab4271a882be84c24ff224f07224b34847179 Mon Sep 17 00:00:00 2001 From: Adam Higgins Date: Tue, 13 Oct 2026 16:55:25 +0100 Subject: [PATCH] feat: reference local checkouts in a message or link them to a thread A checkout is a folder already on disk, so it stays separate from a repository attachment that clones on send. The # menu, paperclip, and mobile attach menu share one picker, and linked checkouts are added to later turns. Co-authored-by: Cursor --- .../src/components/CheckoutPickerSheet.tsx | 197 ++++++++++++ .../components/ComposerAttachmentButton.tsx | 10 + .../features/threads/NewTaskDraftScreen.tsx | 12 + .../src/features/threads/ThreadComposer.tsx | 9 + .../src/lib/composerChipPresentation.ts | 1 + apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/checkouts/CheckoutInspect.ts | 115 +++++++ .../src/observability/RpcInstrumentation.ts | 1 + .../Adapters/ClaudeAdapterV2.ts | 5 + .../src/orchestration-v2/Orchestrator.ts | 33 ++ .../src/orchestration-v2/ProjectionStore.ts | 3 + .../ProviderTurnStartService.ts | 23 +- .../orchestration-v2/RunExecutionService.ts | 5 + .../testkit/OrchestratorScenario.ts | 3 + apps/server/src/server.ts | 4 + apps/server/src/ws.ts | 3 + apps/web/src/checkoutContextStore.ts | 47 +++ apps/web/src/components/ChatView.tsx | 7 + apps/web/src/components/ContextChip.tsx | 3 + apps/web/src/components/chat/ChatComposer.tsx | 49 ++- .../components/chat/CheckoutAttachPicker.tsx | 270 ++++++++++++++++ .../components/chat/ComposerAttachMenu.tsx | 11 +- .../components/chat/ComposerCommandMenu.tsx | 65 ++++ .../src/components/chat/MessagesTimeline.tsx | 56 ++++ .../components/chat/ThreadDetailsPanel.tsx | 5 + .../components/chat/ThreadLinkedCheckouts.tsx | 140 ++++++++ .../chat/composerReferenceMenu.logic.test.ts | 30 ++ .../chat/composerReferenceMenu.logic.ts | 22 +- .../chat/useComposerCheckoutItems.ts | 198 ++++++++++++ .../composerContextPresentation.tsx | 25 ++ apps/web/src/components/contextChipParts.tsx | 45 +++ .../components/contextPresentationRegistry.ts | 4 + apps/web/src/lib/composerContextRecords.ts | 3 + docs/fork-differences.md | 12 + docs/user/composer.md | 18 +- .../client-runtime/src/operations/commands.ts | 42 +++ .../client-runtime/src/state/sourceControl.ts | 4 + .../src/state/threadCommands.ts | 24 ++ packages/contracts/src/checkouts.ts | 58 ++++ .../contracts/src/composerContext.test.ts | 12 + packages/contracts/src/composerContext.ts | 21 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestrationV2.ts | 25 ++ packages/contracts/src/rpc.ts | 10 + .../src/server/ProviderAdapter.ts | 6 + packages/shared/package.json | 4 + packages/shared/src/checkouts.test.ts | 119 +++++++ packages/shared/src/checkouts.ts | 303 ++++++++++++++++++ .../shared/src/composerContextReferences.ts | 3 + 49 files changed, 2055 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/components/CheckoutPickerSheet.tsx create mode 100644 apps/server/src/checkouts/CheckoutInspect.ts create mode 100644 apps/web/src/checkoutContextStore.ts create mode 100644 apps/web/src/components/chat/CheckoutAttachPicker.tsx create mode 100644 apps/web/src/components/chat/ThreadLinkedCheckouts.tsx create mode 100644 apps/web/src/components/chat/useComposerCheckoutItems.ts create mode 100644 packages/contracts/src/checkouts.ts create mode 100644 packages/shared/src/checkouts.test.ts create mode 100644 packages/shared/src/checkouts.ts diff --git a/apps/mobile/src/components/CheckoutPickerSheet.tsx b/apps/mobile/src/components/CheckoutPickerSheet.tsx new file mode 100644 index 000000000000..097cf6cdb4c0 --- /dev/null +++ b/apps/mobile/src/components/CheckoutPickerSheet.tsx @@ -0,0 +1,197 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; +import { + checkoutAccessForDriver, + checkoutCandidates, + checkoutContextRecord, + checkoutDisplayName, + checkoutPathKey, + looksLikeCheckoutPath, + rankCheckoutCandidates, +} from "@t3tools/shared/checkouts"; +import { useMemo, useState, type ReactNode } from "react"; +import { Alert, FlatList, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../state/entities"; +import { useDebouncedValue } from "../state/queries"; +import { useEnvironmentQuery } from "../state/query"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { + captureComposerDraftInsertion, + insertComposerDraftContext, + type ComposerDraftInsertion, +} from "../state/use-composer-drafts"; +import { AppText as Text, AppTextInput } from "./AppText"; +import { PickerSheet } from "./PickerSheet"; + +export interface CheckoutPickerTarget { + readonly environmentId: EnvironmentId; + readonly draftKey: string; + readonly driver: string | null; +} + +type OpenedCheckoutPicker = CheckoutPickerTarget & { + readonly insertion: ComposerDraftInsertion; +}; + +/** Picks a local checkout to reference in the draft. The owner renders `sheet`. */ +export function useCheckoutPicker(target: CheckoutPickerTarget | null): { + readonly open: () => void; + readonly sheet: ReactNode; +} { + const [opened, setOpened] = useState(null); + return { + open: () => { + if (target) + setOpened({ ...target, insertion: captureComposerDraftInsertion(target.draftKey) }); + }, + sheet: opened ? setOpened(null)} /> : null, + }; +} + +function CheckoutPickerSheet(props: OpenedCheckoutPicker & { readonly onClose: () => void }) { + const insets = useSafeAreaInsets(); + const projects = useProjects(); + const threads = useThreadShells(); + const config = useEnvironmentServerConfig(props.environmentId); + const [query, setQuery] = useState(""); + const candidates = useMemo( + () => + checkoutCandidates({ + currentEnvironmentId: props.environmentId, + projects, + worktrees: threads.map((thread) => ({ + environmentId: thread.environmentId, + worktreePath: thread.worktreePath, + })), + environmentLabel: (id) => + id === props.environmentId + ? (config?.environment.label ?? "This environment") + : "Another environment", + }).filter((candidate) => candidate.selectable), + [config?.environment.label, projects, props.environmentId, threads], + ); + const ranked = useMemo( + () => rankCheckoutCandidates(candidates, looksLikeCheckoutPath(query) ? "" : query, new Map()), + [candidates, query], + ); + const pasted = looksLikeCheckoutPath(query) ? checkoutPathKey(query.trim()) : null; + const paths = useMemo(() => { + const listed = ranked.slice(0, 20).map((candidate) => candidate.path); + if (pasted !== null && !listed.includes(pasted)) listed.unshift(pasted); + return listed; + }, [pasted, ranked]); + const settled = useDebouncedValue(paths.join("\n"), 200); + const inspection = useEnvironmentQuery( + settled.length === 0 + ? null + : sourceControlEnvironment.checkouts({ + environmentId: props.environmentId, + input: { paths: settled.split("\n") }, + }), + ); + const rows = useMemo(() => { + const listed = ranked.slice(0, 20); + if (pasted === null || listed.some((candidate) => checkoutPathKey(candidate.path) === pasted)) { + return listed; + } + return [ + { + environmentId: props.environmentId, + environmentLabel: "", + selectable: true, + path: pasted, + name: checkoutDisplayName(pasted), + kind: "project" as const, + }, + ...listed, + ]; + }, [pasted, props.environmentId, ranked]); + + const attach = (path: string, name: string) => { + const entry = inspection.data?.entries.find( + (candidate) => checkoutPathKey(candidate.path) === checkoutPathKey(path), + ); + const record = checkoutContextRecord({ + path: entry?.path ?? path, + name, + branch: entry?.branch ?? null, + headSha: entry?.headSha ?? null, + changedFiles: entry?.changedFiles ?? 0, + ahead: entry?.ahead ?? 0, + access: checkoutAccessForDriver(props.driver), + environmentId: props.environmentId, + }); + if ( + !insertComposerDraftContext( + props.draftKey, + { + text: formatComposerContextReference(record), + context: { version: 1, records: [record] }, + }, + props.insertion, + ) + ) { + Alert.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + props.onClose(); + }; + + return ( + + + + + row.path} + keyboardShouldPersistTaps="handled" + contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) }} + ListEmptyComponent={ + + + {pasted + ? "Press return to use that path." + : "No local checkouts yet. Paste a path to use a folder."} + + + } + renderItem={({ item }) => { + const entry = inspection.data?.entries.find( + (candidate) => checkoutPathKey(candidate.path) === checkoutPathKey(item.path), + ); + const status = + entry === undefined + ? item.path + : `${entry.branch ?? "detached"} · ${entry.changedFiles === 0 ? "clean" : `${entry.changedFiles} changed`}`; + return ( + attach(item.path, item.name)} + className="gap-0.5 px-4 py-3 active:bg-subtle" + > + + {item.name} + + + {status} + + + ); + }} + /> + + ); +} diff --git a/apps/mobile/src/components/ComposerAttachmentButton.tsx b/apps/mobile/src/components/ComposerAttachmentButton.tsx index 28fe93d63f58..280c9cf3e093 100644 --- a/apps/mobile/src/components/ComposerAttachmentButton.tsx +++ b/apps/mobile/src/components/ComposerAttachmentButton.tsx @@ -16,6 +16,11 @@ const GITHUB_ISSUE_ACTION: MenuAction = { const NOTION_ACTION: MenuAction = { id: "notion", title: "Notion page", image: "doc.text" }; const GRANOLA_ACTION: MenuAction = { id: "granola", title: "Granola note", image: "waveform" }; const SLACK_ACTION: MenuAction = { id: "slack", title: "Slack message", image: "number" }; +const CHECKOUT_ACTION: MenuAction = { + id: "checkout", + title: "Checkout", + image: "laptopcomputer", +}; const REPOSITORY_ACTION: MenuAction = { id: "repository", title: "Repository", @@ -37,6 +42,8 @@ export function ComposerAttachmentButton(props: { readonly onPickGranolaNote?: () => void; /** Omit to hide the Slack action. See `useSlackMessagePicker`. */ readonly onPickSlackMessage?: () => void; + /** Omit to hide the checkout action. See `useCheckoutPicker`. */ + readonly onPickCheckout?: () => void; /** Omit to hide the repository action. See `useRepositoryPicker`. */ readonly onPickRepository?: () => void; }) { @@ -49,6 +56,7 @@ export function ComposerAttachmentButton(props: { ...(props.onPickNotionPage ? [NOTION_ACTION] : []), ...(props.onPickGranolaNote ? [GRANOLA_ACTION] : []), ...(props.onPickSlackMessage ? [SLACK_ACTION] : []), + ...(props.onPickCheckout ? [CHECKOUT_ACTION] : []), ...(props.onPickRepository ? [REPOSITORY_ACTION] : []), ]; const usesMenu = !props.disabled && actions.length > 1; @@ -96,6 +104,8 @@ export function ComposerAttachmentButton(props: { props.onPickGranolaNote?.(); } else if (nativeEvent.event === "slack") { props.onPickSlackMessage?.(); + } else if (nativeEvent.event === "checkout") { + props.onPickCheckout?.(); } else if (nativeEvent.event === "repository") { props.onPickRepository?.(); } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 07b18715699f..591a759c1997 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -64,6 +64,7 @@ import { import { useGranolaNotePicker } from "../../components/GranolaNotePickerSheet"; import { useNotionPagePicker } from "../../components/NotionPagePickerSheet"; import { useSlackMessagePicker } from "../../components/SlackMessagePickerSheet"; +import { useCheckoutPicker } from "../../components/CheckoutPickerSheet"; import { useRepositoryPicker } from "../../components/RepositoryPickerSheet"; import { ControlPillMenu } from "../../components/ControlPillMenu"; import { gitHubIssueEnvironment } from "../../state/githubIssues"; @@ -1105,6 +1106,15 @@ export function NewTaskDraftScreen(props: { } : null, ); + const checkoutPicker = useCheckoutPicker( + flow.draftKey && selectedProject + ? { + environmentId: selectedProject.environmentId, + draftKey: flow.draftKey, + driver: flow.selectedProviderStatus?.driver ?? null, + } + : null, + ); const repositoryPicker = useRepositoryPicker( flow.draftKey && selectedProject ? { @@ -1954,6 +1964,7 @@ export function NewTaskDraftScreen(props: { ? slackMessagePicker.open : undefined } + onPickCheckout={flow.draftKey ? checkoutPicker.open : undefined} onPickRepository={flow.draftKey ? repositoryPicker.open : undefined} /> @@ -2045,6 +2056,7 @@ export function NewTaskDraftScreen(props: { {notionPagePicker.sheet} {granolaNotePicker.sheet} {startFromPullRequestPicker.sheet} + {checkoutPicker.sheet} {repositoryPicker.sheet} ); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7a1b73a9183c..77f08c15ddc5 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -78,6 +78,7 @@ import { useGitHubIssuePicker } from "../../components/GitHubIssuePickerSheet"; import { useGranolaNotePicker } from "../../components/GranolaNotePickerSheet"; import { useNotionPagePicker } from "../../components/NotionPagePickerSheet"; import { useSlackMessagePicker } from "../../components/SlackMessagePickerSheet"; +import { useCheckoutPicker } from "../../components/CheckoutPickerSheet"; import { useRepositoryPicker } from "../../components/RepositoryPickerSheet"; import { ComposerAttachmentStrip, @@ -490,6 +491,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ? { environmentId: props.environmentId, draftKey: composerDraftKey, cwd: workspaceCwd } : null, ); + const checkoutPicker = useCheckoutPicker({ + environmentId: props.environmentId, + draftKey: composerDraftKey, + driver: selectedProviderStatus?.driver ?? null, + }); const repositoryPicker = useRepositoryPicker({ environmentId: props.environmentId, draftKey: composerDraftKey, @@ -929,6 +935,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onPickNotionPage={notionPagePicker.open} onPickGranolaNote={granolaNotePicker.open} onPickSlackMessage={slackMessagePicker.open} + onPickCheckout={checkoutPicker.open} onPickRepository={repositoryPicker.open} /> ) : null} @@ -1213,6 +1220,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onPickNotionPage={notionPagePicker.open} onPickGranolaNote={granolaNotePicker.open} onPickSlackMessage={slackMessagePicker.open} + onPickCheckout={checkoutPicker.open} onPickRepository={repositoryPicker.open} /> @@ -1278,6 +1286,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {slackMessagePicker.sheet} {notionPagePicker.sheet} {granolaNotePicker.sheet} + {checkoutPicker.sheet} {repositoryPicker.sheet} ); diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index 260888ebb874..be6ac40af846 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -21,6 +21,7 @@ const CONTEXT_CHIP_PRESENTATIONS = { "github-issue": { accent: "#009f6e", symbol: "smallcircle.filled.circle" }, "slack-thread": { accent: "#c6589c", symbol: "number" }, repository: { accent: "#009c9c", symbol: "folder" }, + checkout: { accent: "#0096af", symbol: "laptopcomputer" }, } as const; /** diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index aec50804ea8e..27a4f79807f1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -154,6 +154,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.contextRepositoriesList]: AuthOrchestrationReadScope, [WS_METHODS.contextRepositoriesInspect]: AuthOrchestrationReadScope, [WS_METHODS.contextRepositoriesRemove]: AuthSourceControlWriteScope, + [WS_METHODS.checkoutsInspect]: AuthOrchestrationReadScope, // Reading issues is a read; connecting or disconnecting the environment's // Linear account changes what every client of this environment can reach. [WS_METHODS.linearSubscribeState]: AuthOrchestrationReadScope, diff --git a/apps/server/src/checkouts/CheckoutInspect.ts b/apps/server/src/checkouts/CheckoutInspect.ts new file mode 100644 index 000000000000..797533ff95fb --- /dev/null +++ b/apps/server/src/checkouts/CheckoutInspect.ts @@ -0,0 +1,115 @@ +import { + CheckoutInspectEntry, + type CheckoutInspectInput, + type CheckoutInspectResult, +} from "@t3tools/contracts"; +import { checkoutDisplayName, checkoutPathKey } from "@t3tools/shared/checkouts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +/** + * Reads git state for paths the client already named. It does not search the disk + * for repositories. + */ +export class CheckoutInspect extends Context.Service< + CheckoutInspect, + { + readonly inspect: (input: CheckoutInspectInput) => Effect.Effect; + } +>()("t3/checkouts/CheckoutInspect") {} + +const make = Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + const readEntry = (rawPath: string) => + Effect.gen(function* () { + const path = checkoutPathKey(rawPath); + const realPath = yield* fileSystem.realPath(path).pipe(Effect.orElseSucceed(() => null)); + if (realPath === null) { + return emptyEntry(path); + } + const status = yield* git + .statusDetailsLocal(realPath, { includeDivergence: false }) + .pipe(Effect.orElseSucceed(() => null)); + const head = yield* git + .execute({ + operation: "CheckoutInspect.head", + cwd: realPath, + args: ["log", "-1", "--format=%H%x00%s"], + allowNonZeroExit: true, + timeoutMs: 10_000, + maxOutputBytes: 8_192, + }) + .pipe(Effect.orElseSucceed(() => null)); + const commonDir = yield* git + .execute({ + operation: "CheckoutInspect.commonDir", + cwd: realPath, + args: ["rev-parse", "--path-format=absolute", "--git-common-dir"], + allowNonZeroExit: true, + timeoutMs: 10_000, + maxOutputBytes: 4_096, + }) + .pipe(Effect.orElseSucceed(() => null)); + const [headSha, headSubject] = splitHead(head); + const repositoryKey = + commonDir !== null && commonDir.exitCode === 0 && commonDir.stdout.trim().length > 0 + ? commonDir.stdout.trim() + : null; + const isRepo = status?.isRepo === true || repositoryKey !== null; + return entry({ + path: realPath, + exists: true, + isRepo, + name: checkoutDisplayName(realPath), + branch: status?.branch ?? null, + headSha, + headSubject, + changedFiles: status?.workingTree.files.length ?? 0, + ahead: status?.aheadCount ?? 0, + repositoryKey: isRepo ? repositoryKey : null, + }); + }); + + return CheckoutInspect.of({ + inspect: (input) => + Effect.gen(function* () { + const entries = yield* Effect.forEach(input.paths, readEntry, { concurrency: 4 }); + return { entries }; + }), + }); +}); + +function emptyEntry(path: string): CheckoutInspectEntry { + return entry({ + path, + exists: false, + isRepo: false, + name: checkoutDisplayName(path), + branch: null, + headSha: null, + headSubject: null, + changedFiles: 0, + ahead: 0, + repositoryKey: null, + }); +} + +function entry(value: CheckoutInspectEntry): CheckoutInspectEntry { + return value; +} + +function splitHead( + result: { readonly exitCode: number; readonly stdout: string } | null, +): [string | null, string | null] { + if (result === null || result.exitCode !== 0) return [null, null]; + const [sha, subject] = result.stdout.trim().split("\0"); + return [sha && sha.length > 0 ? sha : null, subject && subject.length > 0 ? subject : null]; +} + +export const layer = Layer.effect(CheckoutInspect, make); diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 750ce90725e7..632623a93db9 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -19,6 +19,7 @@ const RPC_AGGREGATES = { [WS_METHODS.serverGetResourceUsage]: "server", [WS_METHODS.contextRepositoriesList]: "source-control", [WS_METHODS.contextRepositoriesInspect]: "source-control", + [WS_METHODS.checkoutsInspect]: "source-control", [WS_METHODS.contextRepositoriesRemove]: "source-control", [WS_METHODS.linearSubscribeState]: "linear", [WS_METHODS.linearStartLogin]: "linear", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 6ec8a33c3c21..edd42754ac8f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -974,6 +974,7 @@ export function makeClaudeQueryOptions(input: { * state.sqlite stay ungranted. */ readonly attachmentsDir?: string; + readonly additionalDirectories?: ReadonlyArray; readonly settings?: ClaudeSettings; readonly sdkSettings?: string | ClaudeSdkSettings; readonly environment?: NodeJS.ProcessEnv; @@ -1082,6 +1083,7 @@ export function makeClaudeQueryOptions(input: { const additionalDirectories = [ ...(input.cwd === null ? [] : [input.cwd]), ...(input.attachmentsDir === undefined ? [] : [input.attachmentsDir]), + ...(input.additionalDirectories ?? []), ]; const withDirectories = additionalDirectories.length === 0 ? options : { ...options, additionalDirectories }; @@ -8647,6 +8649,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), cwd: turnInput.runtimePolicy.cwd, attachmentsDir, + ...(turnInput.additionalDirectories === undefined + ? {} + : { additionalDirectories: turnInput.additionalDirectories }), settings: adapterOptions.settings, environment: adapterOptions.environment, tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index f5a34d186bc2..524e5d1a6180 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -10,6 +10,7 @@ import { USAGE_LIMIT_CONTINUATION_TEXT, usageLimitBlockedRun, } from "@t3tools/shared/orchestrationV2ThreadError"; +import { linkedCheckoutsAfter } from "@t3tools/shared/checkouts"; import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; import { normalizeThreadPullRequestKey, @@ -457,6 +458,9 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.mark-unread": case "thread.metadata.update": case "thread.usage-limit.resume-now": + case "thread.checkout.link": + case "thread.checkout.unlink": + case "thread.checkout.relocate": case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": @@ -2477,6 +2481,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.active.reorder" | "thread.mark-unread" | "thread.metadata.update" + | "thread.checkout.link" + | "thread.checkout.unlink" + | "thread.checkout.relocate" | "thread.pull-request.link" | "thread.pull-request.unlink" | "thread.pull-request-link.sync" @@ -3108,6 +3115,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio updatedAt: now, }; } + case "thread.checkout.link": + case "thread.checkout.unlink": + case "thread.checkout.relocate": { + const next = linkedCheckoutsAfter(thread.linkedCheckouts ?? [], { + type: + command.type === "thread.checkout.link" + ? "link" + : command.type === "thread.checkout.unlink" + ? "unlink" + : "relocate", + path: command.path, + ...(command.type === "thread.checkout.relocate" ? { fromPath: command.fromPath } : {}), + ...(command.type === "thread.checkout.unlink" ? {} : { name: command.name }), + ...(command.type === "thread.checkout.link" + ? { linkedAt: DateTime.formatIso(now) } + : {}), + }); + if (!next.changed) return thread; + return { ...thread, linkedCheckouts: [...next.links], updatedAt: now }; + } case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": { @@ -3349,6 +3376,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return "thread.marked-unread" as const; case "thread.metadata.update": case "thread.title.regeneration.complete": + case "thread.checkout.link": + case "thread.checkout.unlink": + case "thread.checkout.relocate": return "thread.metadata-updated" as const; case "thread.pull-request.link": case "thread.pull-request.unlink": @@ -11106,6 +11136,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.active.reorder": case "thread.mark-unread": case "thread.metadata.update": + case "thread.checkout.link": + case "thread.checkout.unlink": + case "thread.checkout.relocate": case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index b9233c1eae6f..5a5156e607af 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1491,6 +1491,9 @@ export function threadShellFromProjection( ...(projection.thread.linkedPullRequest === undefined ? {} : { linkedPullRequest: projection.thread.linkedPullRequest }), + ...(projection.thread.linkedCheckouts === undefined + ? {} + : { linkedCheckouts: projection.thread.linkedCheckouts }), ...(projection.thread.branchPullRequest === undefined ? {} : { branchPullRequest: projection.thread.branchPullRequest }), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 4db4d74836f6..8cd3b6b5f99f 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1,4 +1,5 @@ import { modelSelectionsEqual } from "@t3tools/shared/model"; +import { checkoutDirectoriesForTurn, formatLinkedCheckoutPrompt } from "@t3tools/shared/checkouts"; import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; import { CommandId, @@ -957,9 +958,26 @@ export const layer: Layer.Layer< const routableSubagents = projection.subagents.filter((subagent) => RunExecutionService.canRouteRelatedSubagent(subagent.status), ); - const userText = projectComposerContextForProvider({ + const contextRecords = message.context?.records ?? []; + const projectedUserText = projectComposerContextForProvider({ text: message.text, - records: message.context?.records ?? [], + records: contextRecords, + }); + const linkedCheckouts = projection.thread.linkedCheckouts ?? []; + const checkoutAccess = session.driver === "claudeAgent" ? "read" : "path-only"; + const linkedCheckoutPrompt = formatLinkedCheckoutPrompt( + linkedCheckouts, + contextRecords, + checkoutAccess, + ); + const userText = + linkedCheckoutPrompt === "" + ? projectedUserText + : `${projectedUserText}\n\n${linkedCheckoutPrompt}`; + const checkoutDirectories = checkoutDirectoriesForTurn({ + driver: session.driver, + linked: linkedCheckouts, + records: contextRecords, }); // Delivered once: this run's provider turn marks the work as told. A // restart continuation is prompted by its own text or resumes natively. @@ -1288,6 +1306,7 @@ export const layer: Layer.Layer< }, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, + ...(checkoutDirectories.length === 0 ? {} : { additionalDirectories: checkoutDirectories }), }); }); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index b2fe77f6cd85..f6db79bca3c5 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -544,6 +544,8 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly message: ProviderAdapter.ProviderAdapterV2TurnMessage; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + /** Folders the provider may read in addition to the workspace. Claude uses these. */ + readonly additionalDirectories?: ReadonlyArray; } export interface RunExecutionServiceV2Shape { @@ -1435,6 +1437,9 @@ export const layer: Layer.Layer< modelSelection: input.modelSelection, runtimePolicy: input.runtimePolicy, ...(appContext.length === 0 ? {} : { appContext }), + ...(input.additionalDirectories === undefined + ? {} + : { additionalDirectories: input.additionalDirectories }), }; const compact = input.message.attachments.length === 0 && diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 3bf2fd6c5e21..2fb91e449ed5 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -153,6 +153,9 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray()); @@ -2670,6 +2672,7 @@ const layerWsRpc = ( withPullRequestViewer(input, pullRequests.setLabels(input)), [WS_METHODS.sourceControlLookupRepository]: (input) => sourceControlRepositories.lookupRepository(input), + [WS_METHODS.checkoutsInspect]: (input) => checkoutInspect.inspect(input), [WS_METHODS.contextRepositoriesList]: (input) => contextRepositories.list(input), [WS_METHODS.contextRepositoriesInspect]: (input) => Effect.flatMap(contextRepositoryDirectory, (directory) => diff --git a/apps/web/src/checkoutContextStore.ts b/apps/web/src/checkoutContextStore.ts new file mode 100644 index 000000000000..cf5a3d074e66 --- /dev/null +++ b/apps/web/src/checkoutContextStore.ts @@ -0,0 +1,47 @@ +import { create } from "zustand"; + +import type { CheckoutContextRecord, ThreadId } from "@t3tools/contracts"; + +const EMPTY: ReadonlyArray = []; + +/** + * Checkouts attached to a draft, keyed by the thread being composed in. The prompt owns where + * each chip sits; this holds the snapshot behind it. + */ +interface CheckoutContextStoreState { + recordsByThreadId: Readonly>>; + upsert: (threadId: ThreadId, record: CheckoutContextRecord) => void; + clear: (threadId: ThreadId) => void; +} + +export const useCheckoutContextStore = create()((set) => ({ + recordsByThreadId: {}, + upsert: (threadId, record) => + set((state) => ({ + recordsByThreadId: { + ...state.recordsByThreadId, + [threadId]: [ + ...(state.recordsByThreadId[threadId] ?? EMPTY).filter( + (existing) => existing.contextId !== record.contextId, + ), + record, + ], + }, + })), + clear: (threadId) => + set((state) => { + if (!(threadId in state.recordsByThreadId)) return state; + const { [threadId]: _removed, ...rest } = state.recordsByThreadId; + return { recordsByThreadId: rest }; + }), +})); + +export function useCheckoutContextRecords(threadId: ThreadId | null | undefined) { + return useCheckoutContextStore((state) => + threadId ? (state.recordsByThreadId[threadId] ?? EMPTY) : EMPTY, + ); +} + +export function readCheckoutContextRecords(threadId: ThreadId) { + return useCheckoutContextStore.getState().recordsByThreadId[threadId] ?? EMPTY; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84244d4ce68d..cb763ea008c9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -307,6 +307,7 @@ import { RightPanelTabs } from "./RightPanelTabs"; import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { LinearIssuePickerHost } from "./chat/LinearIssuePicker"; import { ThreadAttachPickerHost } from "./chat/ThreadAttachPicker"; +import { CheckoutAttachPickerHost } from "./chat/CheckoutAttachPicker"; import { RepositoryAttachPickerHost } from "./chat/RepositoryAttachPicker"; import { PullRequestAttachPickerHost } from "./chat/PullRequestAttachPicker"; import { openStartFromPicker, StartFromPickerHost } from "./chat/StartFromPicker"; @@ -418,6 +419,7 @@ import { } from "../lib/composerContextReferences"; import { readThreadTabContextRecords, useThreadTabContextStore } from "../threadTabContextStore"; import { readIssueContextRecords, useIssueContextStore } from "../issueContextStore"; +import { readCheckoutContextRecords, useCheckoutContextStore } from "../checkoutContextStore"; import { readRepositoryContextRecords, useRepositoryContextStore } from "../repositoryContextStore"; import { useThreadTabRecencyStore } from "../threadTabRecencyStore"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; @@ -871,6 +873,7 @@ function readReferencedForkContexts(threadId: ThreadId, text: string) { threadTabs: referenced(readThreadTabContextRecords(threadId)), issues: referenced(readIssueContextRecords(threadId)), repositories: referenced(readRepositoryContextRecords(threadId)), + checkouts: referenced(readCheckoutContextRecords(threadId)), }; } @@ -9359,6 +9362,9 @@ export default function ChatView(props: ChatViewProps) { for (const record of readRepositoryContextRecords(activeThread.id)) { useRepositoryContextStore.getState().upsert(tabRef.threadId, record); } + for (const record of readCheckoutContextRecords(activeThread.id)) { + useCheckoutContextStore.getState().upsert(tabRef.threadId, record); + } if (draft) { store.addImages(tabRef, draft.images.map(cloneComposerImageForRetry), { allowDuplicates: true, @@ -13174,6 +13180,7 @@ export default function ChatView(props: ChatViewProps) { + diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index bac6ebd64618..089330af9748 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -49,6 +49,7 @@ const contextChipVariants = cva( "linear-issue": "[--context-chip-accent:oklch(0.62_0.16_270)]", "github-issue": "[--context-chip-accent:oklch(0.62_0.02_259)]", repository: "[--context-chip-accent:oklch(0.62_0.12_195)]", + checkout: "[--context-chip-accent:oklch(0.62_0.1_200)]", "notion-page": "[--context-chip-accent:oklch(0.62_0.02_259)]", "granola-note": "[--context-chip-accent:oklch(0.62_0.14_125)]", "slack-thread": "[--context-chip-accent:oklch(0.62_0.16_345)]", @@ -66,6 +67,7 @@ const contextChipVariants = cva( "linear-issue", "github-issue", "repository", + "checkout", "notion-page", "granola-note", "slack-thread", @@ -99,6 +101,7 @@ const contextChipVariants = cva( "linear-issue", "github-issue", "repository", + "checkout", "notion-page", "granola-note", "slack-thread", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index aa72d8d327f5..e429335fe0c0 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -288,6 +288,8 @@ import { } from "./ComposerCommandMenu"; import { useCaptureThreadTabContext } from "./ThreadTabs"; import { ComposerAttachMenu } from "./ComposerAttachMenu"; +import { useAttachCheckout, useComposerCheckoutItems } from "./useComposerCheckoutItems"; +import { useCheckoutContextRecords } from "~/checkoutContextStore"; import { useAttachGitHubIssue } from "./GitHubIssuePicker"; import { useAttachLinearIssue } from "./LinearIssuePicker"; import { useComposerGitHubIssueItems } from "./useComposerGitHubIssueItems"; @@ -399,6 +401,7 @@ const PATH_MENU_TABS: ReadonlyArray<{ id: ComposerPathTab; label: string }> = [ ]; const REFERENCE_MENU_TABS: ReadonlyArray<{ id: ComposerReferenceTab; label: string }> = [ + { id: "checkouts", label: "Checkouts" }, { id: "pull-requests", label: "Pull requests" }, { id: "github-issues", label: "GitHub issues" }, { id: "linear-issues", label: "Linear issues" }, @@ -2084,6 +2087,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerThreadTabContexts = useThreadTabContextRecords(routeThreadRef?.threadId); const composerIssueContexts = useIssueContextRecords(routeThreadRef?.threadId); const composerRepositoryContexts = useRepositoryContextRecords(routeThreadRef?.threadId); + const composerCheckoutContexts = useCheckoutContextRecords(routeThreadRef?.threadId); const composerContextRecords = useMemo( () => composerContextRecordsFromDraft({ @@ -2093,6 +2097,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) threadTabs: composerThreadTabContexts, issues: composerIssueContexts, repositories: composerRepositoryContexts, + checkouts: composerCheckoutContexts, threadContexts: composerThreadContexts, images: composerImages, files: composerFiles, @@ -2107,6 +2112,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerThreadTabContexts, composerIssueContexts, composerRepositoryContexts, + composerCheckoutContexts, composerThreadContexts, uploadsByImageId, ], @@ -2927,6 +2933,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) active: referenceTab === "repositories", query: referenceQuery, }); + const checkoutMenu = useComposerCheckoutItems(environmentId, { + enabled: referenceMenuOpen, + query: referenceTab === "checkouts" ? referenceQuery : "", + }); + const attachCheckout = useAttachCheckout(selectedProvider); const nextPredictedReferenceTab = referenceMenuOpen ? predictComposerReferenceTab({ query: referenceQuery, @@ -2939,6 +2950,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "github-issues": githubIssueMenu.preloaded, "notion-pages": notionMenu.preloaded, "granola-notes": granolaMenu.preloaded, + checkouts: checkoutMenu.preloaded, }, }) : "pull-requests"; @@ -2952,6 +2964,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const showSlackMessages = referenceMenuOpen && referenceTab === "slack-messages"; const showGitHubIssues = referenceMenuOpen && referenceTab === "github-issues"; const showRepositories = referenceMenuOpen && referenceTab === "repositories"; + const showCheckouts = referenceMenuOpen && referenceTab === "checkouts"; const referenceTabItems: ReadonlyArray = !referenceMenuOpen ? EMPTY_REFERENCE_MENU_ITEMS : showLinearIssues @@ -2966,7 +2979,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? githubIssueMenu.items : showRepositories ? repositoryMenu.items - : pullRequestMenu.items; + : showCheckouts + ? checkoutMenu.items + : pullRequestMenu.items; const referenceTabRecents = useMemo( () => referenceRecents.filter((item) => composerReferenceTabForItem(item) === referenceTab), [referenceRecents, referenceTab], @@ -3183,6 +3198,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (showLinearIssues && linearIssueMenu.isPending) || (showGitHubIssues && githubIssueMenu.isPending) || (showRepositories && repositoryMenu.isPending) || + (showCheckouts && checkoutMenu.isPending) || (showSlackMessages && slackMenu.isPending) || (showNotionPages && notionMenu.isPending) || (showGranolaNotes && granolaMenu.isPending) || @@ -3226,6 +3242,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? `No GitHub issue matches ${composerTrigger.query}.` : "No open GitHub issues in this repository."; } + if (showCheckouts) { + if (checkoutMenu.error !== null) return checkoutMenu.error; + return composerTrigger?.query + ? `No checkout matches ${composerTrigger.query}. Paste a path to use another folder.` + : "No local checkouts yet. Paste a path to add one."; + } if (showRepositories) { if (repositoryMenu.owners.length === 0) { return "Type an owner like #acme/ to list its repositories, or add repository owners in Settings."; @@ -4322,7 +4344,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) * land at the caret once fetched. */ const attachReferenceItems = useCallback( - (prompt: string, trigger: ComposerTrigger, items: ReadonlyArray) => { + ( + prompt: string, + trigger: ComposerTrigger, + items: ReadonlyArray, + options?: { readonly link?: boolean }, + ) => { + if (items.some((item) => item.type === "checkout" && !item.selectable)) return; const pullRequestComments = items.flatMap((item) => item.type === "pull-request" ? [buildPullRequestReferenceContext(item.pullRequest)] : [], ); @@ -4359,6 +4387,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) remoteUrl: item.remoteUrl, }); } + if (item.type === "checkout") { + attachCheckout(routeThreadRef, item, { link: options?.link === true }); + } } }, [ @@ -4368,6 +4399,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) attachGranolaNote, attachLinearIssue, attachNotionPage, + attachCheckout, attachSlackMessage, composerDraftTarget, environmentId, @@ -4387,7 +4419,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { onUsageLimitsCommand } = props; const onSelectComposerItem = useCallback( - (item: ComposerCommandItem, options?: { toggle: boolean }) => { + (item: ComposerCommandItem, options?: { toggle?: boolean; link?: boolean }) => { if (composerSelectLockRef.current) return; composerSelectLockRef.current = true; window.requestAnimationFrame(() => { @@ -4460,7 +4492,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } - attachReferenceItems(snapshot.value, trigger, [item]); + attachReferenceItems(snapshot.value, trigger, [item], { link: options?.link === true }); return; } if (item.type === "slash-command") { @@ -4935,7 +4967,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } if (key === "Enter" && commitReferenceSelection()) return true; if ((key === "Enter" || key === "Tab") && selectedItem) { - onSelectComposerItem(selectedItem); + onSelectComposerItem(selectedItem, { + link: event.metaKey || event.ctrlKey, + }); return true; } } @@ -7712,6 +7746,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {...(showLinearIssues ? { loadingText: "Searching Linear issues..." } : {})} {...(showGitHubIssues ? { loadingText: "Searching GitHub issues..." } : {})} {...(showRepositories ? { loadingText: "Listing repositories..." } : {})} + {...(showCheckouts ? { loadingText: "Looking up checkouts..." } : {})} + {...(showCheckouts + ? { hint: "Enter references. Command-Enter also links it to the thread." } + : {})} {...(showSlackMessages ? { loadingText: "Searching Slack..." } : {})} {...(composerTrigger?.kind === "path" ? { @@ -8341,6 +8379,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) threadRef={routeThreadRef} pullRequestsAvailable={pullRequestProjectId !== null} githubIssuesAvailable={githubIssueCwd !== null} + checkoutsAvailable={checkoutMenu.hasLocalCandidates} onAttachFiles={() => attachmentInputRef.current?.click()} /> diff --git a/apps/web/src/components/chat/CheckoutAttachPicker.tsx b/apps/web/src/components/chat/CheckoutAttachPicker.tsx new file mode 100644 index 000000000000..07c3aca409ed --- /dev/null +++ b/apps/web/src/components/chat/CheckoutAttachPicker.tsx @@ -0,0 +1,270 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { checkoutPathsEqual, sameCheckoutRepository } from "@t3tools/shared/checkouts"; +import { Atom } from "effect/reactivity"; +import { FolderIcon, GitBranchIcon, LaptopIcon, ServerIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { useServerConfigs, useThreadShell } from "~/state/entities"; +import { sourceControlEnvironment } from "~/state/sourceControl"; +import { threadEnvironment } from "~/state/threads"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { useEnvironmentQuery } from "~/state/query"; +import { CommandPaletteContent } from "../CommandPaletteContent"; +import { Button } from "../ui/button"; +import { + CommandCollection, + CommandDialog, + CommandDialogPopup, + CommandGroup, + CommandItem, + CommandList, +} from "../ui/command"; +import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu"; +import { cn } from "~/lib/utils"; + +import type { ComposerReferenceItem } from "./composerReferenceMenu.logic"; +import { useAttachCheckout, useComposerCheckoutItems } from "./useComposerCheckoutItems"; + +type CheckoutPickerRequest = { + readonly threadRef: ScopedThreadRef; + readonly relocateFrom: string | null; +}; + +const checkoutAttachPickerAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("chat:checkout-attach-picker"), +); + +export function openCheckoutAttachPicker( + threadRef: ScopedThreadRef, + options?: { readonly relocateFrom?: string }, +): void { + appAtomRegistry.set(checkoutAttachPickerAtom, { + threadRef, + relocateFrom: options?.relocateFrom ?? null, + }); +} + +export function CheckoutAttachPickerHost() { + const request = useAtomValue(checkoutAttachPickerAtom); + if (request === null) return null; + return ( + appAtomRegistry.set(checkoutAttachPickerAtom, null)} + /> + ); +} + +function CheckoutAttachPickerDialog(props: { + request: CheckoutPickerRequest; + onClose: () => void; +}) { + const { threadRef, relocateFrom } = props.request; + const [query, setQuery] = useState(""); + const [dirtyOnly, setDirtyOnly] = useState(false); + const [sort, setSort] = useState<"recent" | "name">("recent"); + const [linkToThread, setLinkToThread] = useState(true); + const [locateError, setLocateError] = useState(null); + const shell = useThreadShell(threadRef); + const configs = useServerConfigs(); + const driver = + configs + .get(threadRef.environmentId) + ?.providers.find((provider) => provider.instanceId === shell?.modelSelection.instanceId) + ?.driver ?? null; + const choices = useComposerCheckoutItems(threadRef.environmentId, { + enabled: true, + query, + }); + const attach = useAttachCheckout(driver); + const relocate = useAtomCommand(threadEnvironment.relocateCheckout, { reportFailure: true }); + const comparison = useEnvironmentQuery( + relocateFrom === null + ? null + : sourceControlEnvironment.checkouts({ + environmentId: threadRef.environmentId, + input: { paths: [relocateFrom] }, + }), + ); + const rows = useMemo(() => { + const items = choices.items.flatMap((item) => (item.type === "checkout" ? [item] : [])); + const filtered = dirtyOnly + ? items.filter((item) => item.changedFiles !== null && item.changedFiles > 0) + : items; + if (sort === "name") { + return filtered.toSorted((left, right) => left.label.localeCompare(right.label)); + } + return filtered; + }, [choices.items, dirtyOnly, sort]); + + const select = (item: Extract) => { + if (!item.selectable) return; + if (relocateFrom === null) { + attach(threadRef, item, { link: linkToThread }); + props.onClose(); + return; + } + const from = comparison.data?.entries[0]; + const target = choices.entries.find((entry) => checkoutPathsEqual(entry.path, item.path)); + if (!sameCheckoutRepository(from, target)) { + setLocateError("That folder is not the same repository."); + return; + } + void relocate({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + fromPath: relocateFrom, + path: item.path, + name: item.label, + }, + }).then((result) => { + if (result._tag === "Success") props.onClose(); + }); + }; + + return ( + { + if (!open) props.onClose(); + }} + > + + , + }} + footerActionLabel="Attach" + footerTrailing={ + relocateFrom === null ? ( + + ) : ( + + {locateError ?? "Choose the folder this checkout moved to."} + + ) + } + mode="none" + value={query} + onValueChange={setQuery} + > +
+ +
+ + }> + {sort === "recent" ? "Recently used" : "Name"} + + + setSort(value === "name" ? "name" : "recent")} + > + Recently used + Name + + + +
+ {rows.length === 0 ? ( +
+ {choices.isPending + ? "Looking up checkouts…" + : (choices.error ?? + "No checkouts match. Paste a path to use a folder T3 does not already know.")} +
+ ) : ( + + + + {(item: Extract) => ( + event.preventDefault()} + onClick={() => select(item)} + > + + + )} + + + + )} + + + + ); +} + +function CheckoutPickerRow(props: { item: Extract }) { + const status = + props.item.changedFiles === null + ? "" + : props.item.changedFiles === 0 + ? "clean" + : `${props.item.changedFiles} changed`; + return ( + + {props.item.selectable ? ( + + ) : ( + + )} + {props.item.label} + + + + {props.item.branch ?? (props.item.headSha ? props.item.headSha.slice(0, 7) : "unknown")} + + + + {props.item.path} + + 0 && "text-warning", + )} + > + {status} + + + ); +} diff --git a/apps/web/src/components/chat/ComposerAttachMenu.tsx b/apps/web/src/components/chat/ComposerAttachMenu.tsx index 0799e45b0c96..5fb6ce9d5040 100644 --- a/apps/web/src/components/chat/ComposerAttachMenu.tsx +++ b/apps/web/src/components/chat/ComposerAttachMenu.tsx @@ -1,5 +1,5 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; -import { FolderGit2Icon, MessageSquareIcon, PaperclipIcon } from "lucide-react"; +import { FolderGit2Icon, LaptopIcon, MessageSquareIcon, PaperclipIcon } from "lucide-react"; import { GitHubIcon, GranolaIcon, LinearIcon, NotionIcon, SlackIcon } from "../Icons"; import { memo } from "react"; import { useEnvironmentSettings } from "~/hooks/useSettings"; @@ -12,6 +12,7 @@ import { openGranolaNotePicker } from "./GranolaNotePicker"; import { openLinearIssuePicker } from "./LinearIssuePicker"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { openPullRequestAttachPicker } from "./PullRequestAttachPicker"; +import { openCheckoutAttachPicker } from "./CheckoutAttachPicker"; import { openRepositoryAttachPicker } from "./RepositoryAttachPicker"; import { openNotionPagePicker } from "./NotionPagePicker"; import { openSlackMessagePicker } from "./SlackMessagePicker"; @@ -27,6 +28,8 @@ export const ComposerAttachMenu = memo(function ComposerAttachMenu(props: { pullRequestsAvailable: boolean; /** Whether the thread's project is a GitHub repository whose issues can be attached. */ githubIssuesAvailable: boolean; + /** Whether this environment has a project root or worktree to attach. */ + checkoutsAvailable: boolean; onAttachFiles: () => void; }) { const composerMenuProps = useComposerMenuProps(); @@ -92,6 +95,12 @@ export const ComposerAttachMenu = memo(function ComposerAttachMenu(props: { Pull request ) : null} + {props.checkoutsAvailable ? ( + openCheckoutAttachPicker(props.threadRef)}> + + Checkout + + ) : null} openRepositoryAttachPicker(props.threadRef)}> Repository diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 3ef278c79a26..1d38dc6b667a 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -18,6 +18,8 @@ import { BlocksIcon, FolderGit2Icon, FolderIcon, + GitBranchIcon, + ServerIcon, PackageIcon, SettingsIcon, UserRoundIcon, @@ -128,6 +130,19 @@ export type ComposerCommandItem = label: string; description: string; } + | { + id: string; + type: "checkout"; + environmentId: EnvironmentId; + path: string; + branch: string | null; + headSha: string | null; + changedFiles: number | null; + ahead: number; + selectable: boolean; + label: string; + description: string; + } | { id: string; type: "thread"; @@ -153,6 +168,8 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { isLoading: boolean; triggerKind: ComposerTriggerKind | null; emptyStateText?: string; + /** One line under the list, for a tab whose keys differ from the others. */ + hint?: string; /** Replaces the trigger's default loading text, e.g. for the `#` menu's Linear tab. */ loadingText?: string; /** Tabs over the list, for a trigger that offers more than one kind of item. */ @@ -273,11 +290,50 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
) : null} + {props.hint ? ( +

{props.hint}

+ ) : null} ); }); +function CheckoutCommandRow(props: { item: Extract }) { + const status = + props.item.changedFiles === null + ? "" + : props.item.changedFiles === 0 + ? "clean" + : `${props.item.changedFiles} changed`; + return ( + + {props.item.label} + + + + {props.item.branch ?? (props.item.headSha ? props.item.headSha.slice(0, 7) : "unknown")} + + + + {props.item.path} + + 0 && "text-warning", + )} + > + {status} + + + ); +} + type LinearIssueCommandItem = Extract; /** Fixed-width columns so identifiers, assignees, and statuses line up down the list. */ @@ -421,6 +477,13 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.type === "repository" ? (