From cc2bf5959d760b69e5f1f06759d3c4cbf3ed0e67 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Mon, 10 Aug 2026 14:08:13 +0800 Subject: [PATCH 1/2] refactor(ui): share chat rendering across hosts --- .../test/webui/chat-turn-queue.test.mjs | 4 +- .../test/webui/history-chat-ui.test.mjs | 2 +- .../src/agent-ui-adapters/assistantBubble.ts | 25 + .../agent-gateway/web/src/app/GatewayApp.tsx | 8 +- .../web/src/components/GatewayTranscript.tsx | 16 +- .../src/lib/chat/assistantBubbleAdapter.ts | 33 + .../web/src/lib/chat/changedFilesAdapter.ts | 1 + .../web/src/pages/chat/useChatSkills.ts | 134 --- .../web/test/assistant-bubble-utils.test.mjs | 2 +- .../web/test/chat-file-links.test.mjs | 11 +- .../web/test/live-markdown-caret.test.mjs | 5 +- .../web/test/task-progress.test.mjs | 7 +- .../web/test/thinking-overlay-model.test.mjs | 5 +- .../src/agent-ui-adapters/assistantBubble.ts | 33 + .../src/lib/chat/assistantBubbleAdapter.ts | 36 + .../src/lib/chat/changedFilesAdapter.ts | 1 + crates/agent-gui/src/pages/ChatPage.tsx | 41 +- .../pages/chat/components/AssistantBubble.tsx | 5 +- .../chat/components/ChatFileDropOverlay.tsx | 73 -- .../chat/components/WorkspaceOverlayHost.tsx | 159 ---- .../assistant-bubble/RoundContent.tsx | 141 --- .../assistant-bubble/ToolCallItem.tsx | 611 ------------ .../assistant-bubble/ToolImages.tsx | 527 ----------- .../assistant-bubble/ToolResultDisplay.tsx | 891 ------------------ .../assistant-bubble/ToolTraceGroup.tsx | 137 --- .../assistant-bubble/assistantBubbleUtils.ts | 402 -------- crates/agent-gui/src/pages/chat/index.ts | 2 +- .../src/pages/chat/queue/chatTurnQueue.ts | 9 +- .../src/pages/chat/transcript/rowModel.ts | 8 +- .../test/chat/block-round-keys.test.mjs | 2 +- .../test/chat/live-markdown-caret.test.mjs | 5 +- .../test/chat/markdown-image-policy.test.mjs | 4 +- .../test/chat/thinking-overlay-model.test.mjs | 5 +- .../src/components}/chat/AssistantBubble.tsx | 14 +- .../src/components/chat}/FileDropOverlay.tsx | 10 +- .../chat/assistant-bubble/RoundContent.tsx | 104 +- .../chat/assistant-bubble/ToolCallItem.tsx | 55 +- .../chat/assistant-bubble/ToolImages.tsx | 19 +- .../assistant-bubble/ToolResultDisplay.tsx | 48 +- .../chat/assistant-bubble/ToolTraceGroup.tsx | 7 +- .../assistant-bubble/assistantBubbleUtils.ts | 22 +- .../WorkspaceOverlayHost.tsx | 51 +- .../src/lib/chat/queuedChatTurn.ts} | 5 +- .../src/lib/skills}/useChatSkills.ts | 2 +- docs/images/shared-chat-rendering-preview.jpg | Bin 0 -> 47535 bytes scripts/check-ui-boundaries.mjs | 43 + 46 files changed, 490 insertions(+), 3235 deletions(-) create mode 100644 crates/agent-gateway/web/src/agent-ui-adapters/assistantBubble.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/assistantBubbleAdapter.ts delete mode 100644 crates/agent-gateway/web/src/pages/chat/useChatSkills.ts create mode 100644 crates/agent-gui/src/agent-ui-adapters/assistantBubble.ts create mode 100644 crates/agent-gui/src/lib/chat/assistantBubbleAdapter.ts delete mode 100644 crates/agent-gui/src/pages/chat/components/ChatFileDropOverlay.tsx delete mode 100644 crates/agent-gui/src/pages/chat/components/WorkspaceOverlayHost.tsx delete mode 100644 crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx delete mode 100644 crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx delete mode 100644 crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolImages.tsx delete mode 100644 crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx delete mode 100644 crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx delete mode 100644 crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts rename crates/{agent-gateway/web/src/pages => agent-ui/src/components}/chat/AssistantBubble.tsx (86%) rename crates/{agent-gateway/web/src/app => agent-ui/src/components/chat}/FileDropOverlay.tsx (94%) rename crates/{agent-gateway/web/src/pages => agent-ui/src/components}/chat/assistant-bubble/RoundContent.tsx (76%) rename crates/{agent-gateway/web/src/pages => agent-ui/src/components}/chat/assistant-bubble/ToolCallItem.tsx (90%) rename crates/{agent-gateway/web/src/pages => agent-ui/src/components}/chat/assistant-bubble/ToolImages.tsx (98%) rename crates/{agent-gateway/web/src/pages => agent-ui/src/components}/chat/assistant-bubble/ToolResultDisplay.tsx (98%) rename crates/{agent-gateway/web/src/pages => agent-ui/src/components}/chat/assistant-bubble/ToolTraceGroup.tsx (95%) rename crates/{agent-gateway/web/src/pages => agent-ui/src/components}/chat/assistant-bubble/assistantBubbleUtils.ts (97%) rename crates/{agent-gateway/web/src/app => agent-ui/src/components/workspace-editor}/WorkspaceOverlayHost.tsx (77%) rename crates/{agent-gateway/web/src/pages/chat/queue/chatTurnQueue.ts => agent-ui/src/lib/chat/queuedChatTurn.ts} (51%) rename crates/{agent-gui/src/pages/chat/hooks => agent-ui/src/lib/skills}/useChatSkills.ts (98%) create mode 100644 docs/images/shared-chat-rendering-preview.jpg diff --git a/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs b/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs index b1498ab38..a1413f5be 100644 --- a/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs +++ b/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs @@ -5,7 +5,9 @@ import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; // The chat turn queue itself lives in the desktop GUI (the gateway relays // snapshots); the web module keeps only the composer-side content check. const loader = createWebModuleLoader(); -const { queuedChatTurnHasContent } = loader.loadModule("src/pages/chat/queue/chatTurnQueue.ts"); +const { queuedChatTurnHasContent } = loader.loadModule( + "@liveagent/ui/lib/chat/queuedChatTurn.ts", +); function draft(overrides = {}) { return { diff --git a/crates/agent-gateway/test/webui/history-chat-ui.test.mjs b/crates/agent-gateway/test/webui/history-chat-ui.test.mjs index 877112a48..383ddd63e 100644 --- a/crates/agent-gateway/test/webui/history-chat-ui.test.mjs +++ b/crates/agent-gateway/test/webui/history-chat-ui.test.mjs @@ -794,7 +794,7 @@ test("GatewayTranscript renders folded and live rows in one virtualized list", ( return { type: "ImagePreview", props }; }, }, - "@/pages/chat/AssistantBubble": { + "@liveagent/ui/components/chat/AssistantBubble": { AssistantAvatar() { return { type: "AssistantAvatar", props: {} }; }, diff --git a/crates/agent-gateway/web/src/agent-ui-adapters/assistantBubble.ts b/crates/agent-gateway/web/src/agent-ui-adapters/assistantBubble.ts new file mode 100644 index 000000000..902ae2862 --- /dev/null +++ b/crates/agent-gateway/web/src/agent-ui-adapters/assistantBubble.ts @@ -0,0 +1,25 @@ +import type { AskUserQuestionAnswer } from "@liveagent/ui/lib/chat/askUserQuestion"; +import { readAskUserQuestionDeadlineAt } from "@liveagent/ui/lib/chat/askUserQuestion"; +import { readToolApprovalPending } from "@liveagent/ui/lib/chat/toolApprovalArgs"; +import { submitAskUserQuestionAnswer } from "../lib/chat/askUserQuestionBridge"; + +export const deferLargeToolImages = true; +export const retainRunningToolContent = false; + +export function usePendingToolApproval( + _toolCallId: string, + toolArguments: Record, +) { + return readToolApprovalPending(toolArguments); +} + +export function readAskUserQuestionDeadline( + _toolCallId: string, + toolArguments: Record, +) { + return readAskUserQuestionDeadlineAt(toolArguments) ?? undefined; +} + +export function submitAskUserQuestionAnswers(toolCallId: string, answers: AskUserQuestionAnswer[]) { + return submitAskUserQuestionAnswer(toolCallId, answers); +} diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 6152bacd8..bb65c79f1 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -1,5 +1,6 @@ import { ApplicationView } from "@liveagent/ui/application/ApplicationView"; import { AppErrorBoundary } from "@liveagent/ui/components/AppErrorBoundary"; +import { FileDropOverlay } from "@liveagent/ui/components/chat/FileDropOverlay"; import type { MentionComposerDraft, MentionComposerHandle, @@ -17,9 +18,11 @@ import { RightDockPanel } from "@liveagent/ui/components/project-tools/RightDock import { Button } from "@liveagent/ui/components/ui/button"; import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; import { ScrollArea } from "@liveagent/ui/components/ui/scroll-area"; +import { WorkspaceOverlayHost } from "@liveagent/ui/components/workspace-editor/WorkspaceOverlayHost"; import { LocaleContext, t as translate } from "@liveagent/ui/i18n/index"; import { normalizeLogicalLineEndings } from "@liveagent/ui/lib/chat/composerText"; import { openChatFileLink } from "@liveagent/ui/lib/chat/openChatFileLink"; +import { queuedChatTurnHasContent } from "@liveagent/ui/lib/chat/queuedChatTurn"; import { selectLatestTaskProgress } from "@liveagent/ui/lib/chat/taskProgress"; import { readToolApprovalDeadlineAt, @@ -29,6 +32,7 @@ import { import { memoryDeleteProject } from "@liveagent/ui/lib/memory/api"; import { createUuid } from "@liveagent/ui/lib/shared/id"; import { mergeAlwaysEnabledSkillNames } from "@liveagent/ui/lib/skills/index"; +import { useChatSkills } from "@liveagent/ui/lib/skills/useChatSkills"; import { terminalSessionBelongsToProject } from "@liveagent/ui/lib/terminal/sessionStore"; import type { TerminalSession } from "@liveagent/ui/lib/terminal/types"; import { @@ -140,8 +144,6 @@ import { workspaceProjectPathKey, } from "@/lib/settings"; import { createGatewayWorkspaceActivityClient } from "@/lib/workspace-activity/gatewayWorkspaceActivityClient"; -import { queuedChatTurnHasContent } from "@/pages/chat/queue/chatTurnQueue"; -import { useChatSkills } from "@/pages/chat/useChatSkills"; import type { SectionId } from "@/pages/settings/types"; const LOCAL_DRAFT_PREFIX = "__local_draft__:"; @@ -225,7 +227,6 @@ import { SHARED_HISTORY_LIST_PAGE_SIZE, SKILLS_HUB_BROWSER_TITLE, } from "./constants"; -import { FileDropOverlay } from "./FileDropOverlay"; import { HistorySwitchLoadingOverlay } from "./HistorySwitchLoadingOverlay"; import { createWorkspaceProjectFromPath, @@ -249,7 +250,6 @@ import { } from "./sidebar/gatewaySidebarAvailability"; import type { ModelProviderSource, OverlayState, SendChatFn, SendChatOptions } from "./types"; import { UserMenu } from "./UserMenu"; -import { WorkspaceOverlayHost } from "./WorkspaceOverlayHost"; const STALE_HISTORY_RETRY_INITIAL_DELAY_MS = 1_000; const STALE_HISTORY_RETRY_MAX_DELAY_MS = 30_000; diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index ce23d958f..f0bd2a45e 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -1,3 +1,11 @@ +import { + AssistantAvatar, + AssistantBubble, + AssistantStatus, + CompactingText, + RetryDetailsBlock, + VibingText, +} from "@liveagent/ui/components/chat/AssistantBubble"; import { ChatEmptyState } from "@liveagent/ui/components/chat/ChatEmptyState"; import { getUploadedFileTypeIcon } from "@liveagent/ui/components/chat/fileTypeIcons"; import { ImagePreview, type ImagePreviewSlide } from "@liveagent/ui/components/chat/ImagePreview"; @@ -56,14 +64,6 @@ import { } from "@/lib/chat/userMessageContent"; import { DEFAULT_CHAT_TRANSCRIPT_WIDTH } from "@/lib/settings"; import { extractLiveRange } from "@/lib/transcript-virtual/liveRangeExtractor"; -import { - AssistantAvatar, - AssistantBubble, - AssistantStatus, - CompactingText, - RetryDetailsBlock, - VibingText, -} from "@/pages/chat/AssistantBubble"; import type { RetryAttemptRecord, TranscriptRow } from "../lib/chat/transcript/types"; import type { SectionId } from "../pages/settings/types"; import { CheckCircle2, ChevronDown, Loader2, X } from "./icons"; diff --git a/crates/agent-gateway/web/src/lib/chat/assistantBubbleAdapter.ts b/crates/agent-gateway/web/src/lib/chat/assistantBubbleAdapter.ts new file mode 100644 index 000000000..0c7fe36a7 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/assistantBubbleAdapter.ts @@ -0,0 +1,33 @@ +export type { ImageContent, ToolResultMessage } from "../agentTypes"; +export type { + DeleteResultDetails, + DisplayImageItemDetails, + DisplayImageResultDetails, + EditResultDetails, + GlobResultDetails, + GrepResultDetails, + ListResultDetails, + McpManagerResultDetails, + ReadDocumentResultDetails, + ReadImageResultDetails, + ReadNotebookResultDetails, + ReadPdfResultDetails, + ReadTextResultDetails, + SkillsManagerResultDetails, + WriteResultDetails, +} from "../tools/builtinTypes"; +export { normalizeLiveToolStatus, VIBING_STATUS } from "./chatPageHelpers"; +export { deriveFileChangeStats } from "./fileChangeStats"; +export type { HostedSearchBlock } from "./hostedSearch"; +export { deriveFileToolPreview, FILE_TOOL_TEXT_FIELDS } from "./toolPreview"; +export type { RetryAttemptRecord } from "./transcript/types"; +export { + previewText, + safeStringify, + shouldDisplayToolTraceItem, + summarizeToolCall, + type ToolTraceItem, + toolCallArgsForDisplay, + toolResultMessageToText, + type UiRound, +} from "./uiMessages"; diff --git a/crates/agent-gateway/web/src/lib/chat/changedFilesAdapter.ts b/crates/agent-gateway/web/src/lib/chat/changedFilesAdapter.ts index 3b7de2353..1d6cdb348 100644 --- a/crates/agent-gateway/web/src/lib/chat/changedFilesAdapter.ts +++ b/crates/agent-gateway/web/src/lib/chat/changedFilesAdapter.ts @@ -1 +1,2 @@ export type { ChangedFileEntry, ChangedFilesSummary } from "./changedFiles"; +export { collectChangedFiles } from "./changedFiles"; diff --git a/crates/agent-gateway/web/src/pages/chat/useChatSkills.ts b/crates/agent-gateway/web/src/pages/chat/useChatSkills.ts deleted file mode 100644 index 8c86a8ad6..000000000 --- a/crates/agent-gateway/web/src/pages/chat/useChatSkills.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { - discoverSkills, - isAlwaysEnabledSkillName, - mergeAlwaysEnabledSkillNames, - type SkillSummary, - subscribeSkillsDiscoveryUpdated, -} from "@liveagent/ui/lib/skills/index"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { type AppSettings, updateSkills } from "../../lib/settings"; - -type UseChatSkillsParams = { - skillsEnabled: boolean; - selectedSkillNames: string[]; - setSettings: (updater: (prev: AppSettings) => AppSettings) => void; -}; - -function reconcileSelectedSkills(params: { - skills: SkillSummary[]; - selectedSkillNames: string[]; - setSettings: (updater: (prev: AppSettings) => AppSettings) => void; -}) { - const { skills, selectedSkillNames, setSettings } = params; - const names = new Set(skills.map((skill) => skill.name)); - const filtered = mergeAlwaysEnabledSkillNames(selectedSkillNames).filter( - (name) => isAlwaysEnabledSkillName(name) || names.has(name), - ); - if (filtered.join("\n") === selectedSkillNames.join("\n")) return; - - setSettings((prev) => { - const current = mergeAlwaysEnabledSkillNames(prev.skills.selected); - const next = current.filter((name) => isAlwaysEnabledSkillName(name) || names.has(name)); - if (next.join("\n") === current.join("\n")) return prev; - return updateSkills(prev, { selected: next }); - }); -} - -export function useChatSkills(params: UseChatSkillsParams) { - const { skillsEnabled, selectedSkillNames, setSettings } = params; - const [availableSkills, setAvailableSkills] = useState([]); - const [skillsRootDir, setSkillsRootDir] = useState(""); - const [skillsLoading, setSkillsLoading] = useState(false); - const [skillsLoadError, setSkillsLoadError] = useState(null); - const mountedRef = useRef(true); - const requestSequenceRef = useRef(0); - const selectedSkillNamesRef = useRef(selectedSkillNames); - - useEffect(() => { - selectedSkillNamesRef.current = selectedSkillNames; - }, [selectedSkillNames]); - - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); - - const applyDisabledState = useCallback(() => { - if (!mountedRef.current) return; - setAvailableSkills([]); - setSkillsRootDir(""); - setSkillsLoadError(null); - setSkillsLoading(false); - }, []); - - const runDiscovery = useCallback( - async (options?: { force?: boolean }) => { - if (!skillsEnabled) { - requestSequenceRef.current += 1; - applyDisabledState(); - return null; - } - - const requestId = requestSequenceRef.current + 1; - requestSequenceRef.current = requestId; - if (mountedRef.current) { - setSkillsLoading(true); - setSkillsLoadError(null); - } - - try { - const discovery = await discoverSkills({ force: options?.force }); - if (!mountedRef.current || requestSequenceRef.current !== requestId) { - return null; - } - setSkillsRootDir(discovery.rootDir); - setAvailableSkills(discovery.skills); - reconcileSelectedSkills({ - skills: discovery.skills, - selectedSkillNames: selectedSkillNamesRef.current, - setSettings, - }); - return discovery; - } catch (err) { - if (!mountedRef.current || requestSequenceRef.current !== requestId) { - return null; - } - const msg = err instanceof Error ? err.message : String(err); - setSkillsRootDir(""); - setAvailableSkills([]); - setSkillsLoadError(msg || "加载 skills 失败"); - return null; - } finally { - if (mountedRef.current && requestSequenceRef.current === requestId) { - setSkillsLoading(false); - } - } - }, - [applyDisabledState, setSettings, skillsEnabled], - ); - - const refreshSkills = useCallback(async () => { - return runDiscovery({ force: true }); - }, [runDiscovery]); - - useEffect(() => { - void runDiscovery(); - }, [runDiscovery]); - - useEffect(() => { - if (!skillsEnabled) return; - return subscribeSkillsDiscoveryUpdated(() => { - void runDiscovery({ force: true }); - }); - }, [runDiscovery, skillsEnabled]); - - return { - availableSkills, - skillsRootDir, - skillsLoading, - skillsLoadError, - refreshSkills, - }; -} diff --git a/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs b/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs index 6ab9d2a29..973c15cc0 100644 --- a/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs +++ b/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs @@ -8,7 +8,7 @@ const rootDir = fileURLToPath(new URL("../", import.meta.url)); const loader = createWebModuleLoader({ rootDir }); const { BUILTIN_TOOL_CATALOG } = loader.loadModule("@liveagent/ui/lib/tools/builtinToolCatalog.ts"); const { groupRoundBlocks, isBuiltinShareToolName } = loader.loadModule( - "src/pages/chat/assistant-bubble/assistantBubbleUtils.ts", + "@liveagent/ui/components/chat/assistant-bubble/assistantBubbleUtils.ts", ); test("shared history recognizes every catalog tool as builtin", () => { diff --git a/crates/agent-gateway/web/test/chat-file-links.test.mjs b/crates/agent-gateway/web/test/chat-file-links.test.mjs index e1b0b99f7..124ce0f5d 100644 --- a/crates/agent-gateway/web/test/chat-file-links.test.mjs +++ b/crates/agent-gateway/web/test/chat-file-links.test.mjs @@ -84,8 +84,8 @@ test("Gateway historical and streaming rows keep the explicit file-open prop cha "../src/app/GatewayApp.tsx", "../src/components/GatewayTranscript.tsx", "../../../agent-ui/src/components/chat/ThinkingActivity.tsx", - "../src/pages/chat/AssistantBubble.tsx", - "../src/pages/chat/assistant-bubble/RoundContent.tsx", + "../../../agent-ui/src/components/chat/AssistantBubble.tsx", + "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", ]; for (const relativePath of files) { const source = fs.readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); @@ -93,7 +93,12 @@ test("Gateway historical and streaming rows keep the explicit file-open prop cha } const roundContent = fs.readFileSync( - fileURLToPath(new URL("../src/pages/chat/assistant-bubble/RoundContent.tsx", import.meta.url)), + fileURLToPath( + new URL( + "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", + import.meta.url, + ), + ), "utf8", ); assert.match(roundContent, /isStreaming \? "streaming" : "static"/); diff --git a/crates/agent-gateway/web/test/live-markdown-caret.test.mjs b/crates/agent-gateway/web/test/live-markdown-caret.test.mjs index 393e7cc0b..6827a714e 100644 --- a/crates/agent-gateway/web/test/live-markdown-caret.test.mjs +++ b/crates/agent-gateway/web/test/live-markdown-caret.test.mjs @@ -3,7 +3,10 @@ import fs from "node:fs"; import test from "node:test"; const roundContentSource = fs.readFileSync( - new URL("../src/pages/chat/assistant-bubble/RoundContent.tsx", import.meta.url), + new URL( + "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", + import.meta.url, + ), "utf8", ); diff --git a/crates/agent-gateway/web/test/task-progress.test.mjs b/crates/agent-gateway/web/test/task-progress.test.mjs index 15bf7a3e3..662763762 100644 --- a/crates/agent-gateway/web/test/task-progress.test.mjs +++ b/crates/agent-gateway/web/test/task-progress.test.mjs @@ -88,7 +88,12 @@ test("WebUI hides all task tool blocks while preserving ordinary tools", () => { false, ); const source = readFileSync( - fileURLToPath(new URL("../src/pages/chat/assistant-bubble/RoundContent.tsx", import.meta.url)), + fileURLToPath( + new URL( + "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", + import.meta.url, + ), + ), "utf8", ); assert.match(source, /groupedBlocks\.filter\(\(block\) => !isTaskToolBlock\(block\)\)/); diff --git a/crates/agent-gateway/web/test/thinking-overlay-model.test.mjs b/crates/agent-gateway/web/test/thinking-overlay-model.test.mjs index 869996b28..11f70ab05 100644 --- a/crates/agent-gateway/web/test/thinking-overlay-model.test.mjs +++ b/crates/agent-gateway/web/test/thinking-overlay-model.test.mjs @@ -13,7 +13,10 @@ const componentSource = fs.readFileSync( "utf8", ); const roundContentSource = fs.readFileSync( - new URL("../src/pages/chat/assistant-bubble/RoundContent.tsx", import.meta.url), + new URL( + "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", + import.meta.url, + ), "utf8", ); diff --git a/crates/agent-gui/src/agent-ui-adapters/assistantBubble.ts b/crates/agent-gui/src/agent-ui-adapters/assistantBubble.ts new file mode 100644 index 000000000..f8c85362a --- /dev/null +++ b/crates/agent-gui/src/agent-ui-adapters/assistantBubble.ts @@ -0,0 +1,33 @@ +import type { AskUserQuestionAnswer } from "@liveagent/ui/lib/chat/askUserQuestion"; +import { useSyncExternalStore } from "react"; +import { + answerAskUserQuestion, + getAskUserQuestionDeadlineAt, +} from "../lib/tools/askUserQuestionTools"; +import { + getPendingToolApproval, + getToolApprovalVersion, + subscribeToolApprovals, +} from "../lib/tools/toolApproval"; + +export const deferLargeToolImages = false; +export const retainRunningToolContent = true; + +export function usePendingToolApproval( + toolCallId: string, + _toolArguments: Record, +) { + useSyncExternalStore(subscribeToolApprovals, getToolApprovalVersion, getToolApprovalVersion); + return Boolean(getPendingToolApproval(toolCallId)); +} + +export function readAskUserQuestionDeadline( + toolCallId: string, + _toolArguments: Record, +) { + return getAskUserQuestionDeadlineAt(toolCallId) ?? undefined; +} + +export function submitAskUserQuestionAnswers(toolCallId: string, answers: AskUserQuestionAnswer[]) { + return Promise.resolve(answerAskUserQuestion(toolCallId, answers)); +} diff --git a/crates/agent-gui/src/lib/chat/assistantBubbleAdapter.ts b/crates/agent-gui/src/lib/chat/assistantBubbleAdapter.ts new file mode 100644 index 000000000..7210d9f80 --- /dev/null +++ b/crates/agent-gui/src/lib/chat/assistantBubbleAdapter.ts @@ -0,0 +1,36 @@ +export type { ImageContent, ToolResultMessage } from "@earendil-works/pi-ai"; +export type { RetryAttemptRecord } from "../providers/runtime/streamRetry"; +export type { + DeleteResultDetails, + DisplayImageItemDetails, + DisplayImageResultDetails, + EditResultDetails, + GlobResultDetails, + GrepResultDetails, + ListResultDetails, + McpManagerResultDetails, + ReadDocumentResultDetails, + ReadImageResultDetails, + ReadNotebookResultDetails, + ReadPdfResultDetails, + ReadTextResultDetails, + SkillsManagerResultDetails, + WriteResultDetails, +} from "../tools/builtinTypes"; +export { deriveFileChangeStats } from "./messages/fileChangeStats"; +export type { HostedSearchBlock } from "./messages/hostedSearch"; +export { + deriveFileToolPreview, + FILE_TOOL_TEXT_FIELDS, +} from "./messages/toolPreview"; +export { + previewText, + safeStringify, + shouldDisplayToolTraceItem, + summarizeToolCall, + type ToolTraceItem, + toolCallArgsForDisplay, + toolResultMessageToText, + type UiRound, +} from "./messages/uiMessages"; +export { normalizeLiveToolStatus, VIBING_STATUS } from "./page/chatPageHelpers"; diff --git a/crates/agent-gui/src/lib/chat/changedFilesAdapter.ts b/crates/agent-gui/src/lib/chat/changedFilesAdapter.ts index fc31c9592..fa69b8353 100644 --- a/crates/agent-gui/src/lib/chat/changedFilesAdapter.ts +++ b/crates/agent-gui/src/lib/chat/changedFilesAdapter.ts @@ -2,3 +2,4 @@ export type { ChangedFileEntry, ChangedFilesSummary, } from "./messages/changedFiles"; +export { collectChangedFiles } from "./messages/changedFiles"; diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 335cc67c7..7958872f6 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -4,6 +4,7 @@ import { type ChangedFilesActions, ChangedFilesActionsProvider, } from "@liveagent/ui/components/chat/ChangedFilesCard"; +import { FileDropOverlay } from "@liveagent/ui/components/chat/FileDropOverlay"; import { HistoryShareModal } from "@liveagent/ui/components/chat/HistoryShareModal"; import type { MentionComposerHandle } from "@liveagent/ui/components/chat/MentionComposer"; import { NotifyToast } from "@liveagent/ui/components/chat/NotifyToast"; @@ -21,6 +22,7 @@ import { RightDockPanel } from "@liveagent/ui/components/project-tools/RightDock import { expandedPathsForFileTreePath } from "@liveagent/ui/components/project-tools/rightDockModel"; import { Button } from "@liveagent/ui/components/ui/button"; import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; +import { WorkspaceOverlayHost } from "@liveagent/ui/components/workspace-editor/WorkspaceOverlayHost"; import { useLocale } from "@liveagent/ui/i18n/index"; import { getAutomationState, useAutomation } from "@liveagent/ui/lib/automation/index"; import { openChatFileLink } from "@liveagent/ui/lib/chat/openChatFileLink"; @@ -102,6 +104,7 @@ import { type WorkspaceProject, workspaceProjectPathKey, } from "../lib/settings"; +import { tauriSftpClient } from "../lib/sftp/tauriSftpClient"; import { createGuiSidebarBackend } from "../lib/sidebar/guiSidebarBackend"; import { createSubagentStoreManager } from "../lib/subagents"; import { tauriTerminalClient } from "../lib/terminal/tauriTerminalClient"; @@ -134,8 +137,6 @@ import { usePendingUploads, } from "./chat"; import { appendManagedSkillSelections } from "./chat/chatPageUtils"; -import { ChatFileDropOverlay } from "./chat/components/ChatFileDropOverlay"; -import { WorkspaceOverlayHost } from "./chat/components/WorkspaceOverlayHost"; import { useComposerDraftCache } from "./chat/composer/useComposerDraftCache"; import { useGatewayBridgeReadiness } from "./chat/gateway/useGatewayBridgeReadiness"; import { useGatewayRunMirrorCoordinator } from "./chat/gateway/useGatewayRunMirrorCoordinator"; @@ -2093,7 +2094,7 @@ export function ChatPage(props: ChatPageProps) { approvalBar={approvalBar} /> {isFileDropActive ? ( - workspaceOverlays.setWorkspaceEditorOpen(false)} + onWorkspaceEditorClose={() => { + workspaceOverlays.setWorkspaceEditorOpen(false); + workspaceOverlays.setWorkspaceEditorMounted(false); + workspaceOverlays.setWorkspaceEditorCleanupPending(false); + workspaceOverlays.setWorkspaceEditorOpenRequest(null); + workspaceOverlays.setWorkspaceEditorCloseRequestId(0); + }} + workspaceFilePreviewMounted={workspaceOverlays.workspaceFilePreviewMounted} + workspaceFilePreviewOpenRequest={workspaceOverlays.workspaceFilePreviewOpenRequest} + workspaceFilePreviewOpen={workspaceOverlays.workspaceFilePreviewOpen} + onWorkspaceFilePreviewOpenEditor={workspaceOverlays.openWorkspaceEditorFile} + onWorkspaceFilePreviewRequestClose={ + workspaceOverlays.requestWorkspaceFilePreviewClose + } + onWorkspaceFilePreviewClose={workspaceOverlays.handleWorkspaceFilePreviewClosed} + workspaceSshTerminalMounted={workspaceOverlays.workspaceSshTerminalMounted} + workspaceSshTerminalOpenRequest={workspaceOverlays.workspaceSshTerminalOpenRequest} + workspaceSshTerminalOpen={workspaceOverlays.workspaceSshTerminalOpen} terminalProjectPathKey={terminalProjectPathKey} + terminalClient={tauriTerminalClient} + sftpClient={tauriSftpClient} terminalSessions={terminalSessions} - onInsertCodeMention={handleInsertCodeMention} + onWorkspaceSshTerminalHide={() => + workspaceOverlays.setWorkspaceSshTerminalOpen(false) + } /> } /> diff --git a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx index 536641ee7..f1ef3c795 100644 --- a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx +++ b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx @@ -4,13 +4,16 @@ import { CompactingText, VibingText, } from "@liveagent/ui/components/chat/AssistantStatus"; +import { + RetryDetailsBlock, + RoundBlockContent, +} from "@liveagent/ui/components/chat/assistant-bubble/RoundContent"; import { UsagePanel } from "@liveagent/ui/components/chat/UsagePanel"; import { memo, type ReactNode } from "react"; import type { ChatFileLink } from "../../../lib/chat/chatFileLinks"; import type { RetryAttemptRecord } from "../../../lib/chat/conversation/liveTranscriptStore"; import { VIBING_STATUS } from "../../../lib/chat/page/chatPageHelpers"; import type { AssistantUnitRow } from "../transcript/rowModel"; -import { RetryDetailsBlock, RoundBlockContent } from "./assistant-bubble/RoundContent"; export { AssistantAvatar } from "@liveagent/ui/components/chat/AssistantAvatar"; diff --git a/crates/agent-gui/src/pages/chat/components/ChatFileDropOverlay.tsx b/crates/agent-gui/src/pages/chat/components/ChatFileDropOverlay.tsx deleted file mode 100644 index 18d4248e6..000000000 --- a/crates/agent-gui/src/pages/chat/components/ChatFileDropOverlay.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Ban, Upload } from "../../../components/icons"; - -type ChatFileDropOverlayProps = { - canDropUpload: boolean; - title: string; - description: string; - limitHint: string; -}; - -/** Full-chat drop-zone overlay shown while a Tauri drag-drop is in flight. */ -export function ChatFileDropOverlay(props: ChatFileDropOverlayProps) { - const { canDropUpload, title, description, limitHint } = props; - return ( - - } - > - openWorkspaceEditorFile(request)} - onRequestClose={requestWorkspaceFilePreviewClose} - onClose={handleWorkspaceFilePreviewClosed} - /> - - ) : null} - {workspaceSshTerminalMounted ? ( - - -
- {t("workspaceSshTerminal.loading")} -
- - } - > - setWorkspaceSshTerminalOpen(false)} - /> -
- ) : null} - - ); -} diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx deleted file mode 100644 index 01adf9834..000000000 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { HostedSearchGroupView } from "@liveagent/ui/components/chat/HostedSearchGroupView"; -import { LazyCollapse } from "@liveagent/ui/components/chat/LazyCollapse"; -import { ThinkingActivity } from "@liveagent/ui/components/chat/ThinkingActivity"; -import { Markdown } from "@liveagent/ui/components/Markdown"; -import { useLocale } from "@liveagent/ui/i18n/index"; -import { memo, type ReactNode, useState } from "react"; -import { ChevronRight, RefreshCw } from "../../../../components/icons"; -import type { ChatFileLink } from "../../../../lib/chat/chatFileLinks"; -import type { RetryAttemptRecord } from "../../../../lib/chat/conversation/liveTranscriptStore"; -import type { GroupedRoundBlock } from "./assistantBubbleUtils"; -import { MemoToolCallItem } from "./ToolCallItem"; -import { getNativeDisplayImagePayload, NativeDisplayImageBlock } from "./ToolImages"; -import { ToolTraceGroup } from "./ToolTraceGroup"; - -export const RetryDetailsBlock = memo(function RetryDetailsBlock({ - attempts, -}: { - attempts: RetryAttemptRecord[]; -}) { - const { t } = useLocale(); - const [isOpen, setIsOpen] = useState(false); - - if (attempts.length === 0) return null; - - return ( -
- - - {() => ( -
- {attempts.map((entry, index) => ( -
-
- {t("chat.retryAttemptLabel") - .replace("{attempt}", String(entry.attempt)) - .replace("{maxAttempts}", String(entry.maxAttempts))} -
-
{entry.errorMessage}
-
- ))} -
- )} -
-
- ); -}); - -export const RoundBlockContent = memo(function RoundBlockContent(props: { - block: GroupedRoundBlock; - isLive: boolean; - renderMode: "streaming" | "static"; - runningToolCallIds: string[]; - thinkingOpen: boolean; - isLatestThinking: boolean; - workdir?: string; - onOpenFileLink?: (link: ChatFileLink) => void; -}) { - const { - block, - isLive, - renderMode, - runningToolCallIds, - thinkingOpen, - isLatestThinking, - workdir, - onOpenFileLink, - } = props; - - let content: ReactNode; - if (block.kind === "thinking") { - const isRunning = isLive && thinkingOpen && isLatestThinking; - content = ( - - ); - } else if (block.kind === "tool") { - const displayImagePayload = getNativeDisplayImagePayload(block.item); - if (displayImagePayload) { - content = ; - } else if (block.item.toolCall.name === "Image" && !block.item.toolResult?.isError) { - content = null; - } else { - content = ( - - ); - } - } else if (block.kind === "toolGroup") { - content = ( - - ); - } else if (block.kind === "hostedSearch" || block.kind === "hostedSearchGroup") { - content = ( - - ); - } else if (block.text.trim()) { - content = ( - - ); - } else { - content = null; - } - - if (!content) return null; - - return
{content}
; -}); diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx deleted file mode 100644 index 8d6e418ec..000000000 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx +++ /dev/null @@ -1,611 +0,0 @@ -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { AskUserQuestionCard } from "@liveagent/ui/components/chat/AskUserQuestionCard"; -import { AssistantStatus } from "@liveagent/ui/components/chat/AssistantStatus"; -import { FileChangeBadge } from "@liveagent/ui/components/chat/FileChangeBadge"; -import { FileToolArgsDisplay } from "@liveagent/ui/components/chat/FileToolArgs"; -import { LazyCollapse } from "@liveagent/ui/components/chat/LazyCollapse"; -import { useLocale } from "@liveagent/ui/i18n/index"; -import { - ASK_USER_QUESTION_TOOL_NAME, - type AskUserQuestionAnswer, - parseAskUserQuestionResultDetails, - sanitizeAskUserQuestionItems, -} from "@liveagent/ui/lib/chat/askUserQuestion"; -import { cn } from "@liveagent/ui/lib/shared/utils"; -import { memo, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; -import { ChevronRight, Search } from "../../../../components/icons"; -import { deriveFileChangeStats } from "../../../../lib/chat/messages/fileChangeStats"; -import { - deriveFileToolPreview, - FILE_TOOL_TEXT_FIELDS, -} from "../../../../lib/chat/messages/toolPreview"; -import { - previewText, - safeStringify, - summarizeToolCall, - type ToolTraceItem, - toolCallArgsForDisplay, - toolResultMessageToText, -} from "../../../../lib/chat/messages/uiMessages"; -import { isSubagentCardToolCall } from "../../../../lib/subagents/card"; -import { - answerAskUserQuestion, - getAskUserQuestionDeadlineAt, -} from "../../../../lib/tools/askUserQuestionTools"; -import { - getPendingToolApproval, - getToolApprovalVersion, - subscribeToolApprovals, -} from "../../../../lib/tools/toolApproval"; -import { - areStableValuesEqual, - displayString, - getBuiltinResultKind, - getSubagentInlineSummary, - getToolDisplayTitle, - getToolMeta, - type MetaTag, -} from "./assistantBubbleUtils"; -import { - MetaTags, - PathDisplay, - ToolFactGrid, - ToolResultDisplay, - ToolScrollablePre, - ToolSection, - ToolSurface, - ToolSurfaceLabel, -} from "./ToolResultDisplay"; - -function getToolDisplay(toolCall: { name: string; arguments?: Record }) { - const args = toolCall.arguments || {}; - const name = toolCall.name; - const path = typeof args.path === "string" ? (args.path as string) : null; - const pattern = typeof args.pattern === "string" ? (args.pattern as string) : null; - const tags: MetaTag[] = []; - - switch (name) { - case "Read": - if (typeof args.start_line === "number") - tags.push({ label: "start", value: String(args.start_line) }); - if (typeof args.limit === "number") tags.push({ label: "limit", value: String(args.limit) }); - if (typeof args.page_start === "number") - tags.push({ label: "page", value: String(args.page_start) }); - if (typeof args.page_limit === "number") - tags.push({ label: "pages", value: String(args.page_limit) }); - if (typeof args.cell_start === "number") - tags.push({ label: "cell", value: String(args.cell_start) }); - if (typeof args.cell_limit === "number") - tags.push({ label: "cells", value: String(args.cell_limit) }); - return { type: "file" as const, path, tags }; - case "SkillsManager": - if (typeof args.offset === "number") - tags.push({ label: "start", value: String(args.offset + 1) }); - if (typeof args.length === "number") - tags.push({ label: "limit", value: String(args.length) }); - return { type: "file" as const, path, tags }; - case "MemoryManager": - if (typeof args.action === "string") - tags.push({ label: "action", value: args.action as string }); - if (typeof args.slug === "string") tags.push({ label: "slug", value: args.slug as string }); - if (typeof args.scope === "string") - tags.push({ label: "scope", value: args.scope as string }); - if (typeof args.type === "string") tags.push({ label: "type", value: args.type as string }); - return { type: "generic" as const, path: null, pattern: null, tags }; - case "McpManager": - if (typeof args.action === "string") - tags.push({ label: "action", value: args.action as string }); - if (typeof args.server_id === "string") - tags.push({ label: "server", value: args.server_id as string }); - if (Array.isArray(args.server_ids)) - tags.push({ label: "servers", value: String(args.server_ids.length) }); - if (typeof args.conflict === "string") - tags.push({ label: "conflict", value: args.conflict as string }); - if (args.include_schema === true) tags.push({ label: "schema", value: "true" }); - return { type: "generic" as const, path: null, pattern: null, tags }; - case "SendMessage": - if (typeof args.to === "string") tags.push({ label: "to", value: args.to as string }); - if (typeof args.channel === "string") - tags.push({ label: "channel", value: args.channel as string }); - if (typeof args.subject === "string") - tags.push({ label: "subject", value: args.subject as string }); - if (typeof args.summary === "string" && typeof args.subject !== "string") - tags.push({ label: "subject", value: args.summary as string }); - if (typeof args.message === "string") - tags.push({ label: "message", value: `${(args.message as string).length} chars` }); - return { type: "generic" as const, path: null, pattern: null, tags }; - case "Delete": - return { type: "file" as const, path, tags }; - case "List": - if (typeof args.depth === "number") tags.push({ label: "depth", value: String(args.depth) }); - if (typeof args.offset === "number") - tags.push({ label: "offset", value: String(args.offset) }); - if (typeof args.max_results === "number") - tags.push({ label: "max", value: String(args.max_results) }); - return { type: "file" as const, path: path || "/", tags }; - case "Glob": - if (typeof args.offset === "number") - tags.push({ label: "offset", value: String(args.offset) }); - if (typeof args.max_results === "number") - tags.push({ label: "max", value: String(args.max_results) }); - return { type: "search" as const, path, pattern, tags }; - case "Grep": - if (typeof args.file_pattern === "string") - tags.push({ label: "filter", value: args.file_pattern as string }); - if (typeof args.output_mode === "string") - tags.push({ label: "mode", value: args.output_mode as string }); - if (typeof args.ignore_case === "boolean" && args.ignore_case) - tags.push({ label: "flag", value: "-i" }); - if (typeof args.context === "number" && args.context > 0) - tags.push({ label: "ctx", value: String(args.context) }); - if (typeof args.head_limit === "number") - tags.push({ label: "head", value: String(args.head_limit) }); - if (args.multiline === true) tags.push({ label: "multi", value: "true" }); - return { type: "search" as const, path, pattern, tags }; - case "Bash": - return { type: "bash" as const, path: null, pattern: null, tags }; - case "ManagedProcess": { - if (typeof args.action === "string") tags.push({ label: "action", value: args.action }); - if (typeof args.process_id === "string") - tags.push({ label: "process", value: args.process_id as string }); - if (typeof args.label === "string") - tags.push({ label: "label", value: args.label as string }); - if (typeof args.cwd === "string") tags.push({ label: "cwd", value: args.cwd as string }); - if (args.isolated === true) tags.push({ label: "isolated", value: "true" }); - if (typeof args.max_bytes === "number") - tags.push({ label: "max_bytes", value: String(args.max_bytes) }); - const command = typeof args.command === "string" ? (args.command as string).trim() : ""; - return command - ? { type: "bash" as const, path: null, pattern: null, tags } - : { type: "generic" as const, path: null, pattern: null, tags }; - } - default: { - // Generic: collect all string/number/boolean args - const entries: MetaTag[] = []; - for (const [k, v] of Object.entries(args)) { - if (typeof v === "string") - entries.push({ label: k, value: v.length > 60 ? `${v.slice(0, 60)}…` : v }); - else if (typeof v === "number" || typeof v === "boolean") - entries.push({ label: k, value: String(v) }); - } - return { type: "generic" as const, path: null, pattern: null, tags: entries }; - } - } -} - -/** Expanded args display — tool-aware layout */ -function ToolArgsDisplay({ item }: { item: ToolTraceItem }) { - const toolCall = item.toolCall; - - const filePreview = deriveFileToolPreview(toolCall); - if (filePreview) { - return ; - } - - const display = getToolDisplay(toolCall); - - if (isSubagentCardToolCall(toolCall)) { - const args = toolCall.arguments || {}; - const name = displayString(args.name) || displayString(args.id); - const role = displayString(args.role); - const task = displayString(args.prompt); - - return ( -
- {name ? ( - - -
- {name} -
-
- ) : null} - {role ? ( - - -
- {role} -
-
- ) : null} - {task ? ( - - -
- {task} -
-
- ) : null} -
- ); - } - - // Bash / ManagedProcess(start): terminal block - if (display.type === "bash") { - const cmd = - typeof toolCall.arguments?.command === "string" - ? (toolCall.arguments.command as string).trim() - : ""; - if (!cmd) return null; - return ( -
- - $ - {cmd} - - {display.tags.length > 0 ? : null} -
- ); - } - - // File tools: target path + compact request facts - if (display.type === "file" && (display.path || display.tags.length > 0)) { - return ( -
- {display.path ? ( - - - - - ) : null} - {display.tags.length > 0 ? : null} -
- ); - } - - // Search tools: query, scope, and request facts - if (display.type === "search" && (display.pattern || display.path || display.tags.length > 0)) { - return ( -
- {display.pattern ? ( - - -
- - - {display.pattern} - -
-
- ) : null} - {display.path ? ( - - - - - ) : null} - {display.tags.length > 0 ? : null} -
- ); - } - - // Generic: key-value grid - if (display.type === "generic" && display.tags.length > 0) { - return ; - } - - // Fallback: raw JSON, cached by argument identity — settled tool args are - // immutable, so virtualizer remounts reuse the stringified form. - return ( - - - {getRawArgsDisplayText(toolCall)} - - - ); -} - -const rawArgsDisplayCache = new WeakMap(); - -function getRawArgsDisplayText(toolCall: ToolTraceItem["toolCall"]) { - const cacheKey = toolCall.arguments; - if (!cacheKey || typeof cacheKey !== "object") { - return safeStringify(toolCallArgsForDisplay(toolCall)); - } - const cached = rawArgsDisplayCache.get(cacheKey); - if (cached !== undefined) return cached; - const text = safeStringify(toolCallArgsForDisplay(toolCall)); - rawArgsDisplayCache.set(cacheKey, text); - return text; -} - -function ToolCallItem({ item, isRunning }: { item: ToolTraceItem; isRunning?: boolean }) { - const { t } = useLocale(); - const result = item.toolResult; - const builtinResultKind = getBuiltinResultKind(result); - const isAskUser = item.toolCall.name === ASK_USER_QUESTION_TOOL_NAME; - const askDetails = isAskUser ? parseAskUserQuestionResultDetails(result?.details) : null; - // 参数生成完毕(onToolCall 之后才会入回合)才渲染卡片;对历史/降级数据 - // 再以 isRunning/result 兜底,绝不展示半截问题。 - const askSettled = isAskUser && (Boolean(isRunning) || Boolean(result)); - const askQuestions = - isAskUser && askSettled - ? askDetails && askDetails.questions.length > 0 - ? askDetails.questions - : sanitizeAskUserQuestionItems(item.toolCall.arguments?.questions) - : []; - // 提问卡运行期强制展开等待作答;应答落定后自动收起。 - const shouldKeepAskOpen = isAskUser && (Boolean(isRunning) || !result); - const shouldCloseAnsweredAsk = isAskUser && Boolean(result); - // 权威应答截止时间来自工具挂起表;卡片倒计时与超时兜底同源, - // 会话切换重挂载也不会重置。 - const askDeadlineAt = - isAskUser && isRunning && !result - ? (getAskUserQuestionDeadlineAt(item.toolCall.id) ?? undefined) - : undefined; - const submitAskAnswers = useCallback( - (answers: AskUserQuestionAnswer[]) => - Promise.resolve(answerAskUserQuestion(item.toolCall.id, answers)), - [item.toolCall.id], - ); - // 工具审批挂起是响应式的:被审批的工具调用早已在转录中,挂起在 beforeToolCall - // 处出现/消失,故订阅审批服务版本号触发重渲染(memo 化组件内 hook 照常重跑)。 - useSyncExternalStore(subscribeToolApprovals, getToolApprovalVersion, getToolApprovalVersion); - const pendingApproval = getPendingToolApproval(item.toolCall.id); - const shouldAutoOpen = - item.toolCall.name === "Image" || builtinResultKind === "display_image" || shouldKeepAskOpen; - const [open, setOpen] = useState(shouldAutoOpen); - const isSubagentCard = isSubagentCardToolCall(item.toolCall); - const hasArgs = Object.keys(item.toolCall.arguments || {}).length > 0; - const isStreamingFilePreviewTool = FILE_TOOL_TEXT_FIELDS[item.toolCall.name] !== undefined; - const shouldShowArgs = - !isAskUser && (!isSubagentCard || !result) && (isStreamingFilePreviewTool ? !result : hasArgs); - const isBash = item.toolCall.name === "Bash"; - const isManagedProcess = item.toolCall.name === "ManagedProcess"; - const inlineCommand = - (isBash || isManagedProcess) && typeof item.toolCall.arguments?.command === "string" - ? item.toolCall.arguments.command.trim() - : ""; - const firstLine = inlineCommand ? inlineCommand.split("\n")[0] : ""; - const toolArgsSummary = - isBash || inlineCommand - ? "" - : isAskUser - ? (askQuestions[0]?.prompt ?? "") - : isSubagentCard - ? getSubagentInlineSummary(item) - : summarizeToolCall(item.toolCall, { - includeName: false, - includeManagerAction: false, - }); - const fileChangeStats = useMemo(() => deriveFileChangeStats(item.toolCall), [item.toolCall]); - const meta = getToolMeta(item.toolCall.name); - const ToolIcon = meta.Icon; - const title = isAskUser - ? { name: t("chat.tool.askUserTitle"), action: "" } - : getToolDisplayTitle(item.toolCall); - - const statusLabel = pendingApproval - ? t("chat.toolApproval.waitingStatus") - : isRunning - ? isAskUser - ? askQuestions.length > 0 - ? t("chat.askUser.waiting") - : t("chat.askUser.preparing") - : t("chat.tool.running") - : result - ? result.isError - ? t("chat.tool.failed") - : t("chat.tool.success") - : t("chat.tool.waiting"); - - const statusTextClass = result?.isError - ? "text-[hsl(var(--chat-error))]" - : "text-muted-foreground/60"; - - useEffect(() => { - if (shouldKeepAskOpen) { - setOpen(true); - } else if (shouldCloseAnsweredAsk) { - setOpen(false); - } else if (shouldAutoOpen) { - setOpen(true); - } - }, [shouldAutoOpen, shouldCloseAnsweredAsk, shouldKeepAskOpen]); - - const canExpand = shouldShowArgs || Boolean(result) || (isAskUser && askQuestions.length > 0); - - return ( -
- - - - {() => ( -
- {shouldShowArgs ? ( - - - - ) : null} - - {isAskUser && askQuestions.length > 0 ? ( - - ) : null} - - {/* 提问卡自带应答态展示;仅参数校验失败(无 details)时回落默认错误区。 */} - {result && (!isAskUser || !askDetails) ? ( - - {t("chat.tool.error")} - - ) : null - } - > -
- - - {(() => { - const resultText = toolResultMessageToText(result); - if (!/\S/.test(resultText)) return null; - if (builtinResultKind && builtinResultKind !== "read_image") return null; - - if (isBash) { - return ( - - {previewText(resultText, 6000)} - - ); - } - - // Errors must be readable at a glance — never behind the - // collapsed "view return" toggle. - if (result.isError) { - return ( - - {previewText(resultText, 6000)} - - ); - } - - return ( -
- - - {t("chat.tool.viewReturn")} - - - {previewText(resultText, 6000)} - -
- ); - })()} -
-
- ) : null} -
- )} -
-
- ); -} - -function areToolResultsEqual( - previous: ToolResultMessage | undefined, - next: ToolResultMessage | undefined, -) { - if (!previous || !next) { - return previous === next; - } - - return ( - previous.toolCallId === next.toolCallId && - previous.toolName === next.toolName && - previous.isError === next.isError && - areStableValuesEqual(previous.content, next.content) && - areStableValuesEqual(previous.details, next.details) - ); -} - -export function areToolTraceItemsEqual(previous: ToolTraceItem, next: ToolTraceItem) { - return ( - previous.toolCall.id === next.toolCall.id && - previous.toolCall.name === next.toolCall.name && - areStableValuesEqual(previous.toolCall.arguments, next.toolCall.arguments) && - areToolResultsEqual(previous.toolResult, next.toolResult) - ); -} - -export const MemoToolCallItem = memo( - ToolCallItem, - (previousProps, nextProps) => - previousProps.isRunning === nextProps.isRunning && - areToolTraceItemsEqual(previousProps.item, nextProps.item), -); diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolImages.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolImages.tsx deleted file mode 100644 index 80138aff9..000000000 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolImages.tsx +++ /dev/null @@ -1,527 +0,0 @@ -import type { ImageContent, ToolResultMessage } from "@earendil-works/pi-ai"; -import { ImagePreview, type ImagePreviewSlide } from "@liveagent/ui/components/chat/ImagePreview"; -import { useLocale } from "@liveagent/ui/i18n/index"; -import { prepareImageProxyUrl } from "@liveagent/ui/lib/providers/proxy"; -import { cn } from "@liveagent/ui/lib/shared/utils"; -import { useEffect, useMemo, useState } from "react"; -import { ImageOff, Loader2 } from "../../../../components/icons"; -import type { ToolTraceItem } from "../../../../lib/chat/messages/uiMessages"; -import type { - DisplayImageItemDetails, - DisplayImageResultDetails, -} from "../../../../lib/tools/builtinTypes"; -import { getBuiltinResultKind } from "./assistantBubbleUtils"; - -export function getToolResultImages(result?: ToolResultMessage) { - if (!result) return []; - return result.content.filter((block): block is ImageContent => block.type === "image"); -} - -export type NativeDisplayImageEntry = { - detail: DisplayImageItemDetails; - image?: ImageContent; -}; - -type NativeDisplayImageProxyRequest = { - index: number; - source: string; -}; - -export type NativeDisplayImageSourceState = { - src: string; - status: "loading" | "ready" | "error"; -}; - -type ToolImageLoadState = "loading" | "loaded" | "error"; - -function getImageDataUrl(image: ImageContent) { - return `data:${image.mimeType};base64,${image.data}`; -} - -function isDisplayImageItemDetails(value: unknown): value is DisplayImageItemDetails { - return ( - Boolean(value) && - typeof value === "object" && - typeof (value as { path?: unknown }).path === "string" - ); -} - -function getDisplayImageDetails(result: ToolResultMessage): DisplayImageItemDetails[] { - const details = result.details as DisplayImageResultDetails | undefined; - if (!details || details.kind !== "display_image" || !Array.isArray(details.images)) { - return []; - } - return details.images.filter(isDisplayImageItemDetails); -} - -function shouldRenderDisplayImageThroughProxy(detail: DisplayImageItemDetails) { - return detail.renderMode === "proxy" || detail.sourceType === "url"; -} - -function getProxyImageSource(detail: DisplayImageItemDetails) { - if (!shouldRenderDisplayImageThroughProxy(detail)) return ""; - const source = (detail.sourceUrl || detail.path || "").trim(); - return /^https?:\/\//i.test(source) ? source : ""; -} - -function getNativeDisplayImageEntries(result: ToolResultMessage): NativeDisplayImageEntry[] { - const inlineImages = getToolResultImages(result); - const detailImages = getDisplayImageDetails(result); - if (detailImages.length > 0) { - let inlineImageIndex = 0; - const entries = detailImages - .map((detail) => { - if (shouldRenderDisplayImageThroughProxy(detail)) { - return { detail, image: undefined }; - } - const image = inlineImages[inlineImageIndex]; - inlineImageIndex += 1; - return { detail, image }; - }) - .filter((entry) => Boolean(entry.image) || Boolean(getProxyImageSource(entry.detail))); - if (entries.length > 0) return entries; - } - return inlineImages.map((image, index) => ({ - image, - detail: { - path: `inline-image-${index + 1}`, - renderMode: "inline", - mimeType: image.mimeType, - sizeBytes: Math.ceil((image.data.length * 3) / 4), - }, - })); -} - -function getNativeDisplayImageProxyKey(entries: NativeDisplayImageEntry[]) { - const requests = entries - .map((entry, index) => { - const source = getProxyImageSource(entry.detail); - return source ? { index, source } : null; - }) - .filter((request): request is NativeDisplayImageProxyRequest => request !== null); - return JSON.stringify(requests); -} - -function parseNativeDisplayImageProxyKey(proxyKey: string): NativeDisplayImageProxyRequest[] { - if (!proxyKey || proxyKey === "[]") return []; - try { - const parsed = JSON.parse(proxyKey); - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (item): item is NativeDisplayImageProxyRequest => - item !== null && - typeof item === "object" && - typeof item.index === "number" && - Number.isInteger(item.index) && - item.index >= 0 && - typeof item.source === "string" && - item.source.length > 0, - ); - } catch { - return []; - } -} - -function useNativeDisplayImageSources(entries: NativeDisplayImageEntry[]) { - const proxyKey = getNativeDisplayImageProxyKey(entries); - const [proxySources, setProxySources] = useState>( - {}, - ); - - useEffect(() => { - let cancelled = false; - const pending = parseNativeDisplayImageProxyKey(proxyKey); - - if (pending.length === 0) { - setProxySources({}); - return; - } - - setProxySources( - Object.fromEntries( - pending.map(({ index }) => [index, { src: "", status: "loading" as const }]), - ), - ); - void Promise.all( - pending.map(async ({ index, source }) => { - try { - const preparedSource = await prepareImageProxyUrl(source); - return [ - index, - preparedSource - ? { src: preparedSource, status: "ready" as const } - : { src: "", status: "error" as const }, - ] as const; - } catch { - return [index, { src: "", status: "error" as const }] as const; - } - }), - ).then((items) => { - if (cancelled) return; - const next: Record = {}; - for (const [index, source] of items) { - next[index] = source; - } - setProxySources(next); - }); - - return () => { - cancelled = true; - }; - }, [proxyKey]); - - return entries.map((entry, index) => { - if (entry.image) { - return { src: getImageDataUrl(entry.image), status: "ready" as const }; - } - if (!getProxyImageSource(entry.detail)) { - return { src: "", status: "error" as const }; - } - return proxySources[index] ?? { src: "", status: "loading" as const }; - }); -} - -function estimateBase64Bytes(data: string) { - return Math.ceil((data.length * 3) / 4); -} - -function formatToolResultBytes(sizeBytes: number) { - if (sizeBytes >= 1024 * 1024) { - return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`; - } - if (sizeBytes >= 1024) { - return `${Math.round(sizeBytes / 1024)} KB`; - } - return `${sizeBytes} B`; -} - -function getInitialImageLoadState(source: NativeDisplayImageSourceState): ToolImageLoadState { - if (source.status === "error") return "error"; - if (source.status === "ready" && !source.src) return "error"; - return "loading"; -} - -function formatDisplayImageLabel(t: (key: string) => string, imageCount: number, index: number) { - if (imageCount <= 1) return t("chat.image.display"); - return t("chat.image.displayNumber").replace("{index}", String(index + 1)); -} - -function ToolImageStatusCard(props: { - status: "loading" | "error"; - title?: string; - detail?: string; - className?: string; -}) { - const { status, title, detail, className } = props; - const { t } = useLocale(); - const isError = status === "error"; - const Icon = isError ? ImageOff : Loader2; - - return ( -
-
- -
-
-
- {title ?? (isError ? t("chat.image.unavailable") : t("chat.image.loading"))} -
- {detail ? ( -
- {detail} -
- ) : null} -
-
- ); -} - -export function ToolResultImagePreview(props: { - image: ImageContent; - alt: string; - id: string; - sizeBytes?: number; -}) { - const { image, alt, id, sizeBytes } = props; - const { t } = useLocale(); - const [previewOpen, setPreviewOpen] = useState(false); - const [imageStatus, setImageStatus] = useState("loading"); - const src = getImageDataUrl(image); - const estimatedBytes = sizeBytes ?? estimateBase64Bytes(image.data); - const imageDetail = `${alt} · ${formatToolResultBytes(estimatedBytes)}`; - const slides = useMemo( - () => [ - { - src, - alt, - title: alt, - }, - ], - [alt, src], - ); - - useEffect(() => { - setImageStatus(src ? "loading" : "error"); - setPreviewOpen(false); - }, [src]); - - const canPreview = imageStatus === "loaded"; - - return ( - <> - - {previewOpen ? ( - setPreviewOpen(false)} /> - ) : null} - - ); -} - -export function getNativeDisplayImagePayload(item: ToolTraceItem) { - const result = item.toolResult; - if (!result || result.isError || getBuiltinResultKind(result) !== "display_image") { - return null; - } - - const entries = getNativeDisplayImageEntries(result); - if (entries.length === 0) { - return null; - } - - return { - details: result.details as DisplayImageResultDetails, - entries, - }; -} - -function getNativeImageGridClass(imageCount: number) { - if (imageCount <= 1) { - return "my-1 flex max-w-full flex-col items-start gap-2"; - } - if (imageCount === 2) { - return "my-1 grid w-full max-w-3xl grid-cols-2 gap-2"; - } - if (imageCount === 3) { - return "my-1 grid w-full max-w-3xl grid-cols-2 gap-2 sm:grid-cols-3"; - } - if (imageCount === 4) { - return "my-1 grid w-full max-w-3xl grid-cols-2 gap-2 sm:grid-cols-4"; - } - if (imageCount === 5) { - return "my-1 grid w-full max-w-3xl grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5"; - } - return "my-1 grid w-full max-w-3xl grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6"; -} - -function isSvgDisplayImageEntry(entry: NativeDisplayImageEntry) { - const mimeType = entry.image?.mimeType || entry.detail.mimeType || ""; - return mimeType.split(";")[0]?.trim().toLowerCase() === "image/svg+xml"; -} - -function NativeDisplayImageTile(props: { - source: NativeDisplayImageSourceState; - alt: string; - isGallery: boolean; - isSvgImage: boolean; - loading: "lazy" | "eager"; - onPreview: () => void; -}) { - const { source, alt, isGallery, isSvgImage, loading, onPreview } = props; - const { src, status } = source; - const { t } = useLocale(); - const [imageStatus, setImageStatus] = useState(() => - getInitialImageLoadState({ src, status }), - ); - - useEffect(() => { - setImageStatus(getInitialImageLoadState({ src, status })); - }, [src, status]); - - const canPreview = status === "ready" && imageStatus === "loaded"; - const isWaiting = !canPreview; - const statusTitle = - imageStatus === "error" - ? t("chat.image.unavailable") - : status === "loading" - ? t("chat.image.preparing") - : t("chat.image.loading"); - - return ( - - ); -} - -export function NativeDisplayImageBlock(props: { - payload: NonNullable>; -}) { - const { payload } = props; - const { t } = useLocale(); - const isGallery = payload.entries.length > 1; - const [previewIndex, setPreviewIndex] = useState(null); - const imageSources = useNativeDisplayImageSources(payload.entries); - const slides = useMemo( - () => - payload.entries.map((_entry, index) => ({ - src: imageSources[index]?.src ?? "", - alt: formatDisplayImageLabel(t, payload.entries.length, index), - title: formatDisplayImageLabel(t, payload.entries.length, index), - })), - [imageSources, payload.entries, t], - ); - - return ( - <> -
- {payload.entries.map((entry, index) => { - const id = entry.image - ? `${entry.image.mimeType}-${entry.image.data.length}-${index}` - : `${entry.detail.sourceUrl ?? entry.detail.path}-${index}`; - const slide = slides[index]; - const alt = slide?.alt ?? formatDisplayImageLabel(t, payload.entries.length, index); - const isSvgImage = isSvgDisplayImageEntry(entry); - return ( - setPreviewIndex(index)} - /> - ); - })} -
- {previewIndex !== null ? ( - setPreviewIndex(null)} - /> - ) : null} - - ); -} diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx deleted file mode 100644 index c1e4e1449..000000000 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx +++ /dev/null @@ -1,891 +0,0 @@ -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { EditDiffView } from "@liveagent/ui/components/chat/EditDiffView"; -import { Markdown } from "@liveagent/ui/components/Markdown"; -import { cn } from "@liveagent/ui/lib/shared/utils"; -import type { - SubagentBatchDetails, - SubagentCardDetails, - SubagentMessageDetails, -} from "@liveagent/ui/lib/subagents/protocol"; -import type { ReactNode } from "react"; -import { - previewText, - type ToolTraceItem, - toolResultMessageToText, -} from "../../../../lib/chat/messages/uiMessages"; -import type { - DeleteResultDetails, - EditResultDetails, - GlobResultDetails, - GrepResultDetails, - ListResultDetails, - McpManagerResultDetails, - ReadDocumentResultDetails, - ReadImageResultDetails, - ReadNotebookResultDetails, - ReadPdfResultDetails, - ReadTextResultDetails, - SkillsManagerResultDetails, - WriteResultDetails, -} from "../../../../lib/tools/builtinTypes"; -import { - getBuiltinResultKind, - getStableValueSignature, - getSubagentTask, - isShellResultDetails, - type MetaTag, - shouldShowSubagentApplyStatus, - shouldShowSubagentCleanupStatus, - shouldShowSubagentWorktreeLocation, - summarizeShellStream, -} from "./assistantBubbleUtils"; -import { getToolResultImages, ToolResultImagePreview } from "./ToolImages"; - -export function ToolSection(props: { label?: string; trailing?: ReactNode; children: ReactNode }) { - const { label, trailing, children } = props; - return ( -
- {label || trailing ? ( -
- {label ? ( - - {label} - - ) : null} - {trailing} -
- ) : null} - {children} -
- ); -} - -export function ToolSurface(props: { children: ReactNode; className?: string }) { - const { children, className } = props; - return
{children}
; -} - -export function ToolSurfaceLabel({ label }: { label: string }) { - return ( -
- {label} -
- ); -} - -export function ToolFactGrid({ tags }: { tags: MetaTag[] }) { - if (tags.length === 0) return null; - return ( -
- {tags.map((tag) => ( - - -
- {tag.value} -
-
- ))} -
- ); -} - -function buildPagedResultTags(params: { - label: string; - returned: number; - total: number; - offset: number; - hasMore: boolean; -}) { - const { label, returned, total, offset, hasMore } = params; - return [ - { label, value: `${returned}/${total}` }, - ...(offset > 0 ? [{ label: "offset", value: String(offset) }] : []), - { label: "state", value: hasMore ? "partial" : "complete" }, - ]; -} - -function filePathTags(details: { - scope?: string; - displayPath?: string; - absolutePath?: string; -}): MetaTag[] { - return [ - ...(details.scope && details.scope !== "workspace" - ? [{ label: "scope", value: details.scope }] - : []), - ]; -} - -export function PathDisplay({ path, className }: { path: string; className?: string }) { - const lastSlash = path.lastIndexOf("/"); - if (lastSlash < 0) { - return ( - - {path} - - ); - } - const dir = path.slice(0, lastSlash + 1); - const file = path.slice(lastSlash + 1); - return ( - - - {dir.length > 50 ? `…${dir.slice(-50)}` : dir} - - {file} - - ); -} - -/** Inline meta tags */ -export function MetaTags({ tags }: { tags: MetaTag[] }) { - if (tags.length === 0) return null; - const labelCounts = new Map(); - return ( -
- {tags.map((tag) => { - const seenCount = labelCounts.get(tag.label) ?? 0; - labelCounts.set(tag.label, seenCount + 1); - const stableKey = seenCount === 0 ? tag.label : `${tag.label}-${seenCount}`; - return ( - - {tag.label} - - {tag.value} - - - ); - })} -
- ); -} - -function extractReadBody(text: string) { - const marker = text.indexOf("\n\n"); - return marker >= 0 ? text.slice(marker + 2) : text; -} - -export function ToolScrollablePre(props: { children: ReactNode; className?: string }) { - const { children, className } = props; - return ( -
-      {children}
-    
- ); -} - -export function CodePreview(props: { text: string; maxChars?: number }) { - const { text, maxChars = 4000 } = props; - if (!/\S/.test(text)) return null; - return ( - - {previewText(text, maxChars)} - - ); -} - -function extractResultText(result?: ToolResultMessage) { - return result ? toolResultMessageToText(result) : ""; -} - -export function ToolResultDisplay({ - item, - result, -}: { - item: ToolTraceItem; - result: ToolResultMessage; -}) { - const kind = getBuiltinResultKind(result); - const text = extractResultText(result); - const images = getToolResultImages(result); - const shellDetails = isShellResultDetails(result.details) ? result.details : null; - - if (item.toolCall.name === "Bash") { - if (!shellDetails) return null; - - return ( - - - - ); - } - - if (kind === "read_text") { - const details = result.details as ReadTextResultDetails; - return ( -
- - 0 - ? `${details.startLine}-${details.startLine + details.numLines - 1}/${details.totalLines}` - : `empty/${details.totalLines}`, - }, - { label: "view", value: details.isPartialView ? "partial" : "full" }, - ...(details.truncated ? [{ label: "truncated", value: "true" }] : []), - ...(details.reusedExisting ? [{ label: "cache", value: "unchanged" }] : []), - ]} - /> - - {!details.reusedExisting ? ( - - ) : null} -
- ); - } - - if (kind === "read_skill") { - const details = result.details as SkillsManagerResultDetails; - if (details.kind !== "read_skill") return null; - return ( -
- - 0 - ? `${details.startLine}-${details.startLine + details.numLines - 1}` - : `empty @ ${details.startLine}`, - }, - ...(details.truncated ? [{ label: "truncated", value: "true" }] : []), - ]} - /> - - -
- ); - } - - if (kind === "manage_skill") { - const details = result.details as Extract; - return ( -
- - 0 - ? [{ label: "invalid", value: String(details.invalidCount) }] - : []), - ...(details.backup ? [{ label: "backup", value: details.backup }] : []), - ]} - /> - - -
- ); - } - - if (kind === "manage_mcp") { - const details = result.details as McpManagerResultDetails; - return ( -
- - - - -
- ); - } - - if (kind === "read_image") { - const details = result.details as ReadImageResultDetails; - return ( -
- - - - {!details.reusedExisting && images.length > 0 ? ( -
- {images.map((image, index) => ( - - ))} -
- ) : null} -
- ); - } - - if (kind === "read_pdf") { - const details = result.details as ReadPdfResultDetails; - return ( -
- - 0 - ? `${details.pageStart}-${details.pageStart + details.numPages - 1}/${details.totalPages}` - : `empty/${details.totalPages}`, - }, - ...(details.truncated ? [{ label: "truncated", value: "true" }] : []), - ...(details.reusedExisting ? [{ label: "cache", value: "unchanged" }] : []), - ]} - /> - - {!details.reusedExisting ? ( - - ) : null} -
- ); - } - - if (kind === "read_notebook") { - const details = result.details as ReadNotebookResultDetails; - return ( -
- - 0 - ? `${details.cellStart}-${details.cellStart + details.numCells - 1}/${details.totalCells}` - : `empty/${details.totalCells}`, - }, - ...(details.truncated ? [{ label: "truncated", value: "true" }] : []), - ...(details.reusedExisting ? [{ label: "cache", value: "unchanged" }] : []), - ]} - /> - - {!details.reusedExisting ? ( - - ) : null} -
- ); - } - - if (kind === "read_word" || kind === "read_spreadsheet" || kind === "read_archive") { - const details = result.details as ReadDocumentResultDetails; - return ( -
- - - - {!details.reusedExisting ? ( - - ) : null} -
- ); - } - - if (kind === "write") { - const details = result.details as WriteResultDetails; - return ( -
- - - - -
- ); - } - - if (kind === "edit") { - const details = result.details as EditResultDetails; - return ( - - ); - } - - if (kind === "delete") { - const details = result.details as DeleteResultDetails; - return ( - - - - ); - } - - if (kind === "list") { - const details = result.details as ListResultDetails; - return ( -
- - - - -
- {details.entries.map((entry) => ( -
- - {entry.kind} - - -
- ))} -
-
-
- ); - } - - if (kind === "glob") { - const details = result.details as GlobResultDetails; - return ( -
- - - - -
- {details.paths.map((entry) => ( - - ))} -
-
-
- ); - } - - if (kind === "grep") { - const details = result.details as GrepResultDetails; - return ( -
- - 0 ? [{ label: "offset", value: String(details.offset) }] : []), - { label: "state", value: details.hasMore ? "partial" : "complete" }, - ]} - /> - - {details.outputMode === "count" ? null : details.outputMode === "files" ? ( - -
- {details.files.map((file) => ( -
- - -
- ))} -
-
- ) : ( - - {details.matches.map((match, index) => ( -
-
- - - line {match.line} - -
- {match.before.length > 0 ? ( - - ) : null} - - {match.after.length > 0 ? ( - - ) : null} -
- ))} -
- )} -
- ); - } - - if (kind === "subagent_batch") { - const details = result.details as SubagentBatchDetails; - if (details.status !== "rejected" && result.isError !== true) { - // The successful parent batch is rendered as per-agent cards. - return null; - } - const issues = details.issues ?? []; - return ( - - -
- Agent call rejected — no subagents were started -
- {issues.length > 0 ? ( - - `${index + 1}. [${item.code}]${item.agentId ? ` agent=${item.agentId}` : ""} ${item.message}`, - ) - .join("\n")} - maxChars={2400} - /> - ) : ( - (block.type === "text" ? block.text : "")) - .join("\n")} - maxChars={2400} - /> - )} -
- ); - } - - if (kind === "subagent_card") { - const details = result.details as SubagentCardDetails; - const agent = details.agent; - const agentDisplayName = agent.name || agent.id; - const agentTask = getSubagentTask(agent); - const tags: MetaTag[] = [ - { label: "agent", value: `${details.index + 1}/${details.total}` }, - { label: "status", value: agent.status }, - ]; - if (agent.mode === "worktree") { - tags.push({ label: "mode", value: agent.mode }); - } - if (shouldShowSubagentApplyStatus(agent) && agent.applyStatus) { - tags.push({ label: "apply", value: agent.applyStatus }); - } - if (shouldShowSubagentCleanupStatus(agent) && agent.worktreeCleanupStatus) { - tags.push({ label: "cleanup", value: agent.worktreeCleanupStatus }); - } - - const untrackedFiles = agent.untrackedFiles ?? []; - const candidateArtifacts = agent.candidateArtifacts ?? []; - const showUntrackedFiles = agent.applyStatus !== "applied" && untrackedFiles.length > 0; - const showCandidateArtifacts = Boolean( - candidateArtifacts.length > 0 && - agent.applySkippedReason && - agent.applySkippedReason !== "no_changes", - ); - - return ( - - -
-
- {agentDisplayName} -
- {agent.role ? ( -
- role {agent.role} -
- ) : null} - {agentTask ? ( -
- task {agentTask} -
- ) : null} - {shouldShowSubagentWorktreeLocation(agent) ? ( -
- {agent.branchName ? `${agent.branchName} | ` : ""} - {agent.worktreeRoot} -
- ) : null} - {agent.diffStat ? : null} - {showUntrackedFiles ? ( - `- ${file}`).join("\n")}`} - maxChars={1200} - /> - ) : null} - {agent.worktreeStatusError ? ( - - ) : null} - {agent.applyError ? ( - - ) : agent.applySkippedReason && agent.applySkippedReason !== "no_changes" ? ( - - ) : null} - {agent.applyFallbackReason ? ( - - ) : null} - {agent.applyCopiedFiles && agent.applyCopiedFiles.length > 0 ? ( - `- ${file}`).join("\n")}`} - maxChars={1200} - /> - ) : null} - {agent.applyDeletedFiles && agent.applyDeletedFiles.length > 0 ? ( - `- ${file}`).join("\n")}`} - maxChars={1200} - /> - ) : null} - {agent.applyConflictFiles && agent.applyConflictFiles.length > 0 ? ( - `- ${file}`).join("\n")}`} - maxChars={1200} - /> - ) : null} - {agent.worktreeCleanupError ? ( - - ) : agent.worktreeCleanupReason && agent.worktreeCleanupStatus === "retained" ? ( - - ) : null} - {showCandidateArtifacts ? ( - `- ${file}`).join("\n")}`} - maxChars={1200} - /> - ) : null} - {agent.persistenceWarnings && agent.persistenceWarnings.length > 0 ? ( - `- ${item}`).join("\n")}`} - maxChars={1200} - /> - ) : null} - {agent.error ? ( - - ) : agent.summary ? ( - - ) : null} -
-
- ); - } - - if (kind === "subagent_message") { - const details = result.details as SubagentMessageDetails; - const from = details.senderName || details.senderId; - const to = details.recipientName || details.recipientId; - return ( - - - {details.subject ? ( -
- {details.subject} -
- ) : null} - {details.bodyPreview ? ( -
- -
- ) : null} -
- ); - } - - if (images.length > 0) { - return ( -
-
- {images.map((image, index) => ( - - ))} -
- {/\S/.test(text) ? : null} -
- ); - } - - // Error results (and blocked calls) carry an empty details object — showing - // a literal "{}" would bury the actual error text, which renders below. - if ( - result.details && - typeof result.details === "object" && - Object.keys(result.details).length > 0 - ) { - return ( - - - {getStableValueSignature(result.details)} - - - ); - } - - return null; -} diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx deleted file mode 100644 index a20b80f42..000000000 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { AssistantStatus } from "@liveagent/ui/components/chat/AssistantStatus"; -import { LazyCollapse } from "@liveagent/ui/components/chat/LazyCollapse"; -import { useLocale } from "@liveagent/ui/i18n/index"; -import { cn } from "@liveagent/ui/lib/shared/utils"; -import { memo, useMemo, useState } from "react"; -import { ChevronRight, Terminal } from "../../../../components/icons"; -import type { ToolTraceItem } from "../../../../lib/chat/messages/uiMessages"; -import { - getDominantToolName, - getToolGroupComposition, - getToolGroupCounts, - getToolMeta, - getToolTraceKey, -} from "./assistantBubbleUtils"; -import { areToolTraceItemsEqual, MemoToolCallItem } from "./ToolCallItem"; - -function ToolTraceGroupInner(props: { items: ToolTraceItem[]; runningToolCallIds?: string[] }) { - const { items, runningToolCallIds = [] } = props; - const { t } = useLocale(); - const counts = useMemo( - () => getToolGroupCounts(items, runningToolCallIds), - [items, runningToolCallIds], - ); - const composition = useMemo(() => getToolGroupComposition(items), [items]); - const dominantToolName = useMemo(() => getDominantToolName(items), [items]); - const allBash = useMemo(() => items.every((item) => item.toolCall.name === "Bash"), [items]); - const meta = useMemo( - () => (allBash ? getToolMeta("Bash") : getToolMeta(dominantToolName)), - [allBash, dominantToolName], - ); - const ToolIcon = allBash ? Terminal : meta.Icon; - const [open, setOpen] = useState(false); - - if (items.length === 1) { - const item = items[0]; - return item ? ( - - ) : null; - } - - const statusLabel = - counts.failed > 0 - ? `${counts.failed} ${t("chat.tool.failed")}` - : counts.running > 0 - ? `${counts.running} ${t("chat.tool.running")}` - : counts.waiting > 0 - ? `${counts.waiting} ${t("chat.tool.waiting")}` - : t("chat.tool.success"); - - const statusTextClass = - counts.failed > 0 ? "text-[hsl(var(--chat-error))]" : "text-muted-foreground/60"; - - const countLabel = `${items.length} tools`; - const title = allBash ? "Bash Batch" : "Tool Activity"; - - return ( -
- - - 0}> - {() => ( -
- {items.map((item, index) => ( - - ))} -
- )} -
-
- ); -} - -function areRunningIdsEqual(previous?: string[], next?: string[]) { - if (previous === next) return true; - if (!previous || !next || previous.length !== next.length) return false; - return previous.every((id, index) => id === next[index]); -} - -// A streaming text delta rebuilds the round's grouped-block structure with -// fresh arrays but unchanged tool items — compare element-wise so the whole -// group (every child card) bails unless a tool actually changed. -export const ToolTraceGroup = memo( - ToolTraceGroupInner, - (previous, next) => - previous.items.length === next.items.length && - previous.items.every( - (item, index) => - item === next.items[index] || areToolTraceItemsEqual(item, next.items[index]), - ) && - areRunningIdsEqual(previous.runningToolCallIds, next.runningToolCallIds), -); diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts b/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts deleted file mode 100644 index 823cb0ad8..000000000 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts +++ /dev/null @@ -1,402 +0,0 @@ -import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { isTaskToolName } from "@liveagent/ui/contracts/task"; -import type { - SubagentCardDetails, - SubagentReportDetails, -} from "@liveagent/ui/lib/subagents/protocol"; -import type { IconComponent } from "../../../../components/icons"; -import { - Bot, - Brain, - CircleHelp, - Clock3, - Eye, - FilePenLine, - FileText, - FolderTree, - ImageIcon, - Link2, - ListChecks, - Plug, - Search, - Server, - Terminal, - Trash2, - Wrench, -} from "../../../../components/icons"; -import type { HostedSearchBlock } from "../../../../lib/chat/messages/hostedSearch"; -import { - safeStringify, - shouldDisplayToolTraceItem, - type ToolTraceItem, - type UiRound, -} from "../../../../lib/chat/messages/uiMessages"; - -export function getToolMeta(name: string): { - Icon: IconComponent; - accent: string; - category: string; -} { - if (isTaskToolName(name)) { - return { Icon: ListChecks, accent: "var(--tool-list-accent)", category: "system" }; - } - switch (name) { - case "Bash": - case "ManagedProcess": - return { Icon: Terminal, accent: "var(--tool-bash-accent)", category: "terminal" }; - case "Read": - return { Icon: Eye, accent: "var(--tool-file-accent)", category: "file" }; - case "Image": - return { Icon: ImageIcon, accent: "var(--tool-file-accent)", category: "file" }; - case "SkillsManager": - return { Icon: Eye, accent: "var(--tool-file-accent)", category: "file" }; - case "CronTaskManager": - return { Icon: Clock3, accent: "var(--tool-list-accent)", category: "system" }; - case "MemoryManager": - return { Icon: Brain, accent: "var(--tool-list-accent)", category: "system" }; - case "McpManager": - return { Icon: Plug, accent: "var(--tool-list-accent)", category: "mcp" }; - case "TunnelManager": - return { Icon: Link2, accent: "var(--tool-list-accent)", category: "system" }; - case "SSHManager": - case "SshManager": - return { Icon: Server, accent: "var(--tool-bash-accent)", category: "terminal" }; - case "Agent": - return { Icon: Bot, accent: "var(--tool-list-accent)", category: "system" }; - case "SendMessage": - return { Icon: Bot, accent: "var(--tool-list-accent)", category: "system" }; - case "Write": - return { Icon: FileText, accent: "var(--tool-file-accent)", category: "file" }; - case "Edit": - return { Icon: FilePenLine, accent: "var(--tool-file-accent)", category: "file" }; - case "Delete": - return { Icon: Trash2, accent: "var(--tool-file-accent)", category: "file" }; - case "Glob": - return { Icon: Search, accent: "var(--tool-search-accent)", category: "search" }; - case "Grep": - return { Icon: Search, accent: "var(--tool-search-accent)", category: "search" }; - case "List": - return { Icon: FolderTree, accent: "var(--tool-list-accent)", category: "list" }; - case "AskUserQuestion": - return { Icon: CircleHelp, accent: "var(--tool-list-accent)", category: "system" }; - default: - return { Icon: Wrench, accent: "var(--tool-file-accent)", category: "other" }; - } -} - -export type MetaTag = { label: string; value: string }; - -export function displayString(value: unknown) { - return typeof value === "string" ? value.trim() : ""; -} - -export function compactInlineText(value: unknown, maxChars = 120) { - const text = displayString(value).replace(/\s+/g, " "); - if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}...`; -} - -export function getSubagentTask(agent: { prompt?: unknown }) { - return displayString(agent.prompt); -} - -export function getSubagentInlineSummary(item: ToolTraceItem) { - const details = item.toolResult?.details as Partial | undefined; - const agent = details?.kind === "subagent_card" ? details.agent : undefined; - const args = item.toolCall.arguments || {}; - const name = displayString(agent?.name) || displayString(args.name) || displayString(args.id); - const task = agent ? getSubagentTask(agent) : displayString(args.prompt); - - if (name && task) return `${name} - ${compactInlineText(task, 96)}`; - return name || compactInlineText(task, 120); -} - -export function shouldShowSubagentApplyStatus(agent: SubagentReportDetails) { - if (!agent.applyStatus) return false; - if (agent.applyStatus === "applied" || agent.applyStatus === "failed") return true; - return Boolean(agent.applySkippedReason && agent.applySkippedReason !== "no_changes"); -} - -export function shouldShowSubagentCleanupStatus(agent: SubagentReportDetails) { - return Boolean( - agent.worktreeCleanupStatus && - agent.worktreeCleanupStatus !== "removed" && - agent.worktreeCleanupStatus !== "skipped", - ); -} - -export function shouldShowSubagentWorktreeLocation(agent: SubagentReportDetails) { - return Boolean( - agent.worktreeRoot && - (agent.status !== "completed" || - agent.worktreeCleanupStatus === "retained" || - agent.worktreeCleanupStatus === "failed"), - ); -} - -export type GroupedRoundBlock = - | { - kind: "thinking"; - key: string; - text: string; - } - | { - kind: "text"; - key: string; - text: string; - } - | { - kind: "tool"; - key: string; - item: ToolTraceItem; - } - | { - kind: "hostedSearch"; - key: string; - item: HostedSearchBlock; - } - | { - kind: "hostedSearchGroup"; - key: string; - items: HostedSearchBlock[]; - } - | { - kind: "toolGroup"; - key: string; - items: ToolTraceItem[]; - }; - -export type ShellResultDetails = { - exit_code: number; - shell: string; - stdout: string; - stderr: string; - stdout_truncated: boolean; - stderr_truncated: boolean; - timed_out: boolean; - cancelled?: boolean; - effective_timeout_ms?: number; - duration_ms: number; -}; - -export function isShellResultDetails(value: unknown): value is ShellResultDetails { - if (!value || typeof value !== "object") return false; - const candidate = value as Record; - return ( - typeof candidate.exit_code === "number" && - typeof candidate.shell === "string" && - typeof candidate.stdout === "string" && - typeof candidate.stderr === "string" && - typeof candidate.stdout_truncated === "boolean" && - typeof candidate.stderr_truncated === "boolean" && - typeof candidate.timed_out === "boolean" && - typeof candidate.duration_ms === "number" - ); -} - -export function summarizeShellStream(text: string, truncated: boolean) { - const length = text.length; - if (length === 0) return "empty"; - return truncated ? `${length} chars, truncated` : `${length} chars`; -} - -const stableValueSignatureCache = new WeakMap(); - -export function getStableValueSignature(value: unknown) { - if (value && typeof value === "object") { - const cached = stableValueSignatureCache.get(value); - if (cached !== undefined) { - return cached; - } - const signature = safeStringify(value); - stableValueSignatureCache.set(value, signature); - return signature; - } - return safeStringify(value); -} - -export function areStableValuesEqual(previous: unknown, next: unknown) { - return previous === next || getStableValueSignature(previous) === getStableValueSignature(next); -} - -export function getToolTraceKey(item: ToolTraceItem, index: number) { - const id = item.toolCall.id?.trim(); - if (id) return id; - return `${item.toolCall.name}-${index}-${getStableValueSignature(item.toolCall.arguments)}`; -} - -export function isAgentToolName(name: string) { - return name === "Agent"; -} - -export function getToolDisplayName(name: string) { - if (name === "SshManager") return "SSHManager"; - return name; -} - -const TOOL_CARD_ACTION_NAMES = new Set([ - "SkillsManager", - "CronTaskManager", - "McpManager", - "MemoryManager", - "TunnelManager", - "SSHManager", - "ManagedProcess", -]); - -export function getManagerToolActionName(toolCall: { - name: string; - arguments?: Record; -}) { - const name = getToolDisplayName(toolCall.name); - if (!TOOL_CARD_ACTION_NAMES.has(name)) return ""; - const args = toolCall.arguments || {}; - const action = displayString(args.action); - if (action) return action; - if (name === "SkillsManager") { - return displayString(args.path) ? "read" : "list"; - } - return ""; -} - -export function getToolDisplayTitle(toolCall: { - name: string; - arguments?: Record; -}) { - const name = getToolDisplayName(toolCall.name); - const action = getManagerToolActionName(toolCall); - return { name, action }; -} - -export function groupRoundBlocks(blocks: UiRound["blocks"]): GroupedRoundBlock[] { - const groupedBlocks: GroupedRoundBlock[] = []; - let pendingTools: ToolTraceItem[] = []; - let pendingStartIndex = 0; - let pendingSearches: HostedSearchBlock[] = []; - let pendingSearchStartIndex = 0; - const hasHostedSearch = blocks.some((block) => block.kind === "hostedSearch"); - - const flushPendingTools = () => { - if (pendingTools.length === 0) return; - groupedBlocks.push({ - kind: "toolGroup", - // The wrapper exists from the first ordinary tool onward. Appending a - // second tool therefore updates one activity in place instead of - // replacing a `tool` row with a differently keyed `toolGroup` row. - key: `tool-group-${getToolTraceKey(pendingTools[0], pendingStartIndex)}`, - items: pendingTools, - }); - pendingTools = []; - }; - - const flushPendingSearches = () => { - if (pendingSearches.length === 0) return; - const firstSearch = pendingSearches[0]; - groupedBlocks.push({ - kind: "hostedSearchGroup", - key: `hosted-search-group-${firstSearch?.id || pendingSearchStartIndex}`, - items: pendingSearches, - }); - pendingSearches = []; - }; - - blocks.forEach((block, index) => { - if (block.kind === "tool") { - if (!shouldDisplayToolTraceItem(block.item, { hasHostedSearch })) { - return; - } - flushPendingSearches(); - if ( - block.item.toolCall.name === "Image" || - isTaskToolName(block.item.toolCall.name) || - block.item.toolCall.name === "AskUserQuestion" || - isAgentToolName(block.item.toolCall.name) - ) { - flushPendingTools(); - groupedBlocks.push({ - kind: "tool", - key: `tool-${getToolTraceKey(block.item, index)}`, - item: block.item, - }); - return; - } - if (pendingTools.length === 0) { - pendingStartIndex = index; - } - pendingTools.push(block.item); - return; - } - - flushPendingTools(); - if (block.kind === "hostedSearch") { - if (pendingSearches.length === 0) { - pendingSearchStartIndex = index; - } - pendingSearches.push(block.item); - return; - } - flushPendingSearches(); - if (block.kind === "thinking") { - groupedBlocks.push({ kind: "thinking", key: block.id, text: block.text }); - return; - } - groupedBlocks.push({ kind: "text", key: block.id, text: block.text }); - }); - - flushPendingTools(); - flushPendingSearches(); - return groupedBlocks; -} - -export function getToolGroupCounts(items: ToolTraceItem[], runningToolCallIds: string[]) { - const runningIds = new Set(runningToolCallIds); - let running = 0; - let failed = 0; - let completed = 0; - let waiting = 0; - - for (const item of items) { - if (item.toolCall.id && runningIds.has(item.toolCall.id)) { - running += 1; - continue; - } - if (!item.toolResult) { - waiting += 1; - continue; - } - if (item.toolResult.isError) { - failed += 1; - continue; - } - completed += 1; - } - - return { running, failed, completed, waiting }; -} - -export function getToolGroupComposition(items: ToolTraceItem[]) { - const counts = new Map(); - for (const item of items) { - const name = getToolDisplayName(item.toolCall.name); - counts.set(name, (counts.get(name) ?? 0) + 1); - } - return [...counts.entries()] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .slice(0, 4) - .map(([name, count]) => `${name} ${count}`) - .join(" · "); -} - -export function getDominantToolName(items: ToolTraceItem[]) { - const counts = new Map(); - for (const item of items) { - counts.set(item.toolCall.name, (counts.get(item.toolCall.name) ?? 0) + 1); - } - return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "Tool"; -} - -export function getBuiltinResultKind(result?: ToolResultMessage) { - if (!result?.details || typeof result.details !== "object") return null; - const kind = (result.details as { kind?: unknown }).kind; - return typeof kind === "string" ? kind : null; -} diff --git a/crates/agent-gui/src/pages/chat/index.ts b/crates/agent-gui/src/pages/chat/index.ts index 3bb8e5941..0981851f4 100644 --- a/crates/agent-gui/src/pages/chat/index.ts +++ b/crates/agent-gui/src/pages/chat/index.ts @@ -1,3 +1,4 @@ +export { useChatSkills } from "@liveagent/ui/lib/skills/useChatSkills"; export { ChatComposerBar, type ChatQueueTurnPreview, @@ -10,7 +11,6 @@ export type { export { useGatewayBridgeListeners } from "./gateway/useGatewayBridgeListeners"; export { useConversationHistoryActions } from "./history/useConversationHistoryActions"; export { useChatPageRuntimeStore } from "./hooks/useChatPageRuntimeStore"; -export { useChatSkills } from "./hooks/useChatSkills"; export { useEditResend } from "./hooks/useEditResend"; export { useLiveTranscriptController } from "./hooks/useLiveTranscriptController"; export { MAX_UPLOAD_FILES, usePendingUploads } from "./hooks/usePendingUploads"; diff --git a/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts b/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts index 573b51ff0..80fae9e02 100644 --- a/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts +++ b/crates/agent-gui/src/pages/chat/queue/chatTurnQueue.ts @@ -6,6 +6,8 @@ import type { GatewaySelectedModelEvent, } from "../gateway/gatewayBridgeTypes"; +export { queuedChatTurnHasContent } from "@liveagent/ui/lib/chat/queuedChatTurn"; + export type QueuedGatewayChatRequest = { requestId: string; clientRequestId?: string; @@ -74,13 +76,6 @@ export function createQueuedChatTurn(input: QueuedChatTurnInput): QueuedChatTurn }; } -export function queuedChatTurnHasContent( - draft: MentionComposerDraft | null | undefined, - uploadedFiles: readonly PendingUploadedFile[], -): draft is MentionComposerDraft { - return Boolean(draft && (!draft.isEmpty || draft.text.trim() || uploadedFiles.length > 0)); -} - export function buildQueuedChatTurnPreview(draft: MentionComposerDraft) { const parts = draft.segments.map((segment) => { switch (segment.type) { diff --git a/crates/agent-gui/src/pages/chat/transcript/rowModel.ts b/crates/agent-gui/src/pages/chat/transcript/rowModel.ts index 86d83ffd0..e105da2ef 100644 --- a/crates/agent-gui/src/pages/chat/transcript/rowModel.ts +++ b/crates/agent-gui/src/pages/chat/transcript/rowModel.ts @@ -1,3 +1,7 @@ +import { + type GroupedRoundBlock, + groupRoundBlocks, +} from "@liveagent/ui/components/chat/assistant-bubble/assistantBubbleUtils"; import { isTaskToolBlock } from "@liveagent/ui/lib/chat/taskProgress"; import { CHECKPOINT_ROW_ESTIMATE_PX, @@ -12,10 +16,6 @@ import type { } from "../../../lib/chat/conversation/conversationState"; import type { LiveTranscriptState } from "../../../lib/chat/conversation/liveTranscriptStore"; import { getRoundText, type LiveRound, type UiRound } from "../../../lib/chat/messages/uiMessages"; -import { - type GroupedRoundBlock, - groupRoundBlocks, -} from "../components/assistant-bubble/assistantBubbleUtils"; const TRANSCRIPT_ROW_GAP_PX = 24; const ASSISTANT_UNIT_GAP_PX = 8; diff --git a/crates/agent-gui/test/chat/block-round-keys.test.mjs b/crates/agent-gui/test/chat/block-round-keys.test.mjs index 63cb16178..a3c0b0587 100644 --- a/crates/agent-gui/test/chat/block-round-keys.test.mjs +++ b/crates/agent-gui/test/chat/block-round-keys.test.mjs @@ -6,7 +6,7 @@ const loader = createTsModuleLoader(); const uiMessages = loader.loadModule("src/lib/chat/messages/uiMessages.ts"); const conversationState = loader.loadModule("src/lib/chat/conversation/conversationState.ts"); const bubbleUtils = loader.loadModule( - "src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts", + "@liveagent/ui/components/chat/assistant-bubble/assistantBubbleUtils.ts", ); function user(content, timestamp) { diff --git a/crates/agent-gui/test/chat/live-markdown-caret.test.mjs b/crates/agent-gui/test/chat/live-markdown-caret.test.mjs index a0d6adfee..6827a714e 100644 --- a/crates/agent-gui/test/chat/live-markdown-caret.test.mjs +++ b/crates/agent-gui/test/chat/live-markdown-caret.test.mjs @@ -3,7 +3,10 @@ import fs from "node:fs"; import test from "node:test"; const roundContentSource = fs.readFileSync( - new URL("../../src/pages/chat/components/assistant-bubble/RoundContent.tsx", import.meta.url), + new URL( + "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", + import.meta.url, + ), "utf8", ); diff --git a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs index 6b358164c..770c23060 100644 --- a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs +++ b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs @@ -334,7 +334,7 @@ test("historical and streaming assistant rows share the explicit file-open prop "../../src/pages/chat/transcript/TranscriptList.tsx", "../../src/pages/chat/transcript/AssistantRenderUnit.tsx", "../../src/pages/chat/components/AssistantBubble.tsx", - "../../src/pages/chat/components/assistant-bubble/RoundContent.tsx", + "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", ]; for (const relativePath of files) { const source = fs.readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); @@ -344,7 +344,7 @@ test("historical and streaming assistant rows share the explicit file-open prop const roundContent = fs.readFileSync( fileURLToPath( new URL( - "../../src/pages/chat/components/assistant-bubble/RoundContent.tsx", + "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", import.meta.url, ), ), diff --git a/crates/agent-gui/test/chat/thinking-overlay-model.test.mjs b/crates/agent-gui/test/chat/thinking-overlay-model.test.mjs index 158adb017..5673e170a 100644 --- a/crates/agent-gui/test/chat/thinking-overlay-model.test.mjs +++ b/crates/agent-gui/test/chat/thinking-overlay-model.test.mjs @@ -11,7 +11,10 @@ const componentSource = fs.readFileSync( "utf8", ); const roundContentSource = fs.readFileSync( - new URL("../../src/pages/chat/components/assistant-bubble/RoundContent.tsx", import.meta.url), + new URL( + "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", + import.meta.url, + ), "utf8", ); diff --git a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx b/crates/agent-ui/src/components/chat/AssistantBubble.tsx similarity index 86% rename from crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx rename to crates/agent-ui/src/components/chat/AssistantBubble.tsx index c7bb749d1..498ebb95f 100644 --- a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx +++ b/crates/agent-ui/src/components/chat/AssistantBubble.tsx @@ -1,17 +1,17 @@ -import { AssistantAvatar } from "@liveagent/ui/components/chat/AssistantAvatar"; -import { ChangedFilesCard } from "@liveagent/ui/components/chat/ChangedFilesCard"; +import type { UiRound } from "@liveagent/app/lib/chat/assistantBubbleAdapter"; +import { collectChangedFiles } from "@liveagent/app/lib/chat/changedFilesAdapter"; +import type { ChatFileLink } from "@liveagent/app/lib/chat/chatFileLinks"; import { memo, useMemo } from "react"; -import { collectChangedFiles } from "../../lib/chat/changedFiles"; -import type { ChatFileLink } from "../../lib/chat/chatFileLinks"; -import type { UiRound } from "../../lib/chat/uiMessages"; +import { AssistantAvatar } from "./AssistantAvatar"; import { RoundContent } from "./assistant-bubble/RoundContent"; +import { ChangedFilesCard } from "./ChangedFilesCard"; -export { AssistantAvatar } from "@liveagent/ui/components/chat/AssistantAvatar"; +export { AssistantAvatar } from "./AssistantAvatar"; export { AssistantStatus, CompactingText, VibingText, -} from "@liveagent/ui/components/chat/AssistantStatus"; +} from "./AssistantStatus"; export { RetryDetailsBlock } from "./assistant-bubble/RoundContent"; const EMPTY_RUNNING_TOOL_CALL_IDS: string[] = []; diff --git a/crates/agent-gateway/web/src/app/FileDropOverlay.tsx b/crates/agent-ui/src/components/chat/FileDropOverlay.tsx similarity index 94% rename from crates/agent-gateway/web/src/app/FileDropOverlay.tsx rename to crates/agent-ui/src/components/chat/FileDropOverlay.tsx index 169112666..51955c1b9 100644 --- a/crates/agent-gateway/web/src/app/FileDropOverlay.tsx +++ b/crates/agent-ui/src/components/chat/FileDropOverlay.tsx @@ -1,4 +1,4 @@ -import { Ban, Upload } from "@/components/icons"; +import { Ban, Upload } from "../IconSet"; type FileDropOverlayProps = { canDropUpload: boolean; @@ -7,12 +7,8 @@ type FileDropOverlayProps = { limitHint: string; }; -export function FileDropOverlay({ - canDropUpload, - title, - description, - limitHint, -}: FileDropOverlayProps) { +export function FileDropOverlay(props: FileDropOverlayProps) { + const { canDropUpload, title, description, limitHint } = props; return (
void; +}) { + const { + block, + isLive, + renderMode, + runningToolCallIds, + thinkingOpen, + isLatestThinking, + workdir, + onOpenFileLink, + } = props; + + let content: ReactNode; + if (block.kind === "thinking") { + const isRunning = isLive && thinkingOpen && isLatestThinking; + content = ( + + ); + } else if (block.kind === "tool") { + const displayImagePayload = getNativeDisplayImagePayload(block.item); + if (displayImagePayload) { + content = ; + } else if (block.item.toolCall.name === "Image" && !block.item.toolResult?.isError) { + content = null; + } else { + content = ( + + ); + } + } else if (block.kind === "toolGroup") { + content = ( + + ); + } else if (block.kind === "hostedSearch" || block.kind === "hostedSearchGroup") { + content = ( + + ); + } else if (block.text.trim()) { + content = ( + + ); + } else { + content = null; + } + + if (!content) return null; + + return
{content}
; +}); + export const RoundContent = memo(function RoundContent(props: { round: UiRound; showUsage?: boolean; diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolCallItem.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx similarity index 90% rename from crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolCallItem.tsx rename to crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx index 20ca2e380..6645d55ea 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolCallItem.tsx +++ b/crates/agent-ui/src/components/chat/assistant-bubble/ToolCallItem.tsx @@ -1,3 +1,18 @@ +import { + readAskUserQuestionDeadline, + retainRunningToolContent, + submitAskUserQuestionAnswers, + usePendingToolApproval, +} from "@liveagent/adapters/assistantBubble"; +import { + deriveFileChangeStats, + FILE_TOOL_TEXT_FIELDS, + previewText, + summarizeToolCall, + type ToolResultMessage, + type ToolTraceItem, + toolResultMessageToText, +} from "@liveagent/app/lib/chat/assistantBubbleAdapter"; import { AskUserQuestionCard } from "@liveagent/ui/components/chat/AskUserQuestionCard"; import { AssistantStatus } from "@liveagent/ui/components/chat/AssistantStatus"; import { FileChangeBadge } from "@liveagent/ui/components/chat/FileChangeBadge"; @@ -8,23 +23,11 @@ import { ASK_USER_QUESTION_TOOL_NAME, type AskUserQuestionAnswer, parseAskUserQuestionResultDetails, - readAskUserQuestionDeadlineAt, sanitizeAskUserQuestionItems, } from "@liveagent/ui/lib/chat/askUserQuestion"; -import { readToolApprovalPending } from "@liveagent/ui/lib/chat/toolApprovalArgs"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { memo, useCallback, useEffect, useMemo, useState } from "react"; -import { ChevronRight } from "../../../components/icons"; -import type { ToolResultMessage } from "../../../lib/agentTypes"; -import { submitAskUserQuestionAnswer } from "../../../lib/chat/askUserQuestionBridge"; -import { deriveFileChangeStats } from "../../../lib/chat/fileChangeStats"; -import { FILE_TOOL_TEXT_FIELDS } from "../../../lib/chat/toolPreview"; -import { - previewText, - summarizeToolCall, - type ToolTraceItem, - toolResultMessageToText, -} from "../../../lib/chat/uiMessages"; +import { ChevronRight } from "../../IconSet"; import { areStableValuesEqual, getBuiltinResultKind, @@ -66,27 +69,18 @@ function ToolCallItem({ // 提问卡运行期强制展开等待作答;应答落定后自动收起。 const shouldKeepAskOpen = !readOnly && isAskUser && (Boolean(isRunning) || !result); const shouldCloseAnsweredAsk = isAskUser && Boolean(result); - // 权威应答截止时间:桌面端在网关上报的工具参数上盖章,倒计时与桌面计时 - // 同源;重连/迟开页面也显示真实剩余时间。 + // 截止时间和提交动作由宿主适配器提供,确保两端都使用各自的权威服务。 const askDeadlineAt = isAskUser && isRunning && !result - ? (readAskUserQuestionDeadlineAt(item.toolCall.arguments) ?? undefined) + ? readAskUserQuestionDeadline(item.toolCall.id, item.toolCall.arguments) : undefined; const submitAskAnswers = useCallback( - (answers: AskUserQuestionAnswer[]) => submitAskUserQuestionAnswer(item.toolCall.id, answers), + (answers: AskUserQuestionAnswer[]) => submitAskUserQuestionAnswers(item.toolCall.id, answers), [item.toolCall.id], ); - // 工具审批:桌面端在待审批时把 __toolApprovalPending 标记盖到同步的工具参数上 - // (见 gatewayToolPreview 的重发)。带标记且尚无结果 - // → 渲染审批卡片;审批消解后重发的快照不再带标记,卡片隐藏。 - // 注意:审批在 beforeToolCall 处挂起,此时工具尚未开始执行、并不处于 isRunning - // 状态(不同于 AskUserQuestion 是工具自身执行时挂起),故不能用 isRunning 作门, - // 标记本身即权威的"待审批"信号。 - const isApprovalPending = - !readOnly && - !isRedactedToolContent && - !result && - readToolApprovalPending(item.toolCall.arguments); + // 工具审批由宿主适配器读取。审批发生在工具执行前,不能用 isRunning 作门。 + const pendingApproval = usePendingToolApproval(item.toolCall.id, item.toolCall.arguments); + const isApprovalPending = !readOnly && !isRedactedToolContent && !result && pendingApproval; const shouldAutoOpen = !isRedactedToolContent && (item.toolCall.name === "Image" || builtinResultKind === "display_image" || shouldKeepAskOpen); @@ -233,7 +227,10 @@ function ToolCallItem({ ); const body = ( - + {() => (
{shouldShowArgs ? ( diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolImages.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/ToolImages.tsx similarity index 98% rename from crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolImages.tsx rename to crates/agent-ui/src/components/chat/assistant-bubble/ToolImages.tsx index f09972405..2cafb452c 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolImages.tsx +++ b/crates/agent-ui/src/components/chat/assistant-bubble/ToolImages.tsx @@ -1,15 +1,17 @@ +import { deferLargeToolImages } from "@liveagent/adapters/assistantBubble"; +import type { + DisplayImageItemDetails, + DisplayImageResultDetails, + ImageContent, + ToolResultMessage, + ToolTraceItem, +} from "@liveagent/app/lib/chat/assistantBubbleAdapter"; import { ImagePreview, type ImagePreviewSlide } from "@liveagent/ui/components/chat/ImagePreview"; import { useLocale } from "@liveagent/ui/i18n/index"; import { prepareImageProxyUrl } from "@liveagent/ui/lib/providers/proxy"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Eye, ImageOff, Loader2 } from "../../../components/icons"; -import type { ImageContent, ToolResultMessage } from "../../../lib/agentTypes"; -import type { ToolTraceItem } from "../../../lib/chat/uiMessages"; -import type { - DisplayImageItemDetails, - DisplayImageResultDetails, -} from "../../../lib/tools/builtinTypes"; +import { Eye, ImageOff, Loader2 } from "../../IconSet"; import { getBuiltinResultKind } from "./assistantBubbleUtils"; export function getToolResultImages(result?: ToolResultMessage) { @@ -282,7 +284,8 @@ export function ToolResultImagePreview(props: { const { image, alt, id, sizeBytes, readOnly = false } = props; const { t } = useLocale(); const estimatedBytes = sizeBytes ?? estimateBase64Bytes(image.data); - const shouldDeferImage = estimatedBytes > LARGE_TOOL_IMAGE_INLINE_THRESHOLD_BYTES; + const shouldDeferImage = + deferLargeToolImages && estimatedBytes > LARGE_TOOL_IMAGE_INLINE_THRESHOLD_BYTES; const [shouldLoad, setShouldLoad] = useState(readOnly ? true : !shouldDeferImage); const [previewOpen, setPreviewOpen] = useState(false); const [imageStatus, setImageStatus] = useState("loading"); diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolResultDisplay.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx similarity index 98% rename from crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolResultDisplay.tsx rename to crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx index d1ae23b9d..24408ff67 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolResultDisplay.tsx +++ b/crates/agent-ui/src/components/chat/assistant-bubble/ToolResultDisplay.tsx @@ -1,3 +1,25 @@ +import { + type DeleteResultDetails, + deriveFileToolPreview, + type EditResultDetails, + type GlobResultDetails, + type GrepResultDetails, + type ListResultDetails, + type McpManagerResultDetails, + previewText, + type ReadDocumentResultDetails, + type ReadImageResultDetails, + type ReadNotebookResultDetails, + type ReadPdfResultDetails, + type ReadTextResultDetails, + type SkillsManagerResultDetails, + safeStringify, + type ToolResultMessage, + type ToolTraceItem, + toolCallArgsForDisplay, + toolResultMessageToText, + type WriteResultDetails, +} from "@liveagent/app/lib/chat/assistantBubbleAdapter"; import { EditDiffView } from "@liveagent/ui/components/chat/EditDiffView"; import { FileToolArgsDisplay } from "@liveagent/ui/components/chat/FileToolArgs"; import { @@ -15,31 +37,7 @@ import type { SubagentCardDetails, SubagentMessageDetails, } from "@liveagent/ui/lib/subagents/protocol"; -import { Search } from "../../../components/icons"; -import type { ToolResultMessage } from "../../../lib/agentTypes"; -import { deriveFileToolPreview } from "../../../lib/chat/toolPreview"; -import { - previewText, - safeStringify, - type ToolTraceItem, - toolCallArgsForDisplay, - toolResultMessageToText, -} from "../../../lib/chat/uiMessages"; -import type { - DeleteResultDetails, - EditResultDetails, - GlobResultDetails, - GrepResultDetails, - ListResultDetails, - McpManagerResultDetails, - ReadDocumentResultDetails, - ReadImageResultDetails, - ReadNotebookResultDetails, - ReadPdfResultDetails, - ReadTextResultDetails, - SkillsManagerResultDetails, - WriteResultDetails, -} from "../../../lib/tools/builtinTypes"; +import { Search } from "../../IconSet"; import { displayString, getBuiltinResultKind, diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx b/crates/agent-ui/src/components/chat/assistant-bubble/ToolTraceGroup.tsx similarity index 95% rename from crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx rename to crates/agent-ui/src/components/chat/assistant-bubble/ToolTraceGroup.tsx index 7d533e1ea..3424bd2b9 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx +++ b/crates/agent-ui/src/components/chat/assistant-bubble/ToolTraceGroup.tsx @@ -1,10 +1,11 @@ +import { retainRunningToolContent } from "@liveagent/adapters/assistantBubble"; +import type { ToolTraceItem } from "@liveagent/app/lib/chat/assistantBubbleAdapter"; import { AssistantStatus } from "@liveagent/ui/components/chat/AssistantStatus"; import { LazyCollapse } from "@liveagent/ui/components/chat/LazyCollapse"; import { useLocale } from "@liveagent/ui/i18n/index"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { memo, useMemo, useState } from "react"; -import { ChevronRight, Terminal } from "../../../components/icons"; -import type { ToolTraceItem } from "../../../lib/chat/uiMessages"; +import { ChevronRight, Terminal } from "../../IconSet"; import { getToolDisplayName, getToolMeta, getToolTraceKey } from "./assistantBubbleUtils"; import { areToolTraceItemsEqual, MemoToolCallItem } from "./ToolCallItem"; @@ -145,7 +146,7 @@ function ToolTraceGroupInner(props: {
- + 0}> {() => (
{items.map((item, index) => ( diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts b/crates/agent-ui/src/components/chat/assistant-bubble/assistantBubbleUtils.ts similarity index 97% rename from crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts rename to crates/agent-ui/src/components/chat/assistant-bubble/assistantBubbleUtils.ts index 18f234147..7258e06e2 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts +++ b/crates/agent-ui/src/components/chat/assistant-bubble/assistantBubbleUtils.ts @@ -1,9 +1,18 @@ +import type { + HostedSearchBlock, + ToolResultMessage, + ToolTraceItem, + UiRound, +} from "@liveagent/app/lib/chat/assistantBubbleAdapter"; +import { + safeStringify, + shouldDisplayToolTraceItem, +} from "@liveagent/app/lib/chat/assistantBubbleAdapter"; import { isTaskToolName } from "@liveagent/ui/contracts/task"; import type { SubagentCardDetails, SubagentReportDetails, } from "@liveagent/ui/lib/subagents/protocol"; -import type { IconComponent } from "../../../components/icons"; import { Bot, Brain, @@ -13,6 +22,7 @@ import { FilePenLine, FileText, FolderTree, + type IconComponent, ImageIcon, Link2, ListChecks, @@ -22,15 +32,7 @@ import { Terminal, Trash2, Wrench, -} from "../../../components/icons"; -import type { ToolResultMessage } from "../../../lib/agentTypes"; -import type { HostedSearchBlock } from "../../../lib/chat/hostedSearch"; -import { - safeStringify, - shouldDisplayToolTraceItem, - type ToolTraceItem, - type UiRound, -} from "../../../lib/chat/uiMessages"; +} from "../../IconSet"; export function getToolMeta(name: string): { Icon: IconComponent; diff --git a/crates/agent-gateway/web/src/app/WorkspaceOverlayHost.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceOverlayHost.tsx similarity index 77% rename from crates/agent-gateway/web/src/app/WorkspaceOverlayHost.tsx rename to crates/agent-ui/src/components/workspace-editor/WorkspaceOverlayHost.tsx index 4177fc933..570f7c774 100644 --- a/crates/agent-gateway/web/src/app/WorkspaceOverlayHost.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceOverlayHost.tsx @@ -1,13 +1,18 @@ +import type { CodeMentionReference } from "@liveagent/adapters/mentionReferences"; +import { + WorkspaceOverlayTitleBar, + workspaceOverlayStackClassName, +} from "@liveagent/adapters/workspacePreview"; +import type { AppSettings, EffectiveTheme } from "@liveagent/app/lib/settings"; import type { WorkspaceCodeEditorOpenRequest } from "@liveagent/ui/components/workspace-editor/WorkspaceCodeEditorOverlay"; import type { WorkspaceFilePreviewOpenRequest } from "@liveagent/ui/components/workspace-editor/WorkspaceFilePreviewOverlay"; import type { WorkspaceSshTerminalOpenRequest } from "@liveagent/ui/components/workspace-editor/WorkspaceSshTerminalOverlay"; import { t as translate } from "@liveagent/ui/i18n/index"; import { lockMonacoNlsLocale, preparePreferredMonacoNlsLocale } from "@liveagent/ui/lib/monacoNls"; import type { SftpClient } from "@liveagent/ui/lib/sftp/types"; +import { cn } from "@liveagent/ui/lib/shared/utils"; import type { TerminalClient, TerminalSession } from "@liveagent/ui/lib/terminal/types"; import { lazy, Suspense } from "react"; -import type { CodeMentionReference } from "@/lib/chat/mentionReferences"; -import type { AppSettings, EffectiveTheme } from "@/lib/settings"; const WorkspaceCodeEditorOverlay = lazy(async () => { await preparePreferredMonacoNlsLocale(); @@ -66,10 +71,25 @@ type WorkspaceOverlayHostProps = { onWorkspaceSshTerminalHide: () => void; }; +function WorkspaceOverlayLoading(props: { className: string; label: string }) { + const { className, label } = props; + return ( +
+ +
{label}
+
+ ); +} + /** - * Lazy mount host for workspace overlays. Must live inside `.gateway-main-shell` - * (not the outer editor host) so absolute inset-0 only covers the main column - * and leaves the chat sidebar usable. + * Lazy mount host for workspace overlays. It stays inside the main chat column + * so absolute positioning leaves the sidebar usable in both hosts. */ export function WorkspaceOverlayHost(props: WorkspaceOverlayHostProps) { const { @@ -105,9 +125,10 @@ export function WorkspaceOverlayHost(props: WorkspaceOverlayHostProps) { {workspaceEditorMounted ? ( - {translate("workspaceEditor.loading", locale)} -
+ } > - {translate("workspaceFilePreview.loading", locale)} -
+ } > - {translate("workspaceSshTerminal.loading", locale)} - + } > 0)); } diff --git a/crates/agent-gui/src/pages/chat/hooks/useChatSkills.ts b/crates/agent-ui/src/lib/skills/useChatSkills.ts similarity index 98% rename from crates/agent-gui/src/pages/chat/hooks/useChatSkills.ts rename to crates/agent-ui/src/lib/skills/useChatSkills.ts index c6067ac37..59ab1c5fe 100644 --- a/crates/agent-gui/src/pages/chat/hooks/useChatSkills.ts +++ b/crates/agent-ui/src/lib/skills/useChatSkills.ts @@ -1,3 +1,4 @@ +import { type AppSettings, updateSkills } from "@liveagent/app/lib/settings"; import { discoverSkills, isAlwaysEnabledSkillName, @@ -6,7 +7,6 @@ import { subscribeSkillsDiscoveryUpdated, } from "@liveagent/ui/lib/skills/index"; import { useCallback, useEffect, useRef, useState } from "react"; -import { type AppSettings, updateSkills } from "../../../lib/settings"; type UseChatSkillsParams = { skillsEnabled: boolean; diff --git a/docs/images/shared-chat-rendering-preview.jpg b/docs/images/shared-chat-rendering-preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2f48eeeb6640c7f7e4fa12d53e4f0a4c8a5fe3c0 GIT binary patch literal 47535 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 kiNtb%3E(srcbo9Pk$^|6v4;Z*?D}W4_ Date: Mon, 10 Aug 2026 14:42:05 +0800 Subject: [PATCH 2/2] docs(gui): fix shared chat skills hook path --- docs/architecture/gui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/gui.md b/docs/architecture/gui.md index 6dab54895..2cba07b45 100644 --- a/docs/architecture/gui.md +++ b/docs/architecture/gui.md @@ -37,7 +37,7 @@ | 历史持久化 | V3 segment 写入 Tauri SQLite,支持 append segment、active segment update、rename/delete/pin/share。 | `lib/chat/conversation/conversationState.ts`、`src-tauri/src/commands/history/chat_history/*` | | 上下文压缩 | 在 pre-send、mid-stream、post-tool 等阶段生成 summary checkpoint,避免超上下文。 | `pages/chat/runtime/conversationContextBuilders.ts`、`lib/chat/compaction/*` | | 记忆注入 | 每轮根据 workdir 读取 memory overview,并附加到 system prompt。 | `lib/chat/memory/*`、`src-tauri/src/services/memory/*` | -| Skills 注入 | 根据 Settings Skills 选择与 always-on builtin skills 生成 skills prompt。 | `crates/agent-ui/src/lib/skills/index.ts`、`pages/chat/hooks/useChatSkills.ts` | +| Skills 注入 | 根据 Settings Skills 选择与 always-on builtin skills 生成 skills prompt。 | `crates/agent-ui/src/lib/skills/index.ts`、`crates/agent-ui/src/lib/skills/useChatSkills.ts` | | 上传 | GUI 直接调用 Tauri import readable files/image preview;工作区外文件复制到 `~/.liveagent/uploads` 暂存区(不污染工作区),工作区内文件原地引用。 | `pages/chat/hooks/usePendingUploads.ts`、`src-tauri/src/commands/app/system.rs` | | Gateway bridge | 本地运行时接收远程 command,把 token/thinking/tool/done/error 等事件发布给 Gateway;listener 与 worker id 在组件生命周期内保持稳定。 | `pages/chat/gateway/useGatewayBridgeListeners.ts`、`lib/chat/conversation/run/gatewayBridgeEvents.ts` |