From f71836643ff3a6604642847703cb309673b1e45b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:40:54 -0700 Subject: [PATCH 1/2] feat(web,mobile): @-mention a machine as a context chip Typing @ and part of a machine's name in the composer now offers the user's other T3 Code environments beside threads and files, each with its machine icon. Picking one inserts a chip. The message carries only the machine's identity (environmentId and label, never an address), and the agent is told to link it with t3_environment_link if it is not linked yet, then pass the environmentId to delegate_task, t3_thread_launch and the other tools. Web keeps mentioned machines in the draft beside attached threads, persists them, restores them on undo and failed sends, and renders the chip in the composer and in sent messages. Mobile offers them in the @ popover and draws the chip natively. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../assets/context-chip-icons/server.png | Bin 0 -> 506 bytes .../scripts/sync-context-chip-icons.mjs | 1 + .../src/components/ComposerContextSheet.tsx | 3 + .../threads/ComposerCommandPopover.tsx | 13 +++ .../src/features/threads/ThreadComposer.tsx | 2 + .../threads/use-composer-command-menu.ts | 45 +++++++- .../src/lib/composerChipPresentation.test.ts | 7 ++ .../src/lib/composerChipPresentation.ts | 2 + apps/mobile/src/lib/composerContext.ts | 16 +++ .../src/lib/contextChipIcons.generated.ts | 1 + apps/mobile/src/state/environments.ts | 17 ++- .../web/src/components/ChatView.logic.test.ts | 1 + apps/web/src/components/ChatView.logic.ts | 6 + apps/web/src/components/ContextChip.tsx | 1 + .../src/components/EnvironmentContextChip.tsx | 40 +++++++ apps/web/src/components/chat/ChatComposer.tsx | 59 ++++++++++ .../components/chat/ComposerCommandMenu.tsx | 18 +++ .../chat/UserMessageContextChips.tsx | 11 ++ apps/web/src/components/chat/chatViewSend.ts | 1 + .../components/chat/composerContextUndo.ts | 8 +- .../chat/useComposerContextImport.ts | 15 ++- .../components/chat/useComposerMenuItems.ts | 24 +++- .../src/components/chat/useComposerSend.ts | 22 +++- .../chat/useQueuedRunEdit.browser.test.tsx | 1 + .../src/components/chat/useQueuedRunEdit.ts | 3 + .../composerContextPresentation.tsx | 34 +++++- .../components/contextPresentationRegistry.ts | 4 + apps/web/src/composerDraftStore.test.ts | 64 ++++++++++- apps/web/src/composerDraftStore.ts | 106 +++++++++++++++++- apps/web/src/lib/composerContextRecords.ts | 23 ++++ apps/web/src/lib/contextChipAccents.ts | 1 + .../src/composerThreadItems.test.ts | 49 +++++++- .../client-runtime/src/composerThreadItems.ts | 50 ++++++++- .../contracts/src/composerContext.test.ts | 6 + packages/contracts/src/composerContext.ts | 14 +++ .../src/composerContextReferences.test.ts | 21 ++++ .../shared/src/composerContextReferences.ts | 6 + 37 files changed, 677 insertions(+), 18 deletions(-) create mode 100644 apps/mobile/assets/context-chip-icons/server.png create mode 100644 apps/web/src/components/EnvironmentContextChip.tsx diff --git a/apps/mobile/assets/context-chip-icons/server.png b/apps/mobile/assets/context-chip-icons/server.png new file mode 100644 index 0000000000000000000000000000000000000000..92bc2ce457f16e0f12f68b26b08079186954b21f GIT binary patch literal 506 zcmV;6@c2mMuLR z-9u5?JY*#jQm~U$6;kc|CuKL0JU`=cY=1K$8UY5t6_5k>kvwj!8F0Ky;%C4TSQ9Uw zzydhkHu)#un{?O!Mc3q?Ntb33fMH@^-d733CIObD&s$TsUwdA72X24^iv%}TU;?ZH zjd9?6DGMleZIM!m;A89y&I7@R7Ae3{AgA0HJoQCd=@JH~ll=1*Eb>?kHVjI%M<#y6mJ_YIW*mVvSiB*Ej<)KsZ zwdt@3G{#tPW1qK{eG{%@0++zu9$a~KecV{bJ6(+lA%sKz0OSuy0r>+e~^RH^=dqLjV8(07*qoM6N<$g2Z*xD*ylh literal 0 HcmV?d00001 diff --git a/apps/mobile/scripts/sync-context-chip-icons.mjs b/apps/mobile/scripts/sync-context-chip-icons.mjs index aac4983d75e2c..d99d4d3c98478 100644 --- a/apps/mobile/scripts/sync-context-chip-icons.mjs +++ b/apps/mobile/scripts/sync-context-chip-icons.mjs @@ -43,6 +43,7 @@ const lucideIcons = [ "message-circle", "messages-square", "mouse-pointer-click", + "server", "terminal", ]; diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 20c7485ef15d6..8121bfdece763 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -324,6 +324,9 @@ export function ComposerContextSheet(props: { ) : null} ) : null} + {record.kind === "environment" ? ( + + ) : null} {record.kind === "skill" ? ( diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index cd924e4c61f03..cbe89f5fb8768 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -3,6 +3,8 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import type { + EnvironmentId, + EnvironmentMachineKind, PullRequestContextMetadata, ScopedThreadRef, ServerProviderSkill, @@ -14,6 +16,7 @@ import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-n import { SymbolView, type AppSymbolName } from "../../components/ui/AppSymbol"; import { AppText as Text } from "../../components/ui/AppText"; +import { ENVIRONMENT_MACHINE_SYMBOLS } from "../../components/EnvironmentMachineSymbol"; import { GlassSurface } from "../../components/ui/GlassSurface"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; import { pullRequestStatus } from "../pull-requests/pullRequestStatus"; @@ -40,6 +43,14 @@ export type ComposerCommandItem = readonly label: string; readonly description: string; } + | { + readonly id: string; + readonly type: "environment"; + readonly environmentId: EnvironmentId; + readonly machine: EnvironmentMachineKind; + readonly label: string; + readonly description: string; + } | { readonly id: string; readonly type: "slash-command"; @@ -110,6 +121,8 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null { return null; case "thread": return "text.bubble"; + case "environment": + return ENVIRONMENT_MACHINE_SYMBOLS[item.machine]; } } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 7ac1ad83aa8b5..49e91a6da4919 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -66,6 +66,7 @@ import { import { appAtomRegistry } from "../../state/atom-registry"; import type { ComposerDocumentAttachment } from "../../lib/composerContext"; import { useProject, useThreadShells } from "../../state/entities"; +import { useMentionableEnvironments } from "../../state/environments"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { AppText as Text } from "../../components/ui/AppText"; @@ -528,6 +529,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ownerKey: composerDraftKey, environmentId: props.environmentId, threadShells: useThreadShells(), + environments: useMentionableEnvironments(), currentThreadId: props.selectedThread.id, projectCwd: props.projectCwd, pullRequestProjectId: props.serverConfig?.environment.capabilities.pullRequests diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index af4091a5f023d..89afc891015ac 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -7,13 +7,22 @@ import type { ThreadId, ThreadPullRequestLink, } from "@t3tools/contracts"; -import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; +import { + type ComposerEnvironmentCandidate, + matchComposerEnvironmentItems, + matchComposerThreadItems, +} from "@t3tools/client-runtime/composerThreadItems"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; const EMPTY_THREAD_SHELLS: ReadonlyArray = []; +const EMPTY_ENVIRONMENTS: ReadonlyArray = []; import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; -import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; +import { + environmentComposerContext, + pullRequestComposerContext, + threadComposerContext, +} from "../../lib/composerContext"; import { uuidv4 } from "../../lib/uuid"; import { getComposerDraftSnapshot, @@ -201,6 +210,7 @@ export function useComposerCommandMenu({ ownerKey, environmentId, threadShells = EMPTY_THREAD_SHELLS, + environments = EMPTY_ENVIRONMENTS, currentThreadId = null, projectCwd, pullRequestProjectId = null, @@ -219,6 +229,7 @@ export function useComposerCommandMenu({ readonly ownerKey: string | null; readonly environmentId: EnvironmentId | null; readonly threadShells?: ReadonlyArray; + readonly environments?: ReadonlyArray; readonly currentThreadId?: ThreadId | null; readonly projectCwd: string | null; readonly pullRequestProjectId?: ProjectId | null; @@ -452,8 +463,12 @@ export function useComposerCommandMenu({ now, }) : []; + const environmentItems = environmentId + ? matchComposerEnvironmentItems({ environments, environmentId, query: trigger.query }) + : []; return [ ...threadItems, + ...environmentItems, ...pathSearch.entries.map((entry) => { const parts = entry.path.split("/"); return { @@ -473,6 +488,32 @@ export function useComposerCommandMenu({ const onSelect = (item: ComposerCommandItem) => { if (!trigger) return; + if (item.type === "environment") { + if (!ownerKey || trigger.kind !== "path") return; + const record = environmentComposerContext(item.environmentId, item.label); + const existing = getComposerDraftSnapshot(ownerKey).context?.records ?? []; + const alreadyAttached = existing.some((entry) => entry.contextId === record.contextId); + if (!alreadyAttached && existing.length >= COMPOSER_CONTEXT_MAX_RECORDS) { + Dialog.alert("Too many context items", "Remove some context from the draft and try again."); + return; + } + const result = replaceTextRange( + draftMessage, + trigger.rangeStart, + trigger.rangeEnd, + `${formatComposerContextReference(record)} `, + ); + onChangeDraftMessage(result.text); + if (!alreadyAttached) { + const draft = getComposerDraftSnapshot(ownerKey); + setComposerDraftContext(ownerKey, { + version: 1, + records: [...(draft.context?.records ?? []), record], + }); + } + setSelection({ start: result.cursor, end: result.cursor }); + return; + } if (item.type === "thread") { if (!ownerKey || trigger.kind !== "path") return; const shell = threadShells.find( diff --git a/apps/mobile/src/lib/composerChipPresentation.test.ts b/apps/mobile/src/lib/composerChipPresentation.test.ts index fa578904dcb54..21474f6de7e96 100644 --- a/apps/mobile/src/lib/composerChipPresentation.test.ts +++ b/apps/mobile/src/lib/composerChipPresentation.test.ts @@ -22,6 +22,7 @@ const CHIP_ACCENTS = [ "pull-request", "skill", "thread", + "environment", ].map((kind) => contextChipPresentation(kind)), ...[ { state: "open", isDraft: false }, @@ -107,3 +108,9 @@ describe("contextChipAccent", () => { expect(contextChipAccent("#009f6e", theme, theme["--color-md-body"])).toBe("#009f6e"); }); }); + +describe("contextChipPresentation", () => { + it("draws a mentioned machine as a machine, not the generic file chip", () => { + expect(contextChipPresentation("environment")).toEqual({ accent: "#3f9a4f", icon: "server" }); + }); +}); diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index 34e70e94197bd..4f3a77daf5d90 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -43,6 +43,8 @@ const CONTEXT_CHIP_PRESENTATIONS = { accent: "#009c96", icon: "messages-square", }, + // A machine: the record carries only its identity, so every machine wears one glyph. + environment: { accent: "#3f9a4f", icon: "server" }, } as const satisfies Record; const CHIP_CONTRAST = 5; diff --git a/apps/mobile/src/lib/composerContext.ts b/apps/mobile/src/lib/composerContext.ts index f6c3a0e5c5536..34b4e3fcdcdf5 100644 --- a/apps/mobile/src/lib/composerContext.ts +++ b/apps/mobile/src/lib/composerContext.ts @@ -14,6 +14,8 @@ import { type ScopedThreadRef, type ServerProviderSkill, type ThreadContextRecord, + type EnvironmentContextRecord, + type EnvironmentId, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { @@ -115,6 +117,20 @@ export function threadComposerContext(ref: ScopedThreadRef, title: string): Thre }; } +/** Same identity as web: one record per machine, so mentioning it again reuses the chip. */ +export function environmentComposerContext( + environmentId: EnvironmentId, + label: string, +): EnvironmentContextRecord { + return { + version: 1, + kind: "environment", + contextId: ComposerContextId.make(`environment_${environmentId}`), + label: sanitizeComposerContextLabel(label, "environment"), + environmentId, + }; +} + export function pullRequestComposerContext( pullRequest: PullRequestContextMetadata, id: string, diff --git a/apps/mobile/src/lib/contextChipIcons.generated.ts b/apps/mobile/src/lib/contextChipIcons.generated.ts index 10a8b6af70adb..809f76dca49c3 100644 --- a/apps/mobile/src/lib/contextChipIcons.generated.ts +++ b/apps/mobile/src/lib/contextChipIcons.generated.ts @@ -11,6 +11,7 @@ export const CONTEXT_CHIP_ICON_SOURCES = { "message-circle": require("../../assets/context-chip-icons/message-circle.png"), "messages-square": require("../../assets/context-chip-icons/messages-square.png"), "mouse-pointer-click": require("../../assets/context-chip-icons/mouse-pointer-click.png"), + server: require("../../assets/context-chip-icons/server.png"), skill: require("../../assets/context-chip-icons/skill.png"), terminal: require("../../assets/context-chip-icons/terminal.png"), } as const satisfies Readonly>; diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts index 776a615f46afb..7bfd41e1ca883 100644 --- a/apps/mobile/src/state/environments.ts +++ b/apps/mobile/src/state/environments.ts @@ -4,7 +4,8 @@ import { hasRelayRoute, type EnvironmentPresentation as BaseEnvironmentPresentation, } from "@t3tools/client-runtime/connection"; -import type { EnvironmentId } from "@t3tools/contracts"; +import type { ComposerEnvironmentCandidate } from "@t3tools/client-runtime/composerThreadItems"; +import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; @@ -55,3 +56,17 @@ export function useEnvironments() { export function useEnvironmentConnectionState(environmentId: EnvironmentId) { return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); } + +/** The user's machines as `@` mention candidates, drawn with their machine kind. */ +export function useMentionableEnvironments(): ReadonlyArray { + const { environments } = useEnvironments(); + return useMemo( + () => + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + machine: resolveEnvironmentMachineKind(environment.serverConfig), + })), + [environments], + ); +} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 363e5db0b6071..9ec467e81885a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1014,6 +1014,7 @@ describe("snapshotComposerSend", () => { previewAnnotations: [], reviewComments: [], threadContexts: [], + environmentContexts: [], }); expect(snapshot.terminalContexts).toEqual([live]); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0e36a3ef4681e..24fe3c614516c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -39,6 +39,7 @@ import { type ScopedProjectRef, type ScopedThreadRef, type ThreadContextRecord, + type EnvironmentContextRecord, type ThreadId, type ThreadLinkedPullRequest, type RunId, @@ -1195,6 +1196,7 @@ export interface PlanFollowUpComposerSnapshot { readonly reviewComments: ReadonlyArray; readonly previewAnnotations: ReadonlyArray; readonly threadContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; } export function restorePlanFollowUpComposer(input: { @@ -1204,6 +1206,7 @@ export function restorePlanFollowUpComposer(input: { readonly writeReviewComments: (comments: ReadonlyArray) => void; readonly writePreviewAnnotations: (annotations: ReadonlyArray) => void; readonly writeThreadContexts: (records: ReadonlyArray) => void; + readonly writeEnvironmentContexts: (records: ReadonlyArray) => void; readonly resetCursor: (options: { cursor: number; prompt: string; @@ -1215,6 +1218,7 @@ export function restorePlanFollowUpComposer(input: { input.writeReviewComments(input.snapshot.reviewComments); input.writePreviewAnnotations(input.snapshot.previewAnnotations); input.writeThreadContexts(input.snapshot.threadContexts); + input.writeEnvironmentContexts(input.snapshot.environmentContexts); input.resetCursor({ cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length), prompt: input.snapshot.prompt, @@ -1374,6 +1378,7 @@ export interface ComposerSendContents { readonly previewAnnotations: ReadonlyArray; readonly reviewComments: ReadonlyArray; readonly threadContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; } /** @@ -1393,6 +1398,7 @@ export function snapshotComposerSend(contents: ComposerSendContents) { previewAnnotations: [...contents.previewAnnotations], reviewComments: [...contents.reviewComments], threadContexts: [...contents.threadContexts], + environmentContexts: [...contents.environmentContexts], messageText: contents.terminalContexts .filter((context) => !terminalContexts.includes(context)) .reduce( diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index b6abe578770c8..fcd4c9e4fed30 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -27,6 +27,7 @@ const contextChipVariants = cva( "pr-closed": "[--context-chip-accent:var(--context-chip-pr-closed)]", skill: "[--context-chip-accent:var(--context-chip-skill)]", thread: "[--context-chip-accent:var(--context-chip-thread)]", + environment: "[--context-chip-accent:var(--context-chip-environment)]", citation: "[--context-chip-accent:var(--context-chip-citation)]", "doc-pdf": "[--context-chip-accent:var(--context-chip-doc-pdf)]", "doc-word": "[--context-chip-accent:var(--context-chip-doc-word)]", diff --git a/apps/web/src/components/EnvironmentContextChip.tsx b/apps/web/src/components/EnvironmentContextChip.tsx new file mode 100644 index 0000000000000..9730fc7c342c9 --- /dev/null +++ b/apps/web/src/components/EnvironmentContextChip.tsx @@ -0,0 +1,40 @@ +import { type EnvironmentContextRecord, resolveEnvironmentMachineKind } from "@t3tools/contracts"; + +import { useEnvironment } from "~/state/environments"; +import { ContextChip, ContextChipLabel } from "./ContextChip"; +import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** + * Inline chip for a mentioned machine, in the composer and in sent messages. Prefers the + * name this client knows the machine by, and draws it with its machine icon. + */ +export function EnvironmentContextChip(props: { + record: Pick; + copyMarkdown?: string; +}) { + const environment = useEnvironment(props.record.environmentId); + const label = environment?.label.trim() || props.record.label; + return ( + + + + {label} + + } + /> + + {environment ? "T3 Code environment" : "Machine not in this app"} + + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 26761d92d8fe4..1d17f03e41b67 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -22,6 +22,7 @@ import type { PreviewAnnotationPayload, ProviderApprovalDecision, ThreadContextRecord, + EnvironmentContextRecord, ProviderInteractionMode, ResolvedKeybindingsConfig, RuntimeMode, @@ -169,6 +170,8 @@ import { terminalContextRecord, threadContextRecord, threadContextReference, + environmentContextRecord, + environmentContextReference, } from "~/lib/composerContextRecords"; import { rememberCopiedComposerAttachments } from "~/lib/copiedComposerAttachments"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; @@ -336,6 +339,7 @@ export interface ChatComposerHandle { previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; threadContexts: ThreadContextRecord[]; + environmentContexts: EnvironmentContextRecord[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -721,6 +725,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerPreviewAnnotations = composerDraft.previewAnnotations; const composerReviewComments = composerDraft.reviewComments; const composerThreadContexts = attachmentDraft.threadContexts; + const composerEnvironmentContexts = composerDraft.environmentContexts; const { pendingSnapShotAnimations, pendingSnapShotIds, @@ -772,6 +777,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) reviewComments: composerReviewComments, previewAnnotations: composerPreviewAnnotations, threadContexts: composerThreadContexts, + environmentContexts: composerEnvironmentContexts, images: composerImages, files: composerFiles, uploadsByImageId, @@ -1153,6 +1159,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedProviderSkills, selectedProviderSlashCommands, showSkillsInSlashMenu: settings.showSkillsInSlashMenu, + questionAttachmentTarget, }); const composerMenuOpen = Boolean(composerTrigger); @@ -1304,6 +1311,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const addComposerDraftThreadContexts = useComposerDraftStore((store) => store.addThreadContexts); const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); + const addComposerDraftEnvironmentContexts = useComposerDraftStore( + (store) => store.addEnvironmentContexts, + ); + const setComposerDraftEnvironmentContexts = useComposerDraftStore( + (store) => store.setEnvironmentContexts, + ); const buildContextClipboardFragment = (contextIds: ReadonlyArray): string | null => { const wanted = new Set(contextIds); const copiedFiles = new Map(); @@ -1320,6 +1333,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) .filter((c) => wanted.has(terminalContextReference(c).contextId)) .map(terminalContextRecord), ...composerThreadContexts.filter((record) => wanted.has(record.contextId)), + ...composerEnvironmentContexts.filter((record) => wanted.has(record.contextId)), ...composerReviewComments .filter((c) => wanted.has(reviewCommentContextId(c.id))) .map(reviewCommentContextRecord), @@ -1693,6 +1707,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerDraftThreadContexts(composerDraftTarget, nextThreads); } + const liveEnvironmentIds = new Set( + composerEnvironmentContexts.map((record) => record.contextId), + ); + const restoredEnvironments = [...referenced].flatMap((contextId) => { + if (liveEnvironmentIds.has(contextId)) return []; + const record = retained.environments.get(contextId); + return record ? [record] : []; + }); + const nextEnvironments = [ + ...composerEnvironmentContexts.filter((record) => referenced.has(record.contextId)), + ...restoredEnvironments, + ]; + for (const record of composerEnvironmentContexts) { + if (!referenced.has(record.contextId)) retained.environments.set(record.contextId, record); + } + if ( + nextEnvironments.length !== composerEnvironmentContexts.length || + restoredEnvironments.length > 0 + ) { + setComposerDraftEnvironmentContexts(composerDraftTarget, nextEnvironments); + } + for (const comment of composerReviewComments) { const contextId = reviewCommentContextId(comment.id); if (!referenced.has(contextId)) { @@ -1991,6 +2027,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.type === "environment") { + if (trigger.kind !== "path") return; + const record = environmentContextRecord(item.environmentId, item.label); + const replacement = `${formatInlineContextReference(environmentContextReference(record))} `; + const replacementRangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + trigger.rangeEnd, + replacement, + ); + const applied = applyPromptReplacement(trigger.rangeStart, replacementRangeEnd, replacement, { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }); + if (applied) { + addComposerDraftEnvironmentContexts(composerDraftTarget, [record], { + appendReference: false, + }); + setComposerHighlightedItemId(null); + } + return; + } }; const onSelectComposerItemStable = useStableCallback(onSelectComposerItem); @@ -3134,6 +3190,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, threadContexts: composerThreadContexts, + environmentContexts: composerEnvironmentContexts, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -3189,6 +3246,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerPreviewAnnotations, composerReviewComments, + composerThreadContexts, + composerEnvironmentContexts, focusComposer, environmentId, primaryEnvironmentId, diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 4d556d48be701..d2f224b3f5bec 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -5,6 +5,8 @@ import { type ProviderSkillSourceKind, } from "@t3tools/client-runtime/providerSkills"; import { + type EnvironmentId, + type EnvironmentMachineKind, type ProjectEntry, type ProviderDriverKind, type PullRequestContextMetadata, @@ -27,6 +29,7 @@ import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../compo import { cn } from "~/lib/utils"; import { Badge } from "../ui/badge"; import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command"; +import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { ComposerBanner } from "./ComposerBanner"; import { resolvePullRequestState } from "../pullRequest/pullRequestPresentation"; @@ -77,6 +80,14 @@ export type ComposerCommandItem = thread: ScopedThreadRef; label: string; description: string; + } + | { + id: string; + type: "environment"; + environmentId: EnvironmentId; + machine: EnvironmentMachineKind; + label: string; + description: string; }; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { @@ -202,6 +213,13 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.type === "thread" ? (