diff --git a/apps/mobile/assets/context-chip-icons/globe.png b/apps/mobile/assets/context-chip-icons/globe.png new file mode 100644 index 0000000000000..0ff16b7b15430 Binary files /dev/null and b/apps/mobile/assets/context-chip-icons/globe.png differ diff --git a/apps/mobile/scripts/sync-context-chip-icons.mjs b/apps/mobile/scripts/sync-context-chip-icons.mjs index aac4983d75e2c..08ec73da18a7a 100644 --- a/apps/mobile/scripts/sync-context-chip-icons.mjs +++ b/apps/mobile/scripts/sync-context-chip-icons.mjs @@ -39,6 +39,7 @@ const lucideIcons = [ "git-pull-request-arrow", "git-pull-request-closed", "git-pull-request-draft", + "globe", "image", "message-circle", "messages-square", diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 20c7485ef15d6..dc31908678a21 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -324,6 +324,12 @@ export function ComposerContextSheet(props: { ) : null} ) : null} + {record.kind === "browser-tab" ? ( + + + {record.url ? : null} + + ) : null} {record.kind === "skill" ? ( diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx index 3ec1b2b9a56a9..7f0949d292b35 100644 --- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx +++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx @@ -87,7 +87,19 @@ function BrowserPreviewScreen({ const [canControl, setCanControl] = useState(false); const streamRef = useRef(null); const { tabs, loaded } = useThreadServerBrowserTabs({ environmentId, threadId, enabled: true }); - const tab = tabs.find((entry) => entry.tabId === selectedTabId) ?? latestBrowserTab(tabs); + // A tab mention can outlive its tab; opening another tab in its place would mislead. + const [initialTabState, setInitialTabState] = useState<"pending" | "open" | "closed">( + initialTabId === null ? "open" : "pending", + ); + let initialTabStatus = initialTabState; + if (initialTabStatus === "pending" && loaded) { + initialTabStatus = tabs.some((entry) => entry.tabId === initialTabId) ? "open" : "closed"; + setInitialTabState(initialTabStatus); + } + const tab = + initialTabStatus === "closed" + ? undefined + : (tabs.find((entry) => entry.tabId === selectedTabId) ?? latestBrowserTab(tabs)); const pdfUri = tab?.navStatus._tag === "Success" ? previewPdfAssetUrl(tab.navStatus.url, httpBaseUrl) : null; const tabId = tab?.tabId ?? null; @@ -98,6 +110,12 @@ function BrowserPreviewScreen({ useEffect(() => { if (focused && loaded && tabs.length === 0) onClose(); }, [focused, loaded, tabs.length, onClose]); + const hasTabs = tabs.length > 0; + useEffect(() => { + if (initialTabStatus !== "closed" || !hasTabs) return; + Dialog.alert("Tab closed", "This browser tab is no longer open."); + onClose(); + }, [initialTabStatus, hasTabs, onClose]); const onPictureInPicture = useCallback((state: PreviewPictureInPictureState, detail?: string) => { setPictureInPicture({ supported: state.supported, active: state.active }); if (detail) Dialog.alert("Picture in picture is unavailable", detail); diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index cd924e4c61f03..393465f91919e 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -1,3 +1,4 @@ +import type { ComposerBrowserTabItem } from "@t3tools/client-runtime/composerBrowserTabItems"; import { resolveProviderSkillSourceKind, type ProviderSkillSourceKind, @@ -40,6 +41,7 @@ export type ComposerCommandItem = readonly label: string; readonly description: string; } + | ComposerBrowserTabItem | { readonly id: string; readonly type: "slash-command"; @@ -110,6 +112,8 @@ function itemIcon(item: ComposerCommandItem): AppSymbolName | null { return null; case "thread": return "text.bubble"; + case "browser-tab": + return "globe"; } } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 9f94ecd2edc93..0680cb1226b6a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -63,6 +63,7 @@ import { import { appAtomRegistry } from "../../state/atom-registry"; import type { ComposerDocumentAttachment } from "../../lib/composerContext"; import { useProject, useThreadShells } from "../../state/entities"; +import { useThreadServerBrowserTabs } from "../../state/preview"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { AppText as Text } from "../../components/ui/AppText"; @@ -560,6 +561,11 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ownerKey: composerDraftKey, environmentId: props.environmentId, threadShells: useThreadShells(), + browserTabs: useThreadServerBrowserTabs({ + environmentId: props.environmentId, + threadId: props.selectedThread.id, + enabled: props.serverConfig?.environment.capabilities.serverBrowser === true, + }).tabs, currentThreadId: props.selectedThread.id, projectCwd: props.projectCwd, pullRequestProjectId: props.serverConfig?.environment.capabilities.pullRequests diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index eca66821c7131..b615246590356 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1895,6 +1895,14 @@ function UserMessageContent(props: UserMessageContentProps) { }); return; } + if (record?.kind === "browser-tab" && "tabId" in record) { + navigation.navigate("ThreadBrowserPreview", { + environmentId: String(record.environmentId), + threadId: String(record.threadId), + tabId: record.tabId, + }); + return; + } const document = composerDocumentAttachmentRecord(record); if (document) { navigation.navigate("ThreadAttachment", { 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 d1aba9ccf9b2a..7e871d9a567cd 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -1,16 +1,24 @@ import * as Dialog from "../../components/ui/Dialog"; import type { + BrowserTabContextRecord, EnvironmentId, + PreviewSessionSnapshot, ProjectId, ProviderInteractionMode, ServerProvider, + ThreadContextRecord, ThreadId, ThreadPullRequestLink, } from "@t3tools/contracts"; +import { + browserTabContextRecord, + matchComposerBrowserTabItems, +} from "@t3tools/client-runtime/composerBrowserTabItems"; import { matchComposerThreadItems } from "@t3tools/client-runtime/composerThreadItems"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; const EMPTY_THREAD_SHELLS: ReadonlyArray = []; +const EMPTY_BROWSER_TABS: ReadonlyArray = []; import { COMPOSER_CONTEXT_MAX_RECORDS } from "@t3tools/contracts"; import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { pullRequestComposerContext, threadComposerContext } from "../../lib/composerContext"; @@ -180,6 +188,7 @@ export function useComposerCommandMenu({ ownerKey, environmentId, threadShells = EMPTY_THREAD_SHELLS, + browserTabs = EMPTY_BROWSER_TABS, currentThreadId = null, projectCwd, pullRequestProjectId = null, @@ -198,6 +207,7 @@ export function useComposerCommandMenu({ readonly ownerKey: string | null; readonly environmentId: EnvironmentId | null; readonly threadShells?: ReadonlyArray; + readonly browserTabs?: ReadonlyArray; readonly currentThreadId?: ThreadId | null; readonly projectCwd: string | null; readonly pullRequestProjectId?: ProjectId | null; @@ -445,6 +455,9 @@ export function useComposerCommandMenu({ }) : []; return [ + ...(environmentId && currentThreadId + ? matchComposerBrowserTabItems({ sessions: browserTabs, query: trigger.query }) + : []), ...threadItems, ...pathSearch.entries.map((entry) => { const parts = entry.path.split("/"); @@ -463,6 +476,34 @@ export function useComposerCommandMenu({ return []; })(); + const insertLinkedContext = ( + trigger: ComposerTrigger, + ownerKey: string, + record: ThreadContextRecord | BrowserTabContextRecord, + ) => { + 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 }); + }; + const onSelect = (item: ComposerCommandItem) => { if (!trigger) return; if (item.type === "thread") { @@ -473,28 +514,16 @@ export function useComposerCommandMenu({ candidate.id === item.thread.threadId, ); if (!shell) return; - const record = threadComposerContext(item.thread, shell.title); - 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)} `, + insertLinkedContext(trigger, ownerKey, threadComposerContext(item.thread, shell.title)); + return; + } + if (item.type === "browser-tab") { + if (!ownerKey || trigger.kind !== "path" || !environmentId || !currentThreadId) return; + insertLinkedContext( + trigger, + ownerKey, + browserTabContextRecord({ environmentId, threadId: currentThreadId }, item.tab), ); - 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 === "pull-request") { diff --git a/apps/mobile/src/lib/composerChipPresentation.ts b/apps/mobile/src/lib/composerChipPresentation.ts index 34e70e94197bd..edb962c706a7e 100644 --- a/apps/mobile/src/lib/composerChipPresentation.ts +++ b/apps/mobile/src/lib/composerChipPresentation.ts @@ -43,6 +43,7 @@ const CONTEXT_CHIP_PRESENTATIONS = { accent: "#009c96", icon: "messages-square", }, + "browser-tab": { accent: "#7c9128", icon: "globe" }, } as const satisfies Record; const CHIP_CONTRAST = 5; diff --git a/apps/mobile/src/lib/contextChipIcons.generated.ts b/apps/mobile/src/lib/contextChipIcons.generated.ts index 10a8b6af70adb..6225dd4138226 100644 --- a/apps/mobile/src/lib/contextChipIcons.generated.ts +++ b/apps/mobile/src/lib/contextChipIcons.generated.ts @@ -7,6 +7,7 @@ export const CONTEXT_CHIP_ICON_SOURCES = { "git-pull-request-arrow": require("../../assets/context-chip-icons/git-pull-request-arrow.png"), "git-pull-request-closed": require("../../assets/context-chip-icons/git-pull-request-closed.png"), "git-pull-request-draft": require("../../assets/context-chip-icons/git-pull-request-draft.png"), + globe: require("../../assets/context-chip-icons/globe.png"), image: require("../../assets/context-chip-icons/image.png"), "message-circle": require("../../assets/context-chip-icons/message-circle.png"), "messages-square": require("../../assets/context-chip-icons/messages-square.png"), diff --git a/apps/web/src/components/BrowserTabContextChip.tsx b/apps/web/src/components/BrowserTabContextChip.tsx new file mode 100644 index 0000000000000..8220fde158f67 --- /dev/null +++ b/apps/web/src/components/BrowserTabContextChip.tsx @@ -0,0 +1,57 @@ +import type { BrowserTabContextRecord } from "@t3tools/contracts"; +import { useNavigate } from "@tanstack/react-router"; +import { GlobeIcon } from "lucide-react"; + +import { useFaviconForThreadUrl } from "~/browserFaviconStore"; +import { useThreadPreviewState } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { ContextChip, ContextChipLabel } from "./ContextChip"; +import { FaviconImage } from "./preview/PreviewFaviconIcon"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export function BrowserTabContextChip(props: { + record: Pick; + copyMarkdown?: string; +}) { + const { environmentId, threadId, tabId } = props.record; + const threadRef = { environmentId, threadId }; + const session = useThreadPreviewState(threadRef).sessions[tabId]; + const liveUrl = session && session.navStatus._tag !== "Idle" ? session.navStatus.url : null; + const liveTitle = + session && session.navStatus._tag !== "Idle" ? session.navStatus.title.trim() : ""; + const url = liveUrl ?? props.record.url; + const title = liveTitle || props.record.title; + const favicon = useFaviconForThreadUrl(threadRef, url ?? ""); + const navigate = useNavigate(); + return ( + + } + onClick={() => { + // A chip pasted into another thread still points at the tab's own thread. + useRightPanelStore.getState().openBrowser(threadRef, tabId); + void navigate({ to: "/$environmentId/$threadId", params: threadRef }); + }} + aria-label={`Browser tab, ${title}`} + data-markdown-copy={props.copyMarkdown} + > + } + className="size-[1.17em] self-center rounded-sm object-contain" + /> + {title} + + } + /> + + {session ? "Show in browser" : "Tab closed"} + {url ?
{url}
: null} +
+
+ ); +} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 426818dba7a24..32b2bce926095 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1061,7 +1061,7 @@ describe("snapshotComposerSend", () => { sendableTerminalContexts: [live], previewAnnotations: [], reviewComments: [], - threadContexts: [], + linkedContexts: [], }); expect(snapshot.terminalContexts).toEqual([live]); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e14d296cdd071..a218a10843e42 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -38,7 +38,6 @@ import { type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, - type ThreadContextRecord, type ThreadId, type ThreadLinkedPullRequest, type RunId, @@ -89,6 +88,7 @@ import { previewAnnotationContextLabel, reviewCommentContextLabel, terminalContextReference, + type LinkedContextRecord, } from "../lib/composerContextRecords"; import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; @@ -1269,7 +1269,7 @@ export interface PlanFollowUpComposerSnapshot { readonly terminalContexts: ReadonlyArray; readonly reviewComments: ReadonlyArray; readonly previewAnnotations: ReadonlyArray; - readonly threadContexts: ReadonlyArray; + readonly linkedContexts: ReadonlyArray; } export function restorePlanFollowUpComposer(input: { @@ -1278,7 +1278,7 @@ export function restorePlanFollowUpComposer(input: { readonly writeTerminalContexts: (contexts: ReadonlyArray) => void; readonly writeReviewComments: (comments: ReadonlyArray) => void; readonly writePreviewAnnotations: (annotations: ReadonlyArray) => void; - readonly writeThreadContexts: (records: ReadonlyArray) => void; + readonly writeLinkedContexts: (records: ReadonlyArray) => void; readonly resetCursor: (options: { cursor: number; prompt: string; @@ -1289,7 +1289,7 @@ export function restorePlanFollowUpComposer(input: { input.writeTerminalContexts(input.snapshot.terminalContexts); input.writeReviewComments(input.snapshot.reviewComments); input.writePreviewAnnotations(input.snapshot.previewAnnotations); - input.writeThreadContexts(input.snapshot.threadContexts); + input.writeLinkedContexts(input.snapshot.linkedContexts); input.resetCursor({ cursor: collapseExpandedComposerCursor(input.snapshot.prompt, input.snapshot.prompt.length), prompt: input.snapshot.prompt, @@ -1448,7 +1448,7 @@ export interface ComposerSendContents { readonly sendableTerminalContexts: ReadonlyArray; readonly previewAnnotations: ReadonlyArray; readonly reviewComments: ReadonlyArray; - readonly threadContexts: ReadonlyArray; + readonly linkedContexts: ReadonlyArray; } /** @@ -1467,7 +1467,7 @@ export function snapshotComposerSend(contents: ComposerSendContents) { terminalContexts, previewAnnotations: [...contents.previewAnnotations], reviewComments: [...contents.reviewComments], - threadContexts: [...contents.threadContexts], + linkedContexts: [...contents.linkedContexts], messageText: contents.terminalContexts .filter((context) => !terminalContexts.includes(context)) .reduce( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dae9caa8729ae..01563d0efe44d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -71,6 +71,7 @@ import { ProviderInteractionMode, resolveIdleCompaction, RuntimeMode, + type ThreadContextRecord, } from "@t3tools/contracts"; import { deriveProviderSubagentStatus, @@ -591,10 +592,13 @@ export default function ChatView(props: ChatViewProps) { const referencedThreadContexts = useComposerDraftStore( useShallow((store) => { const draft = store.getComposerDraft(composerDraftTarget); - if (!draft?.threadContexts.length) return []; + if (!draft?.linkedContexts.length) return []; // Linked prompts can replace text without pruning stored context records. const referenced = new Set(collectInlineContextIds(draft.prompt)); - return draft.threadContexts.filter((record) => referenced.has(record.contextId)); + return draft.linkedContexts.filter( + (record): record is ThreadContextRecord => + record.kind === "thread" && referenced.has(record.contextId), + ); }), ); const composerHasNonPromptContent = useComposerDraftStore((store) => { diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index 72633d478e119..65d8a94c4270c 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:oklch(0.62_0.16_16)]", skill: "[--context-chip-accent:oklch(0.62_0.16_322)]", thread: "[--context-chip-accent:oklch(0.62_0.12_190)]", + "browser-tab": "[--context-chip-accent:oklch(0.62_0.13_120)]", citation: "[--context-chip-accent:oklch(0.62_0.16_259)]", }, state: { @@ -52,6 +53,7 @@ const contextChipVariants = cva( "pr-closed", "skill", "thread", + "browser-tab", "citation", ], className: diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index caae00048fc9e..ddbfe42c0bd8d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -21,7 +21,6 @@ import type { ProjectId, PreviewAnnotationPayload, ProviderApprovalDecision, - ThreadContextRecord, ProviderInteractionMode, ResolvedKeybindingsConfig, RuntimeMode, @@ -37,6 +36,7 @@ import { ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, } from "@t3tools/contracts"; +import { browserTabContextRecord } from "@t3tools/client-runtime/composerBrowserTabItems"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Schema from "effect/Schema"; @@ -181,7 +181,8 @@ import { terminalContextReference, terminalContextRecord, threadContextRecord, - threadContextReference, + linkedContextReference, + type LinkedContextRecord, } from "~/lib/composerContextRecords"; import { rememberCopiedComposerAttachments } from "~/lib/copiedComposerAttachments"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; @@ -349,7 +350,7 @@ export interface ChatComposerHandle { terminalContexts: TerminalContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; - threadContexts: ThreadContextRecord[]; + linkedContexts: LinkedContextRecord[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -740,7 +741,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerTerminalContexts = composerDraft.terminalContexts; const composerPreviewAnnotations = composerDraft.previewAnnotations; const composerReviewComments = composerDraft.reviewComments; - const composerThreadContexts = attachmentDraft.threadContexts; + const composerLinkedContexts = attachmentDraft.linkedContexts; const { pendingSnapShotAnimations, pendingSnapShotIds, @@ -791,7 +792,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) terminalContexts: composerTerminalContexts, reviewComments: composerReviewComments, previewAnnotations: composerPreviewAnnotations, - threadContexts: composerThreadContexts, + linkedContexts: composerLinkedContexts, images: composerImages, files: composerFiles, uploadsByImageId, @@ -1355,8 +1356,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const addComposerDraftPreviewAnnotation = useComposerDraftStore( (store) => store.addPreviewAnnotation, ); - const addComposerDraftThreadContexts = useComposerDraftStore((store) => store.addThreadContexts); - const setComposerDraftThreadContexts = useComposerDraftStore((store) => store.setThreadContexts); + const addComposerDraftLinkedContexts = useComposerDraftStore((store) => store.addLinkedContexts); + const setComposerDraftLinkedContexts = useComposerDraftStore((store) => store.setLinkedContexts); const buildContextClipboardFragment = (contextIds: ReadonlyArray): string | null => { const wanted = new Set(contextIds); const copiedFiles = new Map(); @@ -1372,7 +1373,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ...composerTerminalContexts .filter((c) => wanted.has(terminalContextReference(c).contextId)) .map(terminalContextRecord), - ...composerThreadContexts.filter((record) => wanted.has(record.contextId)), + ...composerLinkedContexts.filter((record) => wanted.has(record.contextId)), ...composerReviewComments .filter((c) => wanted.has(reviewCommentContextId(c.id))) .map(reviewCommentContextRecord), @@ -1729,21 +1730,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerDraftTerminalContexts(composerDraftTarget, nextTerminals); } - const liveThreadIds = new Set(composerThreadContexts.map((record) => record.contextId)); + const liveThreadIds = new Set(composerLinkedContexts.map((record) => record.contextId)); const restoredThreads = [...referenced].flatMap((contextId) => { if (liveThreadIds.has(contextId)) return []; - const record = retained.threads.get(contextId); + const record = retained.linked.get(contextId); return record ? [record] : []; }); const nextThreads = [ - ...composerThreadContexts.filter((record) => referenced.has(record.contextId)), + ...composerLinkedContexts.filter((record) => referenced.has(record.contextId)), ...restoredThreads, ]; - for (const record of composerThreadContexts) { - if (!referenced.has(record.contextId)) retained.threads.set(record.contextId, record); + for (const record of composerLinkedContexts) { + if (!referenced.has(record.contextId)) retained.linked.set(record.contextId, record); } - if (nextThreads.length !== composerThreadContexts.length || restoredThreads.length > 0) { - setComposerDraftThreadContexts(composerDraftTarget, nextThreads); + if (nextThreads.length !== composerLinkedContexts.length || restoredThreads.length > 0) { + setComposerDraftLinkedContexts(composerDraftTarget, nextThreads); } for (const comment of composerReviewComments) { @@ -2027,7 +2028,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const shell = readThreadShell(item.thread); if (!shell) return; const record = threadContextRecord(item.thread, shell.title); - const replacement = `${formatInlineContextReference(threadContextReference(record))} `; + const replacement = `${formatInlineContextReference(linkedContextReference(record))} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, trigger.rangeEnd, @@ -2037,7 +2038,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), }); if (applied) { - addComposerDraftThreadContexts(attachmentDraftTarget, [record], { + addComposerDraftLinkedContexts(attachmentDraftTarget, [record], { + appendReference: false, + }); + setComposerHighlightedItemId(null); + } + return; + } + if (item.type === "browser-tab") { + if (trigger.kind !== "path" || activeThreadId === null) return; + const record = browserTabContextRecord({ environmentId, threadId: activeThreadId }, item.tab); + const replacement = `${formatInlineContextReference(linkedContextReference(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) { + addComposerDraftLinkedContexts(attachmentDraftTarget, [record], { appendReference: false, }); setComposerHighlightedItemId(null); @@ -2298,7 +2319,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerCollapsedMobile, composerPreviewAnnotations, composerReviewComments, - composerThreadContexts, + composerLinkedContexts, attachmentTargetKey, isComposerDraftTargetOpen: (key) => composerDraftTargetKeyRef.current === key, composerEditorRef, @@ -2930,7 +2951,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !activePendingIsResponding && activePendingProgress?.activeQuestion?.allowCustomAnswer !== false; const text = `${records - .map((record) => formatInlineContextReference(threadContextReference(record))) + .map((record) => formatInlineContextReference(linkedContextReference(record))) .join(" ")} `; const snapshot = readComposerSnapshotStable(); const cursor = snapshot.expandedCursor; @@ -2950,12 +2971,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } - addComposerDraftThreadContexts(questionAttachmentTarget, records, { + addComposerDraftLinkedContexts(questionAttachmentTarget, records, { appendReference: false, }); if (isComposerCollapsedMobile) expandMobileComposerStable(); } else { - addComposerDraftThreadContexts(composerDraftTarget, records); + addComposerDraftLinkedContexts(composerDraftTarget, records); } }); @@ -3243,7 +3264,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) terminalContexts: composerTerminalContextsRef.current, previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, - threadContexts: composerThreadContexts, + linkedContexts: composerLinkedContexts, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 4d556d48be701..fa276b8ff97ce 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -1,4 +1,5 @@ import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; +import type { ComposerBrowserTabItem } from "@t3tools/client-runtime/composerBrowserTabItems"; import type { ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; import { resolveProviderSkillSourceKind, @@ -15,6 +16,7 @@ import { import { BlocksIcon, FolderIcon, + GlobeIcon, MessagesSquareIcon, PackageIcon, SettingsIcon, @@ -77,7 +79,8 @@ export type ComposerCommandItem = thread: ScopedThreadRef; label: string; description: string; - }; + } + | ComposerBrowserTabItem; export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { listId: string; @@ -202,6 +205,9 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { {props.item.type === "thread" ? (