From fd4ca763fcfbc011cbd578d11fbb9d2f141474df Mon Sep 17 00:00:00 2001 From: dbpolito Date: Wed, 31 Dec 2025 14:42:53 -0300 Subject: [PATCH 1/5] Desktop: Add Subagents Mention Support --- packages/app/src/components/prompt-input.tsx | 231 ++++++++++++++++--- packages/app/src/context/prompt.tsx | 11 +- packages/ui/src/components/message-part.tsx | 50 ++-- 3 files changed, 245 insertions(+), 47 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3c3225137da..af7b64f5c80 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -3,7 +3,15 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat import { createStore, produce } from "solid-js/store" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" -import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt" +import { + ContentPart, + DEFAULT_PROMPT, + isPromptEqual, + Prompt, + usePrompt, + ImageAttachmentPart, + AgentPart, +} from "@/context/prompt" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useNavigate, useParams } from "@solidjs/router" @@ -126,7 +134,7 @@ export const PromptInput: Component = (props) => { const working = createMemo(() => status()?.type !== "idle") const [store, setStore] = createStore<{ - popover: "file" | "slash" | null + popover: "at" | "slash" | null historyIndex: number savedPrompt: Prompt | null placeholder: number @@ -167,6 +175,7 @@ export const PromptInput: Component = (props) => { prompt.map((part) => { if (part.type === "text") return { ...part } if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } return { ...part, selection: part.selection ? { ...part.selection } : undefined, @@ -317,15 +326,43 @@ export const PromptInput: Component = (props) => { if (!isFocused()) setStore("popover", null) }) - const handleFileSelect = (path: string | undefined) => { - if (!path) return - addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 }) + type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string } + + const agentList = createMemo(() => + sync.data.agent + .filter((agent) => !agent.hidden && agent.mode !== "primary") + .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), + ) + + const handleAtSelect = (option: AtOption | undefined) => { + if (!option) return + if (option.type === "agent") { + addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) + } else { + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + } } - const { flat, active, onInput, onKeyDown } = useFilteredList({ - items: local.file.searchFilesAndDirectories, - key: (x) => x, - onSelect: handleFileSelect, + const atKey = (x: AtOption | undefined) => { + if (!x) return "" + return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` + } + + const { + flat: atFlat, + active: atActive, + onInput: atOnInput, + onKeyDown: atOnKeyDown, + } = useFilteredList({ + items: async (query) => { + const agents = agentList() + const files = await local.file.searchFilesAndDirectories(query) + const fileOptions: AtOption[] = files.map((path) => ({ type: "file", path, display: path })) + return [...agents, ...fileOptions] + }, + key: atKey, + filterKeys: ["display"], + onSelect: handleAtSelect, }) const slashCommands = createMemo(() => { @@ -411,6 +448,7 @@ export const PromptInput: Component = (props) => { if (node.nodeType !== Node.ELEMENT_NODE) return false const el = node as HTMLElement if (el.dataset.type === "file") return true + if (el.dataset.type === "agent") return true return el.tagName === "BR" }) if (normalized && isPromptEqual(currentParts, domParts)) return @@ -434,6 +472,16 @@ export const PromptInput: Component = (props) => { pill.style.userSelect = "text" pill.style.cursor = "default" editorRef.appendChild(pill) + } else if (part.type === "agent") { + const pill = document.createElement("span") + pill.textContent = part.content + pill.setAttribute("data-type", "agent") + pill.setAttribute("data-name", part.name) + pill.setAttribute("contenteditable", "false") + pill.style.color = "light-dark(var(--solaris-light-11), var(--solaris-dark-11))" + pill.style.userSelect = "text" + pill.style.cursor = "default" + editorRef.appendChild(pill) } }) @@ -469,6 +517,18 @@ export const PromptInput: Component = (props) => { position += content.length } + const pushAgent = (agent: HTMLElement) => { + const content = agent.textContent ?? "" + parts.push({ + type: "agent", + name: agent.dataset.name!, + content, + start: position, + end: position + content.length, + }) + position += content.length + } + const visit = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { buffer += node.textContent ?? "" @@ -482,6 +542,11 @@ export const PromptInput: Component = (props) => { pushFile(el) return } + if (el.dataset.type === "agent") { + flushText() + pushAgent(el) + return + } if (el.tagName === "BR") { buffer += "\n" return @@ -535,8 +600,8 @@ export const PromptInput: Component = (props) => { const slashMatch = rawText.match(/^\/(\S*)$/) if (atMatch) { - onInput(atMatch[1]) - setStore("popover", "file") + atOnInput(atMatch[1]) + setStore("popover", "at") } else if (slashMatch) { slashOnInput(slashMatch[1]) setStore("popover", "slash") @@ -612,6 +677,62 @@ export const PromptInput: Component = (props) => { setEdge("end", cursorPosition) } + range.deleteContents() + range.insertNode(gap) + range.insertNode(pill) + range.setStartAfter(gap) + range.collapse(true) + selection.removeAllRanges() + selection.addRange(range) + } else if (part.type === "agent") { + const pill = document.createElement("span") + pill.textContent = part.content + pill.setAttribute("data-type", "agent") + pill.setAttribute("data-name", part.name) + pill.setAttribute("contenteditable", "false") + pill.style.color = "light-dark(var(--solaris-light-11), var(--solaris-dark-11))" + pill.style.userSelect = "text" + pill.style.cursor = "default" + + const gap = document.createTextNode(" ") + const range = selection.getRangeAt(0) + + const setEdge = (edge: "start" | "end", offset: number) => { + let remaining = offset + const nodes = Array.from(editorRef.childNodes) + + for (const node of nodes) { + const length = getNodeLength(node) + const isText = node.nodeType === Node.TEXT_NODE + const isPill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (isText && remaining <= length) { + if (edge === "start") range.setStart(node, remaining) + if (edge === "end") range.setEnd(node, remaining) + return + } + + if ((isPill || isBreak) && remaining <= length) { + if (edge === "start" && remaining === 0) range.setStartBefore(node) + if (edge === "start" && remaining > 0) range.setStartAfter(node) + if (edge === "end" && remaining === 0) range.setEndBefore(node) + if (edge === "end" && remaining > 0) range.setEndAfter(node) + return + } + + remaining -= length + } + } + + if (atMatch) { + const start = atMatch.index ?? cursorPosition - atMatch[0].length + setEdge("start", start) + setEdge("end", cursorPosition) + } + range.deleteContents() range.insertNode(gap) range.insertNode(pill) @@ -759,8 +880,8 @@ export const PromptInput: Component = (props) => { } if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) { - if (store.popover === "file") { - onKeyDown(event) + if (store.popover === "at") { + atOnKeyDown(event) } else { slashOnKeyDown(event) } @@ -842,11 +963,12 @@ export const PromptInput: Component = (props) => { if (!existing) return const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path)) - const attachments = currentPrompt.filter( + const fileAttachments = currentPrompt.filter( (part) => part.type === "file", ) as import("@/context/prompt").FileAttachmentPart[] + const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[] - const fileAttachmentParts = attachments.map((attachment) => { + const fileAttachmentParts = fileAttachments.map((attachment) => { const absolute = toAbsolutePath(attachment.path) const query = attachment.selection ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}` @@ -869,6 +991,17 @@ export const PromptInput: Component = (props) => { } }) + const agentAttachmentParts = agentAttachments.map((attachment) => ({ + id: Identifier.ascending("part"), + type: "agent" as const, + name: attachment.name, + source: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + })) + const imageAttachmentParts = store.imageAttachments.map((attachment) => ({ id: Identifier.ascending("part"), type: "file" as const, @@ -930,7 +1063,7 @@ export const PromptInput: Component = (props) => { type: "text" as const, text, } - const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts] + const requestParts = [textPart, ...fileAttachmentParts, ...agentAttachmentParts, ...imageAttachmentParts] const optimisticParts = requestParts.map((part) => ({ ...part, sessionID: existing.id, @@ -967,24 +1100,46 @@ export const PromptInput: Component = (props) => { border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" > - - 0} fallback={
No matching files
}> - - {(i) => ( + + 0} + fallback={
No matching results
} + > + + {(item) => ( )} @@ -1263,12 +1418,24 @@ function getCursorPosition(parent: HTMLElement): number { } function setCursorPosition(parent: HTMLElement, position: number) { + // Bounds check - prevent invalid positions + if (position < 0 || position > 1000000) { + position = 0 + } + + const totalLength = getTextLength(parent) + if (position > totalLength) { + position = totalLength + } + let remaining = position let node = parent.firstChild while (node) { const length = getNodeLength(node) const isText = node.nodeType === Node.TEXT_NODE - const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" + const isPill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" if (isText && remaining <= length) { @@ -1281,13 +1448,13 @@ function setCursorPosition(parent: HTMLElement, position: number) { return } - if ((isFile || isBreak) && remaining <= length) { + if ((isPill || isBreak) && remaining <= length) { const range = document.createRange() const selection = window.getSelection() if (remaining === 0) { range.setStartBefore(node) } - if (remaining > 0 && isFile) { + if (remaining > 0 && isPill) { range.setStartAfter(node) } if (remaining > 0 && isBreak) { diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 8d3590cd996..25d8146eaed 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -21,6 +21,11 @@ export interface FileAttachmentPart extends PartBase { selection?: TextSelection } +export interface AgentPart extends PartBase { + type: "agent" + name: string +} + export interface ImageAttachmentPart { type: "image" id: string @@ -29,7 +34,7 @@ export interface ImageAttachmentPart { dataUrl: string } -export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart +export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart export type Prompt = ContentPart[] export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] @@ -46,6 +51,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) { return false } + if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) { + return false + } if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { return false } @@ -61,6 +69,7 @@ function cloneSelection(selection?: TextSelection) { function clonePart(part: ContentPart): ContentPart { if (part.type === "text") return { ...part } if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } return { ...part, selection: cloneSelection(part.selection), diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index cf4daebbfc3..527323eb1b4 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -12,6 +12,7 @@ import { } from "solid-js" import { Dynamic } from "solid-js/web" import { + AgentPart, AssistantMessage, FilePart, Message as MessageType, @@ -300,6 +301,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp }), ) + const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) + const openImagePreview = (url: string, alt?: string) => { dialog.show(() => ) } @@ -337,33 +340,41 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
- +
) } -function HighlightedText(props: { text: string; references: FilePart[] }) { +type HighlightSegment = { text: string; type?: "file" | "agent" } + +function HighlightedText(props: { text: string; references: FilePart[]; agents: AgentPart[] }) { const segments = createMemo(() => { const text = props.text - const refs = [...props.references].sort((a, b) => (a.source?.text?.start ?? 0) - (b.source?.text?.start ?? 0)) - const result: { text: string; highlight?: boolean }[] = [] + // Combine file and agent references with their types + const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [ + ...props.references + .filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined) + .map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })), + ...props.agents + .filter((a) => a.source?.start !== undefined && a.source?.end !== undefined) + .map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })), + ].sort((a, b) => a.start - b.start) + + const result: HighlightSegment[] = [] let lastIndex = 0 - for (const ref of refs) { - const start = ref.source?.text?.start - const end = ref.source?.text?.end - - if (start === undefined || end === undefined || start < lastIndex) continue + for (const ref of allRefs) { + if (ref.start < lastIndex) continue - if (start > lastIndex) { - result.push({ text: text.slice(lastIndex, start) }) + if (ref.start > lastIndex) { + result.push({ text: text.slice(lastIndex, ref.start) }) } - result.push({ text: text.slice(start, end), highlight: true }) - lastIndex = end + result.push({ text: text.slice(ref.start, ref.end), type: ref.type }) + lastIndex = ref.end } if (lastIndex < text.length) { @@ -375,7 +386,18 @@ function HighlightedText(props: { text: string; references: FilePart[] }) { return ( - {(segment) => {segment.text}} + {(segment) => ( + + {segment.text} + + )} ) } From f311fc65f6ea579b74e1e75a503c4acfa890364e Mon Sep 17 00:00:00 2001 From: dbpolito Date: Wed, 31 Dec 2025 15:24:03 -0300 Subject: [PATCH 2/5] Tweak --- packages/app/src/components/prompt-input.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index af7b64f5c80..2d916e530cb 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1105,7 +1105,7 @@ export const PromptInput: Component = (props) => { when={atFlat().length > 0} fallback={
No matching results
} > - + {(item) => (