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/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` | diff --git a/docs/images/shared-chat-rendering-preview.jpg b/docs/images/shared-chat-rendering-preview.jpg new file mode 100644 index 000000000..2f48eeeb6 Binary files /dev/null and b/docs/images/shared-chat-rendering-preview.jpg differ diff --git a/scripts/check-ui-boundaries.mjs b/scripts/check-ui-boundaries.mjs index 0b0d4bc65..7b56ee190 100644 --- a/scripts/check-ui-boundaries.mjs +++ b/scripts/check-ui-boundaries.mjs @@ -44,6 +44,16 @@ const checks = [ /(?:from\s+|import\s*\(\s*|import\s+)["']@liveagent\/ui\/(?:components\/chat\/ChatHeader|pages\/(?:skills-hub\/SkillsHubPage|mcp-hub\/McpHubPage))["']/, reason: "公共页面与聊天顶部栏必须由共享 ApplicationView 统一组装", }, + { + pattern: + /(?:from\s+|import\s*\(\s*|import\s+)["']@\/pages\/chat\/(?:AssistantBubble|useChatSkills|queue\/chatTurnQueue|assistant-bubble\/[^"']+)["']/, + reason: "聊天渲染、Skill 与队列公共逻辑必须使用 agent-ui 共享实现", + }, + { + pattern: + /(?:from\s+|import\s*\(\s*|import\s+)["']\.\/(?:FileDropOverlay|WorkspaceOverlayHost)["']/, + reason: "聊天 Overlay 必须使用 agent-ui 共享实现", + }, ], }, { @@ -63,6 +73,11 @@ const checks = [ /(?:from\s+|import\s*\(\s*|import\s+)["']@liveagent\/ui\/(?:components\/chat\/ChatHeader|pages\/(?:skills-hub\/SkillsHubPage|mcp-hub\/McpHubPage))["']/, reason: "公共页面与聊天顶部栏必须由共享 ApplicationView 统一组装", }, + { + pattern: + /(?:from\s+|import\s*\(\s*|import\s+)["']\.{1,2}\/(?:components\/(?:ChatFileDropOverlay|WorkspaceOverlayHost|assistant-bubble\/[^"']+)|hooks\/useChatSkills)["']/, + reason: "聊天渲染、Overlay 与 Skill 公共逻辑必须使用 agent-ui 共享实现", + }, ], }, ]; @@ -95,6 +110,34 @@ for (const sharedFile of listSourceFiles(sharedRoot)) { } } +const retiredSharedCopies = [ + "crates/agent-gui/src/pages/chat/components/ChatFileDropOverlay.tsx", + "crates/agent-gui/src/pages/chat/components/WorkspaceOverlayHost.tsx", + "crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx", + "crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx", + "crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolImages.tsx", + "crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx", + "crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolTraceGroup.tsx", + "crates/agent-gui/src/pages/chat/components/assistant-bubble/assistantBubbleUtils.ts", + "crates/agent-gui/src/pages/chat/hooks/useChatSkills.ts", + "crates/agent-gateway/web/src/app/FileDropOverlay.tsx", + "crates/agent-gateway/web/src/app/WorkspaceOverlayHost.tsx", + "crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx", + "crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx", + "crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolCallItem.tsx", + "crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolImages.tsx", + "crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolResultDisplay.tsx", + "crates/agent-gateway/web/src/pages/chat/assistant-bubble/ToolTraceGroup.tsx", + "crates/agent-gateway/web/src/pages/chat/assistant-bubble/assistantBubbleUtils.ts", + "crates/agent-gateway/web/src/pages/chat/queue/chatTurnQueue.ts", + "crates/agent-gateway/web/src/pages/chat/useChatSkills.ts", +]; +for (const retiredPath of retiredSharedCopies) { + if (!existsSync(join(repoRoot, retiredPath))) continue; + failures += 1; + console.error(`${retiredPath}: 已迁移到 agent-ui 的共享源码不能在宿主目录重新创建`); +} + const applicationEntries = [ join(repoRoot, "crates/agent-gui/src/pages/ChatPage.tsx"), join(repoRoot, "crates/agent-gateway/web/src/app/GatewayApp.tsx"),