From a7b5762e9f037d904866dee703d776487341a75d Mon Sep 17 00:00:00 2001 From: DominikScholz Date: Fri, 6 Nov 2026 02:39:24 +0100 Subject: [PATCH 1/2] feat(composer): complete machine and model names as you type Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.logic.ts | 8 +- ...rPromptEditorTiptap.input.browser.test.tsx | 81 +++++++++- .../components/ComposerPromptEditorTiptap.tsx | 146 ++++++++++++++++++ apps/web/src/components/ContextChip.tsx | 1 + apps/web/src/components/ModelContextChip.tsx | 26 ++++ apps/web/src/components/chat/ChatComposer.tsx | 74 ++++++++- .../chat/UserMessageContextChips.tsx | 4 + .../components/chat/composerContextUndo.ts | 9 +- .../chat/useComposerContextImport.ts | 13 +- .../src/components/chat/useQueuedRunEdit.ts | 4 +- .../composerContextPresentation.tsx | 16 +- apps/web/src/composerDraftStore.test.ts | 29 ++++ apps/web/src/composerDraftStore.ts | 29 +++- apps/web/src/index.css | 19 +++ apps/web/src/lib/composerContextRecords.ts | 53 ++++++- apps/web/src/lib/contextChipAccents.ts | 1 + docs/user/composer.md | 5 + packages/client-runtime/package.json | 4 + .../src/composerInlineSuggestion.test.ts | 51 ++++++ .../src/composerInlineSuggestion.ts | 68 ++++++++ 20 files changed, 607 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/ModelContextChip.tsx create mode 100644 packages/client-runtime/src/composerInlineSuggestion.test.ts create mode 100644 packages/client-runtime/src/composerInlineSuggestion.ts diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index d4b54a7e5a848..5de1a35a7d60c 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,3 +1,4 @@ +import type { MentionContextRecord } from "~/lib/composerContextRecords"; import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts"; import { deriveLatestThreadRun, @@ -41,7 +42,6 @@ import { type ScopedProjectRef, type ScopedThreadRef, type ThreadContextRecord, - type EnvironmentContextRecord, type ThreadId, type ThreadLinkedPullRequest, type RunId, @@ -1174,7 +1174,7 @@ export interface PlanFollowUpComposerSnapshot { readonly reviewComments: ReadonlyArray; readonly previewAnnotations: ReadonlyArray; readonly threadContexts: ReadonlyArray; - readonly environmentContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; } // A draft typed while the send was in flight is newer and stays. Composer refs and the cursor @@ -1189,7 +1189,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 writeEnvironmentContexts: (records: ReadonlyArray) => void; readonly resetCursor: (options: { cursor: number; prompt: string; @@ -1364,7 +1364,7 @@ export interface ComposerSendContents { readonly previewAnnotations: ReadonlyArray; readonly reviewComments: ReadonlyArray; readonly threadContexts: ReadonlyArray; - readonly environmentContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; } /** diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index 925319680d40d..ffca42c6d9178 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -7,7 +7,8 @@ import { useRetainedContextPayloads, } from "./chat/composerContextUndo"; -import type { ServerProviderSkill } from "@t3tools/contracts"; +import { matchComposerInlineSuggestion } from "@t3tools/client-runtime/composerInlineSuggestion"; +import { EnvironmentId, type ServerProviderSkill } from "@t3tools/contracts"; import type { Editor } from "@tiptap/core"; import { redoDepth, undoDepth } from "@tiptap/pm/history"; import { NodeSelection } from "@tiptap/pm/state"; @@ -36,12 +37,13 @@ function Composer({ richTextEnabled, skills, onChange, + ...inlineSuggestions }: { value: string; richTextEnabled: boolean; skills: ReadonlyArray; onChange?: ComposerPromptEditorProps["onChange"] | undefined; -}) { +} & Pick) { const [prompt, setPrompt] = useState({ value, cursor: value.length }); return ( {}} + {...inlineSuggestions} /> ); } @@ -233,6 +236,80 @@ it.each([false, true])( }, ); +it("draws a completion while typing, reports Tab, and keeps a dismissed one gone", async () => { + const candidate = { + kind: "environment", + environmentId: EnvironmentId.make("env-vps"), + label: "Hetzner VPS", + } as const; + const onAccept = vi.fn(); + await using screen = await render( + matchComposerInlineSuggestion(text, [candidate])} + onAcceptInlineSuggestion={onAccept} + />, + ); + const ghost = () => screen.container.querySelector(".composer-inline-suggestion")?.textContent; + await act(async () => editorIn(screen.container).view.focus()); + + await userEvent.keyboard("on hetz"); + await vi.waitFor(() => expect(ghost()).toBe("ner VPSTab")); + await userEvent.keyboard("{Tab}"); + expect(onAccept).toHaveBeenCalledWith({ candidate, typed: "hetz", remainder: "ner VPS" }); + + await userEvent.keyboard("{Escape}"); + await vi.waitFor(() => expect(ghost()).toBeUndefined()); + await userEvent.keyboard("n"); + expect(ghost()).toBeUndefined(); + + await userEvent.keyboard(" hetz"); + await vi.waitFor(() => expect(ghost()).toBe("ner VPSTab")); + await userEvent.keyboard("{ArrowLeft}"); + await vi.waitFor(() => expect(ghost()).toBeUndefined()); + expect(onAccept).toHaveBeenCalledTimes(1); +}); + +it("proposes no completion for pasted text or a name crossing formatting", async () => { + const candidate = { + kind: "environment", + environmentId: EnvironmentId.make("env-mini"), + label: "Mac mini", + } as const; + await using screen = await render( + matchComposerInlineSuggestion(text, [candidate])} + />, + ); + const editor = editorIn(screen.container); + const ghost = () => screen.container.querySelector(".composer-inline-suggestion"); + await act(async () => { + editor.view.focus(); + editor.commands.setTextSelection(editor.state.doc.content.size - 1); + }); + + await userEvent.keyboard("i"); + await vi.waitFor(() => expect(editor.state.doc.textContent).toContain("mi")); + expect(ghost()).toBeNull(); + + await userEvent.keyboard(" "); + await act(async () => { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", "mac"); + editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true })); + }); + await vi.waitFor(() => expect(editor.state.doc.textContent).toContain("mi mac")); + expect(ghost()).toBeNull(); + + await userEvent.keyboard(" mac"); + await vi.waitFor(() => expect(ghost()).not.toBeNull()); +}); + it("lets composition replace selected text without surrounding it", async () => { await using screen = await mountEditor("word"); const { editor } = screen; diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 8c4698210b1d5..16f407ab84e78 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1,3 +1,5 @@ +import type { ComposerInlineSuggestion } from "@t3tools/client-runtime/composerInlineSuggestion"; +import type { ComposerInlineCandidate } from "~/lib/composerContextRecords"; import { documentChipKindForFileName } from "~/pierre-icons"; import { isProviderSkillUserInvocable } from "@t3tools/client-runtime/providerSkills"; import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; @@ -177,6 +179,8 @@ export interface ComposerPromptEditorProps { ) => void; onVisibleSelectionChange?: () => void; onCommandKeyDown?: (key: string, event: KeyboardEvent, isTaskItem?: boolean) => boolean; + matchInlineSuggestion?: (textBeforeCaret: string) => ComposerEditorInlineSuggestion | null; + onAcceptInlineSuggestion?: (suggestion: ComposerEditorInlineSuggestion) => void; onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; @@ -669,6 +673,120 @@ const ComposerChipSelectionExtension = Extension.create({ }, }); +/** What the composer proposes after the caret; the editor only draws it and reports Tab. */ +export type ComposerEditorInlineSuggestion = ComposerInlineSuggestion; + +interface InlineSuggestionState { + readonly suggestion: ComposerEditorInlineSuggestion | null; + readonly from: number | null; + /** Where the suggestion Escape dismissed starts, so it stays gone while that word grows. */ + readonly dismissedFrom: number | null; +} + +interface InlineSuggestionStorage { + match: (textBeforeCaret: string) => ComposerEditorInlineSuggestion | null; +} + +const InlineSuggestionPluginKey = new PluginKey( + "composer-inline-suggestion", +); +const CHIP_PLACEHOLDER = "\ufffc"; + +function inlineSuggestionAtCaret( + { selection }: EditorState, + match: InlineSuggestionStorage["match"], +): Pick | null { + const { $from, empty } = selection; + const { parent, parentOffset } = $from; + if (!empty || !parent.isTextblock || parent.type.spec.code) return null; + const next = parent.textBetween( + parentOffset, + Math.min(parentOffset + 1, parent.content.size), + undefined, + CHIP_PLACEHOLDER, + ); + if (next !== "" && !/\s/u.test(next)) return null; + const textBeforeCaret = parent.textBetween(0, parentOffset, undefined, (node) => + node.type.name === "hardBreak" ? "\n" : CHIP_PLACEHOLDER, + ); + const suggestion = match(textBeforeCaret); + // Accepting replaces serialized Markdown, so the name must not span formatting markers. + if (!suggestion || suggestion.typed.length > ($from.nodeBefore?.text?.length ?? 0)) return null; + return { suggestion, from: selection.from - suggestion.typed.length }; +} + +function inlineSuggestionWidget(remainder: string): HTMLElement { + const root = document.createElement("span"); + root.className = "composer-inline-suggestion"; + root.setAttribute("aria-hidden", "true"); + root.append(remainder); + const key = document.createElement("kbd"); + key.className = "composer-inline-suggestion-key"; + key.textContent = "Tab"; + root.append(key); + return root; +} + +const ComposerInlineSuggestionExtension = Extension.create({ + name: "composerInlineSuggestion", + addStorage() { + return { match: () => null }; + }, + addProseMirrorPlugins() { + const storage = this.storage; + return [ + new Plugin({ + key: InlineSuggestionPluginKey, + state: { + init: () => ({ suggestion: null, from: null, dismissedFrom: null }), + apply: (tr, previous, _, state) => { + const dismissing = tr.getMeta(InlineSuggestionPluginKey) === "dismiss"; + if (!tr.docChanged && !tr.selectionSet && !dismissing) return previous; + const dismissedFrom = dismissing + ? previous.from + : previous.dismissedFrom === null + ? null + : tr.mapping.map(previous.dismissedFrom); + const none = { suggestion: null, from: null, dismissedFrom }; + // Follows typing only: a moved caret, a paste, an undo or a restored draft has no word + // in progress, and Tab should keep its usual meaning there. + if ( + !tr.docChanged || + tr.getMeta("uiEvent") || + tr.getMeta("history$") || + tr.getMeta("preventUpdate") || + tr.getMeta("composition") !== undefined + ) { + return none; + } + const found = inlineSuggestionAtCaret(state, storage.match); + return found === null || found.from === dismissedFrom + ? none + : { ...found, dismissedFrom }; + }, + }, + props: { + decorations: (state) => { + const suggestion = InlineSuggestionPluginKey.getState(state)?.suggestion; + if (!suggestion) return null; + return DecorationSet.create(state.doc, [ + Decoration.widget( + state.selection.head, + () => inlineSuggestionWidget(suggestion.remainder), + { + side: 1, + ignoreSelection: true, + key: `inline-suggestion:${suggestion.remainder}`, + }, + ), + ]); + }, + }, + }), + ]; + }, +}); + function chipSelectionDecorations({ doc, selection }: EditorState): DecorationSet { if (selection.empty) return DecorationSet.empty; const decorations: Decoration[] = []; @@ -772,6 +890,7 @@ function createComposerExtensions(richText: boolean) { ComposerCitationExtension, ComposerContextReferenceExtension, ComposerChipSelectionExtension, + ComposerInlineSuggestionExtension, ...(richText ? [ ComposerMarkdownSourceExtension, @@ -879,6 +998,8 @@ function ComposerPromptEditorTiptapInner( onChange, onVisibleSelectionChange, onCommandKeyDown, + matchInlineSuggestion, + onAcceptInlineSuggestion, onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, @@ -905,6 +1026,8 @@ function ComposerPromptEditorTiptapInner( onChange, onVisibleSelectionChange, onCommandKeyDown, + matchInlineSuggestion, + onAcceptInlineSuggestion, isPathQueryActive, importContextFragment, })); @@ -1041,9 +1164,32 @@ function ComposerPromptEditorTiptapInner( literalText, }), editable: !disabled, + onBeforeCreate: ({ editor: created }) => { + const storage = created.storage as unknown as { + composerInlineSuggestion: InlineSuggestionStorage; + }; + storage.composerInlineSuggestion.match = (textBeforeCaret) => + readCallbacks().matchInlineSuggestion?.(textBeforeCaret) ?? null; + }, editorProps: { attributes: editorAttributes, handleKeyDown: (view, event) => { + const inlineSuggestion = InlineSuggestionPluginKey.getState(view.state)?.suggestion; + if ( + inlineSuggestion && + (event.key === "Tab" || event.key === "Escape") && + !event.isComposing && + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey + ) { + event.preventDefault(); + event.stopPropagation(); + if (event.key === "Tab") readCallbacks().onAcceptInlineSuggestion?.(inlineSuggestion); + else view.dispatch(view.state.tr.setMeta(InlineSuggestionPluginKey, "dismiss")); + return true; + } // In a right-to-left line the start is on the right. const backArrow = caretLineDirection(view.state) === "rtl" ? "ArrowRight" : "ArrowLeft"; const boundary = event.metaKey diff --git a/apps/web/src/components/ContextChip.tsx b/apps/web/src/components/ContextChip.tsx index a536f28ccfb12..652c0e114c5d1 100644 --- a/apps/web/src/components/ContextChip.tsx +++ b/apps/web/src/components/ContextChip.tsx @@ -28,6 +28,7 @@ const contextChipVariants = cva( skill: "[--context-chip-accent:var(--context-chip-skill)]", thread: "[--context-chip-accent:var(--context-chip-thread)]", environment: "[--context-chip-accent:var(--context-chip-environment)]", + model: "[--context-chip-accent:var(--context-chip-model)]", 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/ModelContextChip.tsx b/apps/web/src/components/ModelContextChip.tsx new file mode 100644 index 0000000000000..3134d88c20d0a --- /dev/null +++ b/apps/web/src/components/ModelContextChip.tsx @@ -0,0 +1,26 @@ +import { SparklesIcon } from "lucide-react"; + +import { ContextChip, ContextChipLabel } from "./ContextChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** Inline chip for a model the prompt names, in the composer and in sent messages. */ +export function ModelContextChip(props: { label: string; copyMarkdown?: string }) { + return ( + + + + {props.label} + + } + /> + Model + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 69022a93fbeea..7b7e0d666770d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -9,7 +9,7 @@ import { resolveRuntimeModeOptions, } from "./runtimeModeConfig"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; -import { usePrimaryEnvironmentId } from "../../state/environments"; +import { useEnvironmentIdentities, usePrimaryEnvironmentId } from "../../state/environments"; import { useRightPanelStore } from "~/rightPanelStore"; import { importPastedComposerText, readPastedComposerContext } from "../composerInlineTokenPaste"; import { @@ -27,7 +27,6 @@ import type { ProjectId, PreviewAnnotationPayload, ThreadContextRecord, - EnvironmentContextRecord, ProviderInteractionMode, ResolvedKeybindingsConfig, RuntimeMode, @@ -45,6 +44,8 @@ import { ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { matchComposerInlineSuggestion } from "@t3tools/client-runtime/composerInlineSuggestion"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import * as Schema from "effect/Schema"; @@ -159,6 +160,7 @@ import { type ComposerCitationCommentRequest, type ComposerPromptEditorHandle, ComposerPromptEditorTiptap as ComposerPromptEditor, + type ComposerEditorInlineSuggestion, } from "../ComposerPromptEditorTiptap"; import { ComposerContextActionsContext, @@ -186,11 +188,13 @@ import { threadContextReference, environmentContextRecord, environmentContextReference, + modelContextRecord, + type MentionContextRecord, } from "~/lib/composerContextRecords"; import { rememberCopiedComposerAttachments } from "~/lib/copiedComposerAttachments"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; import { readThreadShell } from "~/state/entities"; -import { useLinkedEnvironments } from "~/state/peerLinks"; +import { offersLinkedEnvironmentsAtom, useLinkedEnvironments } from "~/state/peerLinks"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; import type { ComposerContextRecord, ThreadPullRequestLink } from "@t3tools/contracts"; import { threadContextAttachment } from "~/lib/threadContextAttachment"; @@ -334,7 +338,7 @@ export interface ChatComposerHandle { previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; threadContexts: ThreadContextRecord[]; - environmentContexts: EnvironmentContextRecord[]; + environmentContexts: MentionContextRecord[]; selectedPromptEffort: string | null; selectedModelSelection: ModelSelection; multipleModelSelections: ReadonlyArray | null; @@ -1146,6 +1150,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerPreviewAnnotations.length === 0 && composerReviewComments.length === 0; const linkedEnvironments = useLinkedEnvironments(environmentId); + const environmentIdentities = useEnvironmentIdentities(); + const offersMachines = useAtomValue(offersLinkedEnvironmentsAtom(environmentId)); const { items: composerMenuItems, isLoading: isComposerMenuLoading, @@ -2084,6 +2090,64 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const onSelectComposerItemStable = useStableCallback(onSelectComposerItem); + const matchInlineSuggestion = (textBeforeCaret: string) => + isLiteralPendingAnswer || questionAttachmentTarget !== null + ? null + : matchComposerInlineSuggestion(textBeforeCaret, [ + ...(offersMachines + ? environmentIdentities.map(({ environmentId: mentionedId, label }) => ({ + kind: "environment" as const, + environmentId: mentionedId, + label, + })) + : []), + // The thread's own provider wins when two offer a model of the same name. + ...[...modelOptionsByInstance] + .toSorted(([left], [right]) => + left === selectedInstanceId ? -1 : right === selectedInstanceId ? 1 : 0, + ) + .flatMap(([instanceId, options]) => + options + // "Default" and "Auto" are ordinary words first, and name no particular model. + .filter( + (option) => + option.isUnavailable !== true && !/^(?:default|auto)$/iu.test(option.name), + ) + .map((option) => ({ + kind: "model" as const, + instanceId, + model: option.slug, + label: option.name, + })), + ), + ]); + const matchInlineSuggestionStable = useStableCallback(matchInlineSuggestion); + + const onAcceptInlineSuggestion = (accepted: ComposerEditorInlineSuggestion) => { + const { candidate } = accepted; + const snapshot = readComposerSnapshot(); + const rangeStart = snapshot.expandedCursor - accepted.typed.length; + const record = + candidate.kind === "environment" + ? environmentContextRecord(candidate.environmentId, candidate.label) + : modelContextRecord(candidate.instanceId, candidate.model, candidate.label); + const replacement = `${formatInlineContextReference(environmentContextReference(record))} `; + const rangeEnd = extendReplacementRangeForTrailingSpace( + snapshot.value, + snapshot.expandedCursor, + replacement, + ); + const applied = applyPromptReplacement(rangeStart, rangeEnd, replacement, { + expectedText: snapshot.value.slice(rangeStart, rangeEnd), + }); + if (applied) { + addComposerDraftEnvironmentContexts(composerDraftTarget, [record], { + appendReference: false, + }); + } + }; + const onAcceptInlineSuggestionStable = useStableCallback(onAcceptInlineSuggestion); + const onComposerMenuItemHighlighted = (itemId: string | null) => { setComposerHighlightedItemId(itemId); setComposerHighlightedSearchKey(composerMenuSearchKey); @@ -3515,6 +3579,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) className={cn(showMobilePendingAnswerActions && "max-sm:pb-12")} onChange={onPromptChangeStable} onCommandKeyDown={onComposerCommandKeyStable} + matchInlineSuggestion={matchInlineSuggestionStable} + onAcceptInlineSuggestion={onAcceptInlineSuggestionStable} onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} onPageScrollRelease={onPageScrollRelease} diff --git a/apps/web/src/components/chat/UserMessageContextChips.tsx b/apps/web/src/components/chat/UserMessageContextChips.tsx index 2638692caab26..52862e2755f7c 100644 --- a/apps/web/src/components/chat/UserMessageContextChips.tsx +++ b/apps/web/src/components/chat/UserMessageContextChips.tsx @@ -23,6 +23,7 @@ import { import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown from "../ChatMarkdown"; import { EnvironmentContextChip } from "../EnvironmentContextChip"; +import { ModelContextChip } from "../ModelContextChip"; import { ThreadContextChip } from "../ThreadContextChip"; import { MessageCircleIcon, MousePointerClickIcon, PaintbrushIcon } from "lucide-react"; import type { ComposerContextId, KnownComposerContextRecord } from "@t3tools/contracts"; @@ -523,6 +524,9 @@ export function UserMessageContextReferenceChip(props: { contextId: props.reference.contextId as ComposerContextId, label: props.reference.label, }); + if (props.reference.kind === "model") { + return ; + } return userMessageContextPresentationRegistry.render(props.reference.kind, props.record, { environmentId: activeThreadEnvironmentId, reference: props.reference, diff --git a/apps/web/src/components/chat/composerContextUndo.ts b/apps/web/src/components/chat/composerContextUndo.ts index fd2a0cde59341..c7b546dd09694 100644 --- a/apps/web/src/components/chat/composerContextUndo.ts +++ b/apps/web/src/components/chat/composerContextUndo.ts @@ -1,11 +1,8 @@ -import type { - EnvironmentContextRecord, - PreviewAnnotationPayload, - ThreadContextRecord, -} from "@t3tools/contracts"; +import type { PreviewAnnotationPayload, ThreadContextRecord } from "@t3tools/contracts"; import { useEffect, useState } from "react"; import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; +import type { MentionContextRecord } from "../../lib/composerContextRecords"; import { fileContextReference, previewAnnotationContextId } from "../../lib/composerContextRecords"; import type { TerminalContextDraft } from "../../lib/terminalContext"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -24,7 +21,7 @@ export interface RetainedContextPayloads extends RetainedAttachmentContextPayloa terminals: Map; reviewComments: Map; threads: Map; - environments: Map; + environments: Map; } function createRetainedContextPayloads(): RetainedContextPayloads { diff --git a/apps/web/src/components/chat/useComposerContextImport.ts b/apps/web/src/components/chat/useComposerContextImport.ts index 74e97a5076d7d..5668ed6e01381 100644 --- a/apps/web/src/components/chat/useComposerContextImport.ts +++ b/apps/web/src/components/chat/useComposerContextImport.ts @@ -1,3 +1,4 @@ +import { isModelContextRecord } from "~/lib/composerContextRecords"; import { ConnectionBlockedError, ConnectionTransientError, @@ -302,6 +303,14 @@ export function useComposerContextImport(input: { : 0, ); for (const candidate of orderedRecords) { + if (isModelContextRecord(candidate)) { + if (questionAttachmentTarget !== null) continue; + addComposerDraftEnvironmentContexts(composerDraftTarget, [candidate], { + appendReference: false, + }); + rewritten.set(candidate.contextId, candidate.contextId); + continue; + } // Producer ids fold into context ids, so two different excerpts can collide. Only skip // when the draft already holds the same payload; a colliding but different record is // re-minted under a fresh id so both survive the paste. @@ -337,7 +346,9 @@ export function useComposerContextImport(input: { ? existing.record.id : undefined, }) - : existing?.kind === "thread" || existing?.kind === "environment" + : existing?.kind === "thread" || + existing?.kind === "environment" || + existing?.kind === "model" ? existing.record : existing ? (uploadedContextRecordFromDraft(existing) ?? undefined) diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index d9185775ea000..ea497701d0a90 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -1,3 +1,4 @@ +import type { MentionContextRecord } from "~/lib/composerContextRecords"; import { useAtomValue } from "@effect/atom-react"; import { makeQueuedRunEditStore, @@ -7,7 +8,6 @@ import type { ThreadStructure } from "@t3tools/client-runtime/state/shell"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type ChatAttachment, - type EnvironmentContextRecord, type EnvironmentId, type MessageId, type OrchestrationMessageContext, @@ -80,7 +80,7 @@ interface QueuedRunEditContent { readonly reviewComments: ReadonlyArray; readonly previewAnnotations: ReadonlyArray; readonly threadContexts: ReadonlyArray; - readonly environmentContexts: ReadonlyArray; + readonly environmentContexts: ReadonlyArray; } function queuedEditDraftTarget(routeThreadRef: ScopedThreadRef, runId: RunId) { diff --git a/apps/web/src/components/composerContextPresentation.tsx b/apps/web/src/components/composerContextPresentation.tsx index 845e921725b55..9504c3eae1523 100644 --- a/apps/web/src/components/composerContextPresentation.tsx +++ b/apps/web/src/components/composerContextPresentation.tsx @@ -1,3 +1,4 @@ +import type { MentionContextRecord, ModelContextRecord } from "~/lib/composerContextRecords"; import ChatMarkdown from "./ChatMarkdown"; import { isSvgAttachment } from "@t3tools/shared/image"; import { ReadOnlySourcePreview } from "./files/AttachmentFilePreview"; @@ -37,6 +38,7 @@ import type { TerminalContextDraft } from "~/lib/terminalContext"; import type { ReviewCommentContext } from "~/reviewCommentContext"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; import { EnvironmentContextChip } from "./EnvironmentContextChip"; +import { ModelContextChip } from "./ModelContextChip"; import { ThreadContextChip } from "./ThreadContextChip"; import { useLinkedEnvironments } from "~/state/peerLinks"; import { @@ -66,7 +68,8 @@ export type ComposerDraftContextRecord = | { kind: "image"; record: ComposerImageAttachment; upload?: AttachmentUploadState | undefined } | { kind: "file"; record: ComposerFileAttachment; upload?: AttachmentUploadState | undefined } | { kind: "thread"; record: ThreadContextRecord } - | { kind: "environment"; record: EnvironmentContextRecord }; + | { kind: "environment"; record: EnvironmentContextRecord } + | { kind: "model"; record: ModelContextRecord }; export { ComposerContextActionsContext, @@ -91,7 +94,7 @@ export function composerContextRecordsFromDraft(input: { reviewComments?: ReadonlyArray; previewAnnotations?: ReadonlyArray; threadContexts?: ReadonlyArray; - environmentContexts?: ReadonlyArray; + environmentContexts?: ReadonlyArray; images?: ReadonlyArray; files?: ReadonlyArray; uploadsByImageId?: Readonly>; @@ -129,7 +132,10 @@ export function composerContextRecordsFromDraft(input: { records.set(record.contextId, { kind: "thread", record }); } for (const record of input.environmentContexts ?? []) { - records.set(record.contextId, { kind: "environment", record }); + records.set( + record.contextId, + record.kind === "model" ? { kind: "model", record } : { kind: "environment", record }, + ); } return records; } @@ -487,7 +493,9 @@ export function ComposerContextReferenceChip(props: { label: string; }): ReactElement { const records = use(ComposerContextRecordsContext); - return composerContextPresentationRegistry.render(props.kind, records.get(props.contextId), { + const record = records.get(props.contextId); + if (record?.kind === "model") return ; + return composerContextPresentationRegistry.render(props.kind, record, { label: props.label, }); } diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index cd0e74d231aac..cb613432d4ed0 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -98,6 +98,7 @@ import { insertInlineContextReference } from "./lib/composerContextReferences"; import { buildMessageContext, environmentContextRecord, + modelContextRecord, terminalContextReference, } from "./lib/composerContextRecords"; import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; @@ -767,6 +768,34 @@ describe("composerDraftStore environment contexts", () => { beforeEach(resetComposerDraftStore); + it("keeps a named model through persistence and into the sent context", () => { + const opus = modelContextRecord("claudeAgent", "claude-opus-5.5", "Claude Opus 5.5"); + const store = useComposerDraftStore.getState(); + store.setPrompt(threadRef, "Ask"); + store.addEnvironmentContexts(threadRef, [opus]); + + const merge = useComposerDraftStore.persist.getOptions().merge!; + const hydrated = merge( + JSON.parse( + JSON.stringify(partializeComposerDraftStoreState(useComposerDraftStore.getState())), + ), + useComposerDraftStore.getInitialState(), + ); + const restored = hydrated.draftsByThreadKey[scopedThreadKey(threadRef)]; + expect(restored?.environmentContexts).toEqual([opus]); + expect(restored?.prompt).toBe( + `Ask [Claude Opus 5.5](t3-context://v1/model/${opus.contextId}) `, + ); + expect( + buildMessageContext({ + terminalContexts: [], + reviewComments: [], + previewAnnotations: [], + environmentContexts: restored?.environmentContexts ?? [], + })?.records, + ).toEqual([opus]); + }); + it("mentions a machine once, survives persistence, and sends only its identity", () => { const store = useComposerDraftStore.getState(); store.setPrompt(threadRef, "Run it on"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index aef9e88a38572..dd072d9a6ca41 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -21,6 +21,7 @@ import { RuntimeMode, ThreadContextRecord, EnvironmentContextRecord, + UnknownContextRecord, type ServerProvider, type ScopedProjectRef, type ScopedThreadRef, @@ -87,6 +88,8 @@ import { terminalContextReference, threadContextReference, environmentContextReference, + isModelContextRecord, + type MentionContextRecord, } from "./lib/composerContextRecords"; import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; @@ -106,7 +109,9 @@ const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); const isThreadContextRecord = Schema.is(ThreadContextRecord); -const isEnvironmentContextRecord = Schema.is(EnvironmentContextRecord); +const isMachineContextRecord = Schema.is(EnvironmentContextRecord); +const isEnvironmentContextRecord = (record: unknown): record is MentionContextRecord => + isMachineContextRecord(record) || isModelContextRecord(record); const isSnapShotSource = Schema.is(SnapShotSource); const isPreviewAnnotationPayload = Schema.is(PreviewAnnotationPayloadSchema); @@ -247,7 +252,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({ previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), reviewComments: Schema.optionalKey(Schema.Array(ReviewCommentContextSchema)), threadContexts: Schema.optionalKey(Schema.Array(ThreadContextRecord)), - environmentContexts: Schema.optionalKey(Schema.Array(EnvironmentContextRecord)), + // The machines and models the prompt names. The key predates models. + environmentContexts: Schema.optionalKey( + Schema.Array(Schema.Union([EnvironmentContextRecord, UnknownContextRecord])), + ), modelSelectionByProvider: Schema.optionalKey(Schema.Record(ProviderInstanceId, ModelSelection)), activeProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), modelSelectionExplicit: Schema.optionalKey(Schema.Boolean), @@ -371,7 +379,7 @@ export interface ComposerThreadDraftState { previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; threadContexts: ThreadContextRecord[]; - environmentContexts: EnvironmentContextRecord[]; + environmentContexts: MentionContextRecord[]; modelSelectionByProvider: Partial>; activeProvider: ProviderInstanceId | null; modelSelectionExplicit?: boolean; @@ -613,12 +621,12 @@ interface ComposerDraftStoreState { /** Mentions machines as context; already-mentioned machines are skipped. */ addEnvironmentContexts: ( threadRef: ComposerThreadTarget, - records: ReadonlyArray, + records: ReadonlyArray, options?: ComposerContextAddOptions, ) => void; setEnvironmentContexts: ( threadRef: ComposerThreadTarget, - records: ReadonlyArray, + records: ReadonlyArray, ) => void; clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( @@ -728,7 +736,7 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = []; const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = []; const EMPTY_THREAD_CONTEXTS: ThreadContextRecord[] = []; -const EMPTY_ENVIRONMENT_CONTEXTS: EnvironmentContextRecord[] = []; +const EMPTY_ENVIRONMENT_CONTEXTS: MentionContextRecord[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_FILES); Object.freeze(EMPTY_IDS); @@ -2428,7 +2436,9 @@ function toHydratedThreadDraft( ...(persistedDraft.reviewComments ?? []).map(reviewCommentContextReference), ...(persistedDraft.previewAnnotations ?? []).map(previewAnnotationContextReference), ...(persistedDraft.threadContexts ?? []).map(threadContextReference), - ...(persistedDraft.environmentContexts ?? []).map(environmentContextReference), + ...(persistedDraft.environmentContexts ?? []) + .filter(isEnvironmentContextRecord) + .map(environmentContextReference), ...files.map(fileContextReference), ]), images: hydrateImagesFromPersisted(persistedDraft.attachments), @@ -2444,7 +2454,10 @@ function toHydratedThreadDraft( persistedDraft.previewAnnotations?.map((annotation) => ({ ...annotation })) ?? [], reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [], threadContexts: persistedDraft.threadContexts?.map((record) => ({ ...record })) ?? [], - environmentContexts: persistedDraft.environmentContexts?.map((record) => ({ ...record })) ?? [], + environmentContexts: + persistedDraft.environmentContexts + ?.filter(isEnvironmentContextRecord) + .map((record) => ({ ...record })) ?? [], modelSelectionByProvider, activeProvider, ...(persistedDraft.modelSelectionExplicit ? { modelSelectionExplicit: true } : {}), diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3f3e1c742f4cf..f871d00c3c60c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2471,6 +2471,25 @@ code { position: relative; } +/* Ghost text after the caret; Tab turns it into a chip. */ +.composer-tiptap .composer-inline-suggestion { + color: var(--muted-foreground); + opacity: 0.7; + pointer-events: none; + user-select: none; + white-space: pre; +} + +.composer-tiptap .composer-inline-suggestion-key { + margin-inline-start: 0.5em; + padding: 0 0.35em; + border: 1px solid var(--border); + border-radius: 4px; + font-family: inherit; + font-size: 0.7em; + vertical-align: 0.1em; +} + .composer-tiptap.ProseMirror-focused .composer-chip-range-selected::after { content: ""; position: absolute; diff --git a/apps/web/src/lib/composerContextRecords.ts b/apps/web/src/lib/composerContextRecords.ts index 5f8eda0008cd9..3960218365367 100644 --- a/apps/web/src/lib/composerContextRecords.ts +++ b/apps/web/src/lib/composerContextRecords.ts @@ -6,7 +6,9 @@ import { COMPOSER_CONTEXT_REVIEW_DIFF_MAX_CHARS, COMPOSER_CONTEXT_REVIEW_TEXT_MAX_CHARS, COMPOSER_CONTEXT_TERMINAL_TEXT_MAX_CHARS, + UnknownContextRecord, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import type { ComposerContextId, ComposerContextRecord, @@ -169,9 +171,54 @@ export function threadContextReference(record: ThreadContextRecord): ComposerCon /** One record per machine: mentioning the same machine twice reuses the chip. */ export function environmentContextReference( - record: EnvironmentContextRecord, + record: MentionContextRecord, ): ComposerContextReference { - return { kind: "environment", contextId: record.contextId, label: record.label }; + return { kind: record.kind, contextId: record.contextId, label: record.label }; +} + +/** + * A model the prompt names. It stays an unknown-kind record on the wire: every server and client + * already carries those, and the agent reads the payload as is. + */ +export interface ModelContextRecord extends UnknownContextRecord { + readonly kind: "model"; + readonly payload: { + readonly providerInstanceId: string; + readonly model: string; + readonly note: string; + }; +} + +export type MentionContextRecord = EnvironmentContextRecord | ModelContextRecord; + +/** A name the composer's ghost text can complete into a chip. */ +export type ComposerInlineCandidate = { readonly label: string } & ( + | { readonly kind: "environment"; readonly environmentId: EnvironmentId } + | { readonly kind: "model"; readonly instanceId: string; readonly model: string } +); + +const isUnknownContextRecord = Schema.is(UnknownContextRecord); + +export function isModelContextRecord(record: unknown): record is ModelContextRecord { + return isUnknownContextRecord(record) && record.kind === "model"; +} + +export function modelContextRecord( + providerInstanceId: string, + model: string, + label: string, +): ModelContextRecord { + return { + version: 1, + kind: "model", + contextId: toKindScopedComposerContextId("model", `${providerInstanceId}_${model}`), + label: sanitizeComposerContextLabel(label, "model"), + payload: { + providerInstanceId, + model, + note: "The user means this model. To run work on it, pass these as target.providerInstanceId and target.model to delegate_task.", + }, + }; } export function environmentContextRecord( @@ -330,7 +377,7 @@ export function buildMessageContext(input: { reviewComments: ReadonlyArray; previewAnnotations: ReadonlyArray; threadContexts?: ReadonlyArray; - environmentContexts?: ReadonlyArray; + environmentContexts?: ReadonlyArray; attachments?: ReadonlyArray; }): OrchestrationMessageContext | undefined { const screenshotAttachmentIds = new Set( diff --git a/apps/web/src/lib/contextChipAccents.ts b/apps/web/src/lib/contextChipAccents.ts index dfa0276ee08a7..1431afbcdb759 100644 --- a/apps/web/src/lib/contextChipAccents.ts +++ b/apps/web/src/lib/contextChipAccents.ts @@ -15,6 +15,7 @@ export const CONTEXT_CHIP_ACCENTS = { skill: "oklch(0.62 0.16 322)", thread: "oklch(0.62 0.12 190)", environment: "oklch(0.62 0.11 145)", + model: "oklch(0.62 0.15 305)", citation: "oklch(0.62 0.16 259)", "doc-pdf": "oklch(0.62 0.16 24)", "doc-word": "oklch(0.62 0.136 248)", diff --git a/docs/user/composer.md b/docs/user/composer.md index 13f451e2caa73..d74a1098d3824 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -334,6 +334,11 @@ current title and opens it when selected. Your prompt only carries a reference: history on demand, so attaching a long thread costs nothing until the agent looks. Attaching a thread does not change it, and the agent cannot send messages to it unless you ask. +On web and desktop, the composer also completes the names of your models and, while the Linked +environments beta is on, your machines. Type three letters or more, with or without `@`, and the +rest of the name appears as gray text after the cursor. Press Tab to turn it into a chip that tells +the agent exactly which one you mean, or Esc to dismiss it. + A thread from a linked machine, picked, pasted or dropped, stays a reference that the agent reads on that machine. A pasted link to a thread on any other machine stays a link. Dropping a thread from any other environment attaches a saved transcript file. The source diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f52115577f108..fd09bf493c23d 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -19,6 +19,10 @@ "types": "./src/workspaceBasenameLookup.ts", "default": "./src/workspaceBasenameLookup.ts" }, + "./composerInlineSuggestion": { + "types": "./src/composerInlineSuggestion.ts", + "default": "./src/composerInlineSuggestion.ts" + }, "./composerThreadItems": { "types": "./src/composerThreadItems.ts", "default": "./src/composerThreadItems.ts" diff --git a/packages/client-runtime/src/composerInlineSuggestion.test.ts b/packages/client-runtime/src/composerInlineSuggestion.test.ts new file mode 100644 index 0000000000000..bf68d2436fcbc --- /dev/null +++ b/packages/client-runtime/src/composerInlineSuggestion.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { matchComposerInlineSuggestion } from "./composerInlineSuggestion.ts"; + +const candidates = [ + { label: "Hetzner VPS" }, + { label: "Mac mini" }, + { label: "Claude Opus 5.5" }, + { label: "dev-box" }, +]; + +const match = (text: string) => { + const found = matchComposerInlineSuggestion(text, candidates); + return found && [found.candidate.label, found.typed, found.remainder]; +}; + +describe("matchComposerInlineSuggestion", () => { + it.each([ + ["run this on hetz", ["Hetzner VPS", "hetz", "ner VPS"]], + ["run this on HETZNER V", ["Hetzner VPS", "HETZNER V", "PS"]], + ["run on mac mi", ["Mac mini", "mac mi", "ni"]], + ["have opus", ["Claude Opus 5.5", "opus", " 5.5"]], + ["(hetz", ["Hetzner VPS", "hetz", "ner VPS"]], + ["first line\nhetz", ["Hetzner VPS", "hetz", "ner VPS"]], + ["try box", ["dev-box", "box", " (dev-box)"]], + ["use Hetzner VPS", ["Hetzner VPS", "Hetzner VPS", ""]], + ])("completes %j", (text, expected) => { + expect(match(text)).toEqual(expected); + }); + + it.each([ + ["he", "too short"], + ["hetz ", "a finished word"], + ["@hetz", "an @ mention, which has its own menu"], + ["/model opus", "a command, which has its own menu"], + ["5.5", "a run that does not start with a letter"], + ["sohetz", "the middle of a word"], + ["etzner", "the middle of a label's word"], + ["", "an empty line"], + ])("leaves %j alone: %s", (text) => { + expect(match(text)).toBeNull(); + }); + + it("prefers the earliest candidate when several match", () => { + const found = matchComposerInlineSuggestion("mac", [ + { label: "Mac mini" }, + { label: "MacBook" }, + ]); + expect(found?.candidate.label).toBe("Mac mini"); + }); +}); diff --git a/packages/client-runtime/src/composerInlineSuggestion.ts b/packages/client-runtime/src/composerInlineSuggestion.ts new file mode 100644 index 0000000000000..55a8d880272d5 --- /dev/null +++ b/packages/client-runtime/src/composerInlineSuggestion.ts @@ -0,0 +1,68 @@ +export interface ComposerInlineSuggestionCandidate { + readonly label: string; +} + +export interface ComposerInlineSuggestion { + readonly candidate: T; + /** The text before the caret that the suggestion replaces. */ + readonly typed: string; + /** What the caret is followed by: the rest of the label, or the label when `typed` ends it. */ + readonly remainder: string; +} + +// Under three letters, ordinary words like "he" or "ma" would keep suggesting names. +const MIN_TYPED_LENGTH = 3; +// Enough for the longest label a user would type out: "Hetzner VPS", "Claude Opus 5.5". +const MAX_TYPED_WORDS = 4; +const WORD_BOUNDARY = /[\s([{"'`]/u; +const LABEL_WORD_BOUNDARY = /[\s\-_/]/u; + +function typedRunStarts(line: string): number[] { + const starts: number[] = []; + for (let index = line.length - 1; index >= 0; index -= 1) { + if (index > 0 && !WORD_BOUNDARY.test(line[index - 1] ?? "")) continue; + if (WORD_BOUNDARY.test(line[index] ?? "")) continue; + starts.push(index); + if (starts.length === MAX_TYPED_WORDS) break; + } + return starts.reverse(); +} + +function labelWordMatch(label: string, typed: string): number { + for (let index = 0; index + typed.length <= label.length; index += 1) { + if (index > 0 && !LABEL_WORD_BOUNDARY.test(label[index - 1] ?? "")) continue; + if (label.startsWith(typed, index)) return index; + } + return -1; +} + +/** + * The name the user is typing, for the composer's ghost text. The longest run of words before + * the caret wins, then the earliest candidate. A run can start at any word of a label, so "opus" + * finds "Claude Opus 5.5". Commands and `@` mentions have their own menus, so they stay out. + */ +export function matchComposerInlineSuggestion( + textBeforeCaret: string, + candidates: ReadonlyArray, +): ComposerInlineSuggestion | null { + const line = textBeforeCaret.slice(textBeforeCaret.lastIndexOf("\n") + 1); + if (line.startsWith("/") || line.length === 0 || /\s$/u.test(line)) return null; + for (const start of typedRunStarts(line)) { + const typed = line.slice(start); + if (typed.length < MIN_TYPED_LENGTH || !/^\p{L}/u.test(typed)) continue; + const folded = typed.toLowerCase(); + for (const candidate of candidates) { + const label = candidate.label.toLowerCase(); + const index = labelWordMatch(label, folded); + if (index >= 0) { + const rest = candidate.label.slice(index + typed.length); + return { + candidate, + typed, + remainder: rest === "" && index > 0 ? ` (${candidate.label})` : rest, + }; + } + } + } + return null; +} From c712db7ad74f7d148907209e405510db6576cbad Mon Sep 17 00:00:00 2001 From: DominikScholz Date: Fri, 6 Nov 2026 12:44:27 +0100 Subject: [PATCH 2/2] fix(web): model chips keep instance and model apart; docs say completion is without @ Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/composerContextRecords.test.ts | 9 +++++++++ apps/web/src/lib/composerContextRecords.ts | 2 +- docs/user/composer.md | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/composerContextRecords.test.ts b/apps/web/src/lib/composerContextRecords.test.ts index 4f119f1b45573..7929eca7620e3 100644 --- a/apps/web/src/lib/composerContextRecords.test.ts +++ b/apps/web/src/lib/composerContextRecords.test.ts @@ -16,6 +16,7 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + modelContextRecord, asKnownContextRecord, buildMessageContext, composerContextImportLookupIds, @@ -483,3 +484,11 @@ describe("selectedMessageContextFragment", () => { expect(fragment).not.toContain(terminal.contextId); }); }); + +describe("modelContextRecord", () => { + it("keeps instance and model apart in its id", () => { + expect(modelContextRecord("a_b", "charlie", "Charlie").contextId).not.toBe( + modelContextRecord("a", "b_charlie", "Charlie").contextId, + ); + }); +}); diff --git a/apps/web/src/lib/composerContextRecords.ts b/apps/web/src/lib/composerContextRecords.ts index 3960218365367..bb57c6515a705 100644 --- a/apps/web/src/lib/composerContextRecords.ts +++ b/apps/web/src/lib/composerContextRecords.ts @@ -211,7 +211,7 @@ export function modelContextRecord( return { version: 1, kind: "model", - contextId: toKindScopedComposerContextId("model", `${providerInstanceId}_${model}`), + contextId: toKindScopedComposerContextId("model", JSON.stringify([providerInstanceId, model])), label: sanitizeComposerContextLabel(label, "model"), payload: { providerInstanceId, diff --git a/docs/user/composer.md b/docs/user/composer.md index d74a1098d3824..7e36ec33aa979 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -335,7 +335,7 @@ history on demand, so attaching a long thread costs nothing until the agent look thread does not change it, and the agent cannot send messages to it unless you ask. On web and desktop, the composer also completes the names of your models and, while the Linked -environments beta is on, your machines. Type three letters or more, with or without `@`, and the +environments beta is on, your machines. Type three letters or more of a name, without `@`, and the rest of the name appears as gray text after the cursor. Press Tab to turn it into a chip that tells the agent exactly which one you mean, or Esc to dismiss it.