diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.test.ts index ee3644cf..0ab749c7 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.test.ts @@ -1,5 +1,34 @@ import { describe, expect, test } from "bun:test" -import { toolPartFromUpdate } from "./acp-client-support" +import { + requestUserInputFromOriginalEvent, + toolPartFromUpdate, +} from "./acp-client-support" + +describe("ACP original event mapping", () => { + test("extracts request_user_input from the tagged server event wire shape", () => { + const originalEvent = { + kind: "request_user_input", + request: { + request_id: "rq1", + session_id: "s1", + turn_id: "t1", + item_id: null, + }, + questions: [ + { + id: "scope", + header: "Scope", + question: "Which scope?", + isOther: true, + isSecret: false, + options: [{ label: "Repo", description: "Current repository" }], + }, + ], + } + + expect(requestUserInputFromOriginalEvent(originalEvent)).toEqual(originalEvent) + }) +}) describe("ACP tool update mapping", () => { test("infers command tools from raw input instead of exposing the tool call id", () => { diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts index 85c1e056..ddcf554e 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts @@ -133,9 +133,23 @@ export function questionInfoFromAcp(question: unknown): any { header: String(value.header ?? ""), question: String(value.question ?? ""), options, + isOther: Boolean(value.isOther ?? value.is_other ?? true), + isSecret: Boolean(value.isSecret ?? value.is_secret ?? false), } } +export function requestUserInputFromOriginalEvent( + original: unknown, +): Record | undefined { + if (!original || typeof original !== "object") return undefined + const event = original as Record + if (event.kind === "request_user_input") return event + const legacy = event.RequestUserInput + return legacy && typeof legacy === "object" + ? (legacy as Record) + : undefined +} + export function partTime(existingPart: any, now: number): { start: number; end?: number } { return existingPart?.time ?? { start: now } } diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts index 77b7f5af..df9c97ca 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts @@ -1208,22 +1208,23 @@ describe("ACP desktop SDK session mapping", () => { update: { sessionUpdate: "session_info_update" }, _meta: { "devo/originalEvent": { - RequestUserInput: { - request: { - request_id: "rq1", - session_id: "s1", - turn_id: "t1", - item_id: null, - }, - questions: [ - { - id: "scope", - header: "Scope", - question: "Which scope?", - options: [{ label: "Repo", description: "Current repository" }], - }, - ], + kind: "request_user_input", + request: { + request_id: "rq1", + session_id: "s1", + turn_id: "t1", + item_id: null, }, + questions: [ + { + id: "scope", + header: "Scope", + question: "Which scope?", + isOther: true, + isSecret: false, + options: [{ label: "Repo", description: "Current repository" }], + }, + ], }, }, } satisfies AcpSessionNotification) @@ -1255,6 +1256,8 @@ describe("ACP desktop SDK session mapping", () => { header: "Scope", question: "Which scope?", options: [{ label: "Repo", description: "Current repository" }], + isOther: true, + isSecret: false, }, ], }, diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index 0ebd3acd..8f3833e8 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -10,6 +10,7 @@ import { partTime, providerDataFromConfigOptions, questionInfoFromAcp, + requestUserInputFromOriginalEvent, sessionErrorEvent, stableId, statusFromDevo, @@ -68,7 +69,10 @@ import { type ReferenceSearchSnapshot, } from "./reference-search-session" -export type { ReferenceSearchSnapshot } from "./reference-search-session" +export type { + ReferenceSearchResult, + ReferenceSearchSnapshot, +} from "./reference-search-session" export type JsonRpcId = number | string @@ -132,10 +136,28 @@ export type Project = any export type Provider = any export type ProviderAuthMethod = any export type ProviderConfig = any -export type QuestionAnswer = any -export type QuestionInfo = any -export type QuestionOption = any -export type QuestionRequest = any +export type QuestionOption = { + label: string + description: string +} + +export type QuestionInfo = { + id: string + header: string + question: string + options: QuestionOption[] + isOther: boolean + isSecret: boolean +} + +export type QuestionRequest = { + id: string + requestID: string + sessionID: string + questions: QuestionInfo[] +} + +export type QuestionAnswer = string[] export type ReasoningPart = any export type RetryPart = any export type ServerConfig = any @@ -636,7 +658,19 @@ class AcpClient { model?: unknown agent?: string variant?: string + collaborationMode?: string }) => { + // Plan mode uses turn/start which supports collaboration_mode + if (params.collaborationMode === "plan") { + const result = await this.turn.start({ + sessionID: params.sessionID, + parts: params.parts, + model: params.model, + variant: params.variant, + collaborationMode: "plan", + }) + return result + } const model = params.model as { modelID?: string } | undefined if (model?.modelID) await this.setSessionConfigOption(params.sessionID, "model", model.modelID) if (params.variant) await this.setSessionConfigOption(params.sessionID, "thought_level", params.variant) @@ -760,6 +794,7 @@ class AcpClient { model?: unknown variant?: string cwd?: string | null + collaborationMode?: string }) => { const model = params.model as { modelID?: string } | undefined if (model?.modelID) await this.setSessionConfigOption(params.sessionID, "model", model.modelID) @@ -771,7 +806,7 @@ class AcpClient { sandbox: null, approval_policy: null, cwd: params.cwd ?? null, - collaboration_mode: "build", + collaboration_mode: params.collaborationMode ?? "build", })) as TurnStartResult return { data: result } }, @@ -1580,8 +1615,8 @@ class AcpClient { }) return } - if ("RequestUserInput" in original) { - const payload = (original as { RequestUserInput: Record }).RequestUserInput + const payload = requestUserInputFromOriginalEvent(original) + if (payload) { this.handleRequestUserInput(sessionId, directory, payload) } const workspaceChanges = workspaceChangesUpdatedFromOriginalEvent(original) diff --git a/apps/desktop/src/renderer/atoms/sessions.ts b/apps/desktop/src/renderer/atoms/sessions.ts index b75e576c..d32c2999 100644 --- a/apps/desktop/src/renderer/atoms/sessions.ts +++ b/apps/desktop/src/renderer/atoms/sessions.ts @@ -436,12 +436,13 @@ export const updateProjectPaginationAtom = atom( directory: string fetchedCount: number limit: number + hasMore?: boolean }, ) => { set(projectPaginationFamily(args.directory), { loaded: true, currentLimit: args.limit, - hasMore: args.fetchedCount >= args.limit, + hasMore: args.hasMore ?? args.fetchedCount >= args.limit, loading: false, }) }, diff --git a/apps/desktop/src/renderer/components/chat/chat-input.tsx b/apps/desktop/src/renderer/components/chat/chat-input.tsx index 054d3284..992aa82b 100644 --- a/apps/desktop/src/renderer/components/chat/chat-input.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-input.tsx @@ -26,8 +26,8 @@ import { ContextItems } from "./context-items" import { type MentionOption, MentionPopover, type MentionPopoverHandle } from "./mention-popover" import { PromptAttachmentPreview } from "./prompt-attachments" import { - createAgentMention, - createFileMention, + createMentionFromOption, + getMentionKey, getMentionMarker, insertMentionIntoText, type PromptMention, @@ -261,8 +261,7 @@ export function ChatInput({ const currentText = ctrl.getText() const textarea = document.querySelector("textarea[data-prompt-input]") const cursorPos = textarea?.selectionStart ?? currentText.length - const mention = - option.type === "file" ? createFileMention(option.path) : createAgentMention(option.name) + const mention = createMentionFromOption(option) const { text: newText, cursorPosition: newCursor } = insertMentionIntoText( currentText, cursorPos, @@ -270,8 +269,8 @@ export function ChatInput({ ) ctrl.setText(newText) setMentions((prev) => { - const key = mention.type === "file" ? `file:${mention.path}` : `agent:${mention.name}` - if (prev.some((m) => (m.type === "file" ? `file:${m.path}` : `agent:${m.name}`) === key)) + const key = getMentionKey(mention) + if (prev.some((candidate) => getMentionKey(candidate) === key)) return prev return [...prev, mention] }) diff --git a/apps/desktop/src/renderer/components/chat/chat-question.tsx b/apps/desktop/src/renderer/components/chat/chat-question.tsx index 0012368d..b1f2ca73 100644 --- a/apps/desktop/src/renderer/components/chat/chat-question.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-question.tsx @@ -35,10 +35,9 @@ function buildAnswers( customTexts: Map, ): QuestionAnswer[] { return questions.map((_, idx) => { - const selected = Array.from(selections.get(idx) ?? []) const custom = (customTexts.get(idx) ?? "").trim() - if (custom) selected.push(custom) - return selected + if (custom) return [custom] + return Array.from(selections.get(idx) ?? []).slice(0, 1) }) } @@ -78,13 +77,19 @@ function QuestionSection({ onSubmitCustom, disabled, }: QuestionSectionProps) { - const isMultiple = info.multiple === true - const allowCustom = info.custom !== false + // Note: the protocol doesn't support multi-select yet; always false for now. + const isMultiple = false + const allowCustom = info.isOther !== false return (
+ {info.question} {/* Options */} -
+
{info.options.map((option: { label: string; description: string }) => { const isSelected = selected.has(option.label) @@ -92,24 +97,27 @@ function QuestionSection({ {isLastStep ? ( @@ -482,9 +506,9 @@ const QuestionRequestStepper = memo(function QuestionRequestStepper({ aria-label="Send answer" > {submitting ? ( -
diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx index bd5d0670..696d59bc 100644 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx @@ -200,6 +200,7 @@ export function getToolInfo(tool: string): { case "todoread": return { icon: SquareCheckIcon, title: "Todos" } case "question": + case "request_user_input": return { icon: BookOpenIcon, title: "Question" } default: return { icon: WrenchIcon, title: tool } @@ -247,6 +248,9 @@ function getPendingLabel(tool: string): string { return "Preparing agent..." case "webfetch": return "Preparing fetch..." + case "question": + case "request_user_input": + return "Asking a question..." default: return `Preparing ${tool}...` } @@ -341,6 +345,18 @@ export function getToolSubtitle( } break } + case "question": + case "request_user_input": { + const questions = input?.questions as Array<{ question: string }> | undefined + if (questions && questions.length > 0) { + subtitle = questions.length === 1 + ? questions[0].question + : `${questions.length} questions` + } else { + subtitle = title + } + break + } default: // Unknown / MCP tools: always show compact input params like [key=value, key=value] // Input params are more useful than the SDK-generated title for MCP tools diff --git a/apps/desktop/src/renderer/components/chat/chat-view.tsx b/apps/desktop/src/renderer/components/chat/chat-view.tsx index 55687d13..304e6e85 100644 --- a/apps/desktop/src/renderer/components/chat/chat-view.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-view.tsx @@ -107,8 +107,8 @@ import type { MentionOption } from "./mention-popover" import { MentionPopover, type MentionPopoverHandle } from "./mention-popover" import { PromptAttachmentPreview } from "./prompt-attachments" import { - createAgentMention, - createFileMention, + createMentionFromOption, + getMentionKey, getMentionMarker, insertMentionIntoText, type PromptMention, @@ -736,7 +736,7 @@ interface ChatViewProps { onSendMessage?: ( agent: Agent, message: string, - options?: { model?: ModelRef; agentName?: string; variant?: string; files?: FileAttachment[] }, + options?: { model?: ModelRef; agentName?: string; variant?: string; files?: FileAttachment[]; collaborationMode?: string }, ) => Promise /** Callback to stop/abort the running session */ onStop?: (agent: Agent) => Promise @@ -1201,6 +1201,12 @@ function ChatInputSection({ const [activeTrigger, setActiveTrigger] = useState(null) const [activeGoal, setActiveGoal] = useState(null) const [goalAction, setGoalAction] = useState(null) + const [collaborationMode, setCollaborationMode] = useState<"build" | "plan">("build") + + // Reset collaboration mode when session changes + useEffect(() => { + setCollaborationMode("build") + }, [agent.sessionId]) // User requirement: the /goal footer chip is only an input trigger; // the composer-adjacent status row reflects the real session goal state. @@ -1671,6 +1677,7 @@ function ChatInputSection({ agentName: selectedAgent || undefined, variant: selectedVariant, files, + collaborationMode, }) log.debug("handleSend onSendMessage completed", { sessionId: agent.sessionId }) } @@ -1797,8 +1804,7 @@ function ChatInputSection({ const textarea = document.querySelector("textarea[data-prompt-input]") const cursorPos = textarea?.selectionStart ?? currentText.length - const mention = - option.type === "file" ? createFileMention(option.path) : createAgentMention(option.name) + const mention = createMentionFromOption(option) const { text: newText, cursorPosition: newCursor } = insertMentionIntoText( currentText, @@ -1809,8 +1815,8 @@ function ChatInputSection({ ctrl.setText(newText) setMentions((prev) => { - const key = mention.type === "file" ? `file:${mention.path}` : `agent:${mention.name}` - if (prev.some((m) => (m.type === "file" ? `file:${m.path}` : `agent:${m.name}`) === key)) + const key = getMentionKey(mention) + if (prev.some((candidate) => getMentionKey(candidate) === key)) return prev return [...prev, mention] }) @@ -1834,8 +1840,8 @@ function ChatInputSection({ ctrl.setText(currentText.replace(`${marker} `, "").replace(marker, "")) } setMentions((prev) => { - const key = mention.type === "file" ? `file:${mention.path}` : `agent:${mention.name}` - return prev.filter((m) => (m.type === "file" ? `file:${m.path}` : `agent:${m.name}`) !== key) + const key = getMentionKey(mention) + return prev.filter((candidate) => getMentionKey(candidate) !== key) }) }, []) diff --git a/apps/desktop/src/renderer/components/chat/context-items.tsx b/apps/desktop/src/renderer/components/chat/context-items.tsx index 06795980..9815b878 100644 --- a/apps/desktop/src/renderer/components/chat/context-items.tsx +++ b/apps/desktop/src/renderer/components/chat/context-items.tsx @@ -1,13 +1,13 @@ /** - * Context items display — file/agent mention chips shown above the input. + * Context items display — reference/agent mention chips shown above the input. * * Inspired by Devo TUI's context-items.tsx pattern. - * Shows removable chips for each @-mentioned file or agent. + * Shows removable chips for each tracked @ mention. */ import { cn } from "@devo/ui/lib/utils" -import { BrainIcon, FileIcon, XIcon } from "lucide-react" +import { BrainIcon, FileIcon, PlugIcon, SparklesIcon, XIcon } from "lucide-react" import { memo } from "react" -import type { PromptMention } from "./prompt-mentions" +import { getMentionKey, getMentionMarker, type PromptMention } from "./prompt-mentions" // ============================================================ // ContextItems @@ -35,7 +35,7 @@ export const ContextItems = memo(function ContextItems({ > {mentions.map((mention) => ( onRemove(mention)} /> @@ -61,21 +61,42 @@ const ContextChip = memo(function ContextChip({ onRemove: () => void }) { const isAgent = mention.type === "agent" - const label = isAgent ? `@${mention.name}` : getFileName(mention.path) - const tooltip = isAgent ? `Agent: ${mention.name}` : mention.path + const isFile = mention.type === "file" + const label = isAgent + ? `@${mention.name}` + : isFile + ? getFileName(mention.path) + : getMentionMarker(mention) + const tooltip = isAgent + ? `Agent: ${mention.name}` + : isFile + ? mention.path + : `${mention.kind === "skill" ? "Skill" : "MCP"}: ${mention.name}` + const referenceClass = + mention.type === "reference" + ? mention.kind === "skill" + ? "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400" + : "bg-fuchsia-500/10 text-fuchsia-600 dark:text-fuchsia-400" + : undefined return ( {isAgent ? ( - + + ) : isFile ? ( + + ) : mention.kind === "skill" ? ( + ) : ( - + )} {label} ) diff --git a/apps/desktop/src/renderer/components/chat/mention-popover.test.ts b/apps/desktop/src/renderer/components/chat/mention-popover.test.ts new file mode 100644 index 00000000..aaeef220 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/mention-popover.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test" +import type { ReferenceSearchResult } from "@devo-ai/sdk/v2/client" +import { isMentionOptionDisabled, mapReferenceSearchResults } from "./mention-popover" +import { createMentionFromOption, insertMentionIntoText } from "./prompt-mentions" + +describe("mention popover reference results", () => { + test("preserves skill, MCP, and file results from the server", () => { + const results: ReferenceSearchResult[] = [ + { + kind: "skill", + display_name: "openai-docs", + description: "Use official OpenAI documentation", + insert_text: "@openai-docs", + mention_path: "skills/openai-docs/SKILL.md", + }, + { + kind: "mcp", + display_name: "Docs", + description: "Documentation server", + insert_text: "@mcp:docs", + mention_path: "mcp://server/docs", + is_disabled: true, + disabled_reason: "Server is disconnected", + }, + { + kind: "file", + display_name: "src/main.rs", + insert_text: "@main.rs", + mention_path: "src/main.rs", + file_path: "/workspace/src/main.rs", + }, + ] + + expect(mapReferenceSearchResults(results)).toEqual([ + { + type: "skill", + name: "openai-docs", + display: "openai-docs", + description: "Use official OpenAI documentation", + insertText: "@openai-docs", + mentionPath: "skills/openai-docs/SKILL.md", + disabled: false, + disabledReason: undefined, + }, + { + type: "mcp", + name: "Docs", + display: "Docs", + description: "Documentation server", + insertText: "@mcp:docs", + mentionPath: "mcp://server/docs", + disabled: true, + disabledReason: "Server is disconnected", + }, + { + type: "file", + path: "src/main.rs", + display: "src/main.rs", + insertText: "@main.rs", + disabled: false, + disabledReason: undefined, + }, + ]) + }) + + test("inserts the exact server token for Skill and MCP selections", () => { + const [skill, mcp] = mapReferenceSearchResults([ + { + kind: "skill", + display_name: "OpenAI Docs", + insert_text: "@openai-docs", + mention_path: "skills/openai-docs/SKILL.md", + }, + { + kind: "mcp", + display_name: "Documentation", + insert_text: "@mcp:docs", + mention_path: "mcp://server/docs", + }, + ]) + + expect([ + insertMentionIntoText("Ask @open", 9, createMentionFromOption(skill)), + insertMentionIntoText("Use @doc", 8, createMentionFromOption(mcp)), + ]).toEqual([ + { text: "Ask @openai-docs ", cursorPosition: 17 }, + { text: "Use @mcp:docs ", cursorPosition: 14 }, + ]) + }) + + test("excludes references with a disabled reason from selection", () => { + const [mcp] = mapReferenceSearchResults([ + { + kind: "mcp", + display_name: "Disconnected MCP", + insert_text: "@mcp:disconnected", + disabled_reason: "Server is disconnected", + }, + ]) + + expect({ option: mcp, selectable: !isMentionOptionDisabled(mcp) }).toEqual({ + option: { + type: "mcp", + name: "Disconnected MCP", + display: "Disconnected MCP", + description: undefined, + insertText: "@mcp:disconnected", + mentionPath: undefined, + disabled: true, + disabledReason: "Server is disconnected", + }, + selectable: false, + }) + }) +}) diff --git a/apps/desktop/src/renderer/components/chat/mention-popover.tsx b/apps/desktop/src/renderer/components/chat/mention-popover.tsx index 9d0df2c3..d01f13b9 100644 --- a/apps/desktop/src/renderer/components/chat/mention-popover.tsx +++ b/apps/desktop/src/renderer/components/chat/mention-popover.tsx @@ -1,15 +1,21 @@ /** - * @mention popover for file and agent references. + * @mention popover for Skill, MCP, file, and agent references. * - * Shows a searchable list of files and agents when the user types `@`. - * Matches the design language of the Devo TUI — files show with - * path + filename, agents show with a brain icon. + * Preserves server-ranked references and combines them with local agents. */ import { ScrollArea } from "@devo/ui/components/scroll-area" import { cn } from "@devo/ui/lib/utils" +import type { ReferenceSearchResult } from "@devo-ai/sdk/v2/client" import fuzzysort from "fuzzysort" -import { BrainIcon, FileIcon, FolderIcon, SearchIcon } from "lucide-react" +import { + BrainIcon, + FileIcon, + FolderIcon, + PlugIcon, + SearchIcon, + SparklesIcon, +} from "lucide-react" import { forwardRef, memo, @@ -20,7 +26,7 @@ import { useRef, useState, } from "react" -import { useFileSearch } from "../../hooks/use-file-search" +import { useReferenceSearch } from "../../hooks/use-reference-search" import type { SdkAgent } from "../../hooks/use-devo-data" // ============================================================ @@ -29,7 +35,24 @@ import type { SdkAgent } from "../../hooks/use-devo-data" export type MentionOption = | { type: "agent"; name: string; display: string } - | { type: "file"; path: string; display: string } + | { + type: "file" + path: string + display: string + insertText: string + disabled: boolean + disabledReason?: string + } + | { + type: "skill" | "mcp" + name: string + display: string + description?: string + insertText: string + mentionPath?: string + disabled: boolean + disabledReason?: string + } export interface MentionPopoverHandle { /** Handle keyboard events from the parent textarea. Returns true if consumed. */ @@ -70,6 +93,36 @@ function isDirectory(path: string): boolean { return path.endsWith("/") } +export function isMentionOptionDisabled(option: MentionOption): boolean { + return option.type !== "agent" && option.disabled +} + +export function mapReferenceSearchResults(results: ReferenceSearchResult[]): MentionOption[] { + return results.map((result) => { + const disabled = result.is_disabled === true || result.disabled_reason != null + if (result.kind === "file") { + return { + type: "file", + path: result.mention_path ?? result.display_name, + display: result.display_name, + insertText: result.insert_text, + disabled, + disabledReason: result.disabled_reason, + } + } + return { + type: result.kind, + name: result.display_name, + display: result.display_name, + description: result.description, + insertText: result.insert_text, + mentionPath: result.mention_path, + disabled, + disabledReason: result.disabled_reason, + } + }) +} + // ============================================================ // MentionPopover // ============================================================ @@ -91,18 +144,15 @@ export const MentionPopover = memo( [agents], ) - // --- Data: file search (enabled whenever popover is open, even with empty query) --- - const { files, isLoading, error } = useFileSearch(directory, query, open) - const fileOptions = useMemo( - () => files.slice(0, 20).map((f) => ({ type: "file" as const, path: f, display: f })), - [files], - ) + // --- Data: server-ranked Skill, MCP, and File references --- + const { results, isLoading, error } = useReferenceSearch(directory, query, open) + const referenceOptions = useMemo(() => mapReferenceSearchResults(results), [results]) // --- Merge and filter --- const allOptions = useMemo(() => { if (!query) { - // No query — show agents + initial files from the server - return [...agentOptions, ...fileOptions] + // No query — show agents + initial references from the server. + return [...agentOptions, ...referenceOptions] } // Fuzzy filter agents @@ -110,9 +160,13 @@ export const MentionPopover = memo( .go(query, agentOptions, { key: "display", threshold: 0.3 }) .map((r) => r.obj) - // Files come pre-filtered from the server - return [...agentResults, ...fileOptions] - }, [query, agentOptions, fileOptions]) + // References come pre-filtered and ranked by the server. + return [...agentResults, ...referenceOptions] + }, [query, agentOptions, referenceOptions]) + const selectableOptions = useMemo( + () => allOptions.filter((option) => !isMentionOptionDisabled(option)), + [allOptions], + ) // Reset active index when options or query change // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — reset on options/query change @@ -134,23 +188,23 @@ export const MentionPopover = memo( // --- Keyboard handler --- const handleKeyDown = useCallback( (e: React.KeyboardEvent): boolean => { - if (!open || allOptions.length === 0) return false + if (!open || selectableOptions.length === 0) return false switch (e.key) { case "ArrowDown": { e.preventDefault() - setActiveIndex((i) => (i + 1) % allOptions.length) + setActiveIndex((i) => (i + 1) % selectableOptions.length) return true } case "ArrowUp": { e.preventDefault() - setActiveIndex((i) => (i - 1 + allOptions.length) % allOptions.length) + setActiveIndex((i) => (i - 1 + selectableOptions.length) % selectableOptions.length) return true } case "Tab": case "Enter": { e.preventDefault() - const selected = allOptions[activeIndex] + const selected = selectableOptions[activeIndex] if (selected) onSelect(selected) return true } @@ -163,7 +217,7 @@ export const MentionPopover = memo( return false } }, - [open, allOptions, activeIndex, onSelect, onClose], + [open, selectableOptions, activeIndex, onSelect, onClose], ) useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown]) @@ -171,13 +225,15 @@ export const MentionPopover = memo( if (!open) return null // --- Group options --- - const agentItems = allOptions.filter((o) => o.type === "agent") - const fileItems = allOptions.filter((o) => o.type === "file") + const agentItems = allOptions.filter((option) => option.type === "agent") + const skillItems = allOptions.filter((option) => option.type === "skill") + const mcpItems = allOptions.filter((option) => option.type === "mcp") + const fileItems = allOptions.filter((option) => option.type === "file") const hasResults = allOptions.length > 0 const showLoading = isLoading && !hasResults const showError = !!error && !hasResults && !isLoading - let globalIndex = 0 + const selectableIndex = (option: MentionOption) => selectableOptions.indexOf(option) return (
- {query ? `Searching for "${query}"` : "Mention files or agents"} + {query ? `Searching for "${query}"` : "Mention references or agents"}
@@ -201,12 +257,12 @@ export const MentionPopover = memo( {showLoading ? query ? `Searching for "${query}"…` - : "Searching files and agents…" + : "Searching references and agents…" : showError ? error : query ? "No results found" - : "No files or agents available"} + : "No references or agents available"}
)} @@ -217,7 +273,7 @@ export const MentionPopover = memo( Agents {agentItems.map((option) => { - const idx = globalIndex++ + const idx = selectableIndex(option) return ( )} + {skillItems.length > 0 && ( + + )} + + {mcpItems.length > 0 && ( + + )} + {/* File group */} {fileItems.length > 0 && (
@@ -238,7 +316,7 @@ export const MentionPopover = memo( Files
{fileItems.map((option) => { - const idx = globalIndex++ + const idx = selectableIndex(option) const path = option.type === "file" ? option.path : "" return ( onSelect(option)} - onHover={() => setActiveIndex(idx)} + onHover={() => { + if (idx >= 0) setActiveIndex(idx) + }} /> ) })} @@ -259,6 +339,44 @@ export const MentionPopover = memo( }), ) +const MentionGroup = memo(function MentionGroup({ + label, + options, + activeIndex, + selectableIndex, + onSelect, + onHover, +}: { + label: string + options: MentionOption[] + activeIndex: number + selectableIndex: (option: MentionOption) => number + onSelect: (option: MentionOption) => void + onHover: (index: number) => void +}) { + return ( +
+
+ {label} +
+ {options.map((option) => { + const idx = selectableIndex(option) + return ( + onSelect(option)} + onHover={() => { + if (idx >= 0) onHover(idx) + }} + /> + ) + })} +
+ ) +}) + // ============================================================ // MentionItem // ============================================================ @@ -286,12 +404,47 @@ const MentionItem = memo(function MentionItem({ onClick={onSelect} onMouseEnter={onHover} > - + @{option.name} ) } + if (option.type !== "file") { + const disabled = option.disabled + const Icon = option.type === "skill" ? SparklesIcon : PlugIcon + return ( + + ) + } + const path = option.path const dir = getDirectory(path) const name = getFileName(path) @@ -301,17 +454,20 @@ const MentionItem = memo(function MentionItem({ {/* Expandable task list — smooth height transition via grid trick */} diff --git a/apps/desktop/src/renderer/components/chat/tool-card.tsx b/apps/desktop/src/renderer/components/chat/tool-card.tsx index d2b01289..470153fe 100644 --- a/apps/desktop/src/renderer/components/chat/tool-card.tsx +++ b/apps/desktop/src/renderer/components/chat/tool-card.tsx @@ -42,6 +42,7 @@ export function getToolCategory(tool: string): ToolCategory { case "todoread": return "plan" case "question": + case "request_user_input": return "ask" case "webfetch": return "fetch" diff --git a/apps/desktop/src/renderer/components/new-chat.tsx b/apps/desktop/src/renderer/components/new-chat.tsx index 63327a78..799c1367 100644 --- a/apps/desktop/src/renderer/components/new-chat.tsx +++ b/apps/desktop/src/renderer/components/new-chat.tsx @@ -11,8 +11,7 @@ import { } from "@devo/ui/components/ai-elements/prompt-input" import { type MentionOption, MentionPopover, type MentionPopoverHandle } from "./chat/mention-popover" import { - createAgentMention, - createFileMention, + createMentionFromOption, insertMentionIntoText, } from "./chat/prompt-mentions" import { SlashCommandPopover, type SlashCommandPopoverHandle } from "./chat/slash-command-popover" @@ -388,8 +387,7 @@ export function NewChat() { const currentText = ctrl.getText() const textarea = document.querySelector("textarea[data-prompt-input]") const cursorPos = textarea?.selectionStart ?? currentText.length - const mention = - option.type === "file" ? createFileMention(option.path) : createAgentMention(option.name) + const mention = createMentionFromOption(option) const { text: newText, cursorPosition: newCursor } = insertMentionIntoText( currentText, cursorPos, diff --git a/apps/desktop/src/renderer/components/session-view.tsx b/apps/desktop/src/renderer/components/session-view.tsx index c441ff27..ec70922f 100644 --- a/apps/desktop/src/renderer/components/session-view.tsx +++ b/apps/desktop/src/renderer/components/session-view.tsx @@ -228,6 +228,7 @@ export function SessionView({ sessionId }: SessionViewProps) { agentName?: string variant?: string files?: FileAttachment[] + collaborationMode?: string }, ) => { log.debug("handleSendMessage", { @@ -237,6 +238,7 @@ export function SessionView({ sessionId }: SessionViewProps) { model: options?.model, agentName: options?.agentName, variant: options?.variant, + collaborationMode: options?.collaborationMode, }) try { await sendPrompt(agent.directory, agent.sessionId, message, { @@ -244,6 +246,7 @@ export function SessionView({ sessionId }: SessionViewProps) { agent: options?.agentName || undefined, variant: options?.variant, files: options?.files, + collaborationMode: options?.collaborationMode, }) log.debug("handleSendMessage completed", { sessionId: agent.sessionId }) } catch (err) { diff --git a/apps/desktop/src/renderer/components/sidebar-layout.tsx b/apps/desktop/src/renderer/components/sidebar-layout.tsx index 5d3d5650..db917001 100644 --- a/apps/desktop/src/renderer/components/sidebar-layout.tsx +++ b/apps/desktop/src/renderer/components/sidebar-layout.tsx @@ -37,7 +37,10 @@ import { formatShortcut } from "../lib/shortcut-display" import { isSettingsRoute } from "../lib/app-navigation" import type { Agent, SidebarProject } from "../lib/types" import { createDesktopFolder, pickDirectory, statDesktopFolders } from "../services/backend" -import { loadProjectSessions } from "../services/connection-manager" +import { + loadProjectSessions, + refillProjectSessionsAfterDelete, +} from "../services/connection-manager" import { APP_BAR_HEIGHT, AppBar } from "./app-bar" import { DesktopProjectActionsProvider } from "./desktop-project-actions-context" import { DesktopTerminalPanel } from "./desktop-terminal-panel" @@ -272,6 +275,10 @@ export function SidebarLayout() { setDeleteError(null) try { await deleteSession(deleteTarget.directory, deleteTarget.sessionId) + await refillProjectSessionsAfterDelete( + deleteTarget.projectDirectory, + deleteTarget.sessionId, + ) const navigationTarget = deleteSessionNavigationTarget({ deletedSessionId: deleteTarget.sessionId, currentSessionId: sessionId, diff --git a/apps/desktop/src/renderer/hooks/use-file-search.ts b/apps/desktop/src/renderer/hooks/use-reference-search.ts similarity index 60% rename from apps/desktop/src/renderer/hooks/use-file-search.ts rename to apps/desktop/src/renderer/hooks/use-reference-search.ts index 3b3a69ad..ec4c09af 100644 --- a/apps/desktop/src/renderer/hooks/use-file-search.ts +++ b/apps/desktop/src/renderer/hooks/use-reference-search.ts @@ -1,26 +1,18 @@ /** - * Hook for server-backed `@` reference file search in the composer. + * Hook for server-backed composer `@` reference search. * - * Uses connection-local `search/start` + `search/update` RPCs and listens for - * `search/updated` / `search/completed` notifications. Replaces the legacy - * `find.files` stub; debounce + cancel behavior matches the TUI composer. + * Preserves the server-ranked Skill, MCP, and File result stream from the + * connection-local `search/*` session. */ +import type { ReferenceSearchResult, ReferenceSearchSnapshot } from "@devo-ai/sdk/v2/client" import { useEffect, useRef, useState } from "react" -import type { ReferenceSearchSnapshot } from "@devo-ai/sdk/v2/client" import { getProjectClient } from "../services/connection-manager" -const FILE_SEARCH_DEBOUNCE_MS = 150 +const REFERENCE_SEARCH_DEBOUNCE_MS = 150 -function filePathsFromSnapshot(snapshot: ReferenceSearchSnapshot): string[] { - return snapshot.results - .filter((result) => result.kind === "file") - .map((result) => result.display_name) - .filter((path) => path.trim().length > 0) -} - -export function useFileSearch(directory: string | null, query: string, enabled = true) { +export function useReferenceSearch(directory: string | null, query: string, enabled = true) { const [debouncedQuery, setDebouncedQuery] = useState(query) - const [files, setFiles] = useState([]) + const [results, setResults] = useState([]) const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(null) const timerRef = useRef | null>(null) @@ -29,7 +21,7 @@ export function useFileSearch(directory: string | null, query: string, enabled = if (timerRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => { setDebouncedQuery(query) - }, FILE_SEARCH_DEBOUNCE_MS) + }, REFERENCE_SEARCH_DEBOUNCE_MS) return () => { if (timerRef.current) clearTimeout(timerRef.current) } @@ -37,7 +29,7 @@ export function useFileSearch(directory: string | null, query: string, enabled = useEffect(() => { if (!directory || !enabled) { - setFiles([]) + setResults([]) setIsLoading(false) setError(null) return @@ -45,7 +37,7 @@ export function useFileSearch(directory: string | null, query: string, enabled = const client = getProjectClient(directory) if (!client) { - setFiles([]) + setResults([]) setIsLoading(false) setError(null) return @@ -54,7 +46,7 @@ export function useFileSearch(directory: string | null, query: string, enabled = let cancelled = false const applySnapshot = (snapshot: ReferenceSearchSnapshot) => { if (cancelled) return - setFiles(filePathsFromSnapshot(snapshot).slice(0, 20)) + setResults(snapshot.results) setIsLoading(!snapshot.file_search_complete) setError(client.referenceSearch.getState().error) } @@ -62,11 +54,11 @@ export function useFileSearch(directory: string | null, query: string, enabled = const unsubscribe = client.referenceSearch.subscribe(applySnapshot) setIsLoading(true) setError(null) - void client.referenceSearch.startOrUpdate({ query: debouncedQuery }).catch((searchError) => { + void client.referenceSearch.startOrUpdate({ query: debouncedQuery }).catch((searchError: unknown) => { if (cancelled) return - setFiles([]) + setResults([]) setIsLoading(false) - setError(searchError instanceof Error ? searchError.message : "file search failed") + setError(searchError instanceof Error ? searchError.message : "reference search failed") }) return () => { @@ -77,7 +69,7 @@ export function useFileSearch(directory: string | null, query: string, enabled = }, [directory, debouncedQuery, enabled]) return { - files, + results, isLoading, error, } diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 84bdbd55..ed7a3817 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -233,6 +233,12 @@ body { } } +/* Plan card — always fully opaque for readability, overriding glass transparency */ +[data-slot="plan"] { + background: var(--card) !important; + background-color: var(--card) !important; +} + @media (prefers-reduced-motion: reduce) { .devo-brand-mark-attention, .devo-brand-word-attention .devo-brand-word-track { diff --git a/apps/desktop/src/renderer/services/connection-manager.test.ts b/apps/desktop/src/renderer/services/connection-manager.test.ts index 8160032f..fbf13fe5 100644 --- a/apps/desktop/src/renderer/services/connection-manager.test.ts +++ b/apps/desktop/src/renderer/services/connection-manager.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" import type { DevoClient } from "@devo-ai/sdk/v2/client" import type { Event, Session } from "../lib/types" import { partsFamily, partStorageKey } from "../atoms/parts" -import { sessionFamily, upsertSessionAtom } from "../atoms/sessions" +import { projectPaginationFamily, sessionFamily, upsertSessionAtom } from "../atoms/sessions" import { appStore } from "../atoms/store" class FakeEventStream { @@ -185,4 +185,40 @@ describe("connection manager project event bridge", () => { expect(appStore.get(sessionFamily(session.id))?.status).toEqual({ type: "busy" }) }) + + test("refills the current project page after a visible session is deleted", async () => { + const directory = "/repo/delete-refill" + const sessions: Session[] = Array.from({ length: 6 }, (_, index) => ({ + id: `delete-refill-${index + 1}`, + directory, + title: `Session ${index + 1}`, + time: { created: 6 - index, updated: 6 - index }, + })) + listSessionsImpl = async () => sessions + + const manager = await import(`./connection-manager?case=${Date.now()}`) + activeManager = manager + await manager.connectToDevo("devo://stdio") + await manager.loadAllProjects() + await manager.loadProjectSessions(directory, undefined, { limit: 5, roots: true }) + + expect(appStore.get(sessionFamily(sessions[5].id))).toBeNull() + + await manager.refillProjectSessionsAfterDelete(directory, sessions[0].id) + + expect({ + deleted: appStore.get(sessionFamily(sessions[0].id)), + refilled: appStore.get(sessionFamily(sessions[5].id))?.session, + pagination: appStore.get(projectPaginationFamily(directory)), + }).toEqual({ + deleted: null, + refilled: sessions[5], + pagination: { + loaded: true, + currentLimit: 5, + hasMore: false, + loading: false, + }, + }) + }) }) diff --git a/apps/desktop/src/renderer/services/connection-manager.ts b/apps/desktop/src/renderer/services/connection-manager.ts index cc91999c..bdd5dcc8 100644 --- a/apps/desktop/src/renderer/services/connection-manager.ts +++ b/apps/desktop/src/renderer/services/connection-manager.ts @@ -4,6 +4,8 @@ import { authHeaderAtom, serverConnectedAtom, serverUrlAtom } from "../atoms/con import { batchUpsertPartsAtom } from "../atoms/parts" import { SESSIONS_PAGE_SIZE, + projectPaginationFamily, + removeSessionAtom, setProjectPaginationLoadingAtom, setSessionsAtom, updateProjectPaginationAtom, @@ -141,7 +143,14 @@ async function hydrateProjectSessionsFromCache( const baseClient = getBaseClient() if (!baseClient) return false - const sessions = filterDiscoveredSessions(discoveredSessions, directory, options) + const allMatchingSessions = filterDiscoveredSessions(discoveredSessions, directory, { + ...options, + limit: undefined, + }) + const sessions = + options?.limit === undefined + ? allMatchingSessions + : allMatchingSessions.slice(0, options.limit) const statuses = await getSessionStatuses(baseClient) appStore.set(setSessionsAtom, { sessions, statuses, directory, sandboxDirs }) @@ -150,6 +159,7 @@ async function hydrateProjectSessionsFromCache( directory, fetchedCount: sessions.length, limit: options.limit, + hasMore: allMatchingSessions.length > options.limit, }) } @@ -359,6 +369,39 @@ export async function loadMoreProjectSessions( } } +/** + * Remove a confirmed deletion from the discovery cache and refill the project's + * existing pagination window. Keeping the same limit replaces the deleted row + * without implicitly advancing a full page. + */ +export async function refillProjectSessionsAfterDelete( + projectDirectory: string, + sessionId: string, +): Promise { + if (discoveredSessions) { + discoveredSessions = discoveredSessions.filter((session) => session.id !== sessionId) + } + appStore.set(removeSessionAtom, sessionId) + + const pagination = appStore.get(projectPaginationFamily(projectDirectory)) + if (!pagination.loaded) return + + try { + await loadProjectSessions(projectDirectory, undefined, { + limit: pagination.currentLimit, + roots: true, + }) + } catch (error) { + // The server-side deletion already succeeded. Keep the deletion result + // authoritative and allow a later discovery refresh to retry the refill. + log.warn("Failed to refill project sessions after deletion", { + projectDirectory, + sessionId, + error, + }) + } +} + /** * Get or create a project-scoped SDK client. * diff --git a/apps/desktop/src/renderer/services/devo.ts b/apps/desktop/src/renderer/services/devo.ts index 805ca9f2..14fc1822 100644 --- a/apps/desktop/src/renderer/services/devo.ts +++ b/apps/desktop/src/renderer/services/devo.ts @@ -146,6 +146,7 @@ export async function sendPrompt( modelID?: string agent?: string variant?: string + collaborationMode?: string }, ): Promise { await client.session.promptAsync({ @@ -157,6 +158,7 @@ export async function sendPrompt( : undefined, agent: options?.agent, variant: options?.variant, + collaborationMode: options?.collaborationMode, }) } diff --git a/crates/core/models.json b/crates/core/models.json index 817139b9..46805124 100644 --- a/crates/core/models.json +++ b/crates/core/models.json @@ -141,43 +141,22 @@ } }, { - "slug": "glm-5.1", - "display_name": "glm-5.1", + "slug": "glm-5.2", + "display_name": "glm-5.2", "channel": "GLM", "provider": "openai_chat_completions", - "description": "754B flagship open-weight model released at 04/2026 by z.ai", + "description": "flagship open-weight model released by z.ai", "reasoning_capability": "toggle", - "default_reasoning_level": "", - "supported_reasoning_levels": [], - "reasoning_implementation": { - "model_variant": { - "variants": [ - { - "selection_value": "disabled", - "model_slug": "glm-5.1", - "reasoning_effort": null, - "extra_body": { - "thinking": { - "type": "disabled" - } - }, - "label": "Off", - "description": "Disable reasoning effort" - }, - { - "selection_value": "enabled", - "model_slug": "glm-5.1", - "reasoning_effort": "medium", - "label": "On", - "description": "Enable thinking" - } - ] - } - }, + "default_reasoning_level": "high", + "supported_reasoning_levels": [ + "none", + "high", + "max" + ], "temperature": 1.0, "top_p": 0.95, - "context_window": 200000, - "max_tokens": 8192, + "context_window": 1000000, + "max_tokens": 131000, "effective_context_window_percent": 95, "truncation_policy": { "mode": "tokens", @@ -290,9 +269,11 @@ "reasoning_capability": "toggle", "default_reasoning_level": "medium", "supported_reasoning_levels": [ + "none", "low", "medium", - "max" + "high", + "xhigh" ], "context_window": 1000000, "max_tokens": 128000, diff --git a/crates/core/src/history/compaction.rs b/crates/core/src/history/compaction.rs index 6e934dc4..7df49240 100644 --- a/crates/core/src/history/compaction.rs +++ b/crates/core/src/history/compaction.rs @@ -41,6 +41,7 @@ use crate::response_item::ResponseItem; use devo_protocol::RequestContent; use devo_protocol::RequestMessage; use devo_protocol::RequestRole; +use devo_protocol::normalize_tool_result_messages; use super::TokenInfo; use super::normalize; @@ -197,6 +198,12 @@ pub async fn compact_history( split_by_user_message_budget(&filtered, COMPACT_USER_MESSAGE_MAX_TOKENS) }; + // The split can sever ToolCall ↔ ToolCallOutput pairs: e.g. a ToolCall lands + // in `to_compact` while its ToolCallOutput falls into `preserved` (or vice + // versa). Re-run pairing on each half so neither side carries an orphan. + normalize::pair_tool_call_items(&mut to_compact); + normalize::pair_tool_call_items(&mut preserved); + if to_compact.is_empty() { // Nothing to compact — everything is within the preserve budget. return Ok(CompactAction::Skipped); @@ -315,6 +322,7 @@ fn split_by_user_message_budget( fn summarizer_request_messages(to_compact: &[ResponseItem]) -> Vec { let mut messages: Vec = to_compact.iter().map(RequestMessage::from).collect(); merge_consecutive_assistant_messages(&mut messages); + normalize_tool_result_messages(&mut messages); messages.push(RequestMessage { role: RequestRole::Developer.as_str().to_string(), content: vec![RequestContent::Text { diff --git a/crates/provider/src/http.rs b/crates/provider/src/http.rs index 38382fc0..254b4ac8 100644 --- a/crates/provider/src/http.rs +++ b/crates/provider/src/http.rs @@ -9,8 +9,62 @@ use reqwest::header::HeaderMap; use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use serde_json::Value; +use std::sync::Mutex; +use std::sync::OnceLock; use tracing::warn; +#[derive(Clone, Copy)] +enum HttpClientKind { + Request, + Streaming, +} + +#[derive(Default)] +struct HttpClientCache { + request_clients: Vec<(NetworkProxyConfig, Client)>, + streaming_clients: Vec<(NetworkProxyConfig, Client)>, +} + +impl HttpClientCache { + fn get_or_build( + &mut self, + kind: HttpClientKind, + network_proxy: &NetworkProxyConfig, + build: impl FnOnce() -> Result, + ) -> Result { + let clients = match kind { + HttpClientKind::Request => &mut self.request_clients, + HttpClientKind::Streaming => &mut self.streaming_clients, + }; + if let Some((_, client)) = clients + .iter() + .find(|(cached_proxy, _)| cached_proxy == network_proxy) + { + return Ok(client.clone()); + } + + let client = build()?; + clients.push((network_proxy.clone(), client.clone())); + Ok(client) + } +} + +fn cached_http_client( + kind: HttpClientKind, + network_proxy: &NetworkProxyConfig, + build: impl FnOnce() -> Result, +) -> Result { + // An empty config resolves proxy environment variables during the first + // build. The server fixes its environment before provider initialization, + // so equivalent empty configs can safely share that client for its lifetime. + static CACHE: OnceLock> = OnceLock::new(); + CACHE + .get_or_init(|| Mutex::new(HttpClientCache::default())) + .lock() + .expect("provider HTTP client cache mutex should not be poisoned") + .get_or_build(kind, network_proxy, build) +} + /// HTTP options shared by model-provider adapters. #[derive(Clone, Debug, Default)] pub struct ProviderHttpOptions { @@ -46,21 +100,25 @@ impl ProviderHttpOptions { /// HTTP client for non-streaming requests with total request timeout. pub(crate) fn build_request_client(&self) -> Result { - let builder = Client::builder() - .connect_timeout(crate::timeout::connect_timeout()) - .timeout(crate::timeout::request_timeout()); - devo_network_proxy::apply_proxy_config(builder, &self.network_proxy)? - .build() - .context("failed to build provider HTTP client") + cached_http_client(HttpClientKind::Request, &self.network_proxy, || { + let builder = Client::builder() + .connect_timeout(crate::timeout::connect_timeout()) + .timeout(crate::timeout::request_timeout()); + devo_network_proxy::apply_proxy_config(builder, &self.network_proxy)? + .build() + .context("failed to build provider HTTP client") + }) } /// HTTP client for SSE streaming. Duration is bounded by per-chunk idle /// timeout in the stream layer, not a single wall-clock request timeout. pub(crate) fn build_streaming_client(&self) -> Result { - let builder = Client::builder().connect_timeout(crate::timeout::connect_timeout()); - devo_network_proxy::apply_proxy_config(builder, &self.network_proxy)? - .build() - .context("failed to build provider streaming HTTP client") + cached_http_client(HttpClientKind::Streaming, &self.network_proxy, || { + let builder = Client::builder().connect_timeout(crate::timeout::connect_timeout()); + devo_network_proxy::apply_proxy_config(builder, &self.network_proxy)? + .build() + .context("failed to build provider streaming HTTP client") + }) } pub(crate) fn apply_custom_headers(&self, builder: RequestBuilder) -> RequestBuilder { @@ -143,10 +201,98 @@ fn non_empty_owned_string(mut value: String) -> Option { #[cfg(test)] mod tests { + use std::sync::Arc; + use std::sync::Barrier; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use pretty_assertions::assert_eq; use super::*; + #[test] + fn http_client_cache_reuses_equivalent_clients() { + let mut cache = HttpClientCache::default(); + let config = NetworkProxyConfig::default(); + let build_count = AtomicUsize::new(0); + let client = Client::new(); + + for _ in 0..2 { + cache + .get_or_build(HttpClientKind::Request, &config, || { + build_count.fetch_add(1, Ordering::SeqCst); + Ok(client.clone()) + }) + .expect("cached HTTP client"); + } + + assert_eq!(build_count.load(Ordering::SeqCst), 1); + } + + #[test] + fn http_client_cache_separates_kinds_and_proxy_configs() { + let mut cache = HttpClientCache::default(); + let default_proxy = NetworkProxyConfig::default(); + let explicit_proxy = NetworkProxyConfig { + proxy_url: Some("http://proxy.example:8080".to_string()), + no_proxy: Some("localhost".to_string()), + }; + let build_count = AtomicUsize::new(0); + let client = Client::new(); + + for (kind, config) in [ + (HttpClientKind::Request, &default_proxy), + (HttpClientKind::Streaming, &default_proxy), + (HttpClientKind::Request, &explicit_proxy), + ] { + cache + .get_or_build(kind, config, || { + build_count.fetch_add(1, Ordering::SeqCst); + Ok(client.clone()) + }) + .expect("cached HTTP client"); + } + + assert_eq!(build_count.load(Ordering::SeqCst), 3); + } + + #[test] + fn http_client_cache_builds_once_for_concurrent_callers() { + let cache = Arc::new(Mutex::new(HttpClientCache::default())); + let barrier = Arc::new(Barrier::new(4)); + let build_count = Arc::new(AtomicUsize::new(0)); + let client = Client::new(); + let mut threads = Vec::new(); + + for _ in 0..4 { + let cache = Arc::clone(&cache); + let barrier = Arc::clone(&barrier); + let build_count = Arc::clone(&build_count); + let client = client.clone(); + threads.push(std::thread::spawn(move || { + barrier.wait(); + cache + .lock() + .expect("cache mutex") + .get_or_build( + HttpClientKind::Request, + &NetworkProxyConfig::default(), + || { + build_count.fetch_add(1, Ordering::SeqCst); + Ok(client) + }, + ) + .expect("cached HTTP client"); + })); + } + + for thread in threads { + thread.join().expect("cache caller joins"); + } + + assert_eq!(build_count.load(Ordering::SeqCst), 1); + } + /// Trace: L2-DES-APP-005 /// Verifies: provider custom headers parse from a JSON object string. #[test] diff --git a/docs/superpowers/plans/2026-07-12-desktop-reference-search-and-session-refill.md b/docs/superpowers/plans/2026-07-12-desktop-reference-search-and-session-refill.md new file mode 100644 index 00000000..0fee666f --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-desktop-reference-search-and-session-refill.md @@ -0,0 +1,130 @@ +# Desktop Reference Search and Session Refill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Desktop `@` search expose server-provided Skills and MCPs, and keep a paginated project session list full after a deletion. + +**Architecture:** Preserve the server-owned `search/*` ranking and pass the complete typed result snapshot through the renderer hook into the mention popover. For deletion, update the connection manager's discovery cache at the same boundary that refills the current pagination window, then invoke that boundary after a confirmed delete. + +**Tech Stack:** TypeScript, React, Jotai, Bun tests, Devo Desktop SDK + +## Global Constraints + +- Work in `/Users/tsiao/Desktop/devo` and preserve all unrelated dirty-worktree changes. +- Keep inline Desktop icons consistent with `apps/desktop/AGENTS.md` (`size-3.5 stroke-[1.5]`). +- Do not stage or commit changes unless the user asks. + +--- + +### Task 1: Preserve and render all server reference-search result kinds + +**Files:** +- Create: `apps/desktop/src/renderer/hooks/use-reference-search.ts` +- Delete: `apps/desktop/src/renderer/hooks/use-file-search.ts` +- Modify: `apps/desktop/src/renderer/components/chat/mention-popover.tsx` +- Modify: `apps/desktop/src/renderer/components/chat/prompt-mentions.ts` +- Modify: `apps/desktop/src/renderer/components/chat/context-items.tsx` +- Modify: `apps/desktop/src/renderer/components/new-chat.tsx` +- Modify: `apps/desktop/src/renderer/components/chat/chat-input.tsx` +- Modify: `apps/desktop/src/renderer/components/chat/chat-view.tsx` +- Test: `apps/desktop/src/renderer/components/chat/mention-popover.test.ts` + +**Interfaces:** +- Consumes: `ReferenceSearchSnapshot.results: ReferenceSearchResult[]` from `@devo-ai/sdk/v2/client`. +- Produces: `useReferenceSearch(...): { results, isLoading, error }` and mention options for `skill`, `mcp`, `file`, and local `agent` entries. + +- [x] **Step 1: Write a failing result-mapping test** + +Assert that a snapshot containing Skill, MCP, and File results maps all three into Desktop mention options, retains each server `insert_text`, and preserves disabled metadata. + +- [x] **Step 2: Run the focused test and verify the file-only implementation fails** + +Run: `bun test src/renderer/components/chat/mention-popover.test.ts` + +Expected: FAIL because the full-result mapping API and Skill/MCP options do not exist. + +- [x] **Step 3: Replace the file-only hook with a full reference-result hook** + +Subscribe to the existing connection-local search session, store `snapshot.results` without filtering by kind, and retain the existing debounce/cancel/error behavior. + +- [x] **Step 4: Add typed Skill and MCP popover groups and selection behavior** + +Map the server result fields directly, render category-appropriate icons/labels/descriptions, prevent disabled results from being selected, and insert the exact server `insert_text` token while retaining mention tracking. + +- [x] **Step 5: Run the focused renderer test** + +Run: `bun test src/renderer/components/chat/mention-popover.test.ts` + +Expected: PASS. + +### Task 2: Refill the current session page after deletion + +**Files:** +- Modify: `apps/desktop/src/renderer/services/connection-manager.ts` +- Modify: `apps/desktop/src/renderer/components/sidebar-layout.tsx` +- Test: `apps/desktop/src/renderer/services/connection-manager.test.ts` + +**Interfaces:** +- Consumes: `projectPaginationFamily(directory).currentLimit` and the discovery-session cache. +- Produces: `refillProjectSessionsAfterDelete(projectDirectory: string, sessionId: string): Promise`. + +- [x] **Step 1: Write a failing pagination regression test** + +Seed six root sessions, load a five-session window, delete one visible session, and assert that the sixth session is hydrated while `currentLimit` remains five. + +- [x] **Step 2: Run the focused test and verify it fails** + +Run: `bun test src/renderer/services/connection-manager.test.ts` + +Expected: FAIL because deletion neither updates the discovery cache nor refills the current window. + +- [x] **Step 3: Implement the cache-aware refill boundary** + +Remove the deleted ID from renderer state and `discoveredSessions`, read the existing project limit, and reload the same root-session window so the next hidden session fills the gap without making “Show more” jump an extra page. + +- [x] **Step 4: Invoke refill after a confirmed sidebar deletion** + +Call the connection-manager boundary with `deleteTarget.projectDirectory` only after the server delete succeeds, before completing navigation/dialog cleanup. + +- [x] **Step 5: Run the focused pagination test** + +Run: `bun test src/renderer/services/connection-manager.test.ts` + +Expected: PASS. + +### Task 3: Verify the combined Desktop changes + +**Files:** +- Verify only; no additional production files expected. + +**Interfaces:** +- Consumes: Tasks 1 and 2. +- Produces: focused regression evidence and repository hygiene evidence. + +- [x] **Step 1: Run both focused suites together** + +Run: `bun test src/renderer/components/chat/mention-popover.test.ts src/renderer/services/connection-manager.test.ts` + +Expected: all tests pass. + +- [x] **Step 2: Run Desktop type checking and production build** + +Run: `bun run check-types` + +Run: `bun run build` + +Expected: no new errors in touched files; record any unrelated dirty-tree failures exactly. + +- [x] **Step 3: Run diff hygiene** + +Run: `git diff --check` + +Expected: exit 0. + +## Verification Record + +- Focused suites: 7 tests passed across `mention-popover.test.ts` and `connection-manager.test.ts`. +- Production build: passed for main, preload, and renderer bundles. +- Type checking: no errors in task files; the dirty worktree still has three unrelated errors in `packages/ui/src/components/ai-elements/message.tsx`, `src/renderer/components/chat/process-timeline-view.tsx`, and `src/renderer/components/session-view.tsx`. +- Lint: unavailable because the configured `biome` executable is not installed (`/bin/bash: biome: command not found`). +- `git diff --check`: passed. diff --git a/docs/superpowers/plans/2026-07-12-desktop-request-user-input-and-plan-card.md b/docs/superpowers/plans/2026-07-12-desktop-request-user-input-and-plan-card.md new file mode 100644 index 00000000..507c6f60 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-desktop-request-user-input-and-plan-card.md @@ -0,0 +1,162 @@ +# Desktop Request User Input and Plan Card Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make real `request_user_input` server events interactive in Desktop and make the composer todo card opaque with state-correct chevrons. + +**Architecture:** Normalize the server's tagged `ServerEvent` wire shape at the Desktop SDK boundary and continue emitting the existing `question.asked` UI event, preserving the renderer's question atoms and response API. Keep the visual changes local to the existing `ChatQuestionFlow` and `SessionTaskList` components so protocol behavior and presentation remain independently testable. + +**Tech Stack:** TypeScript, React 19, Bun test, Tailwind CSS, Lucide icons, Jotai. + +## Global Constraints + +- Preserve all pre-existing uncommitted work and do not stage or commit without an explicit request. +- Keep renderer inline icons at `size-3.5` with `stroke-[1.5]` unless an established local pattern requires otherwise. +- Do not modify generated protocol types by hand. + +--- + +### Task 1: Normalize real request-user-input events + +**Files:** +- Modify: `apps/desktop/packages/devo-ai-sdk/src/v2/acp-client-support.ts` +- Modify: `apps/desktop/packages/devo-ai-sdk/src/v2/client.ts` +- Test: `apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts` + +**Interfaces:** +- Consumes: `_meta["devo/originalEvent"]` values serialized as `{ kind: "request_user_input", request, questions }` and legacy `{ RequestUserInput: payload }` values. +- Produces: the existing `question.asked` event and `_devo/request_user_input/respond` request path. + +- [x] **Step 1: Change the SDK test fixture to the actual tagged wire shape** + +```ts +"devo/originalEvent": { + kind: "request_user_input", + request: { + request_id: "rq1", + session_id: "s1", + turn_id: "t1", + item_id: null, + }, + questions: [ + { + id: "scope", + header: "Scope", + question: "Which scope?", + isOther: true, + isSecret: false, + options: [{ label: "Repo", description: "Current repository" }], + }, + ], +} +``` + +- [x] **Step 2: Run the focused test and verify it fails before the fix** + +Run: `bun test packages/devo-ai-sdk/src/v2/client.test.ts --test-name-pattern "maps original request_user_input"` + +Expected: FAIL because no `question.asked` event is emitted for the tagged event. + +Actual: the boundary regression test failed before implementation because `requestUserInputFromOriginalEvent` did not exist. + +- [x] **Step 3: Add one boundary parser and use it from the client** + +```ts +export function requestUserInputFromOriginalEvent(original: unknown): Record | undefined { + if (!original || typeof original !== "object") return undefined + const event = original as Record + if (event.kind === "request_user_input") return event + const legacy = event.RequestUserInput + return legacy && typeof legacy === "object" ? legacy as Record : undefined +} +``` + +Replace the legacy-only branch in `handleOriginalEvent` with a call to this parser and pass its result to `handleRequestUserInput`. + +- [x] **Step 4: Run the focused SDK test and verify it passes** + +Run: `bun test packages/devo-ai-sdk/src/v2/client.test.ts --test-name-pattern "maps original request_user_input"` + +Expected: PASS, including the full `question.asked` payload and response request equality. + +### Task 2: Polish the Desktop question card + +**Files:** +- Modify: `apps/desktop/src/renderer/components/chat/chat-question.tsx` +- Verify: `apps/desktop/src/renderer/components/chat/chat-view.tsx` + +**Interfaces:** +- Consumes: `QuestionRequest[]` from the existing Jotai question queue. +- Produces: a focused, keyboard-accessible, opaque card with option selection, custom input, progress, skip, back, and submit controls. + +- [x] **Step 1: Refine the card hierarchy without changing response semantics** + +Use an opaque `bg-card`, subtle shadow/ring treatment, a compact tinted icon container, a visible header label, and distinct option selected states. Keep the custom input and footer controls keyboard accessible. + +- [x] **Step 2: Verify protocol fields map to the UI contract** + +Confirm `isOther` controls custom answer visibility and `isSecret` selects a password input, while the current protocol remains single-select. + +Actual: preset and custom answers are mutually exclusive, options expose radio semantics, and the custom input has an explicit accessible label. + +- [x] **Step 3: Run Desktop type checking** + +Run: `bun run check-types` + +Expected: PASS with no TypeScript errors. + +Actual: the command ran but remains blocked by four pre-existing errors in `message.tsx`, `process-timeline-view.tsx`, `session-view.tsx`, and `use-file-search.ts`; none point to this task's parser or card components. + +### Task 3: Fix the composer todo card surface and arrow semantics + +**Files:** +- Modify: `apps/desktop/src/renderer/components/chat/session-task-list.tsx` + +**Interfaces:** +- Consumes: the existing `isExpanded` state and current session todos. +- Produces: an opaque todo card whose arrow points up when collapsed and down when expanded. + +- [x] **Step 1: Make the visible card opaque** + +Replace the translucent `bg-muted/10` surface with `bg-card` and retain a subtle border and hover state. + +- [x] **Step 2: Correct the arrow direction and accessible state** + +Use `ChevronDownIcon` when expanded and `ChevronUpIcon` when collapsed, add `aria-expanded={isExpanded}`, and keep the icon at `size-3.5 stroke-[1.5]`. + +- [x] **Step 3: Run formatting and focused checks** + +Run: `bunx biome check src/renderer/components/chat/chat-question.tsx src/renderer/components/chat/session-task-list.tsx packages/devo-ai-sdk/src/v2/acp-client-support.ts packages/devo-ai-sdk/src/v2/client.test.ts` + +Expected: PASS. + +Actual: focused source inspection and `git diff --check` pass. The repository's `bun run lint` command cannot start because the configured `biome` executable is not installed; an ad-hoc Biome binary does not resolve the repository's inherited monorepo configuration and reports existing whole-file diagnostics. + +### Task 4: End-to-end verification + +**Files:** +- Verify only: all files above. + +**Interfaces:** +- Consumes: completed Tasks 1-3. +- Produces: evidence that the SDK event path, renderer types, and production Desktop build all remain healthy. + +- [x] **Step 1: Run the SDK test file** + +Run: `bun test packages/devo-ai-sdk/src/v2/client.test.ts` + +Expected: all tests pass. + +- [x] **Step 2: Run Desktop type checking and build** + +Run: `bun run check-types && bun run build` + +Expected: both commands pass. + +Actual: the production Desktop build passes; type checking has the four pre-existing errors recorded in Task 2. + +- [x] **Step 3: Check patch hygiene** + +Run: `git diff --check` + +Expected: no whitespace errors. diff --git a/docs/superpowers/plans/2026-07-12-fast-initialize.md b/docs/superpowers/plans/2026-07-12-fast-initialize.md new file mode 100644 index 00000000..f2ac4c24 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-fast-initialize.md @@ -0,0 +1,66 @@ +# Fast Initialize Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make ACP `initialize` complete quickly by preventing repeated synchronous construction of equivalent provider HTTP clients during server bootstrap. + +**Architecture:** Add a process-wide cache in `devo-provider` that owns one request client and one streaming client per proxy configuration. Provider adapters continue to own their headers, credentials, and endpoint configuration, while cloning the cached `reqwest::Client` handles so they share connection pools and avoid repeated native TLS initialization. + +**Tech Stack:** Rust, reqwest, anyhow, Cargo tests. + +## Global Constraints + +- Preserve existing provider and proxy behavior, including custom per-provider headers. +- Do not change ACP timeout values; remove the work that causes the timeout. +- Do not modify existing user changes outside the provider HTTP module and this plan. + +--- + +### Task 1: Cache equivalent provider HTTP clients + +**Files:** +- Modify: `crates/provider/src/http.rs` +- Test: `crates/provider/src/http.rs` + +**Interfaces:** +- Consumes: `ProviderHttpOptions::network_proxy`, `NetworkProxyConfig`, and the existing request/streaming client builders. +- Produces: unchanged `ProviderHttpOptions::build_request_client() -> Result` and `build_streaming_client() -> Result` behavior backed by shared cached clients. + +- [x] **Step 1: Write a failing unit test** + + Add a cache-level test that requests the same client kind twice for one proxy configuration, increments an `AtomicUsize` in the builder closure, and asserts the builder ran once. + +- [x] **Step 2: Run the test to verify it fails** + + Run `cargo test -p devo-provider http::tests::http_client_cache_reuses_equivalent_clients -- --exact` and confirm the missing cache implementation fails to compile. + +- [x] **Step 3: Implement the minimal cache** + + Add an `HttpClientCache` guarded by `OnceLock>`. Store request and streaming clients separately as `(NetworkProxyConfig, Client)` entries, return cheap `Client` clones on a hit, and build exactly once on a miss. + +- [x] **Step 4: Run focused provider tests** + + Run the new unit test and `cargo test -p devo-provider --test provider_http`; both must pass, preserving headers and explicit proxy routing. + +### Task 2: Verify startup behavior + +**Files:** +- No additional source changes expected. + +**Interfaces:** +- Consumes: the current multi-provider `~/.devo/config.toml` through an isolated temporary `DEVO_HOME`. +- Produces: measured config-load-to-database-open latency below the 10-second ACP response timeout, with `initialize` accepted successfully. + +- [x] **Step 1: Build the current binary** + + Run `cargo build -p devo-cli` and allow Rust compilation to finish normally. + +- [x] **Step 2: Benchmark isolated startup** + + Copy the current config, auth, and model catalog into a temporary home, send one ACP `initialize` request to `target/debug/devo server --transport stdio`, and compare timestamps between `loaded server config`, `opening database`, and `accepted ACP initialize request`. + + Measured result: config-load-to-database-open improved from approximately 12.8 seconds to 435 milliseconds, and ACP `initialize` was accepted at approximately 449 milliseconds. + +- [x] **Step 3: Run final checks** + + Run `cargo test -p devo-provider`, `cargo test -p devo-server provider_config`, and `git diff --check`. Confirm no unrelated dirty-tree files changed.