diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index a0c69895d..275a75609 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -124,6 +124,15 @@ jobs: --password "$APPLE_APP_SPECIFIC_PASSWORD" \ --validate + - name: Install deterministic DMG builder + run: | + set -euo pipefail + python3 -m venv "$RUNNER_TEMP/dmgbuild-venv" + "$RUNNER_TEMP/dmgbuild-venv/bin/python" -m pip install \ + --disable-pip-version-check \ + "dmgbuild==1.6.5" + echo "$RUNNER_TEMP/dmgbuild-venv/bin" >> "$GITHUB_PATH" + - name: Build signed and notarized DMG env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} diff --git a/Makefile b/Makefile index 022f4fe9d..f0cbe9af4 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,10 @@ DESKTOP_LINUX_BUNDLES ?= appimage deb rpm DESKTOP_MACOS_APP_NAME ?= LiveAgent DESKTOP_MACOS_NOTARY_PROFILE ?= liveagent-notary DESKTOP_MACOS_TAURI_CONFIG ?= src-tauri/tauri.macos.conf.json +DESKTOP_MACOS_DMG_SETTINGS ?= scripts/release/macos-dmg-settings.py +DESKTOP_MACOS_DMG_BACKGROUND ?= $(AGENT_GUI_DIR)/src-tauri/dmg/background.png +DESKTOP_MACOS_DMG_VOLUME_ICON ?= $(AGENT_GUI_DIR)/src-tauri/icons/icon.icns +DESKTOP_MACOS_DMG_VERIFY ?= scripts/release/verify-macos-dmg.sh DESKTOP_WINDOWS_TAURI_CONFIG ?= src-tauri/tauri.windows.conf.json DESKTOP_RELEASE_TAURI_CONFIG ?= src-tauri/tauri.macos.release.conf.json DESKTOP_RELEASE_TAURI_CONFIG_FLAGS ?= --config $(DESKTOP_RELEASE_TAURI_CONFIG) $(if $(LIVEAGENT_TAURI_VERSION_CONFIG),--config $(LIVEAGENT_TAURI_VERSION_CONFIG)) @@ -49,22 +53,34 @@ build: desktop-build-macos: check-rust-target-$(DESKTOP_MACOS_TARGET) pnpm --dir $(AGENT_GUI_DIR) tauri build --config $(DESKTOP_MACOS_TAURI_CONFIG) --target $(DESKTOP_MACOS_TARGET) -# tauri-bundler skips the Finder AppleScript that writes the DMG .DS_Store (background, -# window size, icon positions) whenever CI=true. Hosted macOS runners do have a GUI -# session, so release builds opt back in — the same default tauri-action ships with. +# tauri-bundler skips the Finder AppleScript that writes the DMG .DS_Store whenever +# CI=true, so the bundler's own DMG is only used to locate the output path. The +# release DMG is rebuilt from the signed .app with dmgbuild, which writes the Finder +# layout (background, window size, icon positions) directly and deterministically. desktop-build-macos-release: check-rust-target-$(DESKTOP_MACOS_TARGET) check-macos-signing-identity check-macos-notary-profile - env -u APPLE_ID -u APPLE_PASSWORD -u APPLE_API_ISSUER -u APPLE_API_KEY -u APPLE_API_KEY_PATH APPLE_SIGNING_IDENTITY="$(APPLE_SIGNING_IDENTITY)" TAURI_BUNDLER_DMG_IGNORE_CI=true pnpm --dir $(AGENT_GUI_DIR) tauri build $(DESKTOP_RELEASE_TAURI_CONFIG_FLAGS) --target $(DESKTOP_MACOS_TARGET) + env -u APPLE_ID -u APPLE_PASSWORD -u APPLE_API_ISSUER -u APPLE_API_KEY -u APPLE_API_KEY_PATH APPLE_SIGNING_IDENTITY="$(APPLE_SIGNING_IDENTITY)" pnpm --dir $(AGENT_GUI_DIR) tauri build $(DESKTOP_RELEASE_TAURI_CONFIG_FLAGS) --target $(DESKTOP_MACOS_TARGET) @set -e; \ app_path="target/$(DESKTOP_MACOS_TARGET)/release/bundle/macos/$(DESKTOP_MACOS_APP_NAME).app"; \ dmg_path="$$(find "target/$(DESKTOP_MACOS_TARGET)/release/bundle/dmg" -maxdepth 1 -name '$(DESKTOP_MACOS_APP_NAME)_*.dmg' -print -quit)"; \ if [ ! -d "$$app_path" ]; then echo "macOS app not found: $$app_path"; exit 1; fi; \ if [ -z "$$dmg_path" ] || [ ! -f "$$dmg_path" ]; then echo "macOS dmg not found under target/$(DESKTOP_MACOS_TARGET)/release/bundle/dmg"; exit 1; fi; \ - scripts/release/verify-macos-dmg-layout.sh "$$dmg_path" "$(DESKTOP_MACOS_APP_NAME)"; \ + if ! command -v dmgbuild >/dev/null 2>&1; then echo "dmgbuild is required. Install dmgbuild==1.6.5 before building a macOS release."; exit 1; fi; \ codesign --verify --deep --strict --verbose=4 "$$app_path"; \ + styled_dmg_path="$${dmg_path%.dmg}.styled.dmg"; \ + trap 'rm -f "$$styled_dmg_path"' EXIT; \ + dmgbuild \ + -s "$(DESKTOP_MACOS_DMG_SETTINGS)" \ + -D app="$$app_path" \ + -D background="$(DESKTOP_MACOS_DMG_BACKGROUND)" \ + -D volume_icon="$(DESKTOP_MACOS_DMG_VOLUME_ICON)" \ + "$(DESKTOP_MACOS_APP_NAME)" "$$styled_dmg_path"; \ + mv -f "$$styled_dmg_path" "$$dmg_path"; \ + trap - EXIT; \ codesign --force --timestamp --sign "$(APPLE_SIGNING_IDENTITY)" "$$dmg_path"; \ xcrun notarytool submit "$$dmg_path" --keychain-profile "$(DESKTOP_MACOS_NOTARY_PROFILE)" --wait; \ xcrun stapler staple "$$dmg_path"; \ xcrun stapler validate -v "$$dmg_path"; \ + bash "$(DESKTOP_MACOS_DMG_VERIFY)" "$$dmg_path"; \ spctl --assess --type execute --verbose=4 "$$app_path"; \ spctl --assess --type open --context context:primary-signature --verbose=4 "$$dmg_path"; \ echo "macOS release dmg is ready: $$dmg_path" @@ -251,7 +267,7 @@ desktop-verify-macos: dmg_path="$$(find "target/$(DESKTOP_MACOS_TARGET)/release/bundle/dmg" -maxdepth 1 -name '$(DESKTOP_MACOS_APP_NAME)_*.dmg' -print -quit)"; \ if [ ! -d "$$app_path" ]; then echo "macOS app not found: $$app_path"; exit 1; fi; \ if [ -z "$$dmg_path" ] || [ ! -f "$$dmg_path" ]; then echo "macOS dmg not found under target/$(DESKTOP_MACOS_TARGET)/release/bundle/dmg"; exit 1; fi; \ - scripts/release/verify-macos-dmg-layout.sh "$$dmg_path" "$(DESKTOP_MACOS_APP_NAME)"; \ + bash "$(DESKTOP_MACOS_DMG_VERIFY)" "$$dmg_path"; \ codesign -dv --verbose=4 "$$app_path" 2>&1; \ codesign --verify --deep --strict --verbose=4 "$$app_path"; \ xcrun stapler validate -v "$$dmg_path"; \ diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index ca1f12fa0..1f4e7601c 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -14,7 +14,7 @@ import { import { UserAttachmentCards } from "@liveagent/ui/components/chat/UserAttachmentCards"; import { Loader2 } from "@liveagent/ui/components/IconSet"; import { useLocale } from "@liveagent/ui/i18n/LocaleContext"; -import { normalizeLiveToolStatus, VIBING_STATUS } from "@liveagent/ui/lib/chat/assistantStatus"; +import { normalizeLiveToolStatus } from "@liveagent/ui/lib/chat/assistantStatus"; import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import type { ConversationMentionReference } from "@liveagent/ui/lib/chat/mentionReferences"; import { @@ -160,15 +160,6 @@ function resolveNearestScrollViewport(element: HTMLElement | null) { return element?.closest("[data-scroll-viewport]") as HTMLDivElement | null; } -function LiveStatusFooter(props: { status: string; isCompaction?: boolean }) { - const { status, isCompaction = false } = props; - return ( -
- -
- ); -} - function HistoryLoadingState(props: { title?: string }) { const title = props.title?.trim(); return ( @@ -345,8 +336,6 @@ const GatewayUserMessageRowBody = memo(function GatewayUserMessageRowBody(props: ); }); -// Retry actions render only for mounted assistant rows. Resolve their prompt -// locally instead of rebuilding an all-history map on every streamed token. function findRetryTarget(rows: readonly TranscriptRow[], assistantIndex: number) { for (let index = assistantIndex - 1; index >= 0; index -= 1) { const row = rows[index]; @@ -355,14 +344,14 @@ function findRetryTarget(rows: readonly TranscriptRow[], assistantIndex: number) return null; } -// Shared assistant-row hover actions (copy / retry). Retry re-sends the -// nearest preceding user prompt through the edit-resend pipeline: this reply -// and everything after it are discarded, same as editing that prompt -// unchanged. Both transcript regions render it below the bubble. +// Compact assistant footer: primary reply actions stay one click away while +// detailed usage remains behind the info popover. const GatewayAssistantMessageActions = memo(function GatewayAssistantMessageActions(props: { row: Extract; retryTarget: Extract | null; isStreaming: boolean; + showUsage?: boolean; + usageContextWindow?: number; isCopied: boolean; setCopiedMessageId: Dispatch>; onResendFromEdit?: ( @@ -378,6 +367,8 @@ const GatewayAssistantMessageActions = memo(function GatewayAssistantMessageActi row, retryTarget, isStreaming, + showUsage, + usageContextWindow, isCopied, setCopiedMessageId, onResendFromEdit, @@ -389,6 +380,15 @@ const GatewayAssistantMessageActions = memo(function GatewayAssistantMessageActi .map((round) => getRoundText(round).trim()) .filter((text) => text.length > 0) .join("\n\n"); + const usageEntries = useMemo( + () => + showUsage + ? row.rounds.flatMap((round) => + round.meta?.usage ? [{ key: `round-${round.round}`, usage: round.meta.usage }] : [], + ) + : undefined, + [row.rounds, showUsage], + ); const retryMessageRef = retryTarget?.messageRef; const retryDisabled = isStreaming || !onResendFromEdit || !retryMessageRef; const retryTitle = retryMessageRef @@ -415,6 +415,8 @@ const GatewayAssistantMessageActions = memo(function GatewayAssistantMessageActi }, 1500); }); }} + usageEntries={usageEntries} + usageContextWindow={showUsage ? usageContextWindow : undefined} retryDisabled={retryDisabled} retryTitle={retryTitle} onRetry={() => { @@ -870,10 +872,11 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr
-
+
{retryAttempts && retryAttempts.length > 0 ? ( @@ -916,6 +919,11 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr const rowIndex = virtualRow.index - leadingOffset; const isLatestLiveAssistant = rowIndex === liveAssistantIndex; const isLatestLiveStreaming = isStreaming && isLatestLiveAssistant; + const retryTarget = findRetryTarget(rows, rowIndex); + const durationMs = + row.timestamp !== undefined && retryTarget?.timestamp !== undefined + ? Math.max(0, row.timestamp - retryTarget.timestamp) + : undefined; return (
- {isLatestLiveStreaming ? ( - - ) : null} {isLatestLiveStreaming && !shouldShowPendingLiveBubble && retryAttempts && @@ -955,8 +958,10 @@ const GatewayTranscriptListRegion = memo(function GatewayTranscriptListRegion(pr {!readOnly && !isLatestLiveStreaming ? ( group.kind === "single")) return rows; + const stitched: TranscriptRow[] = []; + for (const group of groups) { + if (group.kind === "single") { + stitched.push(group.item); + continue; + } + const leader = group.items[0]; + if (!leader || leader.kind !== "assistant") continue; + if (group.items.length === 1) { + stitched.push(leader); + continue; + } + const reply = assembleContinuousReply(group.items, { + classify: classifyRow, + roundsOf: (row) => (row.kind === "assistant" ? row.rounds : []), + seamOf: (row) => { + if (row.kind !== "checkpoint") throw new Error("seamOf called on a non-checkpoint row"); + return row; + }, + rekeyParts: false, + }); + let timestamp = leader.timestamp; + let turnKey = leader.turnKey; + for (const item of group.items) { + if (item.kind !== "assistant") continue; + if (item.timestamp !== undefined) timestamp = item.timestamp; + if (item.turnKey !== undefined) turnKey = item.turnKey; + } + stitched.push({ + ...leader, + rounds: reply.rounds as GatewayTranscriptRound[], + timestamp, + ...(turnKey !== undefined ? { turnKey } : {}), + }); + } + return stitched; +} + +function buildRawRowsFromEntries( + entries: ChatEntry[], + origin: TranscriptRowOrigin, ): TranscriptRow[] { const rows: TranscriptRow[] = []; let assistantGroup: AssistantGroupBuilder | null = null; diff --git a/crates/agent-gateway/web/src/lib/chatUi.ts b/crates/agent-gateway/web/src/lib/chatUi.ts index 76eddf4c9..4ca0a43ff 100644 --- a/crates/agent-gateway/web/src/lib/chatUi.ts +++ b/crates/agent-gateway/web/src/lib/chatUi.ts @@ -12,6 +12,7 @@ import { normalizeHostedSearchBlock, } from "@liveagent/ui/lib/chat/hostedSearch"; import type { ConversationMentionReference } from "@liveagent/ui/lib/chat/mentionReferences"; +import type { CompactionSeam } from "@liveagent/ui/lib/chat/replyContinuity"; import { getUserMessageAttachments, getUserMessageDisplayText, @@ -31,6 +32,11 @@ export type GatewayTranscriptRound = UiRound & { key: string; runningToolCallIds: string[]; thinkingOpen?: boolean; + /** + * Set on the zero-block seam round that stands in for a context + * compaction inside a stitched reply (see lib/chat/replyContinuity). + */ + checkpoint?: CompactionSeam; }; type SharedGatewayChatEntry = SharedChatEntry< diff --git a/crates/agent-gateway/web/src/styles/base-chat.css b/crates/agent-gateway/web/src/styles/base-chat.css index a8d07f185..ce6b0b49a 100644 --- a/crates/agent-gateway/web/src/styles/base-chat.css +++ b/crates/agent-gateway/web/src/styles/base-chat.css @@ -933,10 +933,6 @@ html[data-liveagent-webui="gateway"] [role="textbox"]:focus-visible { html[data-liveagent-webui="gateway"] .changed-file-row-action { display: none; } - .chat-user-bubble-actions, - .chat-assistant-actions { - opacity: 1; - } .chat-user-bubble-action, .chat-assistant-action { @@ -949,6 +945,15 @@ html[data-liveagent-webui="gateway"] [role="textbox"]:focus-visible { } } +/* Only force the action row visible when *no* hover-capable pointer exists. + * `(pointer: coarse)` alone matches many desktop 2-in-1s that still have a mouse. */ +@media (any-hover: none) { + .chat-user-bubble-actions, + .chat-assistant-actions { + opacity: 1; + } +} + .gateway-bubble { width: min(100%, 760px); border-radius: 22px; 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 cfcd7b922..49dd55a6d 100644 --- a/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs +++ b/crates/agent-gateway/web/test/assistant-bubble-utils.test.mjs @@ -7,9 +7,8 @@ import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; 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( - "@liveagent/ui/components/chat/assistant-bubble/assistantBubbleUtils.ts", -); +const { groupRoundBlocks, isBuiltinShareToolName, resolveAssistantTurnLayout } = + loader.loadModule("@liveagent/ui/components/chat/assistant-bubble/assistantBubbleUtils.ts"); test("shared history recognizes every catalog tool as builtin", () => { for (const entry of BUILTIN_TOOL_CATALOG) { @@ -101,3 +100,76 @@ test("task tools stay standalone so transcript filtering cannot hide ordinary to ["Read", "Read"], ); }); + +test("final native images and hosted search results stay in the answer layer", () => { + const displayImageTool = { + kind: "tool", + item: { + toolCall: { type: "toolCall", id: "image-1", name: "Image", arguments: {} }, + toolResult: { + role: "toolResult", + toolCallId: "image-1", + content: [], + isError: false, + details: { + kind: "display_image", + images: [{ path: "/workspace/icon.png", mimeType: "image/png" }], + loadMode: "inline", + }, + }, + }, + }; + const text = (id, value) => ({ kind: "text", id, text: value }); + const terminalMeta = { stopReason: "stop" }; + + const textThenImage = resolveAssistantTurnLayout( + [ + { + round: 1, + meta: terminalMeta, + blocks: [text("intro", "图像开始"), displayImageTool], + }, + ], + { live: false }, + ); + assert.deepEqual( + textThenImage.answer.map((entry) => entry.block.kind), + ["text", "tool"], + ); + assert.deepEqual(textThenImage.work, []); + + const imageThenText = resolveAssistantTurnLayout( + [ + { + round: 1, + meta: terminalMeta, + blocks: [displayImageTool, text("done", "图像测试完成")], + }, + ], + { live: false }, + ); + assert.deepEqual( + imageThenText.answer.map((entry) => entry.block.kind), + ["tool", "text"], + ); + assert.deepEqual(imageThenText.work, []); + + const textThenSearch = resolveAssistantTurnLayout( + [ + { + round: 1, + meta: terminalMeta, + blocks: [ + text("summary", "搜索完成"), + { kind: "hostedSearch", item: { id: "search-1", query: "OpenAI" } }, + ], + }, + ], + { live: false }, + ); + assert.deepEqual( + textThenSearch.answer.map((entry) => entry.block.kind), + ["text", "hostedSearchGroup"], + ); + assert.deepEqual(textThenSearch.work, []); +}); diff --git a/crates/agent-gateway/web/test/assistant-status.test.mjs b/crates/agent-gateway/web/test/assistant-status.test.mjs index 4ac64cdc7..a83f24b62 100644 --- a/crates/agent-gateway/web/test/assistant-status.test.mjs +++ b/crates/agent-gateway/web/test/assistant-status.test.mjs @@ -4,30 +4,22 @@ import { fileURLToPath } from "node:url"; import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; -function Loader2(props) { - return { type: "Loader2", props }; -} - const loader = createWebModuleLoader({ rootDir: fileURLToPath(new URL("../", import.meta.url)), mocks: { - "@liveagent/ui/components/IconSet": { Loader2 }, "@liveagent/ui/i18n/index": { useLocale: () => ({ t: (key) => key }) }, }, }); const { AssistantStatus } = loader.loadModule("@liveagent/ui/components/chat/AssistantStatus"); -test("assistant running status keeps its spinner animated", () => { +test("assistant running status renders one compact animated text label", () => { const status = AssistantStatus({ children: "Vibing" }); - const icon = status.props.children[0]; - const text = status.props.children[1]; + const text = status.props.children; - assert.equal(icon.type, Loader2); - assert.match(icon.props.className, /(?:^|\s)animate-spin(?:\s|$)/); - assert.doesNotMatch(icon.props.className, /(?:^|\s)motion-reduce:animate-none(?:\s|$)/); assert.match(status.props.className, /(?:^|\s)min-w-0(?:\s|$)/); assert.match(status.props.className, /(?:^|\s)max-w-full(?:\s|$)/); + assert.match(text.props.className, /(?:^|\s)shimmer(?:\s|$)/); assert.match(text.props.className, /(?:^|\s)truncate(?:\s|$)/); assert.match(text.props.className, /(?:^|\s)whitespace-nowrap(?:\s|$)/); }); diff --git a/crates/agent-gateway/web/test/browser/compaction-seam-preview.html b/crates/agent-gateway/web/test/browser/compaction-seam-preview.html new file mode 100644 index 000000000..693f8cfe4 --- /dev/null +++ b/crates/agent-gateway/web/test/browser/compaction-seam-preview.html @@ -0,0 +1,172 @@ + + + + + + + Compaction seam preview + + + +
+
+
Settled · history
+
+
+
+
Streaming · compacting right now
+
+
+
+
Streaming · continuation after compaction
+
+
+
+ + + 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 8bf6477d8..6be842a07 100644 --- a/crates/agent-gateway/web/test/chat-file-links.test.mjs +++ b/crates/agent-gateway/web/test/chat-file-links.test.mjs @@ -87,7 +87,6 @@ test("Gateway historical and streaming rows keep the explicit file-open prop cha const files = [ "../src/app/GatewayAppView.tsx", "../src/components/GatewayTranscript.tsx", - "../../../agent-ui/src/components/chat/ThinkingActivity.tsx", "../../../agent-ui/src/components/chat/AssistantBubble.tsx", "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", ]; @@ -109,15 +108,20 @@ test("Gateway historical and streaming rows keep the explicit file-open prop cha assert.ok((roundContent.match(/onOpenFileLink=\{onOpenFileLink\}/g) ?? []).length >= 2); assert.ok((roundContent.match(/workdir=\{workdir\}/g) ?? []).length >= 2); - const thinkingActivity = fs.readFileSync( + // Reasoning disclosures render Markdown, so file links inside reasoning + // text keep the same explicit open-prop chain as answer prose. + const thinkingDisclosure = fs.readFileSync( fileURLToPath( - new URL("../../../agent-ui/src/components/chat/ThinkingActivity.tsx", import.meta.url), + new URL( + "../../../agent-ui/src/components/chat/assistant-bubble/ThinkingDisclosure.tsx", + import.meta.url, + ), ), "utf8", ); - assert.match(thinkingActivity, / { +test("the streaming assistant keeps live status inside its stable work trace", () => { assert.doesNotMatch(transcriptSource, /function shouldShowLiveStatusForRounds/); + assert.doesNotMatch(transcriptSource, /function LiveStatusFooter/); assert.match( transcriptSource, - /isLatestLiveStreaming\s*\?\s*\(\s* item.kind; + +function seam(key = "cp-1") { + return { + key, + summaryId: key, + content: "summary body", + coveredMessageCount: 12, + generatedBy: { providerId: "deepseek", model: "deepseek-v4-flash" }, + contextUsageTokens: 18_400, + }; +} + +function textRound(round, text) { + return { round, key: `r${round}`, blocks: [{ kind: "text", id: "text-1", text }] }; +} + +function editRound(round, id, path) { + return { + round, + key: `r${round}`, + blocks: [ + { + kind: "tool", + item: { + toolCall: { + type: "toolCall", + id, + name: "Write", + arguments: { path, content: "a\nb\n" }, + }, + toolResult: { + role: "toolResult", + toolCallId: id, + isError: false, + content: [], + details: { path }, + }, + }, + }, + ], + }; +} + +// --------------------------------------------------------------------------- +// Grouping + +test("a checkpoint between two assistant parts stitches them into one reply", () => { + const items = [ + { kind: "user", id: "u1" }, + { kind: "assistant", id: "a1" }, + { kind: "checkpoint", id: "c1" }, + { kind: "assistant", id: "a2" }, + { kind: "user", id: "u2" }, + { kind: "assistant", id: "a3" }, + ]; + const groups = stitchCompactedReplies(items, classify); + assert.deepEqual( + groups.map((group) => (group.kind === "single" ? group.item.id : group.items.map((i) => i.id))), + ["u1", ["a1", "c1", "a2"], "u2", "a3"], + ); +}); + +test("a trailing checkpoint stays a standalone divider", () => { + const items = [ + { kind: "user", id: "u1" }, + { kind: "assistant", id: "a1" }, + { kind: "checkpoint", id: "c1" }, + { kind: "user", id: "u2" }, + ]; + const groups = stitchCompactedReplies(items, classify); + assert.deepEqual( + groups.map((group) => (group.kind === "single" ? group.item.id : group.items.map((i) => i.id))), + ["u1", "a1", "c1", "u2"], + ); +}); + +test("an idle manual compaction (checkpoint with no reply around it) is untouched", () => { + const items = [ + { kind: "user", id: "u1" }, + { kind: "assistant", id: "a1" }, + { kind: "checkpoint", id: "c1" }, + ]; + const groups = stitchCompactedReplies(items, classify); + assert.deepEqual( + groups.map((group) => group.kind), + ["single", "single", "single"], + ); +}); + +test("a leading checkpoint folds into the reply it precedes", () => { + const items = [ + { kind: "user", id: "u1" }, + { kind: "checkpoint", id: "c1" }, + { kind: "assistant", id: "a1" }, + ]; + const groups = stitchCompactedReplies(items, classify); + assert.equal(groups[1].kind, "reply"); + assert.deepEqual( + groups[1].items.map((item) => item.id), + ["c1", "a1"], + ); +}); + +test("two compactions inside one reply produce one group with two seams", () => { + const items = [ + { kind: "assistant", id: "a1" }, + { kind: "checkpoint", id: "c1" }, + { kind: "assistant", id: "a2" }, + { kind: "checkpoint", id: "c2" }, + { kind: "assistant", id: "a3" }, + ]; + const groups = stitchCompactedReplies(items, classify); + assert.equal(groups.length, 1); + assert.equal(groups[0].items.length, 5); +}); + +// --------------------------------------------------------------------------- +// Assembly & re-keying + +test("assembly interleaves seam rounds and re-keys continuation parts positionally", () => { + const reply = assembleContinuousReply( + [ + { kind: "assistant", rounds: [textRound(1, "a"), textRound(2, "b")] }, + { kind: "checkpoint", seam: seam("cp-1") }, + { kind: "assistant", rounds: [textRound(1, "c")] }, + ], + { + classify, + roundsOf: (item) => item.rounds, + seamOf: (item) => item.seam, + rekeyParts: true, + }, + ); + assert.deepEqual( + reply.rounds.map((round) => round.key), + ["r1", "r2", "cp-1", "p1:r1"], + ); + assert.equal(reply.partCount, 2); + assert.equal(reply.seamCount, 1); + assert.equal(getCompactionSeam(reply.rounds[2]).summaryId, "cp-1"); + assert.equal(getCompactionSeam(reply.rounds[0]), null); +}); + +test("re-keying is identity-stable and leaves the first part alone", () => { + const rounds = [textRound(1, "x")]; + assert.equal(rekeyContinuationRounds(rounds, 0), rounds); + const first = rekeyContinuationRounds(rounds, 2); + const second = rekeyContinuationRounds(rounds, 2); + assert.equal(first[0], second[0]); + assert.equal(first[0].key, "p2:r1"); +}); + +test("seam rounds are cached per seam payload", () => { + const payload = seam(); + assert.equal(createCompactionSeamRound(payload), createCompactionSeamRound(payload)); + assert.deepEqual(createCompactionSeamRound(payload).blocks, []); +}); + +// --------------------------------------------------------------------------- +// Layout & aggregation across the seam + +test("turn layout renders the seam as a work entry and keeps the final answer after it", () => { + const rounds = [ + editRound(1, "w1", "src/a.ts"), + createCompactionSeamRound(seam("cp-1")), + { ...editRound(1, "w2", "src/b.ts"), key: "p1:r1", meta: { stopReason: "toolUse" } }, + { ...textRound(2, "done"), key: "p1:r2", meta: { stopReason: "stop" } }, + ]; + const layout = resolveAssistantTurnLayout(rounds, { live: false }); + assert.deepEqual( + layout.work.map((entry) => entry.block.kind), + ["toolGroup", "checkpoint", "toolGroup"], + ); + assert.equal(layout.work[1].block.seam.summaryId, "cp-1"); + assert.deepEqual( + layout.answer.map((entry) => entry.block.text), + ["done"], + ); + assert.equal(resolveActiveWorkEntry([layout.work[1]]), null); +}); + +test("changed files aggregate across both halves of a stitched reply", () => { + const rounds = [ + editRound(1, "w1", "src/a.ts"), + createCompactionSeamRound(seam("cp-1")), + { ...editRound(1, "w2", "src/b.ts"), key: "p1:r1" }, + ]; + const summary = collectChangedFiles(rounds); + assert.deepEqual( + summary.files.map((file) => file.path), + ["src/a.ts", "src/b.ts"], + ); +}); diff --git a/crates/agent-gateway/web/test/shell-session-ui.test.mjs b/crates/agent-gateway/web/test/shell-session-ui.test.mjs index 00c1446f1..0ff51c372 100644 --- a/crates/agent-gateway/web/test/shell-session-ui.test.mjs +++ b/crates/agent-gateway/web/test/shell-session-ui.test.mjs @@ -26,12 +26,14 @@ const loader = createWebModuleLoader({ }, }, "./assistant-bubble/RoundContent": { - RoundContent({ round }) { - const toolNames = round.blocks.flatMap((block) => - block.kind === "tool" ? [block.item.toolCall.name] : [], + AssistantTurnContent({ rounds }) { + const toolNames = rounds.flatMap((round) => + round.blocks.flatMap((block) => + block.kind === "tool" ? [block.item.toolCall.name] : [], + ), ); return jsxRuntime.jsx("div", { - "data-round": round.round, + "data-rounds": rounds.length, children: toolNames.join(","), }); }, diff --git a/crates/agent-gateway/web/test/task-progress-indicator.test.mjs b/crates/agent-gateway/web/test/task-progress-indicator.test.mjs index d83c872ba..c91970209 100644 --- a/crates/agent-gateway/web/test/task-progress-indicator.test.mjs +++ b/crates/agent-gateway/web/test/task-progress-indicator.test.mjs @@ -15,6 +15,9 @@ const localeContextPath = fileURLToPath( const taskProgressIndicatorPath = fileURLToPath( new URL("../../../agent-ui/src/components/chat/TaskProgressIndicator.tsx", import.meta.url), ); +const tooltipPath = fileURLToPath( + new URL("../../../agent-ui/src/components/ui/tooltip.tsx", import.meta.url), +); const labels = { title: "Task progress", @@ -24,65 +27,63 @@ const labels = { pending: "Pending", paused: "Paused", completed: "All completed", + taskPaused: "Paused", + taskCompleted: "Completed", }; function createHookHarness() { - const states = []; - const refs = []; - let stateIndex = 0; - let refIndex = 0; let idIndex = 0; const react = { - useState(initialValue) { - const index = stateIndex++; - if (!(index in states)) { - states[index] = typeof initialValue === "function" ? initialValue() : initialValue; - } - return [ - states[index], - (next) => { - states[index] = typeof next === "function" ? next(states[index]) : next; - }, - ]; - }, - useRef(initialValue) { - const index = refIndex++; - if (!(index in refs)) refs[index] = { current: initialValue }; - return refs[index]; - }, useId() { return `task-progress-panel-${idIndex++}`; }, - useEffect() {}, + useState(initial) { + return [typeof initial === "function" ? initial() : initial, () => {}]; + }, }; return { react, - refs, render(run) { - stateIndex = 0; - refIndex = 0; idIndex = 0; return run(); }, }; } +function createTooltipMock() { + let handleIndex = 0; + return { + createTooltipHandle: () => ({ + kind: "tooltip-handle", + id: handleIndex++, + isOpen: false, + closeCalls: 0, + close() { + this.closeCalls += 1; + }, + }), + Tooltip: (props) => ({ type: "Tooltip", props }), + TooltipTrigger: (props) => ({ type: "TooltipTrigger", props }), + TooltipContent: (props) => ({ type: "TooltipContent", props }), + }; +} + function createIndicatorHarness() { const hooks = createHookHarness(); + const tooltip = createTooltipMock(); const loader = createWebModuleLoader({ rootDir, mocks: { react: hooks.react, [iconsPath]: { - CheckCircle2: (props) => ({ type: "CheckCircle2", props }), - Circle: (props) => ({ type: "Circle", props }), - Loader2: (props) => ({ type: "Loader2", props }), + Check: (props) => ({ type: "Check", props }), }, [utilsPath]: { cn(...values) { return values.filter(Boolean).join(" "); }, }, + [tooltipPath]: tooltip, }, }); const { TaskProgressIndicator } = loader.loadModule( @@ -159,193 +160,229 @@ function treeText(node) { return treeText(node.props?.children); } +function componentsNamed(node, name) { + return findAll(node, (child) => typeof child.type === "function" && child.type.name === name); +} + +function statusIcons(node) { + return componentsNamed(node, "TaskStatusIcon"); +} + function readIndicator(tree) { + const allButtons = findAll(tree, (node) => node.type === "button"); + const trigger = allButtons.find((button) => button.props?.["data-task-progress-toggle"] === ""); return { root: tree, - button: findAll(tree, (node) => node.type === "button")[0], + trigger, + otherButtons: allButtons.filter((button) => button !== trigger), + panel: findAll(tree, (node) => node.props?.["data-task-progress-panel"] === "")[0], + list: findAll(tree, (node) => node.type === "ul")[0], progress: findAll(tree, (node) => node.props?.role === "progressbar")[0], - panel: findAll(tree, (node) => node.type === "section")[0], + rows: findAll(tree, (node) => typeof node.props?.["data-task-status"] === "string"), + subjectTriggers: componentsNamed(tree, "TooltipTrigger"), + tooltip: componentsNamed(tree, "Tooltip")[0], }; } -function installFakeWindow() { - const previousWindow = globalThis.window; - const timers = new Map(); - const delays = []; - let nextId = 1; - globalThis.window = { - setTimeout(callback, delay) { - const id = nextId++; - timers.set(id, callback); - delays.push(delay); - return id; - }, - clearTimeout(id) { - timers.delete(id); - }, - }; - return { - delays, - runTimers() { - const callbacks = Array.from(timers.values()); - timers.clear(); - for (const callback of callbacks) callback(); - }, - restore() { - if (previousWindow === undefined) delete globalThis.window; - else globalThis.window = previousWindow; - }, - }; -} +test("web renders a compact trigger whose task list never occupies layout space", () => { + const { root, trigger, otherButtons, panel, progress, rows } = readIndicator( + createIndicatorHarness().render(), + ); -test("web renders props-only copy, progress semantics, and an absolute reduced-motion panel", () => { - const indicator = createIndicatorHarness(); - const { root, button, progress, panel } = readIndicator(indicator.render()); + assert.equal(root.type, "div"); + assert.equal(root.props["data-task-progress-root"], ""); + // 药丸按内容收缩,不再撑成固定宽度的常驻卡片。 + assert.match(root.props.className, /\binline-flex\b/); + assert.match(root.props.className, /group\/task-progress/); + assert.doesNotMatch(root.props.className, /max-w-\[440px\]/); + assert.doesNotMatch(root.props.className, /\bmb-4\b/); - assert.equal(root.type, "fieldset"); - assert.match(root.props.className, /\bmb-3\b/); - assert.equal(button.props["aria-expanded"], false); - assert.equal(button.props["aria-controls"], panel.props.id); - assert.equal(button.props["aria-label"], "Task progress · Step 2 of 3 · 1/3 completed · Running"); + // 触发器只留状态图标与步进文案。 + assert.equal(treeText(trigger), "Step 2 of 3"); + assert.equal(trigger.props["aria-label"], "Task progress · Step 2 of 3 · 1/3 completed · Running"); + assert.equal(trigger.props["aria-describedby"], panel.props.id); + assert.equal(trigger.props.onClick, undefined); + assert.equal(otherButtons.length, 0); + assert.equal(statusIcons(trigger)[0].props.state, "running"); + + // 浮层绝对定位在触发器之上,默认透明且不吃指针,hover / 键盘聚焦才显形。 + assert.equal(panel.props.role, "tooltip"); + assert.match(panel.props.className, /\babsolute\b/); + assert.match(panel.props.className, /\bbottom-full\b/); + assert.match(panel.props.className, /\bpointer-events-none\b/); + assert.match(panel.props.className, /\bopacity-0\b/); + assert.match(panel.props.className, /group-hover\/task-progress:opacity-100/); + assert.match(panel.props.className, /group-hover\/task-progress:pointer-events-auto/); + assert.match(panel.props.className, /group-focus-within\/task-progress:opacity-100/); + assert.match(panel.props.className, /motion-reduce:transition-none/); + assert.equal(panel.props.hidden, undefined); + + assert.equal(progress.props["aria-label"], "Task progress · Step 2 of 3 · 1/3 completed · Running"); assert.deepEqual( [progress.props["aria-valuemin"], progress.props["aria-valuenow"], progress.props["aria-valuemax"]], [0, 1, 3], ); - assert.equal(panel.props["aria-hidden"], true); - assert.match(panel.props.className, /\babsolute\b/); - assert.match(panel.props.className, /motion-reduce:transition-none/); - assert.match(button.props.className, /motion-reduce:transition-none/); - const completedCount = findAll(button, (node) => treeText(node) === labels.completedCount).at(-1); - assert.ok(completedCount); - assert.doesNotMatch(completedCount.props.className, /\bhidden\b/); - assert.match(completedCount.props.className, /\bshrink-0\b/); - assert.match(treeText(root), /Task progress/); - assert.match(treeText(root), /Implement/); - assert.doesNotMatch(treeText(root), /Implementing/); + + assert.equal(rows.length, 3); + assert.match(treeText(panel), /Inspect/); + assert.match(treeText(panel), /Implement/); + assert.match(treeText(panel), /Verify/); }); -test("web keeps task labels stable and scopes transition motion to the changed row status", () => { - const indicator = createIndicatorHarness(); - const runningSnapshot = createSnapshot({ - tasks: [ - { - id: "stable", - subject: "Stable task", - description: "Stable completion criteria", - status: "in_progress", - activeForm: "Changing label", +test("web lists task subjects only, dropping descriptions and per-row disclosure", () => { + const { panel, rows } = readIndicator(createIndicatorHarness().render()); + + assert.deepEqual( + rows.map((row) => row.type), + ["li", "li", "li"], + ); + assert.doesNotMatch(treeText(panel), /completion criteria/); + assert.doesNotMatch(treeText(panel), /Inspecting|Implementing|Verifying/); + for (const row of rows) { + assert.equal(row.props["aria-expanded"], undefined); + assert.equal(row.props["aria-controls"], undefined); + } +}); + +test("web clamps long subjects to two lines and reveals the full text through one shared tooltip", async () => { + const { list, rows, subjectTriggers, tooltip } = readIndicator( + createIndicatorHarness().render(), + ); + + // 列表容器本身永不出现横向滚动条:无空格长串在行内折行,其余溢出一律裁掉。 + assert.match(list.props.className, /\boverflow-x-hidden\b/); + assert.match(list.props.className, /\boverflow-y-auto\b/); + + // 每一行都是同一个 tooltip 的分离式触发器,payload 携带完整标题。 + assert.equal(subjectTriggers.length, rows.length); + const handle = tooltip.props.handle; + assert.equal(handle.kind, "tooltip-handle"); + const subjects = ["Inspect", "Implement", "Verify"]; + for (const [index, subjectTrigger] of subjectTriggers.entries()) { + assert.equal(subjectTrigger.props.handle, handle); + assert.equal(subjectTrigger.props.payload, subjects[index]); + assert.equal(subjectTrigger.props.children, subjects[index]); + assert.equal(subjectTrigger.props.closeOnClick, false); + assert.equal(subjectTrigger.props.render.type, "span"); + const textClass = subjectTrigger.props.render.props.className; + assert.match(textClass, /\bline-clamp-2\b/); + assert.match(textClass, /\bbreak-words\b/); + assert.match(textClass, /\bmin-w-0\b/); + assert.match(textClass, /\bflex-1\b/); + } + // 运行中的行加粗、已完成的行降为次要色,与之前的行样式一致。 + assert.match(subjectTriggers[0].props.render.props.className, /text-muted-foreground/); + assert.match(subjectTriggers[1].props.render.props.className, /font-medium/); + + // 只有真被 line-clamp 截断的行才允许弹出;完整可见的行取消这次打开。 + assert.equal(tooltip.props.disableHoverablePopup, true); + const attemptOpen = (open, trigger) => { + let canceled = false; + tooltip.props.onOpenChange(open, { + trigger, + cancel() { + canceled = true; }, - ], - completedCount: 0, - totalCount: 1, - currentStep: 1, - state: "in_progress", - }); - const runningTree = indicator.render({ snapshot: runningSnapshot }); - const runningRow = findAll(runningTree, (node) => node.type === "li")[0]; - const statusVisual = findAll( - runningRow, - (node) => typeof node.props?.className === "string" && node.props.className.includes("animate-in"), - )[0]; - - assert.equal(treeText(runningRow), "Stable task"); + }); + return canceled; + }; + assert.equal(attemptOpen(true, { scrollHeight: 60, clientHeight: 40 }), false); + assert.equal(attemptOpen(true, { scrollHeight: 40, clientHeight: 40 }), true); + // 亚像素舍入带来的 1px 差值不算截断。 + assert.equal(attemptOpen(true, { scrollHeight: 41, clientHeight: 40 }), true); + assert.equal(attemptOpen(true, undefined), true); + // 关闭请求从不拦截,否则弹层会卡在打开态。 + assert.equal(attemptOpen(false, undefined), false); + // 弹层本就没开时,否决不会多余地触发一次关闭。 + await Promise.resolve(); + assert.equal(handle.closeCalls, 0); + + // 弹层还挂在上一条被截断的行上、指针直接滑进相邻完整行:hover 逻辑把它当作 + // "换触发器"而不主动收起,这里否决新行的同时必须把旧弹层关掉,否则会卡住不动。 + handle.isOpen = true; + assert.equal(attemptOpen(true, { scrollHeight: 20, clientHeight: 20 }), true); + await Promise.resolve(); + assert.equal(handle.closeCalls, 1); + // 换到另一条同样被截断的行则交给 tooltip 自己迁移锚点,不能误关。 + assert.equal(attemptOpen(true, { scrollHeight: 60, clientHeight: 40 }), false); + await Promise.resolve(); + assert.equal(handle.closeCalls, 1); + + // 弹层内容就是当前触发行的完整标题,且不吃指针,避免盖住上一行时把外层 hover 面板打断。 + const content = tooltip.props.children({ payload: "Implement completion criteria" }); + assert.equal(content.type.name, "TooltipContent"); + assert.equal(content.props.children, "Implement completion criteria"); + assert.equal(content.props.side, "top"); + assert.match(content.props.className, /\bpointer-events-none\b/); + assert.match(content.props.className, /\bbreak-words\b/); +}); + +test("web keeps task labels stable and scopes the spinning ring to the running row", () => { + const indicator = createIndicatorHarness(); + const runningRow = readIndicator( + indicator.render({ + snapshot: createSnapshot({ + tasks: [ + { + id: "stable", + subject: "Stable task", + description: "Stable completion criteria", + status: "in_progress", + activeForm: "Changing label", + }, + ], + completedCount: 0, + totalCount: 1, + currentStep: 1, + state: "in_progress", + }), + }), + ).rows[0]; + + assert.match(treeText(runningRow), /Stable task/); + assert.doesNotMatch(treeText(runningRow), /Changing label/); assert.equal(runningRow.props["data-task-status"], "in_progress"); assert.equal(runningRow.props["aria-current"], "step"); - assert.match(runningRow.props.className, /transition-colors/); - assert.match(statusVisual.props.className, /motion-reduce:animate-none/); - - const completedTree = indicator.render({ - snapshot: createSnapshot({ - tasks: [ - { - id: "stable", - subject: "Stable task", - description: "Stable completion criteria", - status: "completed", - activeForm: "Changed again", - }, - ], - completedCount: 1, - totalCount: 1, - currentStep: 1, - state: "completed", + assert.equal(statusIcons(runningRow)[0].props.state, "running"); + + const completedRow = readIndicator( + indicator.render({ + snapshot: createSnapshot({ + tasks: [ + { + id: "stable", + subject: "Stable task", + description: "Stable completion criteria", + status: "completed", + activeForm: "Changed again", + }, + ], + completedCount: 1, + totalCount: 1, + currentStep: 1, + state: "completed", + }), }), - }); - const completedRow = findAll(completedTree, (node) => node.type === "li")[0]; - assert.equal(treeText(completedRow), "Stable task"); + ).rows[0]; + + assert.match(treeText(completedRow), /Stable task/); + assert.doesNotMatch(treeText(completedRow), /Changed again/); assert.equal(completedRow.props["data-task-status"], "completed"); assert.equal(completedRow.props["aria-current"], undefined); + assert.equal(statusIcons(completedRow)[0].props.state, "completed"); }); -test("web hover and keyboard focus expand, then collapse only after the close delay", () => { - const fakeWindow = installFakeWindow(); - const previousHTMLElement = globalThis.HTMLElement; - globalThis.HTMLElement = class TestHTMLElement { - constructor(focusVisible) { - this.focusVisible = focusVisible; - } - matches(selector) { - return selector === ":focus-visible" && this.focusVisible; - } - }; - try { - const indicator = createIndicatorHarness(); - let view = readIndicator(indicator.render()); - view.root.props.onPointerEnter({ pointerType: "mouse" }); - view = readIndicator(indicator.render()); - assert.equal(view.button.props["aria-expanded"], true); - - view.root.props.onPointerLeave({ pointerType: "mouse" }); - assert.equal(readIndicator(indicator.render()).button.props["aria-expanded"], true); - assert.equal(fakeWindow.delays.at(-1), 140); - fakeWindow.runTimers(); - assert.equal(readIndicator(indicator.render()).button.props["aria-expanded"], false); - - view = readIndicator(indicator.render()); - view.root.props.onFocusCapture({ target: new globalThis.HTMLElement(true) }); - view = readIndicator(indicator.render()); - assert.equal(view.button.props["aria-expanded"], true); - view.root.props.onBlurCapture({ - currentTarget: { contains: () => false }, - relatedTarget: null, - }); - assert.equal(readIndicator(indicator.render()).button.props["aria-expanded"], true); - assert.equal(fakeWindow.delays.at(-1), 140); - fakeWindow.runTimers(); - assert.equal(readIndicator(indicator.render()).button.props["aria-expanded"], false); - } finally { - fakeWindow.restore(); - if (previousHTMLElement === undefined) delete globalThis.HTMLElement; - else globalThis.HTMLElement = previousHTMLElement; - } -}); +test("web reflects pending, paused, and completed states in the trigger and rows", () => { + const indicator = createIndicatorHarness(); -test("web Escape closes while touch clicks toggle", () => { - const fakeWindow = installFakeWindow(); - try { - const indicator = createIndicatorHarness(); - let view = readIndicator(indicator.render()); - view.button.props.onPointerDown({ pointerType: "touch" }); - view.button.props.onClick(); - view = readIndicator(indicator.render()); - assert.equal(view.button.props["aria-expanded"], true); - - view.root.props.onKeyDown({ key: "Escape" }); - view = readIndicator(indicator.render()); - assert.equal(view.button.props["aria-expanded"], false); - - view.button.props.onPointerDown({ pointerType: "touch" }); - view.button.props.onClick(); - assert.equal(readIndicator(indicator.render()).button.props["aria-expanded"], true); - readIndicator(indicator.render()).button.props.onClick(); - assert.equal(readIndicator(indicator.render()).button.props["aria-expanded"], false); - } finally { - fakeWindow.restore(); - } -}); + const paused = readIndicator(indicator.render({ isConversationRunning: false })); + assert.equal(statusIcons(paused.trigger)[0].props.state, "paused"); + assert.equal(statusIcons(paused.rows[0])[0].props.state, "completed"); + assert.equal(statusIcons(paused.rows[1])[0].props.state, "paused"); + assert.equal(statusIcons(paused.rows[2])[0].props.state, "pending"); + assert.match(paused.progress.props["aria-label"], /Paused/); + assert.match(treeText(paused.panel), /Paused/); -test("web shows pending, paused, and completed states without auto-dismissing completion", () => { - const indicator = createIndicatorHarness(); const pending = createSnapshot({ tasks: [ { @@ -361,30 +398,23 @@ test("web shows pending, paused, and completed states without auto-dismissing co currentStep: 1, state: "pending", }); - assert.match(treeText(indicator.render({ snapshot: pending })), /Pending/); - assert.match( - treeText(indicator.render({ snapshot: pending, isConversationRunning: false })), - /Paused/, - ); + const pendingView = readIndicator(indicator.render({ snapshot: pending })); + assert.equal(statusIcons(pendingView.trigger)[0].props.state, "pending"); + assert.equal(treeText(pendingView.trigger), "Step 2 of 3"); + assert.match(treeText(pendingView.panel), /Pending/); - const completedTasks = [ - { - id: "done", - subject: "Done", - description: "Done completion criteria", - status: "completed", - activeForm: "Finishing", - }, - ]; const completed = createSnapshot({ - tasks: completedTasks, - completedCount: 1, - totalCount: 1, - currentStep: 1, + tasks: createSnapshot().tasks.map((task) => ({ ...task, status: "completed" })), + completedCount: 3, + currentStep: 3, state: "completed", }); - assert.match(treeText(indicator.render({ snapshot: completed })), /All completed/); - assert.match(treeText(indicator.render({ snapshot: completed })), /All completed/); + const completedView = readIndicator(indicator.render({ snapshot: completed })); + assert.equal(statusIcons(completedView.trigger)[0].props.state, "completed"); + // 计划跑完后药丸改用汇总文案,步进数字已无信息量。 + assert.equal(treeText(completedView.trigger), "All completed"); + assert.match(completedView.progress.props["aria-label"], /All completed/); + assert.equal(completedView.rows.length, 3); }); test("web uses the shared localized task progress bar", () => { @@ -397,6 +427,8 @@ test("web uses the shared localized task progress bar", () => { "chat.taskProgress.pending": "Pending", "chat.taskProgress.paused": "Paused", "chat.taskProgress.completed": "All completed", + "chat.taskProgress.taskPaused": "Paused", + "chat.taskProgress.taskCompleted": "Completed", }; const loader = createWebModuleLoader({ rootDir, diff --git a/crates/agent-gateway/web/test/task-progress.test.mjs b/crates/agent-gateway/web/test/task-progress.test.mjs index 0ce7c781a..966110035 100644 --- a/crates/agent-gateway/web/test/task-progress.test.mjs +++ b/crates/agent-gateway/web/test/task-progress.test.mjs @@ -90,13 +90,13 @@ test("WebUI hides all task tool blocks while preserving ordinary tools", () => { const source = readFileSync( fileURLToPath( new URL( - "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", + "../../../agent-ui/src/components/chat/assistant-bubble/assistantBubbleUtils.ts", import.meta.url, ), ), "utf8", ); - assert.match(source, /groupedBlocks\.filter\(\(block\) => !isTaskToolBlock\(block\)\)/); + assert.match(source, /return !isTaskToolBlock\(block\);/); }); test("WebUI app selects a snapshot directly without a sequencing compatibility layer", () => { diff --git a/crates/agent-gateway/web/test/thinking-overlay-model.test.mjs b/crates/agent-gateway/web/test/thinking-overlay-model.test.mjs deleted file mode 100644 index 11f70ab05..000000000 --- a/crates/agent-gateway/web/test/thinking-overlay-model.test.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import assert from "node:assert/strict"; -import fs from "node:fs"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; -import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; - -const loader = createWebModuleLoader({ rootDir: fileURLToPath(new URL("../", import.meta.url)) }); -const { resolveThinkingOverlayPlacement } = loader.loadModule( - "@liveagent/ui/lib/chat/thinkingOverlayModel.ts", -); -const componentSource = fs.readFileSync( - new URL("../../../agent-ui/src/components/chat/ThinkingActivity.tsx", import.meta.url), - "utf8", -); -const roundContentSource = fs.readFileSync( - new URL( - "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", - import.meta.url, - ), - "utf8", -); - -test("thinking overlay placement is upward and narrow-safe", () => { - const above = resolveThinkingOverlayPlacement( - { left: 200, right: 700, top: 500, bottom: 532, width: 500, height: 32 }, - { width: 1200, height: 800 }, - ); - assert.equal(above.side, "above"); - const narrow = resolveThinkingOverlayPlacement( - { left: 8, right: 312, top: 60, bottom: 92, width: 304, height: 32 }, - { width: 320, height: 480 }, - ); - assert.equal(narrow.width, 296); -}); - -test("keeps a renderable overlay inside an extremely narrow viewport", () => { - const placement = resolveThinkingOverlayPlacement( - { left: 0, right: 8, top: 60, bottom: 92, width: 8, height: 32 }, - { width: 8, height: 480 }, - ); - assert.equal(placement.left, 3.5); - assert.equal(placement.width, 1); - assert.ok(placement.left + placement.width <= 8); -}); - -test("thinking details use inline collapse instead of a portal overlay", () => { - assert.match(componentSource, /open\?: boolean;/); - assert.match(componentSource, //); - assert.match(componentSource, /userInteractedRef/); - assert.doesNotMatch(componentSource, /createPortal/); - assert.doesNotMatch(componentSource, /role="dialog"/); - assert.doesNotMatch(componentSource, /thinkingOverlayModel/); -}); - -test("WebUI transcript forwards live thinking auto-open into the shared collapse", () => { - assert.match(roundContentSource, /const autoOpenThinking = isLive \? Boolean\(isActive && thinkingOpen\) : false;/); - assert.match( - roundContentSource, - /open=\{autoOpenThinking && block\.key === latestThinkingKey\}/, - ); -}); diff --git a/crates/agent-gateway/web/test/thinking-trace.test.mjs b/crates/agent-gateway/web/test/thinking-trace.test.mjs new file mode 100644 index 000000000..aa893f34b --- /dev/null +++ b/crates/agent-gateway/web/test/thinking-trace.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; + +const readSource = (relativePath) => + fs.readFileSync(new URL(relativePath, import.meta.url), "utf8"); + +const roundContentSource = readSource( + "../../../agent-ui/src/components/chat/assistant-bubble/RoundContent.tsx", +); +const disclosureSource = readSource( + "../../../agent-ui/src/components/chat/assistant-bubble/ThinkingDisclosure.tsx", +); +const workTraceSource = readSource("../../../agent-ui/src/components/chat/AssistantWorkTrace.tsx"); +const statusSource = readSource("../../../agent-ui/src/components/chat/AssistantStatus.tsx"); +const sparkleSource = readSource("../../../agent-ui/src/components/chat/LiveSparkle.tsx"); + +test("WebUI transcript renders reasoning as expandable disclosures", () => { + assert.match(roundContentSource, / { + assert.match( + roundContentSource, + /\{running \? : null\}/, + ); + assert.match(sparkleSource, /data-live-sparkle/); + // The cluster twinkles via SMIL animations baked into the SVG itself. + assert.match(sparkleSource, //); +}); + +test("a collapsed processing trace keeps the active block visible outside it", () => { + assert.match(workTraceSource, /data-chat-work-collapsed-tail/); + assert.match( + roundContentSource, + /collapsedTail=\{collapsedTailEntry \? renderEntry\(collapsedTailEntry, true\) : null\}/, + ); +}); diff --git a/crates/agent-gateway/web/test/transcript-rows.test.mjs b/crates/agent-gateway/web/test/transcript-rows.test.mjs index 2ef8b6914..2cdd0c777 100644 --- a/crates/agent-gateway/web/test/transcript-rows.test.mjs +++ b/crates/agent-gateway/web/test/transcript-rows.test.mjs @@ -76,7 +76,7 @@ test("thinking-only rounds count as content", () => { assert.equal(rows[0].kind, "assistant"); }); -test("checkpoint and error entries flush the assistant group", () => { +test("a mid-reply checkpoint stitches both halves into one assistant row with a seam round", () => { const rows = buildRowsFromEntries( [ { id: "a-1", kind: "assistant", text: "before", round: 1 }, @@ -87,18 +87,60 @@ test("checkpoint and error entries flush the assistant group", () => { summaryId: "s1", coveredMessageCount: 3, generatedBy: { providerId: "liveagent", model: "summary" }, + contextUsageTokens: 1200, }, - { id: "a-2", kind: "assistant", text: "after", round: 1 }, + { id: "a-2", kind: "assistant", text: "after", round: 1, timestamp: 42 }, { id: "err-1", kind: "error", text: "boom" }, ], "history", ); assert.deepEqual( rows.map((row) => row.kind), - ["assistant", "checkpoint", "assistant", "error"], + ["assistant", "error"], + ); + assert.equal(rows[0].key, "ag:a-1", "the leading part keys the stitched row"); + assert.equal(rows[0].timestamp, 42, "the reply timestamp is the last part's"); + assert.deepEqual( + rows[0].rounds.map((round) => [round.key, round.blocks.length, Boolean(round.checkpoint)]), + [ + ["ag:a-1:r1", 1, false], + ["checkpoint-s1", 0, true], + ["ag:a-2:r1", 1, false], + ], + ); + assert.equal(rows[0].rounds[1].checkpoint.summaryId, "s1"); + assert.equal(rows[0].rounds[1].checkpoint.contextUsageTokens, 1200); + assert.deepEqual( + rows[0].rounds.map((round) => + round.blocks.flatMap((block) => (block.kind === "text" ? [block.text] : [])).join(""), + ), + ["before", "", "after"], + ); +}); + +test("a checkpoint that ends the chain stays a standalone card; errors still flush", () => { + const rows = buildRowsFromEntries( + [ + { id: "a-1", kind: "assistant", text: "before", round: 1 }, + { + id: "checkpoint-s1", + kind: "checkpoint", + content: "summary", + summaryId: "s1", + coveredMessageCount: 3, + generatedBy: { providerId: "liveagent", model: "summary" }, + }, + { id: "err-1", kind: "error", text: "boom" }, + { id: "a-2", kind: "assistant", text: "after", round: 1 }, + ], + "history", + ); + assert.deepEqual( + rows.map((row) => row.kind), + ["assistant", "checkpoint", "error", "assistant"], ); assert.equal(rowText(rows[0]), "before"); - assert.equal(rowText(rows[2]), "after"); + assert.equal(rowText(rows[3]), "after"); }); test("buildTurnRows emits the user bubble before any assistant content, tagged with the turn key", () => { diff --git a/crates/agent-gateway/web/test/transcript-store.test.mjs b/crates/agent-gateway/web/test/transcript-store.test.mjs index a0add3493..bfee65cb9 100644 --- a/crates/agent-gateway/web/test/transcript-store.test.mjs +++ b/crates/agent-gateway/web/test/transcript-store.test.mjs @@ -2697,3 +2697,91 @@ test("manual compaction checkpoint stays a single card after the next exchange's assertUniqueKeys(snapshot); } }); + +test("a compaction inside a streaming reply renders one assistant row with an inline seam", () => { + const { parseHistoryMessagesJson } = loader.loadModule("src/lib/chatUi.ts"); + const store = createTranscriptStore(); + + store.applyEvent(runStarted("run-1", 1)); + store.applyEvent(userMessage("run-1", 2, "do a lot", { message_id: "user-1" })); + store.applyEvent(token("run-1", 3, "first half")); + store.flush(); + const before = store.getSnapshot(); + const liveRowBefore = allRows(before).at(-1); + assert.equal(liveRowBefore.kind, "assistant"); + + // 运行中压缩:checkpoint token 落在同一 run 内,之后继续流式。 + store.applyEvent({ + type: "token", + conversation_id: "conv-1", + run_id: "run-1", + seq: 4, + text: "summary body", + provider: "liveagent", + model: "summary", + api: "liveagent-compaction", + checkpoint: { + summaryId: "sum-mid", + segmentIndex: 1, + coveredMessageCount: 9, + timestamp: 1000, + generatedBy: { providerId: "anthropic", model: "claude", promptVersion: "v1" }, + contextUsageTokens: 4321, + }, + }); + store.applyEvent(token("run-1", 5, "second half", { round: 2 })); + store.flush(); + + const mid = store.getSnapshot(); + const midRows = allRows(mid); + assert.deepEqual( + midRows.map((row) => row.kind), + ["user", "assistant"], + "no standalone checkpoint card and no second assistant row mid-run", + ); + const liveRow = midRows[1]; + assert.equal(liveRow.key, liveRowBefore.key, "the live assistant row keeps its identity"); + assert.deepEqual( + liveRow.rounds.map((round) => Boolean(round.checkpoint)), + [false, true, false], + ); + assert.equal(liveRow.rounds[1].checkpoint.summaryId, "sum-mid"); + assert.equal(liveRow.rounds[1].checkpoint.contextUsageTokens, 4321); + assertUniqueKeys(mid); + + store.applyEvent(runFinished("run-1", 6)); + store.flush(); + + // 历史刷新后的形态:assistant → summary → assistant,同样缝合成一条。 + const historyEntries = parseHistoryMessagesJson( + JSON.stringify([ + { role: "user", id: "user-1", content: "do a lot", timestamp: 500 }, + { role: "assistant", content: "first half", timestamp: 900 }, + { + role: "summary", + id: "sum-mid", + content: "summary body", + timestamp: 1000, + summaryMeta: { + coveredMessageCount: 9, + generatedBy: { providerId: "anthropic", model: "claude", promptVersion: "v1" }, + stats: { sourceMessageCount: 9, contextTokensAfter: 4321 }, + }, + }, + { role: "assistant", content: "second half", timestamp: 1100 }, + ]), + ); + store.applyHistorySnapshot(historyEntries, { mode: "replace" }); + store.flush(); + const settled = store.getSnapshot(); + const settledRows = allRows(settled); + assert.deepEqual( + settledRows.map((row) => row.kind), + ["user", "assistant"], + ); + assert.deepEqual( + settledRows[1].rounds.map((round) => Boolean(round.checkpoint)), + [false, true, false], + ); + assertUniqueKeys(settled); +}); diff --git a/crates/agent-gui/src/components/app/PaneLoadingSkeleton.tsx b/crates/agent-gui/src/components/app/PaneLoadingSkeleton.tsx index 9f7844d0b..dce3a3630 100644 --- a/crates/agent-gui/src/components/app/PaneLoadingSkeleton.tsx +++ b/crates/agent-gui/src/components/app/PaneLoadingSkeleton.tsx @@ -11,8 +11,9 @@ export function PaneLoadingSkeleton(props: PaneLoadingSkeletonProps) { return (
loadEarlierHistoryActionRef.current(conversationId), isHistorySwitching: false, - isAgentMode, showUsage: isAgentDevExecutionMode, usageContextWindow: paneContextWindow, liveTranscriptStore: getConversationLiveTranscriptStore(conversationId), diff --git a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx index 4df313e0c..253504bfc 100644 --- a/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx +++ b/crates/agent-gui/src/pages/chat/components/AssistantBubble.tsx @@ -1,8 +1,13 @@ import { AssistantAvatar } from "@liveagent/ui/components/chat/AssistantAvatar"; import { LiveAssistantStatus } from "@liveagent/ui/components/chat/AssistantStatus"; +import { AssistantWorkTrace } from "@liveagent/ui/components/chat/AssistantWorkTrace"; +import type { AssistantTurnLayoutEntry } from "@liveagent/ui/components/chat/assistant-bubble/assistantBubbleUtils"; +import { + resolveActiveThinkingEntryKey, + resolveActiveWorkEntry, +} from "@liveagent/ui/components/chat/assistant-bubble/assistantBubbleUtils"; import { RoundBlockContent } from "@liveagent/ui/components/chat/assistant-bubble/RoundContent"; import { RetryDetailsBlock } from "@liveagent/ui/components/chat/RetryDetailsBlock"; -import { UsagePanel } from "@liveagent/ui/components/chat/UsagePanel"; import type { ChatFileLink } from "@liveagent/ui/lib/chat/chatFileLinks"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { memo } from "react"; @@ -13,10 +18,8 @@ export { AssistantAvatar } from "@liveagent/ui/components/chat/AssistantAvatar"; export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { row: AssistantUnitRow; - showUsage?: boolean; - usageContextWindow?: number; - isAgentMode: boolean; isCompactionRunning: boolean; + awaitingDecision?: boolean; toolStatus: string | null; retryAttempts?: RetryAttemptRecord[]; workdir?: string; @@ -24,10 +27,8 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { }) { const { row, - showUsage, - usageContextWindow, - isAgentMode, isCompactionRunning, + awaitingDecision = false, toolStatus, retryAttempts, workdir, @@ -36,17 +37,29 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { const { unit } = row; if (unit.kind === "footer") return null; - // 只有仍在直播的状态单元才渲染转圈状态行。落定交接阶段的同一单元 - // (live:false) 若继续渲染,会在底部留下一个永远旋转的 spinner——运行早已 - // 结束,用户却以为任务还在后台跑。 - const status = - unit.kind === "status" && row.live ? ( - - ) : null; + const workEntries = unit.kind === "work-trace" ? unit.entries : []; + const activeThinkingKey = + unit.kind === "work-trace" && row.live ? resolveActiveThinkingEntryKey(workEntries) : null; + const collapsedTailEntry = + unit.kind === "work-trace" && row.live ? resolveActiveWorkEntry(workEntries) : null; + + const renderWorkEntry = (entry: AssistantTurnLayoutEntry) => ( + + ); return (
@@ -55,14 +68,7 @@ export const AssistantBubbleUnit = memo(function AssistantBubbleUnit(props: { ) : (