From 6f1a450f5a67d9d4fd83d667b5d6576a1ff9c387 Mon Sep 17 00:00:00 2001 From: GCWing Date: Mon, 28 Sep 2026 17:34:35 +0800 Subject: [PATCH 1/3] feat(flowchat): refine presentation, session drafts, and usage settings --- .../design-lab/src/i18n/componentMetadata.ts | 2 + .../design-lab/src/i18n/flowChatMessages.ts | 21 + .../apps/design-lab/src/i18n/messages.ts | 16 + .../src/i18n/subagentMotionMessages.ts | 6 +- .../src/i18n/thinkingIndicatorMessages.ts | 38 + .../src/pages/ComponentDetailPage.tsx | 16 + .../src/preview/FlowChatPreviewRegistry.tsx | 5 +- .../src/preview/FlowChatScenarios.tsx | 25 +- .../src/preview/ThinkingIndicatorPreview.css | 72 + .../src/preview/ThinkingIndicatorPreview.tsx | 37 + .../src/preview/componentPresentation.ts | 3 +- .../ui/src/brand/subagent/subagentMotion.ts | 27 +- .../brand/subagent/useSubagentAvatarMotion.ts | 27 +- .../ShimmerText/ShimmerText.meta.ts | 14 + .../ShimmerText/ShimmerText.module.css | 41 + .../components/ShimmerText/ShimmerText.tsx | 23 + .../ui/src/components/ShimmerText/index.ts | 1 + .../ThinkingIndicator.meta.ts | 15 + .../ThinkingIndicator.module.css | 27 + .../ThinkingIndicator/ThinkingIndicator.tsx | 120 ++ .../src/components/ThinkingIndicator/index.ts | 1 + .../conversation/ConversationBlocks.css | 233 +++- .../conversation/ConversationBlocks.meta.ts | 11 +- .../FileRevisionSummary.module.css | 186 +++ .../conversation/FileRevisionSummary.tsx | 64 + .../src/flow-chat/conversation/FlowGroup.tsx | 22 +- .../flow-chat/conversation/ThinkingBlock.tsx | 78 +- .../ui/src/flow-chat/conversation/index.ts | 2 +- .../conversation/useThinkingAnnotation.ts | 75 +- .../conversation/useThinkingHandoff.ts | 101 ++ .../conversation/useThinkingSuccessor.ts | 217 +++ .../AgentControlToolCard.module.css | 24 +- .../tool-cards/AgentControlToolCard.tsx | 11 +- .../tool-cards/CommandToolCard.module.css | 26 +- .../flow-chat/tool-cards/CommandToolCard.tsx | 4 +- .../tool-cards/ContextCompressionToolCard.tsx | 7 + .../tool-cards/FileOperationToolCard.tsx | 10 +- .../tool-cards/FlowChatToolCard.module.css | 67 +- .../flow-chat/tool-cards/FlowChatToolCard.tsx | 72 +- .../tool-cards/ProminentToolCards.meta.ts | 6 +- .../tool-cards/ProminentToolCards.tsx | 20 +- .../flow-chat/tool-cards/ReadFileToolCard.tsx | 16 +- .../flow-chat/tool-cards/SessionToolCards.tsx | 3 +- .../tool-cards/ToolCardInteraction.tsx | 3 +- .../ToolCardRollingNumber.module.css | 49 + .../tool-cards/ToolCardRollingNumber.tsx | 98 ++ .../tool-cards/ToolRelationRow.meta.ts | 2 +- .../flow-chat/tool-cards/ToolRelationRow.tsx | 23 +- design-system/packages/ui/src/index.ts | 2 + design-system/packages/ui/src/registry.ts | 4 + .../ui/tests/flow-chat-tool-card.test.mjs | 43 +- .../packages/ui/tests/registry.test.mjs | 2 + .../src/exec/ExecProcessPresentation.tsx | 83 +- .../src/exec/ExecRelationPresentation.tsx | 3 +- packages/flow-chat-presentation/src/scroll.ts | 12 +- .../tests/flow-group.test.tsx | 40 + .../tests/presentation.test.tsx | 38 +- .../tests/relation-row.test.tsx | 14 +- .../tests/terminal-and-replay.test.tsx | 6 +- .../sections/workspaces/WorkspaceItem.tsx | 2 +- src/web-ui/src/app/layout/AppLayout.tsx | 4 +- .../components/ArchivedSessionsConfig.tsx | 4 +- .../components/KeyboardShortcutsTab.tsx | 11 + .../app/scenes/settings/settingsRegistry.ts | 15 +- src/web-ui/src/flow_chat/_item-rhythm.scss | 5 + .../src/flow_chat/components/ChatInput.tsx | 284 ++-- .../ChatInputWorkspaceStrip.test.tsx | 54 +- .../components/ChatInputWorkspaceStrip.tsx | 30 +- .../ChatInputWorkspaceStripLayout.test.ts | 9 +- .../src/flow_chat/components/FlowToolCard.tsx | 2 + .../components/WelcomePanel.test.tsx | 149 +- .../src/flow_chat/components/WelcomePanel.tsx | 85 +- .../modern/ExploreGroupRenderer.appearance.ts | 8 + .../components/modern/ExportImageButton.tsx | 1 + .../components/modern/FileEditGroupView.tsx | 31 + .../modern/FlowGroupLifecycle.test.tsx | 45 +- .../components/modern/FlowGroupRenderer.tsx | 48 +- .../modern/FlowItemVisibility.test.tsx | 30 +- .../modern/ModernFlowChatContainer.tsx | 9 +- .../modern/VirtualMessageList.layout.test.ts | 12 +- ...rtualMessageList.session-boundary.test.tsx | 8 +- .../components/modern/VirtualMessageList.tsx | 2 +- .../modern/virtualItemHeightEstimators.ts | 21 +- .../grouping/FileEditGroupContext.ts | 8 + .../grouping/activityGrouping.test.ts | 10 +- .../src/flow_chat/grouping/browse.test.ts | 4 +- .../src/flow_chat/grouping/companions.test.ts | 9 +- .../src/flow_chat/grouping/fileEdits.test.ts | 93 ++ .../src/flow_chat/grouping/fileEdits.ts | 49 + .../src/flow_chat/grouping/groupProjection.ts | 12 +- .../src/flow_chat/grouping/lifecycle.ts | 2 +- src/web-ui/src/flow_chat/grouping/policies.ts | 39 +- .../src/flow_chat/grouping/roundGroups.ts | 5 +- src/web-ui/src/flow_chat/grouping/types.ts | 12 +- src/web-ui/src/flow_chat/hooks/useFlowChat.ts | 2 +- .../flow_chat/hooks/useMessageSender.test.tsx | 2 +- .../src/flow_chat/hooks/useMessageSender.ts | 2 +- .../hooks/useSessionModeSelection.test.tsx | 13 + .../hooks/useSessionModeSelection.ts | 6 +- .../hooks/useSessionWorkspaceSelection.ts | 52 + .../src/flow_chat/services/FlowChatManager.ts | 15 +- .../flow-chat-manager/MessageModule.test.ts | 72 + .../flow-chat-manager/MessageModule.ts | 36 + .../flow-chat-manager/PersistenceModule.ts | 4 +- .../flow-chat-manager/SessionModule.ts | 14 +- .../src/flow_chat/services/goalService.ts | 7 +- .../services/sessionDraftService.test.ts | 146 ++ .../flow_chat/services/sessionDraftService.ts | 193 +++ .../src/flow_chat/services/storeSync.test.ts | 20 + .../local/LocalSessionDriver.test.ts | 52 +- .../local/LocalSessionDriver.ts | 23 +- .../src/flow_chat/session-drivers/types.ts | 2 + .../src/flow_chat/store/FlowChatStore.ts | 3 + .../store/modernFlowChatStore.test.ts | 14 +- .../flow_chat/store/modernFlowChatStore.ts | 3 +- .../subagent-identity/SubagentAvatar.test.tsx | 57 + .../tool-cards/AgentControlToolCard.test.tsx | 35 +- .../tool-cards/AgentControlToolCard.tsx | 3 +- .../tool-cards/AgentDeleteToolCard.tsx | 2 - .../tool-cards/CanvasToolCard.test.tsx | 27 +- .../flow_chat/tool-cards/CanvasToolCard.tsx | 39 +- .../tool-cards/CodeReviewToolCard.test.tsx | 27 + .../tool-cards/CodeReviewToolCard.tsx | 52 +- .../ContextCompressionDisplay.test.tsx | 18 + .../tool-cards/ContextCompressionDisplay.tsx | 36 +- .../ExecProcessToolCardView.test.tsx | 66 +- .../tool-cards/FileOperationToolCard.test.tsx | 259 ++-- .../tool-cards/FileOperationToolCard.tsx | 249 +--- .../GenerativeWidgetToolCard.test.tsx | 56 + .../tool-cards/GenerativeWidgetToolCard.tsx | 26 +- .../tool-cards/GetFileDiffDisplay.tsx | 6 +- .../tool-cards/MCPToolDisplay.test.tsx | 8 +- .../flow_chat/tool-cards/MCPToolDisplay.tsx | 22 +- .../tool-cards/ModelThinkingDisplay.test.tsx | 312 ++++- .../tool-cards/ModelThinkingDisplay.tsx | 196 ++- .../ModelThinkingDisplay.typewriter.test.tsx | 94 +- .../tool-cards/ReadFileDisplay.test.tsx | 63 + .../tool-cards/RelationshipToolCards.test.tsx | 50 +- .../tool-cards/SessionControlToolCard.tsx | 3 +- .../tool-cards/SessionMessageToolCard.tsx | 2 +- .../flow_chat/tool-cards/TaskToolDisplay.tsx | 2 +- .../tool-cards/ViewImageToolCard.test.tsx | 46 +- .../tool-cards/ViewImageToolCard.tsx | 11 +- .../tool-cards/fileOperationDiffStats.test.ts | 32 + .../tool-cards/fileOperationDiffStats.ts | 53 + .../tool-cards/useFileOperationDiffStats.ts | 70 + .../tool-cards/useToolSessionParticipant.ts | 29 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 3 + .../chatInputSessionSubscription.test.ts | 1 + .../utils/chatInputSessionSubscription.ts | 3 + .../flow_chat/utils/modelSelectionTarget.ts | 3 +- .../src/flow_chat/utils/sessionDraft.ts | 14 + .../src/flow_chat/utils/sessionLifecycle.ts | 20 + .../src/flow_chat/utils/sessionWorktree.ts | 10 +- .../registry/defaultAppearanceRegistry.ts | 3 +- .../config/components/AcpAgentsConfig.tsx | 1 + .../components/DefaultHarnessConfig.tsx | 1 - .../config/components/EditorConfig.tsx | 6 +- .../config/components/ExternalMcpOverview.tsx | 1 + .../config/components/McpToolsConfig.tsx | 1 + .../config/components/MemorySettingsPage.tsx | 10 +- .../config/components/ModelSettingsPage.tsx | 6 +- .../config/components/QuickActionsConfig.tsx | 3 +- .../components/RuntimeSettingsPages.tsx | 1 - .../components/UsageActivityHeatmap.tsx | 154 +++ .../UsageStatisticsConfig.appearance.ts | 4 +- .../components/UsageStatisticsConfig.scss | 469 ++----- .../components/UsageStatisticsConfig.test.tsx | 463 ++----- .../components/UsageStatisticsConfig.tsx | 1202 +++++------------ .../config/components/VoiceInputConfig.tsx | 1 - .../components/WebSearchSettingsPage.tsx | 5 +- .../config/components/usageActivity.test.ts | 67 + .../config/components/usageActivity.ts | 73 + .../components/FontPreferencePanel.tsx | 1 - .../infrastructure/i18n/core/I18nService.ts | 18 + .../src/infrastructure/markdown/Markdown.scss | 47 +- .../markdown/MarkdownRenderer.test.tsx | 17 +- .../markdown/MarkdownRenderer.tsx | 9 +- .../markdown/ThinkingMarkdown.test.tsx | 86 +- .../markdown/ThinkingMarkdown.tsx | 39 +- .../markdown/thinkingPreview.ts | 153 +++ src/web-ui/src/locales/en-US/flow-chat.json | 12 + src/web-ui/src/locales/en-US/settings.json | 54 +- .../locales/en-US/settings/acp-agents.json | 7 +- .../locales/en-US/settings/agentic-tools.json | 13 +- .../locales/en-US/settings/appearance.json | 6 +- .../locales/en-US/settings/application.json | 53 +- .../locales/en-US/settings/default-model.json | 6 +- .../src/locales/en-US/settings/editor.json | 23 +- .../src/locales/en-US/settings/hooks.json | 8 +- .../src/locales/en-US/settings/mcp-tools.json | 2 +- .../src/locales/en-US/settings/mcp.json | 21 +- .../src/locales/en-US/settings/memory.json | 34 +- .../src/locales/en-US/settings/models.json | 52 +- .../locales/en-US/settings/quick-actions.json | 8 +- .../en-US/settings/review-capacity.json | 12 +- .../src/locales/en-US/settings/runtime.json | 25 +- .../src/locales/en-US/settings/usage.json | 64 +- .../locales/en-US/settings/voice-input.json | 17 +- .../locales/en-US/settings/web-search.json | 49 +- src/web-ui/src/locales/en-US/worktrees.json | 14 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 12 + src/web-ui/src/locales/zh-CN/settings.json | 54 +- .../locales/zh-CN/settings/acp-agents.json | 7 +- .../locales/zh-CN/settings/agentic-tools.json | 13 +- .../locales/zh-CN/settings/appearance.json | 6 +- .../locales/zh-CN/settings/application.json | 53 +- .../locales/zh-CN/settings/default-model.json | 6 +- .../src/locales/zh-CN/settings/editor.json | 23 +- .../src/locales/zh-CN/settings/hooks.json | 8 +- .../src/locales/zh-CN/settings/mcp-tools.json | 2 +- .../src/locales/zh-CN/settings/mcp.json | 21 +- .../src/locales/zh-CN/settings/memory.json | 34 +- .../src/locales/zh-CN/settings/models.json | 52 +- .../locales/zh-CN/settings/quick-actions.json | 8 +- .../zh-CN/settings/review-capacity.json | 12 +- .../src/locales/zh-CN/settings/runtime.json | 25 +- .../src/locales/zh-CN/settings/usage.json | 70 +- .../locales/zh-CN/settings/voice-input.json | 17 +- .../locales/zh-CN/settings/web-search.json | 49 +- src/web-ui/src/locales/zh-CN/worktrees.json | 14 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 12 + src/web-ui/src/locales/zh-TW/settings.json | 54 +- .../locales/zh-TW/settings/acp-agents.json | 7 +- .../locales/zh-TW/settings/agentic-tools.json | 13 +- .../locales/zh-TW/settings/appearance.json | 6 +- .../locales/zh-TW/settings/application.json | 53 +- .../locales/zh-TW/settings/default-model.json | 6 +- .../src/locales/zh-TW/settings/editor.json | 23 +- .../src/locales/zh-TW/settings/hooks.json | 8 +- .../src/locales/zh-TW/settings/mcp-tools.json | 2 +- .../src/locales/zh-TW/settings/mcp.json | 21 +- .../src/locales/zh-TW/settings/memory.json | 34 +- .../src/locales/zh-TW/settings/models.json | 52 +- .../locales/zh-TW/settings/quick-actions.json | 8 +- .../zh-TW/settings/review-capacity.json | 12 +- .../src/locales/zh-TW/settings/runtime.json | 25 +- .../src/locales/zh-TW/settings/usage.json | 70 +- .../locales/zh-TW/settings/voice-input.json | 17 +- .../locales/zh-TW/settings/web-search.json | 49 +- src/web-ui/src/locales/zh-TW/worktrees.json | 14 +- .../src/shared/ui/ThinkingHandoff.test.tsx | 179 +++ .../src/shared/ui/ThinkingSuccessor.test.tsx | 192 +++ .../shared/ui/ToolCardChangeSummary.test.tsx | 136 ++ 244 files changed, 7349 insertions(+), 3463 deletions(-) create mode 100644 design-system/apps/design-lab/src/i18n/thinkingIndicatorMessages.ts create mode 100644 design-system/apps/design-lab/src/preview/ThinkingIndicatorPreview.css create mode 100644 design-system/apps/design-lab/src/preview/ThinkingIndicatorPreview.tsx create mode 100644 design-system/packages/ui/src/components/ShimmerText/ShimmerText.meta.ts create mode 100644 design-system/packages/ui/src/components/ShimmerText/ShimmerText.module.css create mode 100644 design-system/packages/ui/src/components/ShimmerText/ShimmerText.tsx create mode 100644 design-system/packages/ui/src/components/ShimmerText/index.ts create mode 100644 design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.meta.ts create mode 100644 design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.module.css create mode 100644 design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.tsx create mode 100644 design-system/packages/ui/src/components/ThinkingIndicator/index.ts create mode 100644 design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.module.css create mode 100644 design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.tsx create mode 100644 design-system/packages/ui/src/flow-chat/conversation/useThinkingHandoff.ts create mode 100644 design-system/packages/ui/src/flow-chat/conversation/useThinkingSuccessor.ts create mode 100644 design-system/packages/ui/src/flow-chat/tool-cards/ToolCardRollingNumber.module.css create mode 100644 design-system/packages/ui/src/flow-chat/tool-cards/ToolCardRollingNumber.tsx create mode 100644 src/web-ui/src/flow_chat/components/modern/FileEditGroupView.tsx create mode 100644 src/web-ui/src/flow_chat/grouping/FileEditGroupContext.ts create mode 100644 src/web-ui/src/flow_chat/grouping/fileEdits.test.ts create mode 100644 src/web-ui/src/flow_chat/grouping/fileEdits.ts create mode 100644 src/web-ui/src/flow_chat/hooks/useSessionWorkspaceSelection.ts create mode 100644 src/web-ui/src/flow_chat/services/sessionDraftService.test.ts create mode 100644 src/web-ui/src/flow_chat/services/sessionDraftService.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/GenerativeWidgetToolCard.test.tsx create mode 100644 src/web-ui/src/flow_chat/tool-cards/fileOperationDiffStats.test.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/fileOperationDiffStats.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/useFileOperationDiffStats.ts create mode 100644 src/web-ui/src/flow_chat/utils/sessionDraft.ts create mode 100644 src/web-ui/src/flow_chat/utils/sessionLifecycle.ts create mode 100644 src/web-ui/src/infrastructure/config/components/UsageActivityHeatmap.tsx create mode 100644 src/web-ui/src/infrastructure/config/components/usageActivity.test.ts create mode 100644 src/web-ui/src/infrastructure/config/components/usageActivity.ts create mode 100644 src/web-ui/src/infrastructure/markdown/thinkingPreview.ts create mode 100644 src/web-ui/src/shared/ui/ThinkingHandoff.test.tsx create mode 100644 src/web-ui/src/shared/ui/ThinkingSuccessor.test.tsx create mode 100644 src/web-ui/src/shared/ui/ToolCardChangeSummary.test.tsx diff --git a/design-system/apps/design-lab/src/i18n/componentMetadata.ts b/design-system/apps/design-lab/src/i18n/componentMetadata.ts index bad37dd9f3..8aaf44405e 100644 --- a/design-system/apps/design-lab/src/i18n/componentMetadata.ts +++ b/design-system/apps/design-lab/src/i18n/componentMetadata.ts @@ -51,6 +51,7 @@ const descriptionKeys: Readonly> = { GlobSearchToolCard: "component.GlobSearchToolCard.description", GrepSearchToolCard: "component.GrepSearchToolCard.description", Icon: "component.Icon.description", + ThinkingIndicator: "component.ThinkingIndicator.description", IconButton: "component.IconButton.description", Input: "component.Input.description", KeyHint: "component.KeyHint.description", @@ -92,6 +93,7 @@ const descriptionKeys: Readonly> = { ReadFileToolCard: "component.ReadFileToolCard.description", ReviewSummaryToolCard: "component.ReviewSummaryToolCard.description", RollingText: "component.RollingText.description", + ShimmerText: "component.ShimmerText.description", RunCodeToolCard: "component.RunCodeToolCard.description", SearchField: "component.SearchField.description", SegmentedControl: "component.SegmentedControl.description", diff --git a/design-system/apps/design-lab/src/i18n/flowChatMessages.ts b/design-system/apps/design-lab/src/i18n/flowChatMessages.ts index 858caa32b8..ee8e30ed70 100644 --- a/design-system/apps/design-lab/src/i18n/flowChatMessages.ts +++ b/design-system/apps/design-lab/src/i18n/flowChatMessages.ts @@ -64,6 +64,13 @@ export const flowChatEn = { "runtimeCards.controlTitle": "Inspect capabilities", "runtimeCards.result": "Result", "flowChat.contextLoadGroup.summary": "{count} context loads", + "flowChat.fileEditGroup.summary": "{count} edits", + "flowChat.fileEditGroup.label": "{count} edits:", + "flowChat.fileEditGroup.expanded": "File edits:", + "flowChat.fileEditGroup.changes": "{additions} lines added, {deletions} lines removed", + "flowChat.fileEditGroup.description": "{path}, {count} consecutive edits", + "flowChat.fileEditGroup.revision": "Edit {count}", + "flowChat.fileEditGroup.failed": "{count} failed", "flowChat.contextLoadGroup.description": "{count} context-loading calls, including repeat loads", "component.FlowGroup.description": "A reusable collection with shared disclosure, scrolling and arrival feedback.", "component.ContextLoadGroup.description": "Context loads with controlled disclosure for skills, tool specifications and capability discovery.", @@ -204,6 +211,13 @@ export const flowChatZhCN = { "runtimeCards.controlTitle": "查询控制能力", "runtimeCards.result": "执行结果", "flowChat.contextLoadGroup.summary": "{count} 次上下文加载", + "flowChat.fileEditGroup.summary": "{count} 次编辑", + "flowChat.fileEditGroup.label": "{count} 次编辑:", + "flowChat.fileEditGroup.expanded": "文件编辑:", + "flowChat.fileEditGroup.changes": "新增 {additions} 行,删除 {deletions} 行", + "flowChat.fileEditGroup.description": "{path},连续编辑 {count} 次", + "flowChat.fileEditGroup.revision": "第 {count} 次编辑", + "flowChat.fileEditGroup.failed": "{count} 次失败", "flowChat.contextLoadGroup.description": "{count} 次上下文加载,含重复加载", "component.FlowGroup.description": "可复用的收纳组,统一展开收起、内容滚动和接收反馈。", "component.ContextLoadGroup.description": "上下文加载组,收纳技能、工具说明与能力查询,统一展开收起。", @@ -344,6 +358,13 @@ export const flowChatZhTW = { "runtimeCards.controlTitle": "查詢控制能力", "runtimeCards.result": "執行結果", "flowChat.contextLoadGroup.summary": "{count} 次上下文載入", + "flowChat.fileEditGroup.summary": "{count} 次編輯", + "flowChat.fileEditGroup.label": "{count} 次編輯:", + "flowChat.fileEditGroup.expanded": "檔案編輯:", + "flowChat.fileEditGroup.changes": "新增 {additions} 行,刪除 {deletions} 行", + "flowChat.fileEditGroup.description": "{path},連續編輯 {count} 次", + "flowChat.fileEditGroup.revision": "第 {count} 次編輯", + "flowChat.fileEditGroup.failed": "{count} 次失敗", "flowChat.contextLoadGroup.description": "{count} 次上下文載入,含重複載入", "component.FlowGroup.description": "可重用的收納組,統一展開收合、內容捲動與接收回饋。", "component.ContextLoadGroup.description": "上下文載入組,收納技能、工具說明與能力查詢,統一展開收合。", diff --git a/design-system/apps/design-lab/src/i18n/messages.ts b/design-system/apps/design-lab/src/i18n/messages.ts index 59520e22c7..45f7795cc5 100644 --- a/design-system/apps/design-lab/src/i18n/messages.ts +++ b/design-system/apps/design-lab/src/i18n/messages.ts @@ -4,6 +4,7 @@ import { brandAssetsEn, brandAssetsZhCN, brandAssetsZhTW } from "./brandMessages import { designEn, designZhCN, designZhTW } from "./designMessages"; import { flowChatEn, flowChatZhCN, flowChatZhTW } from "./flowChatMessages"; import { subagentMotionEn, subagentMotionZhCN, subagentMotionZhTW } from "./subagentMotionMessages"; +import { thinkingIndicatorEn, thinkingIndicatorZhCN, thinkingIndicatorZhTW } from "./thinkingIndicatorMessages"; import type { DesignLabLocale } from "./core.mjs"; export const enUSMessages = { @@ -12,6 +13,7 @@ export const enUSMessages = { ...designEn, ...brandAssetsEn, ...subagentMotionEn, + ...thinkingIndicatorEn, ...flowChatEn, "component.OpenBitFunSolidMark.description": "The original silver solid mark on a transparent canvas, preserving its material and shading.", "component.OpenBitFunAppIcon.description": "The application icon used by the installed product: silver mark, black rounded-square tile, and assets for different display sizes.", @@ -184,6 +186,7 @@ export const enUSMessages = { "component.Switch.description": "Toggles a single setting between on and off with native checkbox semantics.", "component.TabGroup.description": "A compact single-selection tab list with optional leading icons, end actions, and automatic keyboard activation.", "component.RollingText.description": "Vertical text replacement with coordinated width, interruption handling, and reduced-motion support. Try repeated clicks in the replacement specimen.", + "component.ShimmerText.description": "A soft fade moves slowly across activity text, keeping every glyph visible. Reduced motion, high contrast and print keep the text static and readable.", "components.preview.replaceText": "Replace title", "components.preview.rollingTextLong": "Review workspace navigation and session switching", "detail.option.replacing": "Replacement", @@ -596,6 +599,9 @@ export const enUSMessages = { "detail.option.display": "Display", "detail.option.completed": "Completed", "detail.option.expanded": "Expanded", + "detail.option.file-collapsed": "File revisions, collapsed", + "detail.option.file-expanded": "File revisions, expanded", + "detail.option.file-error": "File revisions, failed edit", "detail.option.loading": "Loading", "detail.option.multiple": "Multiple selection", "detail.option.custom": "Custom value", @@ -811,6 +817,7 @@ export const zhCNMessages = { ...designZhCN, ...brandAssetsZhCN, ...subagentMotionZhCN, + ...thinkingIndicatorZhCN, ...flowChatZhCN, "component.OpenBitFunSolidMark.description": "透明底的银色实体标志,保留原稿的材质、光泽与立体明暗。", "component.OpenBitFunAppIcon.description": "实际应用使用的图标:银色实体标志、黑色圆角底板,以及适配不同显示尺寸的资源。", @@ -983,6 +990,7 @@ export const zhCNMessages = { "component.Switch.description": "通过原生复选框语义在开启与关闭之间切换单项设置。", "component.TabGroup.description": "紧凑的单选标签组,支持前置图标、尾部操作与方向键自动切换。", "component.RollingText.description": "垂直滚动替换文本,宽度同步过渡,支持连续切换与减少动态效果。可在替换示例中连续点击体验。", + "component.ShimmerText.description": "柔和的渐隐在运行文字间缓慢流动,始终保留完整字形;减少动态效果、高对比度和打印时保持静态可读。", "components.preview.replaceText": "替换标题", "components.preview.rollingTextLong": "检查工作区导航与会话切换时的交互行为", "detail.option.replacing": "替换", @@ -1395,6 +1403,9 @@ export const zhCNMessages = { "detail.option.display": "展示", "detail.option.completed": "已完成", "detail.option.expanded": "展开", + "detail.option.file-collapsed": "文件修订 · 折叠", + "detail.option.file-expanded": "文件修订 · 展开", + "detail.option.file-error": "文件修订 · 编辑失败", "detail.option.loading": "加载中", "detail.option.multiple": "多选", "detail.option.custom": "自定义值", @@ -1608,6 +1619,7 @@ export const zhTWMessages = { ...previewZhTW, ...brandAssetsZhTW, ...subagentMotionZhTW, + ...thinkingIndicatorZhTW, ...flowChatZhTW, "patterns.description": "查看元件在常見介面中的組合與互動。", "patterns.title": "組合範例", @@ -1822,6 +1834,7 @@ export const zhTWMessages = { "component.Switch.description": "以原生核取方塊語意在開啟與關閉之間切換單一設定。", "component.TabGroup.description": "緊湊的單選標籤群組,支援前置圖示、尾端操作與方向鍵自動切換。", "component.RollingText.description": "垂直捲動替換文字,寬度同步過渡,支援連續切換與減少動態效果。可在替換範例中連續點擊體驗。", + "component.ShimmerText.description": "柔和的漸隱在執行文字間緩慢流動,始終保留完整字形;減少動態效果、高對比度和列印時保持靜態可讀。", "components.preview.replaceText": "替換標題", "components.preview.rollingTextLong": "檢查工作區導覽與工作階段切換時的互動行為", "detail.option.replacing": "替換", @@ -2136,6 +2149,9 @@ export const zhTWMessages = { "detail.option.display": "展示", "detail.option.completed": "已完成", "detail.option.expanded": "展開", + "detail.option.file-collapsed": "檔案修訂 · 收合", + "detail.option.file-expanded": "檔案修訂 · 展開", + "detail.option.file-error": "檔案修訂 · 編輯失敗", "detail.option.loading": "載入中", "detail.option.multiple": "多選", "detail.option.custom": "自訂值", diff --git a/design-system/apps/design-lab/src/i18n/subagentMotionMessages.ts b/design-system/apps/design-lab/src/i18n/subagentMotionMessages.ts index 33fca41a16..0248a23298 100644 --- a/design-system/apps/design-lab/src/i18n/subagentMotionMessages.ts +++ b/design-system/apps/design-lab/src/i18n/subagentMotionMessages.ts @@ -50,7 +50,7 @@ export const subagentMotionEn = { "subagentMotion.nod.description": "One small nod acknowledges taking the task. The body moves first; the joystick follows and settles.", "subagentMotion.working.label": "Focused work", "subagentMotion.working.hint": "A measured rhythm", - "subagentMotion.working.description": "The body stays grounded while the joystick moves gently, with pauses between movements. Loops while working.", + "subagentMotion.working.description": "The eyes lead each small lean, followed by the joystick. A grounded two-beat rhythm with brief pauses repeats while working.", "subagentMotion.waiting.label": "Wait for you", "subagentMotion.waiting.hint": "Look up, then hold", "subagentMotion.waiting.description": "The character lifts its gaze once, then holds still while waiting for a response.", @@ -122,7 +122,7 @@ export const subagentMotionZhCN = { "subagentMotion.nod.description": "身体先轻轻点头,顶部摇杆延迟跟随,再回到原位。用于任务实际被接手之后。", "subagentMotion.working.label": "专注工作", "subagentMotion.working.hint": "有停顿的工作节奏", - "subagentMotion.working.description": "身体保持稳定,顶部摇杆小幅摆动,动作之间留出停顿。工作期间轻缓循环。", + "subagentMotion.working.description": "视线先偏移,身体轻轻侧倾,顶部摇杆随后跟上。底部保持稳定,以两拍动作和短暂停顿循环表达正在工作。", "subagentMotion.waiting.label": "等待回应", "subagentMotion.waiting.hint": "抬眼,安静等你", "subagentMotion.waiting.description": "抬起视线看向你,只提示一次,然后保持注视,等待确认或补充信息。", @@ -192,7 +192,7 @@ export const subagentMotionZhTW = { "subagentMotion.nod.description": "身體先輕輕點頭,頂部搖桿延遲跟隨,再回到原位。用於任務實際被接手之後。", "subagentMotion.working.label": "專注工作", "subagentMotion.working.hint": "有停頓的工作節奏", - "subagentMotion.working.description": "身體保持穩定,頂部搖桿小幅擺動,動作之間留出停頓。工作期間輕緩循環。", + "subagentMotion.working.description": "視線先偏移,身體輕輕側傾,頂部搖桿隨後跟上。底部保持穩定,以兩拍動作和短暫停頓循環表達正在工作。", "subagentMotion.waiting.label": "等待回應", "subagentMotion.waiting.hint": "抬眼,安靜等你", "subagentMotion.waiting.description": "抬起視線看向你,只提示一次,然後保持注視,等待確認或補充資訊。", diff --git a/design-system/apps/design-lab/src/i18n/thinkingIndicatorMessages.ts b/design-system/apps/design-lab/src/i18n/thinkingIndicatorMessages.ts new file mode 100644 index 0000000000..9bd01d6ecb --- /dev/null +++ b/design-system/apps/design-lab/src/i18n/thinkingIndicatorMessages.ts @@ -0,0 +1,38 @@ +export const thinkingIndicatorEn = { + "component.ThinkingIndicator.description": "The original nodes and lines gradually form the mark, hold its complete shape, then dissolve before forming again.", + "thinkingIndicator.title": "Form, dissolve, form again", + "thinkingIndicator.route": "Nodes appear as lines unfold downwards. The complete mark holds, then retracts from bottom to top.", + "thinkingIndicator.detail": "Enlarged detail · 2.2-second loop", + "thinkingIndicator.sizes": "Actual icon sizes", + "thinkingIndicator.static": "Static", + "thinkingIndicator.stop": "Show static icon", + "thinkingIndicator.play": "Play loop", + "thinkingIndicator.label": "Thinking", + "thinkingIndicator.note": "Lines stop at node boundaries. Reduced motion keeps the complete static mark.", +} as const; + +export const thinkingIndicatorZhCN = { + "component.ThinkingIndicator.description": "原有节点和连线逐步形成图标,完整停留后逐步消散,再重新形成。", + "thinkingIndicator.title": "形成、消散、再形成", + "thinkingIndicator.route": "节点依次出现,连线向下展开;完整停留后,从底部逐步收回。", + "thinkingIndicator.detail": "放大细节 · 2.2 秒循环", + "thinkingIndicator.sizes": "实际图标尺寸", + "thinkingIndicator.static": "静态", + "thinkingIndicator.stop": "切换为静态", + "thinkingIndicator.play": "播放循环", + "thinkingIndicator.label": "正在思考", + "thinkingIndicator.note": "连线止于节点边缘;开启减少动态效果后,保留完整的静态图标。", +} satisfies Record; + +export const thinkingIndicatorZhTW = { + "component.ThinkingIndicator.description": "原有節點和連線逐步形成圖示,完整停留後逐步消散,再重新形成。", + "thinkingIndicator.title": "形成、消散、再形成", + "thinkingIndicator.route": "節點依次出現,連線向下展開;完整停留後,從底部逐步收回。", + "thinkingIndicator.detail": "放大細節 · 2.2 秒循環", + "thinkingIndicator.sizes": "實際圖示尺寸", + "thinkingIndicator.static": "靜態", + "thinkingIndicator.stop": "切換為靜態", + "thinkingIndicator.play": "播放循環", + "thinkingIndicator.label": "正在思考", + "thinkingIndicator.note": "連線止於節點邊緣;開啟減少動態效果後,保留完整的靜態圖示。", +} satisfies Record; diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index 10f9635a99..c926d48961 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -72,6 +72,7 @@ import { Select, Sheet, StatusPill, + ShimmerText, Spinner, Switch, TabGroup, @@ -128,6 +129,7 @@ import type { ComponentMeta } from "@openbitfun/ui/registry"; import previewImage from "../assets/design-system-hero.webp"; import { IconCompositionPreview } from "../preview/IconCompositionPreview"; import { RollingTextPreview } from "../preview/RollingTextPreview"; +import { ThinkingIndicatorPreview } from "../preview/ThinkingIndicatorPreview"; import { NestedMenuPattern } from "./ReferencePatterns"; import { useI18n, type MessageKey } from "../i18n"; import { @@ -180,6 +182,7 @@ const optionLabelKeys: Readonly> = { summary: "detail.option.summary", visible: "detail.option.visible", active: "detail.option.active", + static: "thinkingIndicator.static", "active-option": "detail.option.active-option", always: "detail.option.always", asking: "detail.option.asking", @@ -440,6 +443,12 @@ export function ComponentDetailPage({ const inspectorStates = states; const codeSample = useMemo(() => { + if (component.name === "ThinkingIndicator") { + return `import { ThinkingIndicator } from "@openbitfun/ui";\n\n\n`; + } + if (component.name === "ShimmerText") { + return `import { ShimmerText } from "@openbitfun/ui";\n\n${t("components.preview.flowChat.running")}\n`; + } if (component.name === "RollingText") { return 'import { RollingText, TabGroup } from "@openbitfun/ui";\n\n// Keep the identity stable for title edits; change it when replacing the resource.\n{record.title}\n\n// TabGroup owns the text slot and composes RollingText without nested clipping.\n\n'; } @@ -891,6 +900,9 @@ export function ComponentDetailPage({ if (component.name === "Icon") { return ; } + if (component.name === "ThinkingIndicator") { + return ; + } if (component.name === "MobileButton") { return {t("components.preview.actionCardTitle")}; @@ -1873,6 +1885,10 @@ export function ComponentDetailPage({ return ; } + if (component.name === "ShimmerText") { + return {t("components.preview.flowChat.running")}; + } + if (component.name === "TabGroup") { return ; } diff --git a/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx b/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx index a7b854c954..9bcea133bd 100644 --- a/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx +++ b/design-system/apps/design-lab/src/preview/FlowChatPreviewRegistry.tsx @@ -148,7 +148,7 @@ function RelationPreview({ state, interactive }: PreviewProps) { ? t('toolCards.interaction.sendingMessage') : t('toolCards.interaction.messageSent'); return ; } @@ -768,7 +768,8 @@ function ConcreteProminentPreview({ agentName="reviewer" onOpenAgent={interactive ? () => undefined : undefined} openAgentLabel="Open agent" - statusLabel={state === "error" ? t("components.preview.flowChat.failed") : undefined} + statusLabel={state === "error" ? t("components.preview.flowChat.failed") + : state === "loading" ? t("components.preview.flowChat.running") : undefined} statusTone={state === "error" ? "danger" : "neutral"} summary="Review the shared FlowChat card boundary" /> diff --git a/design-system/apps/design-lab/src/preview/FlowChatScenarios.tsx b/design-system/apps/design-lab/src/preview/FlowChatScenarios.tsx index c197f4393a..88405913c8 100644 --- a/design-system/apps/design-lab/src/preview/FlowChatScenarios.tsx +++ b/design-system/apps/design-lab/src/preview/FlowChatScenarios.tsx @@ -1,7 +1,7 @@ import { useMemo, useRef, useState } from 'react'; import { Button, Select } from '@openbitfun/ui'; import { - FlowGroup, ContextLoadGroup, ExploreGroup, FlowChatRuntimeStatus, GetToolSpecToolCard, ReadFileToolCard, SkillToolCard, ThinkingBlock, ToolDuration, + FlowGroup, FileOperationToolCard, ContextLoadGroup, ExploreGroup, FlowChatRuntimeStatus, GetToolSpecToolCard, ReadFileToolCard, SkillToolCard, ThinkingBlock, ToolDuration, ToolCardActions, ToolCardCopyButton, } from '@openbitfun/ui/flow-chat'; import { themes, themeCssVariables, type ThemeTokenName } from '@openbitfun/theme-openbitfun'; @@ -166,9 +166,30 @@ export function FlowChatScenarios() { /** Public collection anatomy using the same localized presenter and native cards. */ export function FlowGroupPreview({ state, interactive = true }: { state: string; interactive?: boolean }) { - const [expanded, setExpanded] = useState(state === 'expanded'); + const [expanded, setExpanded] = useState(state === 'expanded' || state === 'file-expanded' || state === 'file-error'); const t = usePresentationTranslate(); const formatNumber = usePresentationFormatNumber(); + if (state.startsWith('file-')) { + const path = 'src/components/Editor.tsx'; + const summary = t('fileEditGroup.summary', { count: formatNumber(3) }); + const changes = (add: number, remove: number) => { + const additions = formatNumber(add); + const deletions = formatNumber(remove); + return { additions, deletions, label: t('fileEditGroup.changes', { additions, deletions }) }; + }; + return + {[1, 2, 3].map(revision => )} + ; + } return diff --git a/design-system/apps/design-lab/src/preview/ThinkingIndicatorPreview.css b/design-system/apps/design-lab/src/preview/ThinkingIndicatorPreview.css new file mode 100644 index 0000000000..83cb6f4c02 --- /dev/null +++ b/design-system/apps/design-lab/src/preview/ThinkingIndicatorPreview.css @@ -0,0 +1,72 @@ +.thinking-indicator-preview { + display: grid; + inline-size: min(100%, 28rem); + gap: var(--openbitfun-space-5); + padding: var(--openbitfun-space-4); + color: var(--openbitfun-color-content-secondary); +} + +.thinking-indicator-preview__heading, +.thinking-indicator-preview__sizes { + display: grid; + gap: var(--openbitfun-space-2); +} + +.thinking-indicator-preview__heading strong, +.thinking-indicator-preview__sizes strong { + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-body-sm-font-family); + font-size: var(--openbitfun-type-body-sm-font-size); + font-weight: var(--openbitfun-type-body-sm-font-weight); + line-height: var(--openbitfun-type-body-sm-line-height); +} + +.thinking-indicator-preview__heading p, +.thinking-indicator-preview__note { + margin: 0; + font-family: var(--openbitfun-type-support-font-family); + font-size: var(--openbitfun-type-support-font-size); + font-weight: var(--openbitfun-type-support-font-weight); + line-height: var(--openbitfun-type-support-line-height); +} + +.thinking-indicator-preview__detail { + display: grid; + justify-items: center; + gap: var(--openbitfun-space-4); + padding-block: var(--openbitfun-space-5); + color: var(--openbitfun-color-content-primary); +} + +.thinking-indicator-preview__enlarged { + display: grid; + place-items: center; + inline-size: 6rem; + block-size: 6rem; +} + +.thinking-indicator-preview__enlarged > [data-openbitfun-component="thinking-indicator"] { transform: scale(3); } +.thinking-indicator-preview__controls { display: flex; justify-content: center; } + +.thinking-indicator-preview__size-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--openbitfun-space-3); +} + +.thinking-indicator-preview__specimen { + display: grid; + grid-template-rows: var(--openbitfun-control-icon-size-lg) auto; + place-items: center; + gap: var(--openbitfun-space-2); +} + +.thinking-indicator-preview__detail > span, +.thinking-indicator-preview__specimen > span:last-child { + color: var(--openbitfun-color-content-muted); + font-family: var(--openbitfun-type-support-font-family); + font-size: var(--openbitfun-type-support-font-size); + font-weight: var(--openbitfun-type-support-font-weight); + line-height: var(--openbitfun-type-support-line-height); +} diff --git a/design-system/apps/design-lab/src/preview/ThinkingIndicatorPreview.tsx b/design-system/apps/design-lab/src/preview/ThinkingIndicatorPreview.tsx new file mode 100644 index 0000000000..6fe667da61 --- /dev/null +++ b/design-system/apps/design-lab/src/preview/ThinkingIndicatorPreview.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import { Button, ThinkingIndicator } from "@openbitfun/ui"; +import { useI18n } from "../i18n"; +import "./ThinkingIndicatorPreview.css"; + +/** Live public component specimens; the controls only set its active property. */ +export function ThinkingIndicatorPreview({ state }: { state: string }) { + const { t } = useI18n(); + const [active, setActive] = useState(state !== "static"); + return
+
+ {t("thinkingIndicator.title")} +

{t("thinkingIndicator.route")}

+
+
+
+ +
+ {t("thinkingIndicator.detail")} +
+
+ +
+
+ {t("thinkingIndicator.sizes")} +
+ {(["2xs", "xs", "sm", "md", "lg"] as const).map(size =>
+ + {size} +
)} +
+
+

{t("thinkingIndicator.note")}

+
; +} diff --git a/design-system/apps/design-lab/src/preview/componentPresentation.ts b/design-system/apps/design-lab/src/preview/componentPresentation.ts index 064e42f154..4172487780 100644 --- a/design-system/apps/design-lab/src/preview/componentPresentation.ts +++ b/design-system/apps/design-lab/src/preview/componentPresentation.ts @@ -19,7 +19,8 @@ const presentations: Record = { Disclosure: "composition", Composer: "composition", Listbox: "composition", Menu: "composition", NavigationPanel: "composition", PageHeader: "composition", ScrollArea: "composition", SegmentedControl: "composition", TabGroup: "composition", - Icon: "icons", RollingText: "motion", VoiceCallPanel: "motion", + Icon: "icons", RollingText: "motion", ShimmerText: "motion", VoiceCallPanel: "motion", + ThinkingIndicator: "motion", MobileBadge: "compact", MobileButton: "compact", MobileIconButton: "compact", MobileFileButton: "compact", MobileLink: "compact", MobileTextField: "fields", MobileTextarea: "fields", diff --git a/design-system/packages/ui/src/brand/subagent/subagentMotion.ts b/design-system/packages/ui/src/brand/subagent/subagentMotion.ts index 1b546d2b66..cf60336aa4 100644 --- a/design-system/packages/ui/src/brand/subagent/subagentMotion.ts +++ b/design-system/packages/ui/src/brand/subagent/subagentMotion.ts @@ -61,12 +61,29 @@ export const subagentMotionClips: Record = { }, }, working: { - duration: 3000, loop: true, + duration: 1600, loop: true, tracks: { - eyes: [frame(0), frame(0.78), frame(0.81, "scaleY(0.08)"), frame(0.85, "scaleY(0.08)"), frame(0.89), frame(1)], - body: [frame(0), frame(0.25, "rotate(1.3deg)"), frame(0.45, "rotate(1.3deg)"), frame(0.72, "rotate(-1.1deg)"), frame(0.84, "rotate(-1.1deg)"), frame(1)], - lever: [frame(0), frame(0.32, "rotate(7deg)"), frame(0.45, "rotate(7deg)"), frame(0.78, "rotate(-6deg)"), frame(0.88, "rotate(-6deg)"), frame(1)], - face: [frame(0), frame(0.25, "translate(2px, 1px)"), frame(0.45, "translate(2px, 1px)"), frame(0.74, "translate(-2px, 1px)"), frame(1)], + // Two grounded beats: eyes lead, the body leans, then the joystick follows. + // Authored SVG units scale down by about 6x in a 40px card avatar. + body: [ + frame(0), frame(0.2, "rotate(3deg) scale(1.018, 0.97)"), + frame(0.3, "rotate(3deg) scale(1.018, 0.97)"), frame(0.46), + frame(0.64, "rotate(-3deg) scale(1.018, 0.97)"), + frame(0.74, "rotate(-3deg) scale(1.018, 0.97)"), frame(0.94), frame(1), + ], + lever: [ + frame(0), frame(0.25, "translateY(3px) rotate(14deg)"), + frame(0.33, "translateY(3px) rotate(14deg)"), frame(0.5), + frame(0.69, "translateY(3px) rotate(-14deg)"), + frame(0.77, "translateY(3px) rotate(-14deg)"), frame(0.98), frame(1), + ], + face: [ + frame(0), frame(0.16, "translate(5px, 2px)"), + frame(0.28, "translate(5px, 2px)"), frame(0.44), + frame(0.6, "translate(-5px, 2px)"), + frame(0.72, "translate(-5px, 2px)"), frame(0.92), frame(1), + ], + eyes: [frame(0), frame(0.8), frame(0.84, "scaleY(0.08)"), frame(0.87, "scaleY(0.08)"), frame(0.91), frame(1)], }, }, waiting: { diff --git a/design-system/packages/ui/src/brand/subagent/useSubagentAvatarMotion.ts b/design-system/packages/ui/src/brand/subagent/useSubagentAvatarMotion.ts index 98cf35bfdc..cedfaa007b 100644 --- a/design-system/packages/ui/src/brand/subagent/useSubagentAvatarMotion.ts +++ b/design-system/packages/ui/src/brand/subagent/useSubagentAvatarMotion.ts @@ -2,12 +2,13 @@ import { useEffect, useRef, type RefObject } from 'react'; import { createSubagentMotionPlayer, type SubagentMotionPlayer } from './subagentMotionPlayer'; import { pressHoldClip, pressReleaseClip, subagentMotionClips } from './subagentMotion'; +const isWorking = (status: string) => status === 'running' || status === 'finishing'; + /** Animate authored avatar parts, never the measured card or its text. */ export function useSubagentAvatarMotion(ref: RefObject, status: string, enabled: boolean, identity?: string) { const playerRef = useRef(); const statusRef = useRef(status); const previousStatus = useRef(status); - const hoveringRef = useRef(false); statusRef.current = status; useEffect(() => { @@ -23,12 +24,15 @@ export function useSubagentAvatarMotion(ref: RefObject, status: str let hovering = false; const rest = () => { if (pressing) return; + if (isWorking(statusRef.current)) { + player.play(subagentMotionClips.working, { notify: false }); + return; + } if (hovering) { player.play(subagentMotionClips.hoverBlink, { notify: false }); return; } - const working = statusRef.current === 'running' || statusRef.current === 'finishing'; - player.play(working ? subagentMotionClips.working : subagentMotionClips.settle, { notify: false }); + player.play(subagentMotionClips.settle, { notify: false }); }; const player = createSubagentMotionPlayer(host, rest, '[data-subagent-motion-art] svg'); playerRef.current = player; @@ -47,14 +51,17 @@ export function useSubagentAvatarMotion(ref: RefObject, status: str reduced?.addEventListener('change', sync); forced?.addEventListener('change', sync); const trigger = host.closest('[data-agent-capsule-trigger]'); - const hover = () => { hovering = true; hoveringRef.current = true; if (active() && !pressing) rest(); }; + const hover = () => { + hovering = true; + // Pointer and focus changes must not replace or restart a working loop. + if (active() && !pressing && !isWorking(statusRef.current)) rest(); + }; const press = () => { if (active()) { pressing = true; player.play(pressHoldClip, { notify: false }); } }; const release = () => { if (pressing) { pressing = false; if (active()) player.play({ ...pressReleaseClip, duration: 300 }); } }; const leave = () => { hovering = false; - hoveringRef.current = false; if (pressing) release(); - else if (active()) rest(); + else if (active() && !isWorking(statusRef.current)) rest(); }; const click = () => { pressing = false; @@ -89,7 +96,6 @@ export function useSubagentAvatarMotion(ref: RefObject, status: str view.removeEventListener('pointerup', release); player.dispose(); playerRef.current = undefined; - hoveringRef.current = false; }; }, [enabled, identity, ref]); @@ -98,10 +104,11 @@ export function useSubagentAvatarMotion(ref: RefObject, status: str previousStatus.current = status; if (before === status) return; const player = playerRef.current; - if (!player || hoveringRef.current) return; - const working = status === 'running' || status === 'finishing'; + if (!player) return; + const working = isWorking(status); + if (working && isWorking(before)) return; const clip = working ? subagentMotionClips.working - : status === 'completed' && (before === 'running' || before === 'finishing') + : status === 'completed' && isWorking(before) ? { ...subagentMotionClips.success, duration: 520 } : status === 'waiting' ? subagentMotionClips.waiting : status === 'error' ? subagentMotionClips.blocked diff --git a/design-system/packages/ui/src/components/ShimmerText/ShimmerText.meta.ts b/design-system/packages/ui/src/components/ShimmerText/ShimmerText.meta.ts new file mode 100644 index 0000000000..0c2f379b83 --- /dev/null +++ b/design-system/packages/ui/src/components/ShimmerText/ShimmerText.meta.ts @@ -0,0 +1,14 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const shimmerTextMeta = { + category: "primitive", + description: "A slow, softly feathered fade across activity text that keeps every glyph visible, with static fallbacks for reduced motion, high contrast and print.", + maturity: "stable", + name: "ShimmerText", + props: [ + { name: "children", type: "ReactNode" }, + { name: "active", type: "boolean", defaultValue: "true" }, + ], + states: ["default", "static"], + tokens: ["color.content.primary", "motion.duration.loop"], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/ShimmerText/ShimmerText.module.css b/design-system/packages/ui/src/components/ShimmerText/ShimmerText.module.css new file mode 100644 index 0000000000..d4bc52d3ef --- /dev/null +++ b/design-system/packages/ui/src/components/ShimmerText/ShimmerText.module.css @@ -0,0 +1,41 @@ +@layer openbitfun.components { + .root { + display: inline-block; + color: var(--openbitfun-color-content-primary); + } + + /* Keep ordinary text as the fallback for print, reduced motion and high contrast. */ + @media screen and (prefers-reduced-motion: no-preference) and (forced-colors: none) { + @supports ((mask-image: linear-gradient(currentColor, currentColor)) or (-webkit-mask-image: linear-gradient(currentColor, currentColor))) and (color: color-mix(in srgb, currentColor 40%, transparent)) { + .root[data-active="true"] { + /* One alpha mask also covers composed text and its overflow viewports, + retaining each label's color and a readable 40% opacity floor. */ + --_shimmer-text-mask: linear-gradient( + 90deg, + currentColor 35%, + color-mix(in srgb, currentColor 90%, transparent) 40%, + color-mix(in srgb, currentColor 55%, transparent) 46%, + color-mix(in srgb, currentColor 40%, transparent) 50%, + color-mix(in srgb, currentColor 55%, transparent) 54%, + color-mix(in srgb, currentColor 90%, transparent) 60%, + currentColor 65% + ); + -webkit-mask-image: var(--_shimmer-text-mask); + mask-image: var(--_shimmer-text-mask); + -webkit-mask-size: 300% 100%; + mask-size: 300% 100%; + -webkit-mask-position: 100% 0; + mask-position: 100% 0; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + animation: openbitfun-text-shimmer calc(var(--openbitfun-motion-duration-loop) * 4) linear infinite; + } + } + } + + @keyframes openbitfun-text-shimmer { + /* The band rests outside the text at both ends, making the loop seam invisible. */ + from, 6% { -webkit-mask-position: 100% 0; mask-position: 100% 0; } + 92%, to { -webkit-mask-position: 0% 0; mask-position: 0% 0; } + } +} diff --git a/design-system/packages/ui/src/components/ShimmerText/ShimmerText.tsx b/design-system/packages/ui/src/components/ShimmerText/ShimmerText.tsx new file mode 100644 index 0000000000..610553f8ef --- /dev/null +++ b/design-system/packages/ui/src/components/ShimmerText/ShimmerText.tsx @@ -0,0 +1,23 @@ +import type { HTMLAttributes } from "react"; +import { classNames } from "../../internal/classNames"; +import styles from "./ShimmerText.module.css"; + +export interface ShimmerTextProps extends HTMLAttributes { + /** Animate while work is active; false keeps the same readable text. */ + active?: boolean; +} + +/** A slow, softly feathered fade for short, host-provided activity labels. */ +export function ShimmerText({ active = true, children, className, ...props }: ShimmerTextProps) { + return ( + + {children} + + ); +} diff --git a/design-system/packages/ui/src/components/ShimmerText/index.ts b/design-system/packages/ui/src/components/ShimmerText/index.ts new file mode 100644 index 0000000000..ecbdd6a19c --- /dev/null +++ b/design-system/packages/ui/src/components/ShimmerText/index.ts @@ -0,0 +1 @@ +export { ShimmerText, type ShimmerTextProps } from "./ShimmerText"; diff --git a/design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.meta.ts b/design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.meta.ts new file mode 100644 index 0000000000..f9f0d4c28e --- /dev/null +++ b/design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.meta.ts @@ -0,0 +1,15 @@ +import type { ComponentMeta } from "../../registry.types"; + +export const thinkingIndicatorMeta = { + category: "feedback", + description: "The thinking mark forms node by node, holds its complete shape, then dissolves in reverse order.", + maturity: "stable", + name: "ThinkingIndicator", + props: [ + { defaultValue: "true", name: "active", type: "boolean" }, + { defaultValue: "sm", name: "size", type: "2xs | xs | sm | md | lg" }, + { name: "label", type: "string" }, + ], + states: ["active", "static"], + tokens: ["control.icon.size2xs", "control.icon.sizeXs", "control.icon.sizeSm", "control.icon.sizeMd", "control.icon.sizeLg"], +} as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.module.css b/design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.module.css new file mode 100644 index 0000000000..78913c7bfa --- /dev/null +++ b/design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.module.css @@ -0,0 +1,27 @@ +@layer openbitfun.components { + .root { + display: inline-block; + flex: 0 0 auto; + inline-size: var(--openbitfun-control-icon-size-sm); + block-size: var(--openbitfun-control-icon-size-sm); + color: inherit; + vertical-align: -0.125em; + } + + .root[data-size="2xs"] { inline-size: var(--openbitfun-control-icon-size2xs); block-size: var(--openbitfun-control-icon-size2xs); } + .root[data-size="xs"] { inline-size: var(--openbitfun-control-icon-size-xs); block-size: var(--openbitfun-control-icon-size-xs); } + .root[data-size="md"] { inline-size: var(--openbitfun-control-icon-size-md); block-size: var(--openbitfun-control-icon-size-md); } + .root[data-size="lg"] { inline-size: var(--openbitfun-control-icon-size-lg); block-size: var(--openbitfun-control-icon-size-lg); } + + .root > svg { display: block; inline-size: 100%; block-size: 100%; } + .signal > path, .signal > circle { opacity: 0; } + + .root[data-active="true"] [data-playing="true"] .track, + .root[data-active="true"] [data-playing="true"] .nodes { opacity: 0; } + + @media (prefers-reduced-motion: reduce), (forced-colors: active), print { + .root[data-active="true"] [data-playing="true"] .track, + .root[data-active="true"] [data-playing="true"] .nodes { opacity: 1; } + .signal { display: none; } + } +} diff --git a/design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.tsx b/design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.tsx new file mode 100644 index 0000000000..06557fa240 --- /dev/null +++ b/design-system/packages/ui/src/components/ThinkingIndicator/ThinkingIndicator.tsx @@ -0,0 +1,120 @@ +import { forwardRef, useEffect, useId, useRef, type HTMLAttributes } from "react"; +import { startBrandPlayback } from "../../brand/brandPlayback"; +import { classNames } from "../../internal/classNames"; +import type { IconSize } from "../Icon"; +import styles from "./ThinkingIndicator.module.css"; + +// Preserve the seven nodes and eight edges of Icon/assets/thinking.svg. +// Every edge points downwards, so drawing and retracting share one path. +const edges = [ + "M19.5 3.75 3.5 10.5", "M19.5 3.75 35.5 10.5", + "M3.5 10.5 19.5 19.5", "M35.5 10.5 19.5 19.5", + "M19.5 19.5 3.5 28.5", "M19.5 19.5 35.5 28.5", + "M3.5 28.5 19.5 35.25", "M35.5 28.5 19.5 35.25", +]; +// Trace each branch as one path so velocity continues through its junctions. +// The node cutouts below still prevent either line from crossing a visible dot. +const branches = [ + "M19.5 3.75 3.5 10.5 19.5 19.5 3.5 28.5 19.5 35.25", + "M19.5 3.75 35.5 10.5 19.5 19.5 35.5 28.5 19.5 35.25", +]; +const nodes = [ + [19.5, 3.75], [3.5, 10.5], [35.5, 10.5], [19.5, 19.5], + [3.5, 28.5], [35.5, 28.5], [19.5, 35.25], +] as const; +// Node bounds are x=-0.5..39.5 and y=-0.25..39.25. Center the canvas on +// (19.5, 19.5) and retain padding for every complete circle at all icon sizes. +const viewBox = "-1.5 -1.5 42 42"; +// Cut every node out of the edge layers; translucent nodes must never reveal +// either the resting track or the animated signal beneath them. +const edgeClipPath = [ + "M-1.5 -1.5H40.5V40.5H-1.5Z", + ...nodes.map(([cx, cy]) => `M${cx - 4} ${cy}a4 4 0 1 0 8 0a4 4 0 1 0-8 0Z`), +].join(" "); +const nodeLevels = [0, 1, 1, 2, 3, 3, 4]; +// Broad, overlapping fades bridge the parts of each trace hidden by a node. +const nodeFormation = [0, 0.08, 0.155, 0.23, 0.32]; +const nodeDissolution = [0.84, 0.775, 0.71, 0.645, 0.58]; +const playback = { duration: 2200, iterations: Infinity }; +const easing = "cubic-bezier(0.45, 0, 0.55, 1)"; + +function createFormationAnimations(svg: SVGSVGElement): Animation[] { + const animations: Animation[] = []; + // Keep the static mark as the fallback until native playback is available. + svg.setAttribute("data-playing", "true"); + // Ease only the complete drawing/retraction, never each short edge. + svg.querySelectorAll("[data-signal-edge]").forEach(edge => { + animations.push(edge.animate([ + { offset: 0, strokeDashoffset: "1", opacity: 0 }, + { offset: 0.02, strokeDashoffset: "1", opacity: 1, easing }, + { offset: 0.43, strokeDashoffset: "0", opacity: 1 }, + { offset: 0.62, strokeDashoffset: "0", opacity: 1, easing }, + { offset: 0.95, strokeDashoffset: "1", opacity: 0 }, + { offset: 1, strokeDashoffset: "1", opacity: 0 }, + ], playback)); + }); + svg.querySelectorAll("[data-signal-node]").forEach((node, index) => { + const level = nodeLevels[index]!; + const form = nodeFormation[level]!; + const dissolve = nodeDissolution[level]!; + animations.push(node.animate([ + { offset: 0, opacity: 0 }, + { offset: form, opacity: 0, easing }, + { offset: form + (level === 0 ? 0.12 : 0.15), opacity: 1 }, + { offset: dissolve, opacity: 1, easing }, + { offset: dissolve + 0.16, opacity: 0 }, + { offset: 1, opacity: 0 }, + ], playback)); + }); + const startTime = svg.ownerDocument.timeline.currentTime; + if (startTime !== null) animations.forEach(animation => { animation.startTime = startTime; }); + return animations; +} + +export interface ThinkingIndicatorProps extends Omit, "children" | "aria-label"> { + /** The host owns activity; completing or stopping returns to the original mark. */ + active?: boolean; + label?: string; + size?: IconSize; +} + +/** Form the original mark, hold it, then dissolve it in reverse order. */ +export const ThinkingIndicator = forwardRef(function ThinkingIndicator({ + active = true, className, label, size = "sm", ...props +}, ref) { + const edgeClipId = useId(); + const svgRef = useRef(null); + useEffect(() => { + const svg = svgRef.current; + if (!active || !svg) return; + const stop = startBrandPlayback(svg, () => createFormationAnimations(svg)); + return () => { + stop(); + svg.removeAttribute("data-playing"); + }; + }, [active]); + + return + + ; +}); diff --git a/design-system/packages/ui/src/components/ThinkingIndicator/index.ts b/design-system/packages/ui/src/components/ThinkingIndicator/index.ts new file mode 100644 index 0000000000..d70299df56 --- /dev/null +++ b/design-system/packages/ui/src/components/ThinkingIndicator/index.ts @@ -0,0 +1 @@ +export { ThinkingIndicator, type ThinkingIndicatorProps } from "./ThinkingIndicator"; diff --git a/design-system/packages/ui/src/flow-chat/conversation/ConversationBlocks.css b/design-system/packages/ui/src/flow-chat/conversation/ConversationBlocks.css index a094324842..b848c27599 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/ConversationBlocks.css +++ b/design-system/packages/ui/src/flow-chat/conversation/ConversationBlocks.css @@ -2,13 +2,16 @@ .flow-thinking-item { --_thinking-header-size: max(1lh, var(--openbitfun-control-tool-card-ambient-row-min-block-size)); - --_thinking-continuation-center: calc(var(--_thinking-header-size) / 2); + --_thinking-last-center: calc(var(--_thinking-header-size) / 2); + --_thinking-continuation-center: var(--_thinking-last-center); --_thinking-text-start: 0px; --_thinking-text-end: 0px; --_thinking-toggle-size: var(--openbitfun-control-icon-button-xs-size); --_thinking-side-offset: calc(var(--_thinking-toggle-size) + var(--openbitfun-control-flow-chat-row-icon-gap)); - --_thinking-fold-duration: 360ms; - --_thinking-fold-ease: cubic-bezier(0.22, 1, 0.36, 1); + --_thinking-fold-duration: var(--openbitfun-motion-duration-base); + --_thinking-fold-ease: var(--openbitfun-motion-easing-smooth); + --_thinking-fade-duration: var(--openbitfun-motion-duration-instant); + --_thinking-fold-delay: 0ms; padding: 0; margin: 0; background: transparent; @@ -16,7 +19,9 @@ box-sizing: border-box; font-size: var(--openbitfun-type-flow-control-font-size); line-height: var(--openbitfun-type-flow-support-line-height); - transition: margin-bottom var(--_thinking-fold-duration) var(--_thinking-fold-ease); + transition: + margin-bottom var(--_thinking-fold-duration) var(--_thinking-fold-ease), + min-block-size var(--_thinking-fold-duration) var(--_thinking-fold-ease); } .thinking-header-slot { @@ -27,10 +32,12 @@ .thinking-collapsed-header { position: absolute; - inset-block-start: 0; - inset-inline-start: 0; - inline-size: 100%; - min-block-size: var(--_thinking-header-size); + /* The live control and the completed annotation share one gutter column. + Titles and reasoning keep the successor's original content edge. */ + inset-block-start: calc((var(--_thinking-header-size) - var(--_thinking-toggle-size)) / 2); + inset-inline-start: calc(0px - var(--_thinking-side-offset)); + inline-size: calc(100% + var(--_thinking-side-offset)); + block-size: var(--_thinking-toggle-size); display: flex; align-items: center; gap: var(--openbitfun-control-flow-chat-row-icon-gap); @@ -38,18 +45,13 @@ font-size: var(--openbitfun-type-flow-control-font-size); line-height: var(--openbitfun-type-flow-support-line-height); box-sizing: border-box; - transition: - inline-size var(--_thinking-fold-duration) var(--_thinking-fold-ease), - opacity 140ms ease, - inset-inline-start var(--_thinking-fold-duration) var(--_thinking-fold-ease), - inset-block-start var(--_thinking-fold-duration) var(--_thinking-fold-ease); + opacity: 1; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } .thinking-toggle { - /* Preserve the disclosure's existing glyph column; the public button owns - its size, radius, hover surface, focus ring and native keyboard behavior. */ - margin-inline: calc((var(--openbitfun-control-flow-chat-row-icon-size) - var(--_thinking-toggle-size)) / 2); - transition: margin-inline var(--_thinking-fold-duration) var(--_thinking-fold-ease); + margin-inline: 0; + flex-shrink: 0; } .thinking-annotation-line { @@ -70,7 +72,7 @@ inset-block-start: calc(100% + var(--openbitfun-space-1)); max-block-size: max(0px, calc(var(--_thinking-text-end) - var(--openbitfun-space-1) - var(--_thinking-continuation-center) - var(--_thinking-toggle-size) / 2 - var(--openbitfun-space-1))); } -.flow-thinking-item[data-thinking-annotation='text'] .thinking-annotation-line { +.flow-thinking-item[data-thinking-phase='side'][data-thinking-annotation='text'] .thinking-annotation-line { display: block; } @@ -101,14 +103,14 @@ display: inline-flex; align-items: center; justify-content: center; - transition: opacity 0.15s ease; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } -.thinking-collapsed-header .thinking-leading-icon > [data-openbitfun-component=icon] { +.thinking-collapsed-header .thinking-leading-icon > :is([data-openbitfun-component=icon], [data-openbitfun-component=thinking-indicator]) { position: absolute; inset: 0; inline-size: 100%; block-size: 100%; - transition: opacity 0.15s ease; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } .thinking-collapsed-header .thinking-leading-icon__default { opacity: 1; @@ -123,7 +125,7 @@ color: var(--openbitfun-color-content-secondary); line-height: var(--openbitfun-type-flow-support-line-height); opacity: 1; - transition: opacity 100ms ease; + transition: opacity var(--_thinking-fade-duration) var(--openbitfun-motion-easing-exit); } .flow-thinking-item.summary.collapsed .thinking-collapsed-header { @@ -151,13 +153,13 @@ grid-template-rows: 0fr; opacity: 0; transition: - grid-template-rows var(--_thinking-fold-duration) var(--_thinking-fold-ease) 60ms, - opacity 100ms ease; + grid-template-rows var(--_thinking-fold-duration) var(--_thinking-fold-ease) var(--_thinking-fold-delay), + opacity var(--_thinking-fade-duration) var(--openbitfun-motion-easing-exit); } .thinking-expand-container--open { grid-template-rows: 1fr; opacity: 1; - transition-delay: 0ms, 80ms; + transition-delay: 0ms, 0ms; } .thinking-expand-container > .thinking-content-wrapper { overflow: hidden; @@ -166,23 +168,20 @@ /* A nonempty successor anchors a side icon in the existing leading gutter. Neither the successor nor its descendants reserve or animate any inline space. Keep both siblings mounted so docking preserves Markdown, tool state and selection. */ -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)) { +.flow-thinking-item[data-thinking-attachment='side'] { margin-bottom: 0; - transition-delay: 60ms; + transition-delay: var(--_thinking-fold-delay); } -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)) .thinking-header-slot { +.flow-thinking-item[data-thinking-attachment='side'] .thinking-header-slot { block-size: 0; - transition-delay: 60ms; + transition-delay: var(--_thinking-fold-delay); } -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)) .thinking-collapsed-header { +.flow-thinking-item[data-thinking-phase='side'] .thinking-collapsed-header { z-index: 1; - inset-inline-start: calc(0px - var(--_thinking-side-offset)); inset-block-start: calc(var(--_thinking-continuation-center) - var(--_thinking-toggle-size) / 2); inline-size: var(--_thinking-toggle-size); - min-block-size: var(--_thinking-toggle-size); - block-size: var(--_thinking-toggle-size); gap: 0; opacity: 0; pointer-events: none; @@ -190,33 +189,31 @@ transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)) .thinking-toggle { - margin-inline: 0; - transition-delay: 60ms; -} - -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)) .thinking-leading-icon__default { +.flow-thinking-item[data-thinking-phase='side'] .thinking-leading-icon__default { opacity: 1; } -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)) .thinking-leading-icon__collapsed-hover { +.flow-thinking-item[data-thinking-phase='side'] .thinking-leading-icon__collapsed-hover { opacity: 0; } -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)) .thinking-label-target { +.flow-thinking-item[data-thinking-phase='side'] .thinking-label-target, +.flow-thinking-item[data-thinking-phase='leaving'][data-thinking-origin='side'] .thinking-label-target { visibility: hidden; pointer-events: none; - transition: visibility 0s 100ms; } -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)) .thinking-label { - opacity: 0; - overflow: hidden; +.flow-thinking-item[data-thinking-phase='leaving'] .thinking-collapsed-header { + /* Freeze the outgoing vertical anchor, including interrupted returns from a + long paragraph. The final anchor is applied only at zero opacity. */ + inset-block-start: var(--_thinking-handoff-top); pointer-events: none; + opacity: 0; + transition: opacity var(--_thinking-fade-duration) var(--openbitfun-motion-easing-exit); } /* Keep the pointer path from the content to its side control continuous. */ -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)) .thinking-collapsed-header::after { +.flow-thinking-item[data-thinking-phase='side'] .thinking-collapsed-header::after { content: ''; position: absolute; inset-block: 0; @@ -227,11 +224,13 @@ /* The whole leading column belongs to this continuation's hover area. A reader can leave any text line and move up to the button without crossing a dead gap. This hit area stays outside the content box, below the actual icon button. */ -.flow-thinking-item[data-thinking-attachment='side'] + [data-thinking-continuation]:has(> :not(:empty)) { +.flow-thinking-item[data-thinking-attachment='side'] + [data-thinking-continuation], +[data-thinking-continuation][data-thinking-side-target='true'] { position: relative; } -.flow-thinking-item[data-thinking-attachment='side'] + [data-thinking-continuation]:has(> :not(:empty))::before { +.flow-thinking-item[data-thinking-attachment='side'] + [data-thinking-continuation]::before, +[data-thinking-continuation][data-thinking-side-target='true']::before { content: ''; position: absolute; inset-block: 0; @@ -242,34 +241,42 @@ /* Pointer clicks retain native focus without pinning the side control open. Only keyboard-visible focus keeps it revealed after the pointer leaves. */ -.flow-thinking-item[data-thinking-attachment='side'][data-thinking-active='true']:has(+ [data-thinking-continuation] > :not(:empty)) .thinking-collapsed-header, -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)):has(.thinking-toggle:focus-visible) .thinking-collapsed-header, -.flow-thinking-item[data-thinking-attachment='side']:has(+ [data-thinking-continuation] > :not(:empty)):has(.thinking-toggle:active) .thinking-collapsed-header { +.flow-thinking-item[data-thinking-phase='side'][data-thinking-active='true'] .thinking-collapsed-header, +.flow-thinking-item[data-thinking-phase='side']:has(.thinking-toggle:focus-visible) .thinking-collapsed-header, +.flow-thinking-item[data-thinking-phase='side']:has(.thinking-toggle:active) .thinking-collapsed-header { opacity: 1; pointer-events: auto; transition-delay: 0ms; } -.flow-thinking-item[data-thinking-attachment='side'][data-thinking-in-view='false'] .thinking-collapsed-header { +.flow-thinking-item[data-thinking-phase='side'][data-thinking-in-view='false'] .thinking-collapsed-header { opacity: 0; pointer-events: none; } /* Extend only the clipping boundary into the gutter. Equal padding/margin keep the existing content edge and wrapping, including bounded exploration scroll. */ -.explore-region--expanded:not(.explore-region--hidden):has(.flow-thinking-item[data-thinking-attachment]) { +.explore-region--expanded:not(.explore-region--hidden):has(.flow-thinking-item) { --_explore-thinking-gutter: calc(var(--openbitfun-control-icon-button-xs-size) + var(--openbitfun-control-flow-chat-row-icon-gap) + var(--openbitfun-focus-width) + var(--openbitfun-focus-offset)); overflow: visible; } -.explore-region--expanded:not(.explore-region--hidden):has(.flow-thinking-item[data-thinking-attachment]) > .smooth-height-collapse--open > .explore-region__content-inner { +.explore-region--expanded:not(.explore-region--hidden):has(.flow-thinking-item) > .smooth-height-collapse--open > .explore-region__content-inner { overflow: visible; } -.explore-region--expanded:not(.explore-region--hidden):has(.flow-thinking-item[data-thinking-attachment]) > .explore-region__content-wrapper > .explore-region__content-inner > .explore-region__content { +.explore-region--expanded:not(.explore-region--hidden):has(.flow-thinking-item) > .explore-region__content-wrapper > .explore-region__content-inner > .explore-region__content { margin-inline-start: calc(0px - var(--_explore-thinking-gutter)); padding-inline-start: var(--_explore-thinking-gutter); } +/* Measuring a newly mounted history row must not create an entrance/fold. */ +.flow-thinking-item[data-thinking-motion='initial'], +.flow-thinking-item[data-thinking-motion='initial'] .thinking-header-slot, +.flow-thinking-item[data-thinking-motion='initial'] .thinking-collapsed-header, +.flow-thinking-item[data-thinking-motion='initial'] .thinking-expand-container { + transition: none !important; +} + @media (prefers-reduced-motion: reduce) { .flow-thinking-item, .thinking-header-slot, @@ -277,7 +284,7 @@ .thinking-toggle, .thinking-label-target, .thinking-collapsed-header .thinking-leading-icon, - .thinking-collapsed-header .thinking-leading-icon > [data-openbitfun-component=icon], + .thinking-collapsed-header .thinking-leading-icon > :is([data-openbitfun-component=icon], [data-openbitfun-component=thinking-indicator]), .thinking-collapsed-header .thinking-label, .thinking-expand-container { transition: none !important; @@ -302,10 +309,117 @@ user-select: text; } -.flow-thinking-item.streaming .thinking-content { +.flow-thinking-item[data-openbitfun-state~='streaming'] .thinking-content { color: var(--openbitfun-color-content-muted); } +/* Keep the outgoing viewport and its inline header geometry through completion. + A standalone row reserves its final header; a side annotation occupies none. */ +.flow-thinking-item[data-thinking-viewport] { + --_thinking-header-size: max(calc(var(--openbitfun-type-flow-body-font-size) * var(--openbitfun-type-flow-body-line-height)), var(--openbitfun-control-tool-card-ambient-row-min-block-size)); + min-block-size: var(--_thinking-header-size); +} +.flow-thinking-item[data-thinking-viewport][data-thinking-attachment='side'] { + min-block-size: 0; +} +.flow-thinking-item[data-thinking-viewport] .thinking-header-slot { + block-size: 0; +} +.flow-thinking-item[data-thinking-viewport]:not([data-thinking-phase='side']):not([data-thinking-phase='leaving']) .thinking-collapsed-header { + inset-block-start: calc((var(--openbitfun-type-flow-body-font-size) * var(--openbitfun-type-flow-body-line-height) - var(--_thinking-toggle-size)) / 2); +} +.flow-thinking-item[data-streaming-expanded] .thinking-collapsed-header { + z-index: 1; + pointer-events: none; +} +.flow-thinking-item[data-streaming-expanded] .thinking-toggle { + pointer-events: auto; +} +.flow-thinking-item[data-streaming-expanded] .thinking-label-target { + block-size: calc(var(--openbitfun-type-flow-body-font-size) * var(--openbitfun-type-flow-body-line-height)); +} +.flow-thinking-item[data-thinking-viewport][data-expanded='true'] .thinking-label { + opacity: 0; + visibility: hidden; +} +.flow-thinking-item[data-thinking-viewport][data-expanded='false']:not([data-thinking-attachment='side']) .thinking-label { + transition-delay: var(--_thinking-fade-duration); +} +.flow-thinking-item[data-streaming-expanded='false'] .thinking-label-target { + pointer-events: auto; +} +.flow-thinking-item[data-thinking-viewport] .thinking-content { + box-sizing: border-box; + font-size: var(--openbitfun-type-flow-body-font-size); + padding-block: 0; + min-height: 1lh; + max-height: 7lh; +} +.flow-thinking-item[data-thinking-viewport='compact'] .thinking-content { + display: flex; + flex-direction: column; + justify-content: flex-end; + block-size: 1lh; + max-height: 1lh; + overflow: clip; +} +.flow-thinking-item[data-thinking-viewport='compact'] .thinking-content-body { + /* The latest typeset line replaces the previous one in a fixed slot. The + browser owns wrapping; no scroll offset or animated translation is used. */ + flex: 0 0 auto; +} +.thinking-content-body { + display: flow-root; + min-inline-size: 0; +} +.flow-thinking-item[data-thinking-viewport='compact'] .thinking-content-wrapper::before, +.flow-thinking-item[data-thinking-viewport='compact'] .thinking-content-wrapper::after { + display: none; +} + +/* The live predecessor owns this short transaction. The successor keeps its + React tree and execution state, but takes no space until the row is ready. */ +[data-thinking-continuation][data-thinking-successor='held'] { + /* display:none also removes the line box of an inline capsule. Unlike + conditional rendering, it preserves the actual card and its subscriptions. */ + display: none !important; +} +.flow-thinking-item[data-thinking-exchange='closing'], +.flow-thinking-item[data-thinking-exchange='closing'][data-thinking-viewport] { + min-block-size: var(--_thinking-header-size); + transition: none; +} +.flow-thinking-item[data-thinking-exchange='closing'] .thinking-collapsed-header { + opacity: 0; + pointer-events: none; + transition: opacity var(--_thinking-fade-duration) var(--openbitfun-motion-easing-exit); +} +.flow-thinking-item[data-thinking-exchange='closing'] .thinking-header-slot { + block-size: 0; + transition: none; +} +.flow-thinking-item[data-thinking-exchange='closing'] .thinking-expand-container { + opacity: 0; +} +.flow-thinking-item[data-thinking-exchange='closing'][data-thinking-viewport='compact'] .thinking-expand-container { + /* A single line fades in place; there is no visible one-line fold. */ + grid-template-rows: 1fr; +} +.flow-thinking-item[data-thinking-exchange='released'] { + min-block-size: 0; + margin-bottom: 0; + transition: none; +} +.flow-thinking-item[data-thinking-exchange='released'] .thinking-header-slot { + block-size: 0; + transition: none; +} +.flow-thinking-item[data-thinking-exchange='released'] .thinking-expand-container { + grid-template-rows: 0fr; + opacity: 0; + transition: none; +} + .thinking-content-wrapper { position: relative; min-height: 0; @@ -510,6 +624,11 @@ .task-with-subagent-wrapper, .subagent-projection-text--truncated) { margin: 0 0 var(--openbitfun-control-flow-chat-flow-item-gap) 0; } +/* Side reasoning is positioned beside its successor; the surrounding tools + supply the full gap, including across deferred content segments. */ +.explore-region--collapsible :is(.explore-region__content, .flow-group-content-segment) > .flow-thinking-item[data-thinking-attachment='side'] { + margin-bottom: 0; +} .explore-region--collapsible :is(.explore-region__content, .flow-group-content-segment) > .flowchat-flow-item:has(> .flow-tool-card-wrapper[data-tool-capsule=true]) { inline-size: 100%; max-inline-size: 100%; diff --git a/design-system/packages/ui/src/flow-chat/conversation/ConversationBlocks.meta.ts b/design-system/packages/ui/src/flow-chat/conversation/ConversationBlocks.meta.ts index bcc815a61c..72be0ec00a 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/ConversationBlocks.meta.ts +++ b/design-system/packages/ui/src/flow-chat/conversation/ConversationBlocks.meta.ts @@ -10,6 +10,8 @@ export const thinkingBlockMeta = { description: 'Controlled reasoning and reasoning-summary anatomy shared with the production transcript.', props: [{ name: 'expanded', type: 'boolean' }, { name: 'label', type: 'string' }, { name: 'onToggle', type: '() => void' }, { name: 'onOpenDetails', type: '() => void' }, + { name: 'streamingExpanded', type: 'boolean', defaultValue: 'false' }, + { name: 'onStreamingExpandedChange', type: '(expanded: boolean) => void' }, { name: 'scrollOwner', type: "'self' | 'parent'", defaultValue: 'self' }], states: ['collapsed', 'expanded', 'streaming', 'summary'], tokens: [...tokens, 'control.flowChat.streamViewportBlockSize'], } as const satisfies ComponentMeta; @@ -37,16 +39,19 @@ export const flowChatRuntimeStatusMeta = { export const flowGroupMeta = { category: 'flow-chat', name: 'FlowGroup', maturity: 'stable', - description: 'A controlled collection with a shared summary, disclosure, natural-height content and one-shot arrival feedback.', + description: 'A controlled collection with capsule or bound file-revision summaries, shared disclosure and natural-height content.', props: [{ name: 'expanded', type: 'boolean' }, { name: 'summary', type: 'string' }, { name: 'leading', type: 'ReactNode' }, { name: 'onExpandedChange', type: '(expanded: boolean) => void' }, { name: 'itemCount', type: 'number' }, { name: 'receiveFeedback', type: 'FlowGroupReceiveFeedback' }, { name: 'contentProps', type: 'HTMLAttributes' }, { name: 'browser', type: 'FlowGroupBrowserProps' }, + { name: 'fileRevision', type: 'FlowGroupFileRevision' }, { name: 'placement', type: "'standalone' | 'inline'", defaultValue: 'standalone' }, { name: 'bounded', type: 'boolean', defaultValue: 'false' }], - states: ['collapsed', 'expanded', 'streaming'], - tokens: [...exploreGroupMeta.tokens, 'layout.scrollArea.fadeExtent', 'motion.duration.fast', 'motion.easing.standard'], + states: ['collapsed', 'expanded', 'streaming', 'file-collapsed', 'file-expanded', 'file-error'], + tokens: [...exploreGroupMeta.tokens, 'layout.scrollArea.fadeExtent', 'motion.duration.fast', 'motion.easing.standard', + 'color.surface.panel', 'color.border.default', 'color.border.strong', 'color.status.danger.content', + 'control.activityItem.surfaceHeight', 'control.activityItem.surfaceRadius', 'type.body.xs.fontSize'], } as const satisfies ComponentMeta; export const contextLoadGroupMeta = { diff --git a/design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.module.css b/design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.module.css new file mode 100644 index 0000000000..0ee858144c --- /dev/null +++ b/design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.module.css @@ -0,0 +1,186 @@ +@layer openbitfun.components { + .group { + min-inline-size: 0; + } + + .header { + position: relative; + isolation: isolate; + min-inline-size: 0; + padding-block-end: var(--openbitfun-space-1); + } + + .front, + .page { + border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-default); + border-radius: var(--openbitfun-control-activity-item-surface-radius); + background: var(--openbitfun-color-surface-panel); + } + + .page { + position: absolute; + pointer-events: none; + inset-inline-start: 0; + inset-inline-end: var(--openbitfun-space-2); + inset-block-start: calc(var(--openbitfun-space-1) / 2); + inset-block-end: calc(var(--openbitfun-space-1) / 2); + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), + transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + .page[data-layer='2'] { + inset-inline-end: var(--openbitfun-space-4); + inset-block-start: var(--openbitfun-space-1); + inset-block-end: 0; + } + + .header[data-expanded='true'] .page { + opacity: 0; + transform: translateY(calc(-1 * var(--openbitfun-space-1))); + } + + .front { + position: relative; + display: flex; + align-items: center; + transition: border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + .front:is(:hover, :has(:focus-visible)) { + border-color: var(--openbitfun-color-border-strong); + } + + .toggle { + box-sizing: border-box; + display: flex; + gap: var(--openbitfun-space-2); + align-items: center; + flex: 1 1 auto; + min-inline-size: 0; + min-block-size: calc(var(--openbitfun-control-activity-item-surface-height) - 2 * var(--openbitfun-border-width-default)); + padding-block: var(--openbitfun-control-activity-item-surface-padding-block); + padding-inline: var(--openbitfun-control-flow-chat-card-padding-inline); + border: 0; + border-radius: inherit; + background: transparent; + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-body-sm-font-family); + font-size: var(--openbitfun-type-body-sm-font-size); + font-weight: var(--openbitfun-type-body-sm-font-weight); + line-height: var(--openbitfun-type-body-sm-line-height); + text-align: start; + cursor: pointer; + } + + .toggle:focus-visible { + outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring); + outline-offset: calc(-1 * var(--openbitfun-focus-width)); + } + + .icon { + position: relative; + display: inline-flex; + flex: 0 0 auto; + color: var(--openbitfun-color-content-secondary); + } + + .disclosure { + position: absolute; + inset: 0; + display: inline-flex; + opacity: 0; + background: var(--openbitfun-color-surface-panel); + } + + .toggle:is(:hover, :focus-visible) .disclosure { + opacity: 1; + } + + .subject { + display: flex; + align-items: center; + gap: var(--openbitfun-space-2); + min-inline-size: 0; + flex: 1 1 0; + } + + .filename { + min-inline-size: 0; + flex: 1 1 0; + color: var(--openbitfun-color-content-secondary); + } + + .metadata { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--openbitfun-space-3); + min-inline-size: 0; + max-inline-size: 65%; + margin-inline-start: auto; + } + + .result { + display: inline-flex; + align-items: center; + min-inline-size: 0; + max-inline-size: 12em; + gap: var(--openbitfun-space-1); + color: var(--openbitfun-color-content-secondary); + } + + .result[data-status='error'] { + color: var(--openbitfun-color-status-danger-content); + } + + .count { + flex: 0 0 auto; + white-space: nowrap; + color: var(--openbitfun-color-content-primary); + font-weight: var(--openbitfun-type-label-lg-font-weight); + } + + .front[data-has-file-action='true']:is(:hover, :has(:focus-visible)) .changes, + .front[data-has-file-action='true']:is(:hover, :has(:focus-visible)) .metadata[data-has-status='false'] { + display: none; + } + + .actions { + position: absolute; + display: flex; + align-items: center; + inset-inline-end: var(--openbitfun-control-flow-chat-card-padding-inline); + inset-block: 0; + opacity: 0; + pointer-events: none; + } + + .header[data-expanded='true'] .actions, + .front:is(:hover, :has(:focus-visible)) .actions { + position: static; + align-self: stretch; + margin-inline-end: var(--openbitfun-control-flow-chat-card-padding-inline); + opacity: 1; + pointer-events: auto; + } + + @media (hover: none) { + .actions { + position: static; + align-self: stretch; + margin-inline-end: var(--openbitfun-control-flow-chat-card-padding-inline); + opacity: 1; + pointer-events: auto; + } + } + + @media (prefers-reduced-motion: reduce) { + .page, + .front { transition: none; } + } + + @media (forced-colors: active) { + .front, + .page { border-color: CanvasText; } + } +} diff --git a/design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.tsx b/design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.tsx new file mode 100644 index 0000000000..10225ee30c --- /dev/null +++ b/design-system/packages/ui/src/flow-chat/conversation/FileRevisionSummary.tsx @@ -0,0 +1,64 @@ +import { FileEdit, TriangleAlert } from 'lucide-react'; +import { Icon } from '../../components/Icon/Icon'; +import { IconButton } from '../../components/IconButton/IconButton'; +import { OverflowText } from '../../primitives/OverflowText'; +import { ToolCardChangeSummary } from '../tool-cards/FlowChatToolCard'; +import { ToolProcessingDots } from '../tool-cards/ToolProcessingDots'; +import styles from './FileRevisionSummary.module.css'; + +export interface FlowGroupFileRevision { + path: string; + label: string; + /** Localized count prefix, including its separator before the filename. */ + countLabel: string; + /** Localized expanded prefix, including its separator before the filename. */ + expandedLabel?: string; + status?: 'running' | 'error' | 'stopped'; + statusLabel?: string; + /** Cumulative additions and deletions displayed by the individual revisions. */ + changeSummary?: { additions: number | string; deletions: number | string; label: string }; + openFile?: { label: string; onPress: () => void }; +} + +export function FileRevisionSummary({ file, itemCount, expanded, onToggle, contentId, description, component, testId }: { + file: FlowGroupFileRevision; + itemCount: number; + expanded: boolean; + onToggle?: () => void; + contentId: string; + description: string; + component: string; + testId: string; +}) { + const prefixLabel = expanded ? file.expandedLabel : file.countLabel; + return
+ {itemCount > 2 &&
; +} diff --git a/design-system/packages/ui/src/flow-chat/conversation/FlowGroup.tsx b/design-system/packages/ui/src/flow-chat/conversation/FlowGroup.tsx index aabead361e..091e755250 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/FlowGroup.tsx +++ b/design-system/packages/ui/src/flow-chat/conversation/FlowGroup.tsx @@ -9,8 +9,12 @@ import { Tooltip } from '../../components/Tooltip/Tooltip'; import { OverflowText } from '../../primitives/OverflowText'; import { receiveCapsule, useCapsuleMotion } from '../motion/capsuleMotion'; import { FlowChatCollapse } from './FlowChatCollapse'; +import { FileRevisionSummary, type FlowGroupFileRevision } from './FileRevisionSummary'; +import fileRevisionStyles from './FileRevisionSummary.module.css'; import './ConversationBlocks.css'; +export type { FlowGroupFileRevision } from './FileRevisionSummary'; + /** A host-owned, single-use receipt. Counts, hydration and mounting are not arrivals. */ export interface FlowGroupReceiveFeedback { claim: () => boolean; @@ -62,6 +66,8 @@ export interface FlowGroupProps extends Omit, 'ch onContentScroll?: HTMLAttributes['onScroll']; contentProps?: HTMLAttributes; browser?: FlowGroupBrowserProps; + /** A shared file identity with bound revision pages instead of a capsule. */ + fileRevision?: FlowGroupFileRevision; /** Stable anatomy identity for semantic presets and installed Appearance packages. */ 'data-openbitfun-component'?: string; 'data-testid'?: string; @@ -72,7 +78,7 @@ export interface FlowGroupProps extends Omit, 'ch export const FlowGroup = forwardRef(function FlowGroup({ children, expanded, onExpandedChange, leading, summary, summaryItems, summaryDescription, itemCount = 0, placement = 'standalone', streaming = false, bounded = false, - receiveFeedback, contentRef, onContentScroll, contentProps, browser, className = '', + receiveFeedback, contentRef, onContentScroll, contentProps, browser, fileRevision, className = '', 'data-openbitfun-component': component = 'flow-group', 'data-testid': testId = 'chat-flow-group', 'data-group-kind': kind, ...props @@ -94,8 +100,8 @@ export const FlowGroup = forwardRef(function Flo // must never replay when the reader scrolls back or later closes the group. const claimed = receiveFeedback?.claim(); if (expanded) motion.cancel(); - else if (changed && claimed) motion.play('receive', receiveCapsule); - }, [expanded, receiveFeedback, motion.play, motion.cancel]); + else if (!fileRevision && changed && claimed) motion.play('receive', receiveCapsule); + }, [expanded, fileRevision, receiveFeedback, motion.play, motion.cancel]); const handleToggle = () => { if (!onExpandedChange) return; @@ -107,10 +113,14 @@ export const FlowGroup = forwardRef(function Flo data-openbitfun-component={component} data-openbitfun-part="root" data-flow-group="" data-openbitfun-state={expanded ? 'expanded' : undefined} data-testid={testId} data-group-kind={kind} data-placement={placement} data-expanded={expanded ? 'true' : 'false'} + data-file-revisions={fileRevision ? 'true' : undefined} data-collected={itemCount > 1 ? 'true' : 'false'} data-item-count={itemCount} className={['explore-region', 'explore-region--collapsible', expanded ? 'explore-region--expanded' : 'explore-region--collapsed', - streaming && 'explore-region--streaming', bounded && 'explore-region--bounded', className].filter(Boolean).join(' ')}> -
+ streaming && 'explore-region--streaming', bounded && 'explore-region--bounded', fileRevision && fileRevisionStyles.group, className].filter(Boolean).join(' ')}> + {fileRevision ? + :
(function Flo className="explore-region__controls" onFocusCapture={browser.onInteract}>
} -
+
} , 'children'> { children?: ReactNode; expanded: boolean; streaming?: boolean; visuallyStreaming?: boolean; + /** The reader's seven-line viewport during streaming and visual reveal. */ + streamingExpanded?: boolean; + onStreamingExpandedChange?: (expanded: boolean) => void; + /** A closing parent owns the fold; preserve the last live viewport until unmount. */ + retainStreamingViewport?: boolean; reasoningKind?: 'reasoning' | 'summary'; status?: string; context?: 'default' | 'subagent-projection'; label: string; /** Accessible name and tooltip for the completed reasoning side icon. */ capsuleLabel?: string; - /** Dock a settled disclosure beside the next sibling marked data-thinking-continuation. */ + /** Dock a settled disclosure beside a continuation marked data-thinking-continuation. */ collapseIntoNext?: boolean; + /** Resolve a continuation in a peer container when it is not a direct sibling. */ + resolveContinuation?: ThinkingContinuationResolver; + /** Coordinate the first live successor with the outgoing row, without remounting it. */ + coordinateContinuation?: boolean; + /** Scope a handoff to the host's session/item/attempt identity. */ + handoffIdentity?: string; + /** Availability, attention bypass, and whether the local handoff is still pending. */ + onContinuationReadyChange?: (ready: boolean, immediate: boolean, pending: boolean) => void; /** A containing FlowGroup can own scrolling for the entire execution stream. */ scrollOwner?: 'self' | 'parent'; onToggle?: () => void; @@ -35,9 +54,11 @@ export interface ThinkingBlockProps extends Omit, /** Controlled view. Reveal, stream and scrolling ownership remain with the consumer. */ export const ThinkingBlock = forwardRef(function ThinkingBlock({ children, expanded, streaming = false, visuallyStreaming = streaming, - reasoningKind = 'reasoning', status, context = 'default', label, capsuleLabel = label, onToggle, onOpenDetails, collapseIntoNext = false, scrollOwner = 'self', + streamingExpanded = false, onStreamingExpandedChange, retainStreamingViewport = false, + reasoningKind = 'reasoning', status, context = 'default', label, capsuleLabel = label, onToggle, onOpenDetails, collapseIntoNext = false, resolveContinuation, scrollOwner = 'self', + coordinateContinuation = false, handoffIdentity, onContinuationReadyChange, contentRef, expandContainerRef, mountContent = true, contentProps, scrollState = { hasScroll: false, atTop: true, atBottom: true }, - className = '', ...props + className = '', style, ...props }, ref) { const contentId = useId(); const toggleId = useId(); @@ -48,11 +69,30 @@ export const ThinkingBlock = forwardRef(func if (typeof ref === 'function') ref(element); else if (ref) ref.current = element; }, [ref]); - const docked = collapseIntoNext && !expanded && !visuallyStreaming; + const hasStreamingDisclosure = visuallyStreaming && Boolean(onStreamingExpandedChange); + const liveViewport = hasStreamingDisclosure && expanded + ? (streamingExpanded ? 'expanded' : 'compact') : undefined; + const lastLiveViewport = useRef(liveViewport); + // Interaction ends immediately, but the outgoing body must keep its geometry. + // Retain the inline header layout after release as well: restoring a separate + // header row would replay a second height change at the end of the fold. + const viewport = liveViewport ?? ((!expanded || retainStreamingViewport) ? lastLiveViewport.current : undefined); + useIsomorphicLayoutEffect(() => { + if (liveViewport !== undefined || (expanded && !retainStreamingViewport)) lastLiveViewport.current = liveViewport; + }, [expanded, liveViewport, retainStreamingViewport]); + const wantsDock = collapseIntoNext && !expanded && !visuallyStreaming; + const { hasContinuation, tooltipBlocked, tooltipActive, canShowTooltip } = useThinkingAnnotation(rootRef, toggleRef, wantsDock, resolveContinuation); + const docked = wantsDock && hasContinuation; + const handoff = useThinkingHandoff(rootRef, docked, label, wantsDock); + const exchange = useThinkingSuccessor(rootRef, { + identity: handoffIdentity, enabled: coordinateContinuation, + expanded, visuallyStreaming, resolveContinuation, onReadyChange: onContinuationReadyChange, + }); const accessibleLabel = docked ? capsuleLabel : label; - const onActivate = onOpenDetails ?? onToggle; - - const { tooltipBlocked, tooltipActive, canShowTooltip } = useThinkingAnnotation(rootRef, toggleRef, docked); + const compactViewport = expanded && viewport === 'compact'; + const onActivate = hasStreamingDisclosure + ? () => onStreamingExpandedChange?.(!streamingExpanded) + : onOpenDetails ?? onToggle; return
(func data-openbitfun-state={[expanded && 'expanded', visuallyStreaming && 'streaming'].filter(Boolean).join(' ')} data-testid="chat-thinking-panel" data-status={status} data-streaming={streaming ? 'true' : 'false'} data-expanded={expanded ? 'true' : 'false'} + data-streaming-expanded={hasStreamingDisclosure && expanded ? String(streamingExpanded) : undefined} + data-thinking-viewport={viewport} data-reasoning-kind={reasoningKind} data-scroll-owner={scrollOwner} data-thinking-attachment={collapseIntoNext ? (docked ? 'side' : 'block') : undefined} + data-thinking-phase={handoff.phase} data-thinking-origin={handoff.origin} + data-thinking-exchange={exchange} + data-thinking-motion={handoff.motionReady ? undefined : 'initial'} + style={{ ...style, '--_thinking-handoff-top': handoff.top } as CSSProperties} className={`flow-thinking-item ${reasoningKind} ${expanded ? 'expanded' : 'collapsed'} ${className}`.trim()} >
@@ -72,17 +118,18 @@ export const ThinkingBlock = forwardRef(func {/* Keep one tooltip owner across docking; retained click focus must not reopen a title fallback when the disclosure changes state. */}
@@ -104,8 +151,9 @@ export const ThinkingBlock = forwardRef(func data-openbitfun-component="model-thinking-display" data-openbitfun-part="contentWrapper">
- {children} +
{children}
} diff --git a/design-system/packages/ui/src/flow-chat/conversation/index.ts b/design-system/packages/ui/src/flow-chat/conversation/index.ts index cc51af8ba9..09de2d399e 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/index.ts +++ b/design-system/packages/ui/src/flow-chat/conversation/index.ts @@ -1,5 +1,5 @@ export { ThinkingBlock, type ThinkingBlockProps } from './ThinkingBlock'; -export { FlowGroup, type FlowGroupProps, type FlowGroupReceiveFeedback, type FlowGroupBrowserProps, type FlowGroupFilter } from './FlowGroup'; +export { FlowGroup, type FlowGroupProps, type FlowGroupFileRevision, type FlowGroupReceiveFeedback, type FlowGroupBrowserProps, type FlowGroupFilter } from './FlowGroup'; export { ExploreGroup, type ExploreGroupProps } from './ExploreGroup'; export { ContextLoadGroup, type ContextLoadGroupProps } from './ContextLoadGroup'; export { FlowChatRuntimeStatus, type FlowChatRuntimeStatusProps } from './FlowChatRuntimeStatus'; diff --git a/design-system/packages/ui/src/flow-chat/conversation/useThinkingAnnotation.ts b/design-system/packages/ui/src/flow-chat/conversation/useThinkingAnnotation.ts index a0c8249de8..f740bfae05 100644 --- a/design-system/packages/ui/src/flow-chat/conversation/useThinkingAnnotation.ts +++ b/design-system/packages/ui/src/flow-chat/conversation/useThinkingAnnotation.ts @@ -4,6 +4,16 @@ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : us const LEAVE_DELAY_MS = 120; const CARD_HEADER = '[data-openbitfun-component="flow-chat-tool-card"][data-openbitfun-part="surface"]'; +export interface ThinkingContinuationResolution { + element: HTMLElement | null; + /** Watches peer containers that can mount or remove the continuation. */ + observeRoot: HTMLElement; + /** Watches a mounted peer while its first visible item is still arriving. */ + observeSubtree?: HTMLElement | null; +} + +export type ThinkingContinuationResolver = (root: HTMLDivElement) => ThinkingContinuationResolution | null; + function textEdge(root: HTMLElement, end = false): { anchor: HTMLElement; bounds: DOMRect } | null { const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT); const range = root.ownerDocument.createRange(); @@ -47,13 +57,15 @@ function visibleBounds(element: HTMLElement, bounds: DOMRect) { return { top, bottom }; } -/** Reads layout; writes only the detached control. Never owns transcript scrolling. */ +/** Reads layout; writes positioning only on the thinking root. Never owns transcript scrolling. */ export function useThinkingAnnotation( rootRef: RefObject, toggleRef: RefObject, docked: boolean, + resolveContinuation?: ThinkingContinuationResolver, ) { const blockedRef = useRef(false); + const [hasContinuation, setHasContinuation] = useState(false); const [tooltip, setTooltip] = useState({ blocked: false, active: false }); const canShowTooltip = useCallback(() => !blockedRef.current && rootRef.current?.dataset.thinkingInView !== 'false', [rootRef]); @@ -63,9 +75,15 @@ export function useThinkingAnnotation( const button = toggleRef.current; const parent = root?.parentElement; const view = root?.ownerDocument.defaultView; - if (!docked || !root || !button || !parent || !view) return; + if (!docked || !root || !button || !parent || !view) { + setHasContinuation(false); + return; + } let successor: HTMLElement | null = null; + let detachedTarget = false; + let observedRoot: HTMLElement | null = null; + let observedSubtree: HTMLElement | null = null; let observedAnchor: HTMLElement | null = null; let hovered = false; let focused = false; @@ -84,8 +102,13 @@ export function useThinkingAnnotation( setTooltip(current => current.blocked && !current.active ? current : { blocked: true, active: false }); }; const update = () => { + // Match the side attachment's nonempty DOM contract, including a + // successor that receives its first content after reasoning has settled. + setHasContinuation(Boolean(successor && !successor.hidden + && successor.querySelector(':scope > :not(:empty)'))); if (!successor) return; - const header = successor.querySelector(CARD_HEADER); + const header = successor.querySelector(successor.hasAttribute('data-flow-group') + ? ':scope > .explore-region__toolbar .explore-region__header' : CARD_HEADER); const first = header ? null : textEdge(successor); const anchor = header ?? first?.anchor ?? null; if (anchor !== observedAnchor) { @@ -100,6 +123,7 @@ export function useThinkingAnnotation( return; } const rect = successor.getBoundingClientRect(); + const anchorTop = detachedTarget ? root.getBoundingClientRect().top : rect.top; const last = first ? textEdge(successor, true) : null; const multiline = Boolean(first && last && last.bounds.top > first.bounds.top + first.bounds.height / 2); root.dataset.thinkingAnnotation = multiline ? 'text' : 'compact'; @@ -117,11 +141,13 @@ export function useThinkingAnnotation( // Fully visible text uses its own midpoint; longer or clipped text uses // the visible portion's midpoint. Pointer position never moves the control. const center = multiline ? (visible.top + visible.bottom) / 2 : bounds.top + bounds.height / 2; - write('--_thinking-continuation-center', `${center - rect.top}px`); + write('--_thinking-continuation-center', `${center - anchorTop}px`); + write('--_thinking-last-center', `${center - anchorTop}px`); // Use the first/last glyph bounds, excluding paragraph padding. Clamp // internally scrolled text to its content box; CSS gives both tips an inset. - write('--_thinking-text-start', `${Math.max(0, (first?.bounds.top ?? rect.top) - rect.top)}px`); - write('--_thinking-text-end', `${Math.min(rect.height, last ? last.bounds.top + last.bounds.height - rect.top : rect.height)}px`); + write('--_thinking-text-start', `${Math.max(0, (first?.bounds.top ?? rect.top) - anchorTop)}px`); + write('--_thinking-text-end', `${Math.min(rect.top + rect.height - anchorTop, + last ? last.bounds.top + last.bounds.height - anchorTop : rect.top + rect.height - anchorTop)}px`); if (!inView) blockTooltip(); }; const scheduleUpdate = () => { @@ -203,11 +229,18 @@ export function useThinkingAnnotation( successor?.removeEventListener('mouseenter', onEnter); successor?.removeEventListener('mouseleave', onLeave); successor?.removeEventListener('mousemove', onMove); + if (detachedTarget) successor?.removeAttribute('data-thinking-side-target'); }; const updateSuccessor = () => { const sibling = root.nextElementSibling; - const next = sibling instanceof view.HTMLElement && sibling.hasAttribute('data-thinking-continuation') ? sibling : null; - if (next !== successor) { + const direct = sibling instanceof view.HTMLElement && sibling.hasAttribute('data-thinking-continuation') ? sibling : null; + const resolved = direct ? null : resolveContinuation?.(root); + const next = direct ?? resolved?.element ?? null; + const nextDetachedTarget = Boolean(next && next !== sibling); + const nextObservedRoot = resolved?.observeRoot ?? null; + const nextObservedSubtree = resolved?.observeSubtree ?? null; + if (next !== successor || nextDetachedTarget !== detachedTarget + || nextObservedRoot !== observedRoot || nextObservedSubtree !== observedSubtree) { watch(false); unbindSuccessor(); clearTimeout(leaveTimer); @@ -215,18 +248,36 @@ export function useThinkingAnnotation( resizeObserver.disconnect(); observedAnchor = null; successor = next; + detachedTarget = nextDetachedTarget; + observedRoot = nextObservedRoot; + observedSubtree = nextObservedSubtree; mutationObserver.disconnect(); mutationObserver.observe(parent, { childList: true }); + if (observedRoot && observedRoot !== parent) mutationObserver.observe(observedRoot, { + childList: true, attributes: true, + attributeFilter: ['data-turn-id', 'data-item-type', 'data-collected-empty'], + }); + if (observedSubtree && observedSubtree !== parent && observedSubtree !== observedRoot) { + mutationObserver.observe(observedSubtree, { childList: true, subtree: true, + attributes: true, attributeFilter: ['data-turn-id', 'data-item-type', 'data-collected-empty'] }); + } root.style.removeProperty('--_thinking-continuation-center'); delete root.dataset.thinkingAnnotation; delete root.dataset.thinkingInView; if (successor) { + if (detachedTarget) successor.setAttribute('data-thinking-side-target', 'true'); resizeObserver.observe(successor); - mutationObserver.observe(successor, { childList: true, characterData: true, subtree: true }); + // The source row shrinks during the fold. A target outside the next + // sibling slot moves relative to this root without changing its size. + if (detachedTarget) resizeObserver.observe(parent); + mutationObserver.observe(successor, { childList: true, characterData: true, subtree: true, + attributes: true, attributeFilter: ['hidden'] }); successor.addEventListener('mouseenter', onEnter); successor.addEventListener('mouseleave', onLeave); successor.addEventListener('mousemove', onMove); + hovered = successor.matches(':hover') || button.matches(':hover'); } + focused = button.matches(':focus-visible'); syncActive(); } update(); @@ -260,8 +311,10 @@ export function useThinkingAnnotation( root.style.removeProperty('--_thinking-continuation-center'); root.style.removeProperty('--_thinking-text-start'); root.style.removeProperty('--_thinking-text-end'); + // The last-center fallback retains the outgoing anchor through its fade. + // A block does not consume it; the next valid successor replaces it. }; - }, [docked, rootRef, toggleRef]); + }, [docked, resolveContinuation, rootRef, toggleRef]); - return { tooltipBlocked: tooltip.blocked, tooltipActive: tooltip.active, canShowTooltip }; + return { hasContinuation: docked && hasContinuation, tooltipBlocked: tooltip.blocked, tooltipActive: tooltip.active, canShowTooltip }; } diff --git a/design-system/packages/ui/src/flow-chat/conversation/useThinkingHandoff.ts b/design-system/packages/ui/src/flow-chat/conversation/useThinkingHandoff.ts new file mode 100644 index 0000000000..abbb3b7004 --- /dev/null +++ b/design-system/packages/ui/src/flow-chat/conversation/useThinkingHandoff.ts @@ -0,0 +1,101 @@ +import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'; + +const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; +type Position = 'block' | 'side'; +interface Handoff { + target: boolean; + phase: Position | 'leaving'; + origin: Position; + label: string; + top?: string; +} + +/** Move the retained control only after it has faded out. CSS owns the fold and + * its reversal; this hook waits for those actual transitions, never a timer. */ +export function useThinkingHandoff(rootRef: RefObject, side: boolean, label: string, wantsDock: boolean) { + const previousLabel = useRef(label); + const [motionReady, setMotionReady] = useState(false); + const [handoff, setHandoff] = useState(() => ({ + target: side, phase: side ? 'side' : 'block', origin: side ? 'side' : 'block', label, + })); + + useIsomorphicLayoutEffect(() => { + const view = rootRef.current?.ownerDocument.defaultView; + if (!view?.requestAnimationFrame) { + setMotionReady(true); + return; + } + // Initial annotation measurement can flush styles before the first paint. + // Keep those initial commits instant; only later state changes may move. + const frame = view.requestAnimationFrame(() => setMotionReady(true)); + return () => view.cancelAnimationFrame(frame); + }, [rootRef]); + + useIsomorphicLayoutEffect(() => { + if (handoff.target === side) return; + const root = rootRef.current; + const header = root?.querySelector('.thinking-collapsed-header'); + const view = root?.ownerDocument.defaultView; + const position = side ? 'side' : 'block'; + // History/remounts have no running transitions. Hidden groups and reduced + // motion also settle directly rather than queueing an invisible handoff. + if (!header?.getAnimations || !view || root?.closest('[hidden], [inert]') + || view.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + setHandoff({ target: side, phase: position, origin: position, label: previousLabel.current }); + return; + } + setHandoff({ + target: side, + phase: 'leaving', + origin: handoff.phase === 'leaving' ? handoff.origin : handoff.phase, + label: handoff.phase === 'leaving' ? handoff.label : previousLabel.current, + top: view.getComputedStyle(header).insetBlockStart, + }); + }, [handoff, rootRef, side]); + + useIsomorphicLayoutEffect(() => { + if (handoff.phase !== 'leaving' || handoff.target !== side) return; + const root = rootRef.current; + if (!root) return; + const parts: [Element | null, string][] = [ + [root.querySelector('.thinking-collapsed-header'), 'opacity'], + ]; + if (side) parts.push( + [root, 'margin-bottom'], + [root, 'min-block-size'], + [root.querySelector('.thinking-header-slot'), 'block-size'], + [root.querySelector('.thinking-expand-container'), 'grid-template-rows'], + ); + const transitions = parts.flatMap(([element, property]) => ( + element?.getAnimations?.().filter(animation => 'transitionProperty' in animation + && (animation.transitionProperty === property + // Engines may expose the physical property for logical block-size. + || (property === 'block-size' && animation.transitionProperty === 'height') + || (property === 'min-block-size' && animation.transitionProperty === 'min-height'))) ?? [] + )); + let cancelled = false; + const settle = () => { + if (cancelled) return; + setHandoff(current => current === handoff + ? { ...current, phase: side ? 'side' : 'block', origin: side ? 'side' : 'block', top: undefined } + : current); + }; + if (transitions.length) void Promise.allSettled(transitions.map(animation => animation.finished)).then(settle); + else settle(); + return () => { cancelled = true; }; + }, [handoff, rootRef, side]); + + // Annotation discovery may take another layout commit. Keep the last inline + // label across that commit instead of flashing the completed character count. + useIsomorphicLayoutEffect(() => { + if (!wantsDock) previousLabel.current = label; + }, [label, wantsDock]); + + return { + phase: handoff.phase, + motionReady, + origin: handoff.origin, + top: handoff.top, + label: handoff.phase === 'leaving' ? handoff.label : label, + }; +} diff --git a/design-system/packages/ui/src/flow-chat/conversation/useThinkingSuccessor.ts b/design-system/packages/ui/src/flow-chat/conversation/useThinkingSuccessor.ts new file mode 100644 index 0000000000..aeb98ccd16 --- /dev/null +++ b/design-system/packages/ui/src/flow-chat/conversation/useThinkingSuccessor.ts @@ -0,0 +1,217 @@ +import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'; +import type { ThinkingContinuationResolver } from './useThinkingAnnotation'; + +const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; +const PRIORITY = '[data-thinking-handoff-priority="immediate"], [data-openbitfun-status="error"], [data-openbitfun-status="pending_confirmation"], [role="alert"], [role="alertdialog"]'; +type Phase = 'holding' | 'closing' | 'released'; + +interface Options { + identity?: string; + enabled: boolean; + expanded: boolean; + visuallyStreaming: boolean; + resolveContinuation?: ThinkingContinuationResolver; + onReadyChange?: (ready: boolean, immediate: boolean, pending: boolean) => void; +} + +/** A live predecessor owns one handoff. The real successor stays mounted and + * updating, but cannot paint below a row that is about to disappear. Nothing + * here writes transcript scrolling or infers a live arrival from mounting. */ +export function useThinkingSuccessor(rootRef: RefObject, options: Options) { + const latest = useRef(options); + const syncRef = useRef<() => void>(); + const [phase, setPhase] = useState(); + useIsomorphicLayoutEffect(() => { latest.current = options; }); + + useIsomorphicLayoutEffect(() => { + const root = rootRef.current; + const parent = root?.parentElement; + const view = root?.ownerDocument.defaultView; + if (!root || !parent || !view || !options.enabled) { + setPhase(undefined); + options.onReadyChange?.(false, false, false); + return; + } + const media = view.matchMedia?.('(prefers-reduced-motion: reduce)'); + let alive = true; + let armed = false; + let first = true; + let target: HTMLElement | null = null; + let held: { element: HTMLElement; inert: string | null; ariaHidden: string | null } | undefined; + let closing = false; + let bypass = false; + let generation = 0; + let frame: number | undefined; + let reveal: Animation | undefined; + let reported = ''; + let availability = { ready: false, immediate: false }; + setPhase(undefined); + const seen = new WeakSet(); + const changePhase = (next?: Phase) => { + if (next) root.dataset.thinkingExchange = next; + else delete root.dataset.thinkingExchange; + setPhase(next); + }; + const restore = () => { + if (!held) return; + const { element, inert, ariaHidden } = held; + delete element.dataset.thinkingSuccessor; + if (inert === null) element.removeAttribute('inert'); else element.setAttribute('inert', inert); + if (ariaHidden === null) element.removeAttribute('aria-hidden'); else element.setAttribute('aria-hidden', ariaHidden); + held = undefined; + }; + const report = (ready = availability.ready, immediate = availability.immediate) => { + availability = { ready, immediate }; + const pending = Boolean(held); + const value = `${ready}:${immediate}:${pending}`; + if (reported === value) return; + reported = value; + latest.current.onReadyChange?.(ready, immediate, pending); + }; + const release = (fade: boolean, fold = true) => { + generation++; + closing = false; + const element = held?.element; + restore(); + changePhase(fold ? 'released' : undefined); + report(); + // Layout is committed at its final position before the opacity-only + // reveal. No measured reservation, translation, or mount CSS animation. + if (!fade || !element?.animate || media?.matches || root.ownerDocument.hidden) return; + const style = view.getComputedStyle(root); + const token = style.getPropertyValue('--openbitfun-motion-duration-fast').trim(); + const duration = parseFloat(token) * (token.endsWith('ms') ? 1 : 1000); + if (!Number.isFinite(duration) || duration <= 0) return; + reveal = element.animate([{ opacity: 0 }, { opacity: 1 }], { + duration, easing: style.getPropertyValue('--openbitfun-motion-easing-standard').trim() || 'ease', + }); + const animation = reveal; + void animation.finished.then(() => { if (reveal === animation) reveal = undefined; }, () => {}); + }; + const settle = () => { + reveal?.cancel(); + reveal = undefined; + if (held) { bypass = true; report(true, true); release(false, !root.closest('[hidden], [inert]')); } + }; + const unavailable = () => media?.matches || root.ownerDocument.hidden + || Boolean(root.closest('[hidden], [inert]')); + const sync = () => { + if (!alive) return; + const sibling = root.nextElementSibling; + const direct = sibling instanceof HTMLElement && sibling.hasAttribute('data-thinking-continuation') ? sibling : null; + const resolved = direct ? null : latest.current.resolveContinuation?.(root); + const candidate = direct ?? resolved?.element ?? null; + // A backward annotation (consecutive thoughts across rounds) is not an arrival. + const next = candidate && !candidate.hidden && candidate.querySelector(':scope > :not(:empty)') + && Boolean(root.compareDocumentPosition(candidate) & Node.DOCUMENT_POSITION_FOLLOWING) ? candidate : null; + observer.disconnect(); + if (latest.current.visuallyStreaming || armed || held) { + observer.observe(parent, { childList: true, subtree: true, attributes: true, + attributeFilter: ['hidden', 'inert', 'data-thinking-handoff-priority', 'data-openbitfun-status', 'role'] }); + // A collection can close above a memoized member without rendering it. + for (let ancestor = parent.parentElement; ancestor; ancestor = ancestor.parentElement) { + observer.observe(ancestor, { attributes: true, attributeFilter: ['hidden', 'inert'] }); + } + if (resolved?.observeRoot && resolved.observeRoot !== parent) { + observer.observe(resolved.observeRoot, { childList: true }); + } + if (resolved?.observeSubtree && resolved.observeSubtree !== parent) { + observer.observe(resolved.observeSubtree, { childList: true, subtree: true, attributes: true, + attributeFilter: ['hidden', 'data-thinking-handoff-priority', 'data-openbitfun-status', 'role'] }); + } + } + if (next !== target) bypass = false; + // A burst that already has more output should commit as one layout, + // rather than let later cards overtake a held first card. + let following = next?.nextElementSibling; + while (following instanceof HTMLElement && (following.hidden || following.getAttribute('aria-hidden') === 'true')) { + following = following.nextElementSibling; + } + const burst = following instanceof HTMLElement && !following.hidden + && following.matches('[data-thinking-continuation], .flow-thinking-item') + && following.querySelector(':scope > :not(:empty)'); + const immediate = Boolean(next && (bypass || next.matches(PRIORITY) || next.querySelector(PRIORITY) + || next.contains(root.ownerDocument.activeElement) || burst)); + if (next !== target) { + generation++; + closing = false; + restore(); + reveal?.cancel(); + reveal = undefined; + target = next; + changePhase(undefined); + // Only a successor arriving after this live row has painted can claim + // motion. History, StrictMode rehearsal and virtual remounts settle. + if (next && !seen.has(next)) { + seen.add(next); + if (!first && armed && !immediate && !unavailable()) { + held = { element: next, inert: next.getAttribute('inert'), ariaHidden: next.getAttribute('aria-hidden') }; + next.dataset.thinkingSuccessor = 'held'; + next.setAttribute('inert', ''); + next.setAttribute('aria-hidden', 'true'); + changePhase('holding'); + } else if (!first && armed && !root.closest('[hidden], [inert]')) { + changePhase('released'); + } + armed = false; + } + } + first = false; + if (!target && !armed && frame === undefined && latest.current.visuallyStreaming) { + frame = view.requestAnimationFrame(() => { + frame = undefined; + armed = latest.current.visuallyStreaming && !target && !unavailable(); + }); + } + report(Boolean(next), immediate); + if (!held) return; + if (immediate || unavailable()) { release(false, !root.closest('[hidden], [inert]')); return; } + if (latest.current.expanded || latest.current.visuallyStreaming) { + if (closing) { generation++; closing = false; changePhase('holding'); } + return; + } + if (closing) return; + closing = true; + changePhase('closing'); + const ticket = ++generation; + // Querying actual transitions flushes the new fold styles. Compact rows + // retain one line until the fade; expanded rows fold down to that line. + const parts: [Element | null, string[]][] = [ + [root.querySelector('.thinking-collapsed-header'), ['opacity']], + [root.querySelector('.thinking-expand-container'), ['opacity', 'grid-template-rows']], + [root.querySelector('.thinking-header-slot'), ['height', 'block-size']], + ]; + const transitions = parts.flatMap(([element, properties]) => element?.getAnimations?.().filter(animation => ( + 'transitionProperty' in animation && properties.includes(String(animation.transitionProperty)) + )) ?? []); + const finish = () => { if (alive && ticket === generation && held) release(true); }; + if (transitions.length) void Promise.allSettled(transitions.map(animation => animation.finished)).then(finish); + else finish(); + }; + const observer = new MutationObserver(sync); + syncRef.current = sync; + sync(); + const onVisibility = () => { if (unavailable()) settle(); }; + const onFocus = () => { if (target?.contains(root.ownerDocument.activeElement)) settle(); }; + media?.addEventListener?.('change', onVisibility); + root.ownerDocument.addEventListener('visibilitychange', onVisibility); + parent.addEventListener('focusin', onFocus); + return () => { + alive = false; + generation++; + observer.disconnect(); + if (frame !== undefined) view.cancelAnimationFrame(frame); + media?.removeEventListener?.('change', onVisibility); + root.ownerDocument.removeEventListener('visibilitychange', onVisibility); + parent.removeEventListener('focusin', onFocus); + reveal?.cancel(); + restore(); + delete root.dataset.thinkingExchange; + syncRef.current = undefined; + }; + }, [rootRef, options.identity, options.enabled, options.resolveContinuation]); + + // React updates can change an existing empty successor without a new sibling. + useIsomorphicLayoutEffect(() => { syncRef.current?.(); }); + return phase; +} diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/AgentControlToolCard.module.css b/design-system/packages/ui/src/flow-chat/tool-cards/AgentControlToolCard.module.css index 3b970cad66..8bf01921fe 100644 --- a/design-system/packages/ui/src/flow-chat/tool-cards/AgentControlToolCard.module.css +++ b/design-system/packages/ui/src/flow-chat/tool-cards/AgentControlToolCard.module.css @@ -26,11 +26,11 @@ box-sizing: border-box; display: flex; align-items: center; - gap: var(--openbitfun-space-3); + gap: var(--openbitfun-layout-card-gap-md); inline-size: 100%; max-inline-size: 100%; min-block-size: calc(2 * var(--openbitfun-control-activity-item-surface-height) - 2 * var(--openbitfun-border-width-default)); - padding: var(--openbitfun-space-3); + padding: var(--openbitfun-layout-card-padding-md); border: 0; border-radius: inherit; background: transparent; @@ -64,6 +64,8 @@ flex: 0 0 var(--openbitfun-space-10); inline-size: var(--openbitfun-space-10); block-size: var(--openbitfun-space-10); + /* Canonical artwork fills 87.5% of its canvas; align its visible leading edge. */ + margin-inline-start: calc(-1 * var(--openbitfun-space-10) / 16); pointer-events: none; } @@ -72,7 +74,7 @@ display: grid; flex: 1 1 0; min-inline-size: 0; - gap: var(--openbitfun-space-2); + gap: var(--openbitfun-layout-card-gap-sm); } .name { position: relative; @@ -80,8 +82,6 @@ align-items: center; gap: var(--openbitfun-space-2); min-inline-size: 0; - /* Reserve the action slot so hover never shifts the name or status. */ - padding-inline-end: var(--openbitfun-space-5); } .title { min-inline-size: 0; flex: 1 1 0; font-weight: var(--openbitfun-type-label-lg-font-weight); } .metadata { @@ -96,8 +96,8 @@ line-height: var(--openbitfun-type-label-sm-line-height); } .agentType { flex: 0 1 auto; min-inline-size: 0; max-inline-size: 45%; } - .separator { flex: none; } .model { flex: 1 1 0; min-inline-size: 0; } + .model:not([data-overflow='true']) { text-align: end; } .status, .statusDot, .name > [data-openbitfun-part='statusSlot'] { align-self: center; } @@ -110,6 +110,7 @@ } .status[data-tone='danger'] { color: var(--openbitfun-color-status-danger-content); } .status[data-tone='warning'] { color: var(--openbitfun-color-status-warning-content); } + .runningStatus { flex: none; } .statusDot { flex: none; inline-size: calc(var(--openbitfun-space-1) * 1.5); @@ -134,11 +135,14 @@ pointer-events: none; } - .surface:not(:disabled, [aria-disabled='true']):is(:hover, :focus-visible) .openIcon { display: block; } - - @media (hover: none), (pointer: coarse) { - .surface:not(:disabled, [aria-disabled='true']) .openIcon { display: block; } + /* The action replaces the status in place; metadata never moves on hover. */ + .surface:not(:disabled, [aria-disabled='true']):is(:hover, :focus-visible) .name > :is(.status, .statusDot, [data-openbitfun-part='statusSlot']) { + visibility: hidden; + } + .surface:not(:disabled, [aria-disabled='true']):is(:hover, :focus-visible) .name:not(:has(> .status, > .statusDot, > [data-openbitfun-part='statusSlot'])) { + padding-inline-end: var(--openbitfun-space-5); } + .surface:not(:disabled, [aria-disabled='true']):is(:hover, :focus-visible) .openIcon { display: block; } .preview { display: grid; diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/AgentControlToolCard.tsx b/design-system/packages/ui/src/flow-chat/tool-cards/AgentControlToolCard.tsx index e1a7776c1c..64bd43ad44 100644 --- a/design-system/packages/ui/src/flow-chat/tool-cards/AgentControlToolCard.tsx +++ b/design-system/packages/ui/src/flow-chat/tool-cards/AgentControlToolCard.tsx @@ -3,6 +3,7 @@ import { UserRound } from 'lucide-react'; import { Icon } from '../../components/Icon/Icon'; import { OverflowText } from '../../primitives/OverflowText'; import { Tooltip } from '../../components/Tooltip'; +import { ShimmerText } from '../../components/ShimmerText'; import { classNames } from '../../internal/classNames'; import type { FlowChatToolStatus } from './FlowChatToolCard'; import { ToolCardStatusSlot } from './ToolCardStatusSlot'; @@ -63,6 +64,8 @@ export function AgentControlToolCard({ && !['completed', 'confirmed', 'cancelled', 'rejected', 'error'].includes(status); const state = [failed && 'failed', confirmation && 'confirmation'].filter(Boolean).join(' ') || undefined; const tone = failed ? 'danger' : confirmation ? 'warning' : statusTone; + const running = !failed && !confirmation + && ['running', 'preparing', 'streaming', 'receiving'].includes(status); const label = [action, title, preview?.agentType, preview?.model, preview && summary, statusLabel, openAgentLabel] .filter(value => typeof value === 'string').join(' · '); const tooltipContent = preview ? ( @@ -130,7 +133,12 @@ export function AgentControlToolCard({ {displayTitle} - {statusLabel && preview ? ( + {statusLabel && running ? ( + + {statusLabel} + + ) : statusLabel && preview ? ( ); @@ -768,6 +773,8 @@ export interface ProminentToolCardSummaryProps { icon?: ReactNode; onAffordanceClick?: (event: ReactMouseEvent) => void; statusIcon?: ReactNode; + /** Opt into one action/content shimmer scope; false retains its layout while inactive. */ + textShimmer?: boolean; trailingActions?: ReactNode; } @@ -786,6 +793,7 @@ export function ProminentToolCardSummary({ onAffordanceClick, primaryActions, statusIcon, + textShimmer, trailingActions, }: ProminentToolCardSummaryProps) { const { messages } = useDesignSystem(); @@ -830,26 +838,8 @@ export function ProminentToolCardSummary({ const subjectText = typeof content === "string" || typeof content === "number" ? {content} : content; - - return ( -
{ - if (shouldIgnoreToggleClick(event, event.currentTarget)) { - return; - } - event.stopPropagation(); - affordanceButtonRef.current?.click(); - } : undefined} - > - {isExpandAction && affordanceButton} - {icon !== undefined && icon !== null && icon !== false && icon !== "" && ( - - )} + const summaryText = ( + <> {action !== undefined && action !== null && action !== false && action !== "" && ( )} + + ); + + return ( +
{ + if (shouldIgnoreToggleClick(event, event.currentTarget)) { + return; + } + event.stopPropagation(); + affordanceButtonRef.current?.click(); + } : undefined} + > + {isExpandAction && affordanceButton} + {icon !== undefined && icon !== null && icon !== false && icon !== "" && ( + + )} + {textShimmer === undefined ? summaryText : ( + + + {summaryText} + + + )} {extra !== undefined && extra !== null && extra !== false && ( )} {hasActionRegion && ( - + {primaryActions && {primaryActions}} - {hasAuxiliaryActions && + {hasAuxiliaryActions && {actions} {contentActions} {hasPanelAction && affordanceButton} diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/ProminentToolCards.meta.ts b/design-system/packages/ui/src/flow-chat/tool-cards/ProminentToolCards.meta.ts index 23f9b199a9..76e6869cd6 100644 --- a/design-system/packages/ui/src/flow-chat/tool-cards/ProminentToolCards.meta.ts +++ b/design-system/packages/ui/src/flow-chat/tool-cards/ProminentToolCards.meta.ts @@ -42,7 +42,11 @@ export const agentControlToolCardMeta = { { name: "openAgentLabel", type: "string" }, ], states: ["default", "hover", "loading", "error"], - tokens: [...prominentTokens, "color.surface.panel", "control.activityItem.surfaceHeight", "control.activityItem.surfaceRadius", "control.flowChat.cardGap", "control.flowChat.transcriptMaxWidth"], + tokens: [ + ...prominentTokens, "color.surface.panel", "control.activityItem.surfaceHeight", + "control.activityItem.surfaceRadius", "control.flowChat.cardGap", "control.flowChat.transcriptMaxWidth", + "layout.card.paddingMd", "layout.card.gapMd", "layout.card.gapSm", + ], } as const satisfies ComponentMeta; export const fileDiffToolCardMeta = { diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/ProminentToolCards.tsx b/design-system/packages/ui/src/flow-chat/tool-cards/ProminentToolCards.tsx index 615197aa76..492ed94149 100644 --- a/design-system/packages/ui/src/flow-chat/tool-cards/ProminentToolCards.tsx +++ b/design-system/packages/ui/src/flow-chat/tool-cards/ProminentToolCards.tsx @@ -107,8 +107,8 @@ export function GitToolCard({ )} summaryExpandAffordance={hasDetails} - isExpanded={Boolean(isExpanded && hasDetails && status !== "error")} + isExpanded={Boolean(isExpanded && hasDetails)} onToggle={hasDetails && onToggle ? onToggle : undefined} status={status} /> @@ -171,6 +171,8 @@ export function FileDiffToolCard({ {error}
: undefined} expandedContent={body} expandedContentLayout="flush" @@ -198,9 +200,9 @@ export function FileDiffToolCard({ statusIcon={loading ? : undefined} /> )} - summaryExpandAffordance={Boolean(body)} - isExpanded={Boolean(isExpanded && body && status !== "error")} - onToggle={body && onToggle ? onToggle : undefined} + summaryExpandAffordance={Boolean(body || error)} + isExpanded={Boolean(isExpanded && (body || error))} + onToggle={(body || error) && onToggle ? onToggle : undefined} status={status} /> ); @@ -314,8 +316,8 @@ function PageLifecycleToolCardBase({ )} summaryExpandAffordance={hasDetails} - isExpanded={Boolean(isExpanded && hasDetails && status !== "error")} + isExpanded={Boolean(isExpanded && hasDetails)} onToggle={hasDetails && onToggle ? onToggle : undefined} status={status} /> diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/ReadFileToolCard.tsx b/design-system/packages/ui/src/flow-chat/tool-cards/ReadFileToolCard.tsx index 3ed3119b6e..16e44d6b86 100644 --- a/design-system/packages/ui/src/flow-chat/tool-cards/ReadFileToolCard.tsx +++ b/design-system/packages/ui/src/flow-chat/tool-cards/ReadFileToolCard.tsx @@ -1,5 +1,7 @@ import type { HTMLAttributes, ReactNode } from "react"; import { Icon } from "../../components/Icon/Icon"; +import { IconButton } from "../../components/IconButton/IconButton"; +import { useDesignSystem } from "../../overlay/useDesignSystem"; import { AmbientToolCard, AmbientToolCardHeader, @@ -29,6 +31,7 @@ export function ReadFileToolCard({ statusDescription, ...props }: ReadFileToolCardProps) { + const { messages } = useDesignSystem(); const canOpen = interactive && Boolean(onOpen); return ( @@ -41,7 +44,18 @@ export function ReadFileToolCard({
; } diff --git a/design-system/packages/ui/src/index.ts b/design-system/packages/ui/src/index.ts index c788269417..03007ef67f 100644 --- a/design-system/packages/ui/src/index.ts +++ b/design-system/packages/ui/src/index.ts @@ -138,6 +138,7 @@ export { type IconTone, } from "./components/Icon"; export { IconButton, type IconButtonProps } from "./components/IconButton"; +export { ThinkingIndicator, type ThinkingIndicatorProps } from "./components/ThinkingIndicator"; export { Input, type InputProps } from "./components/Input"; export { KeyHint, type KeyHintProps } from "./components/KeyHint"; export { @@ -221,6 +222,7 @@ export { export { PageHeader, type PageHeaderProps } from "./components/PageHeader"; export { Radio, type RadioProps, type RadioSize } from "./components/Radio"; export { RollingText, type RollingTextProps } from "./components/RollingText"; +export { ShimmerText, type ShimmerTextProps } from "./components/ShimmerText"; export { ScrollArea, type ScrollAreaEdgeFade, diff --git a/design-system/packages/ui/src/registry.ts b/design-system/packages/ui/src/registry.ts index d092383269..d930a1c09c 100644 --- a/design-system/packages/ui/src/registry.ts +++ b/design-system/packages/ui/src/registry.ts @@ -15,6 +15,7 @@ import { fieldMeta } from "./components/Field/Field.meta"; import { fieldGroupMeta } from "./components/FieldGroup/FieldGroup.meta"; import { iconMeta } from "./components/Icon/Icon.meta"; import { iconButtonMeta } from "./components/IconButton/IconButton.meta"; +import { thinkingIndicatorMeta } from "./components/ThinkingIndicator/ThinkingIndicator.meta"; import { inputMeta } from "./components/Input/Input.meta"; import { keyHintMeta } from "./components/KeyHint/KeyHint.meta"; import { launcherButtonMeta } from "./components/LauncherButton/LauncherButton.meta"; @@ -28,6 +29,7 @@ import { numberBadgeMeta } from "./components/NumberBadge/NumberBadge.meta"; import { pageHeaderMeta } from "./components/PageHeader/PageHeader.meta"; import { radioMeta } from "./components/Radio/Radio.meta"; import { rollingTextMeta } from "./components/RollingText/RollingText.meta"; +import { shimmerTextMeta } from "./components/ShimmerText/ShimmerText.meta"; import { scrollAreaMeta } from "./components/ScrollArea/ScrollArea.meta"; import { searchFieldMeta } from "./components/SearchField/SearchField.meta"; import { segmentedControlMeta } from "./components/SegmentedControl/SegmentedControl.meta"; @@ -153,6 +155,7 @@ export const componentRegistry = [ grepSearchToolCardMeta, iconMeta, iconButtonMeta, + thinkingIndicatorMeta, inputMeta, keyHintMeta, launcherButtonMeta, @@ -196,6 +199,7 @@ export const componentRegistry = [ readFileToolCardMeta, reviewSummaryToolCardMeta, rollingTextMeta, + shimmerTextMeta, runCodeToolCardMeta, scrollAreaMeta, searchFieldMeta, diff --git a/design-system/packages/ui/tests/flow-chat-tool-card.test.mjs b/design-system/packages/ui/tests/flow-chat-tool-card.test.mjs index 9eb217227d..b0070f7913 100644 --- a/design-system/packages/ui/tests/flow-chat-tool-card.test.mjs +++ b/design-system/packages/ui/tests/flow-chat-tool-card.test.mjs @@ -387,6 +387,26 @@ test("prominent error status can opt into expandable supporting details", () => assert.match(markup, /Command failed/); }); +test("ordinary failure details honor disclosure for commands, diffs, pages and compression", () => { + const views = [ + [CommandToolCard, { action: 'Run', command: 'build' }], + [FileDiffToolCard, { action: 'Diff', path: '/repo/a.ts', pathLabel: 'a.ts' }], + [GitToolCard, { action: 'Git', command: 'git status' }], + [PageDeployToolCard, { action: 'Deploy', subject: 'Page' }], + [PagePublishToolCard, { action: 'Publish', subject: 'Page' }], + [ContextCompressionToolCard, { title: 'Compress context' }], + ]; + for (const [View, props] of views) { + const render = isExpanded => renderToStaticMarkup(createElement(View, { + ...props, status: 'error', error: 'Detailed failure', isExpanded, onToggle() {}, + })); + assert.doesNotMatch(render(false), /Detailed failure/); + assert.match(render(false), /aria-expanded="false"/); + assert.match(render(true), /Detailed failure/); + assert.match(render(true), /aria-expanded="true"/); + } +}); + test("cancelled and rejected tool cards rely on status copy instead of a duplicate x glyph", () => { for (const [status, statusLabel] of [ ["cancelled", "Cancelled"], @@ -495,7 +515,7 @@ test("auxiliary actions reveal on hover or keyboard focus and remain available w const styles = await readFile(new URL("../src/flow-chat/tool-cards/FlowChatToolCard.module.css", import.meta.url), "utf8"); assert.match(styles, /data-openbitfun-preview-state="hover"/); - assert.match(styles, /:focus-within/); + assert.match(styles, /:has\(:focus-visible\)/); assert.match(styles, /@media \(hover: none\), \(pointer: coarse\)/); assert.match(styles, /opacity:\s*0/); assert.match(styles, /pointer-events:\s*none/); @@ -555,7 +575,7 @@ test("ambient disclosure keeps compact headers and puts detail in a separate tok assert.match(resultRule, /background:\s*var\(--openbitfun-color-action-neutral-surface\)/); }); -test("prominent summary geometry stays stable when actions appear and empty slots stay inert", async () => { +test("prominent headers keep their height while hidden actions and empty slots release their space", async () => { const styles = await readFile( new URL("../src/flow-chat/tool-cards/FlowChatToolCard.module.css", import.meta.url), "utf8", @@ -564,11 +584,15 @@ test("prominent summary geometry stays stable when actions appear and empty slot const prominentSummaryRule = styles.match(/\.prominentSummary\s*\{([^}]*)\}/s)?.[1]; const actionRegionRule = styles.match(/\.prominentSummary \.actionRegion\s*\{([^}]*)\}/s)?.[1]; const hiddenActionRule = styles.match(/\.toolCardActions\[data-reveal="hover"\]\s*\{([^}]*)\}/s)?.[1]; + const hiddenLayoutRule = styles.match(/\.prominentSummary \.hoverActions\s*\{([^}]*)\}/s)?.[1]; + const revealedLayoutRule = styles.match(/\.prominentSummary:is\(:hover, :has\(:focus-visible\)\) \.hoverActions,[^{]*\{([^}]*)\}/s)?.[1]; assert.ok(rootRule, "root token block"); assert.ok(prominentSummaryRule, "prominent summary rule"); assert.ok(actionRegionRule, "right-aligned prominent action region"); assert.ok(hiddenActionRule, "shared auxiliary action reveal"); + assert.ok(hiddenLayoutRule, "hidden trailing controls leave the flex layout"); + assert.ok(revealedLayoutRule, "hover and keyboard focus restore trailing controls"); // The 40px outer surface includes its borders. A 20px layout slot lets the // existing larger button hit targets remain usable without growing the row. @@ -580,10 +604,15 @@ test("prominent summary geometry stays stable when actions appear and empty slot assert.match(prominentSummaryRule, /padding-inline:\s*var\(--openbitfun-control-flow-chat-card-padding-inline\);/); assert.match(styles, /\.prominentSummary \.actionRegion\s*\{\s*block-size:\s*var\(--openbitfun-space-5\);/); - // Reveal only changes visibility. The text and trailing controls keep their widths. + // Hidden trailing controls leave no width or flex gap, but remain focusable. assert.match(actionRegionRule, /margin-inline-start:\s*auto;/); assert.match(hiddenActionRule, /opacity:\s*0;/); - assert.doesNotMatch(hiddenActionRule, /(?:inline-size|margin|padding|transform|visibility):/); + assert.match(hiddenLayoutRule, /position:\s*absolute;/); + assert.match(hiddenLayoutRule, /pointer-events:\s*none;/); + assert.doesNotMatch(hiddenLayoutRule, /display:\s*none|visibility:\s*hidden/); + assert.match(revealedLayoutRule, /position:\s*static;/); + assert.match(revealedLayoutRule, /pointer-events:\s*auto;/); + assert.match(styles, /@media \(hover: none\), \(pointer: coarse\)\s*\{\s*\.prominentSummary \.hoverActions\s*\{[^}]*position:\s*static;/s); // A fragment whose conditions are all false still mounts the slot. assert.match(styles, /\.extra:empty\s*\{[^}]*display:\s*none/); @@ -1090,6 +1119,11 @@ test("concrete tool views expose semantic parts instead of legacy CSS selectors" status: "running", statusLabel: "Running", summary: "Review the shared FlowChat boundary", + preview: { + agentType: "Reviewer", + model: "Model", + labels: { agentType: "Agent type", model: "Model", description: "Task" }, + }, })); const fetchMarkup = renderToStaticMarkup(createElement(WebFetchToolCard, { details: ["markdown"], @@ -1111,6 +1145,7 @@ test("concrete tool views expose semantic parts instead of legacy CSS selectors" assert.match(agentMarkup, /data-openbitfun-part="agentSummary"/); assert.match(agentMarkup, /data-openbitfun-part="agentStatus"/); + assert.match(agentMarkup, /data-openbitfun-component="shimmer-text"[^>]*>Running<\/span>/); assert.match(agentMarkup, /data-agent-capsule-trigger="true"/); assert.match(agentMarkup, /data-openbitfun-affordance="open-panel-right"/); assert.doesNotMatch(agentMarkup, /aria-expanded|expandedCollapse|interruptAgentButton|lucide-chevron-down/); diff --git a/design-system/packages/ui/tests/registry.test.mjs b/design-system/packages/ui/tests/registry.test.mjs index b8ef56364d..54ece71bca 100644 --- a/design-system/packages/ui/tests/registry.test.mjs +++ b/design-system/packages/ui/tests/registry.test.mjs @@ -54,6 +54,7 @@ test("registry exposes only the formal stable components", () => { "GrepSearchToolCard", "Icon", "IconButton", + "ThinkingIndicator", "Input", "KeyHint", "LauncherButton", @@ -97,6 +98,7 @@ test("registry exposes only the formal stable components", () => { "ReadFileToolCard", "ReviewSummaryToolCard", "RollingText", + "ShimmerText", "RunCodeToolCard", "ScrollArea", "SearchField", diff --git a/packages/flow-chat-presentation/src/exec/ExecProcessPresentation.tsx b/packages/flow-chat-presentation/src/exec/ExecProcessPresentation.tsx index 14cfc27a0c..4e29e5df13 100644 --- a/packages/flow-chat-presentation/src/exec/ExecProcessPresentation.tsx +++ b/packages/flow-chat-presentation/src/exec/ExecProcessPresentation.tsx @@ -1,13 +1,11 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type Ref } from 'react'; +import { useCallback, useMemo, useRef, useState, type ReactNode, type Ref } from 'react'; import { CommandToolCard, type CommandToolCardFooterItem } from '@openbitfun/ui/flow-chat'; -import { systemPresentationClock, type ExecProcessCardModel, type ExecToolSnapshot, type PresentationClock, type PresentationTranslate } from './contracts'; +import type { ExecProcessCardModel, ExecToolSnapshot, PresentationClock, PresentationTranslate } from './contracts'; import { formatSessionViewPreviewText } from '../sessionViewPreview'; import { ExecRelationPresentation } from './ExecRelationPresentation'; const EXEC_COLLAPSED_STATUSES = new Set(['completed', 'cancelled', 'error', 'rejected']); -const EXEC_OUTPUT_STREAMING_MAX_ROWS = 4; const EXEC_OUTPUT_EXPANDED_MAX_ROWS = 15; -const EXEC_MINIMUM_EXPANDED_MS = 1000; export interface ExecStatusPresentation { startTime?: number; @@ -42,10 +40,6 @@ function isCollapsedStatus(status: string): boolean { return EXEC_COLLAPSED_STATUSES.has(status); } -function getInitialExpandedState(status: string, hasLiveOutput: boolean, prominent: boolean): boolean { - return !isCollapsedStatus(status) && (hasLiveOutput || (prominent && status === 'pending_confirmation')); -} - function isCancelledStatus(status: string): boolean { return status === 'cancelled'; } @@ -89,7 +83,7 @@ export function ExecProcessPresentation(props: ExecProcessPresentationProps) { function ExecCommandPresentation({ toolItem, model, attention = 'prominent', t, rootRef, onExpandedChange, primaryCopied = false, onCopyPrimary, renderOutput, renderOutputAction, renderStatus, - clock = systemPresentationClock, initialExpanded, previewState, + initialExpanded = false, previewState, }: ExecProcessPresentationProps) { const status = toolItem.status || 'pending'; const isParamsStreaming = Boolean(toolItem.isParamsStreaming); @@ -107,74 +101,15 @@ function ExecCommandPresentation({ ? 'toolCards.terminal.rejected' : 'toolCards.terminal.cancelled'; const toolId = toolItem.id ?? toolItem.toolCall?.id; - const hasLiveOutput = liveOutput.length > 0; - - const [isExpanded, setIsExpandedState] = useState(() => initialExpanded ?? getInitialExpandedState(status, hasLiveOutput, attention === 'prominent')); - const userToggledRef = useRef(initialExpanded !== undefined); - const autoExpandedAtRef = useRef(null); + const [isExpanded, setIsExpandedState] = useState(initialExpanded); const outputRendererRef = useRef(null); - const applyExecExpandedState = useCallback((nextExpanded: boolean) => { - if (nextExpanded === isExpanded) { - return; - } - + const toggleExpanded = useCallback(() => { + const nextExpanded = !isExpanded; setIsExpandedState(nextExpanded); onExpandedChange?.(nextExpanded); }, [isExpanded, onExpandedChange]); - const toggleExpanded = useCallback(() => { - userToggledRef.current = true; - applyExecExpandedState(!isExpanded); - }, [applyExecExpandedState, isExpanded]); - - useLayoutEffect(() => { - if (userToggledRef.current) { - return; - } - - if (isRunning && hasLiveOutput) { - // Start once, including when mounting with output already available. - autoExpandedAtRef.current ??= clock.now(); - applyExecExpandedState(true); - } else if (isCollapsedStatus(status)) { - const remainingMs = autoExpandedAtRef.current === null - ? 0 - : EXEC_MINIMUM_EXPANDED_MS - (clock.now() - autoExpandedAtRef.current); - if (isExpanded && remainingMs > 0) { - return clock.schedule(() => { - if (!userToggledRef.current) { - applyExecExpandedState(false); - } - }, remainingMs); - } - applyExecExpandedState(false); - } else if (isRunning && autoExpandedAtRef.current === null) { - applyExecExpandedState(false); - } - }, [ - applyExecExpandedState, - clock, - hasLiveOutput, - isExpanded, - isRunning, - status, - ]); - - const compactSettledPreview = - isExpanded && - isCollapsedStatus(status) && - !userToggledRef.current; - // Keep auto-managed completed cards on the compact preview through the - // collapse animation. A manually expanded card remains eligible for the - // full output preview. - const keepAutoCompletionPreview = - status === 'completed' && - !userToggledRef.current; - const keepCompactCompletionPreview = - keepAutoCompletionPreview || compactSettledPreview; - const maxRows = isRunning || keepCompactCompletionPreview - ? EXEC_OUTPUT_STREAMING_MAX_ROWS - : EXEC_OUTPUT_EXPANDED_MAX_ROWS; + const maxRows = EXEC_OUTPUT_EXPANDED_MAX_ROWS; const completedDurationMs = formatSecondsAsMs(model.wallTimeSeconds) ?? toolItem.toolResult?.duration_ms ?? toolItem.durationMs; const timeoutMs = typeof toolItem.toolCall?.input?.yield_time_ms === 'number' && toolItem.toolCall.input.yield_time_ms > 0 @@ -293,8 +228,8 @@ function ExecCommandPresentation({ output={outputText ? renderOutput({ ref: outputRendererRef, content: outputText, maxRows, surface: 'embedded' }) : undefined} outputAction={outputText ? renderOutputAction(getOutputText) : undefined} outputLabel={t('toolCards.common.executionResult')} - outputDensity={keepCompactCompletionPreview || isRunning ? 'compact' : 'expanded'} - outputSizing={isRunning || isParamsStreaming ? 'fixed' : 'content'} + outputDensity="expanded" + outputSizing="content" reserveFooter={isRunning || isParamsStreaming} reserveOutput={isRunning || isParamsStreaming} requiresConfirmation={status === 'pending_confirmation'} diff --git a/packages/flow-chat-presentation/src/exec/ExecRelationPresentation.tsx b/packages/flow-chat-presentation/src/exec/ExecRelationPresentation.tsx index ebbd0fd9cb..46e3307f1b 100644 --- a/packages/flow-chat-presentation/src/exec/ExecRelationPresentation.tsx +++ b/packages/flow-chat-presentation/src/exec/ExecRelationPresentation.tsx @@ -37,8 +37,7 @@ export function ExecRelationPresentation({ toolItem, model, t, rootRef, renderOu ; return
-
; } diff --git a/packages/flow-chat-presentation/src/scroll.ts b/packages/flow-chat-presentation/src/scroll.ts index 2e87aa47cf..44044fcb49 100644 --- a/packages/flow-chat-presentation/src/scroll.ts +++ b/packages/flow-chat-presentation/src/scroll.ts @@ -158,6 +158,16 @@ export function useContainedTailFollow({ enabled: isExpanded, active: isActive, tailFollowFrameRef.current = requestAnimationFrame(runFrame); }, [detectUpwardScroll, recordScrollPosition]); + /** An explicit return to the live preview releases a reader's earlier pause. */ + const resume = useCallback(() => { + stopTailFollow(); + shouldFollowTailRef.current = true; + tailFollowPauseVersionRef.current += 1; + tailFollowUserPauseUntilMsRef.current = 0; + lastScrollPositionRef.current = null; + if (isExpanded && contentRef.current) scheduleTailFollow(tailFollowPauseVersionRef.current); + }, [isExpanded, scheduleTailFollow, stopTailFollow]); + /** A follow in flight outlives neither the card nor its collapse. */ useEffect(() => stopTailFollow, [isExpanded, stopTailFollow]); @@ -312,7 +322,7 @@ export function useContainedTailFollow({ enabled: isExpanded, active: isActive, }, [isExpanded, pauseTailFollowForUserScroll]); return { - contentRef, scrollState, pause: pauseTailFollowForUserScroll, + contentRef, scrollState, pause: pauseTailFollowForUserScroll, resume, contentProps: { onScroll: checkScrollState, onWheelCapture: handleContentWheelCapture, onTouchStart: handleContentTouchStart, onTouchMove: handleContentTouchMove, diff --git a/packages/flow-chat-presentation/tests/flow-group.test.tsx b/packages/flow-chat-presentation/tests/flow-group.test.tsx index ad36386dd3..f7cdb95840 100644 --- a/packages/flow-chat-presentation/tests/flow-group.test.tsx +++ b/packages/flow-chat-presentation/tests/flow-group.test.tsx @@ -55,6 +55,46 @@ it('supports keyboard disclosure while leaving state with the host', () => { expect(header.getAttribute('aria-expanded')).toBe('false'); }); +it('shares one file identity and keeps open-file independent from revision disclosure', () => { + const toggle = vi.fn(); + const open = vi.fn(); + const render = (expanded: boolean, count = 3) => act(() => root.render()); + render(false); + const header = container.querySelector('[data-openbitfun-part="header"]'); + const disclosure = container.querySelector('[aria-expanded]')!; + expect(disclosure.tagName).toBe('BUTTON'); + expect(disclosure.getAttribute('aria-label')).toContain('/remote/src/App.tsx'); + expect(container.querySelectorAll('[data-layer]')).toHaveLength(2); + expect(container.querySelector('[data-openbitfun-part="summary"]')?.textContent).toBe('3 edits: App.tsx'); + expect(header?.querySelector('[data-openbitfun-change="added"]')?.textContent).toBe('+18'); + expect(header?.querySelector('[data-openbitfun-change="removed"]')?.textContent).toBe('-6'); + const openButton = container.querySelector('[aria-label="Open file"]')!; + act(() => openButton.click()); + expect(open).toHaveBeenCalledOnce(); + expect(toggle).not.toHaveBeenCalled(); + act(() => disclosure.click()); + expect(toggle).toHaveBeenCalledWith(true); + render(true); + expect(container.querySelector('[data-openbitfun-part="header"]')).toBe(header); + expect(header?.querySelector('[data-openbitfun-part="summary"]')?.textContent).toBe('File edits: App.tsx'); + expect(header?.querySelector('[data-openbitfun-part="changeSummary"]')).toBeNull(); + expect(header?.querySelector('[aria-label="Open file"]')).toBe(openButton); + expect(disclosure.getAttribute('aria-expanded')).toBe('true'); + expect(container.textContent).toContain('Revision details'); + expect(container.querySelector('[data-file-revisions]')).not.toBeNull(); + expect(container.querySelector('.explore-region--bounded')).toBeNull(); + render(false, 2); + expect(header?.querySelector('[data-openbitfun-part="summary"]')?.textContent).toBe('3 edits: App.tsx'); + expect(header?.querySelector('[data-openbitfun-change="added"]')?.textContent).toBe('+18'); + expect(container.querySelectorAll('[data-layer]')).toHaveLength(1); + expect(container.querySelector('[data-openbitfun-part="contentWrapper"]')?.hasAttribute('inert')).toBe(true); +}); + function BrowserHarness({ expanded = true, toggle, initialQuery = '' }: { expanded?: boolean; toggle?: (expanded: boolean) => void; initialQuery?: string; }) { diff --git a/packages/flow-chat-presentation/tests/presentation.test.tsx b/packages/flow-chat-presentation/tests/presentation.test.tsx index 00482ce575..adce0c5e13 100644 --- a/packages/flow-chat-presentation/tests/presentation.test.tsx +++ b/packages/flow-chat-presentation/tests/presentation.test.tsx @@ -149,33 +149,35 @@ describe('shared raw-input scenario playback', () => { expect(preview.hasAttribute('tabindex')).toBe(false); }); - it.each(['ExecCommand'])('%s waits for actual output and honors the original one-second grace period', (toolName) => { - const steps = execScenarios.find(({ id }) => id === `${toolName}-lifecycle`)!.steps; - const p = player(); - p.render(steps[0]); - expect(container.querySelector(expandedSurface)).toBeNull(); - p.render(steps[1]); - expect(container.querySelector(expandedSurface)).toBeNull(); - p.render(steps[2]); - expect(container.querySelector(expandedSurface)).not.toBeNull(); - expect(p.output.mock.lastCall?.[0].maxRows).toBe(4); + it.each(['ambient', 'prominent'] as const)('keeps %s Shell collapsed through live output and completion with progress in the left icon', (attention) => { + const steps = execScenarios.find(({ id }) => id === 'ExecCommand-lifecycle')!.steps; + const p = player(attention); + for (const step of steps.slice(0, 3)) { + p.render(step); + expect(container.querySelector(expandedSurface)).toBeNull(); + expect(container.querySelector('[data-openbitfun-part="icon"] [data-openbitfun-part="processing"]')).not.toBeNull(); + expect(container.querySelector('[data-openbitfun-part="statusIcon"]')).toBeNull(); + } p.render(steps[3]); - expect(container.querySelector(expandedSurface)).not.toBeNull(); - act(() => p.clock.advanceTo(SCENARIO_EPOCH + 1199)); - expect(container.querySelector(expandedSurface)).not.toBeNull(); - act(() => p.clock.advanceTo(SCENARIO_EPOCH + 1200)); expect(container.querySelector(expandedSurface)).toBeNull(); - expect(p.change.mock.calls.map(([open]) => open)).toEqual([true, false]); + expect(container.querySelector('[data-openbitfun-part="processing"]')).toBeNull(); + expect(p.change).not.toHaveBeenCalled(); p.toggle(); expect(p.output.mock.lastCall?.[0].maxRows).toBe(15); }); - it('manual choices override pending auto-collapse; replay restores defaults', () => { + it('preserves manual expansion through completion; replay restores the collapsed default', () => { const steps = execScenarios[0].steps; const p = player(); - p.render(steps[2]); p.render(steps[3]); p.toggle(); p.toggle(); + p.render(steps[2]); + expect(container.querySelector(expandedSurface)).toBeNull(); + p.toggle(); + expect(p.output.mock.lastCall?.[0].maxRows).toBe(15); + p.render(steps[3]); act(() => p.clock.advanceTo(SCENARIO_EPOCH + 1500)); expect(container.querySelector(expandedSurface)).not.toBeNull(); + expect(p.output.mock.lastCall?.[0].maxRows).toBe(15); + expect(p.change.mock.calls.map(([open]) => open)).toEqual([true]); p.render({ ...steps[3], at: 1500 }, 'reloaded-history'); expect(container.querySelector(expandedSurface)).toBeNull(); }); @@ -183,7 +185,7 @@ describe('shared raw-input scenario playback', () => { it('does not reopen a manually collapsed stream when additional output arrives', () => { const p = player(); const step = execScenarios[0].steps[2]; - p.render(step); p.toggle(); + p.render(step); p.toggle(); p.toggle(); p.render({ ...step, at: 350, item: { ...step.item, _progressLogs: ['More output'] } }); expect(container.querySelector(expandedSurface)).toBeNull(); }); diff --git a/packages/flow-chat-presentation/tests/relation-row.test.tsx b/packages/flow-chat-presentation/tests/relation-row.test.tsx index 85ad709436..7d838c67bb 100644 --- a/packages/flow-chat-presentation/tests/relation-row.test.tsx +++ b/packages/flow-chat-presentation/tests/relation-row.test.tsx @@ -16,7 +16,7 @@ afterEach(() => { act(() => root.unmount()); container.remove(); }); const relationship: ToolCardInteraction = { operation: 'send', source: { label: 'Current session', kind: 'session' }, - target: { id: 'result', label: 'Maintainer', kind: 'agent', details: 'Historical agent record' }, + target: { id: 'result', label: 'Maintainer', kind: 'agent' }, }; function click(selector: string) { act(() => container.querySelector(selector)!.click()); } @@ -51,11 +51,15 @@ describe('relationship element interactions', () => { expect(container.querySelector('.lucide-arrow-right')).toBeNull(); }); - it('inspects an unavailable object independently, including IDs that match result slot names', () => { + it('keeps unavailable nodes disabled and opens their recorded details only through the outcome', () => { act(() => root.render()); + const target = container.querySelector('[data-openbitfun-part="target"]')!; + expect(target.disabled).toBe(true); + expect(target.hasAttribute('aria-haspopup')).toBe(false); click('[data-openbitfun-part="target"]'); - expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Historical agent record'); - expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain('Deletion record'); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + click('[data-openbitfun-part="result"]'); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Deletion record'); }); }); @@ -74,6 +78,8 @@ describe('process relationship records', () => { toolCall: { input: { session_id: 24, chars: 'continue\n' } }, _progressMessage: 'running output' }); expect(expanded()).toBe(false); expect(container.querySelector('[aria-expanded]')).toBeNull(); + click('[data-openbitfun-part="target"]'); + expect(document.querySelector('[role="dialog"]')).toBeNull(); click('[data-openbitfun-part="result"]'); expect(document.querySelector('[role="dialog"]')?.textContent).toContain('continue\n'); expect(document.querySelector('[role="dialog"]')?.textContent).toContain('running output'); diff --git a/packages/flow-chat-presentation/tests/terminal-and-replay.test.tsx b/packages/flow-chat-presentation/tests/terminal-and-replay.test.tsx index 52c7f9b1af..6c617e4292 100644 --- a/packages/flow-chat-presentation/tests/terminal-and-replay.test.tsx +++ b/packages/flow-chat-presentation/tests/terminal-and-replay.test.tsx @@ -123,14 +123,14 @@ it('Lab next-step playback retains the real card and manual state; Replay resets act(() => next.click()); expect(expanded()).toBeNull(); await act(async () => { next.click(); }); + expect(expanded()).toBeNull(); + await act(async () => { card!.querySelector('[data-openbitfun-part="surface"]')!.click(); }); await act(async () => { await vi.dynamicImportSettled(); }); expect(container.querySelector('[data-openbitfun-adapter="exec-process-tool-card"]')).toBe(card); expect(expanded()).not.toBeNull(); expect(container.querySelector('.terminal-output-renderer__xterm-host')).not.toBeNull(); act(() => next.click()); - // Override the pending grace-period collapse, exactly as a reader can in production. - act(() => (expanded() as HTMLElement).click()); - act(() => card!.querySelector('[data-openbitfun-part="surface"]')!.click()); + // Completion preserves the reader's explicit expansion. act(() => next.click()); expect(expanded()).not.toBeNull(); expect(next.disabled).toBe(true); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 464a369874..070840d469 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -692,7 +692,7 @@ const WorkspaceItem: React.FC = ({ }); return; } - const newSessionId = await flowChatManager.createChatSession( + const newSessionId = await flowChatManager.createChatDraft( flowChatSessionConfigForWorkspace(workspace), resolvedMode ); diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index f8b72d4f8f..73300daa8a 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -326,7 +326,7 @@ const AppLayout: React.FC = ({ className = '' }) => { currentWorkspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : explicitPreferredMode; - sessionId = await flowChatManager.createChatSession( + sessionId = await flowChatManager.createChatDraft( flowChatSessionConfigForWorkspace(currentWorkspace), initialSessionMode, ); @@ -584,7 +584,7 @@ const AppLayout: React.FC = ({ className = '' }) => { } const flowChatManager = FlowChatManager.getInstance(); const sessionConfig = flowChatSessionConfigForWorkspace(currentWorkspace); - const sessionId = await flowChatManager.createChatSession(sessionConfig); + const sessionId = await flowChatManager.createChatDraft(sessionConfig); await openMainSession(sessionId); } catch (error) { log.error('Failed to create FlowChat session', error); diff --git a/src/web-ui/src/app/scenes/settings/components/ArchivedSessionsConfig.tsx b/src/web-ui/src/app/scenes/settings/components/ArchivedSessionsConfig.tsx index d90c3f2e54..45c44ecb81 100644 --- a/src/web-ui/src/app/scenes/settings/components/ArchivedSessionsConfig.tsx +++ b/src/web-ui/src/app/scenes/settings/components/ArchivedSessionsConfig.tsx @@ -26,7 +26,7 @@ import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import type { SessionMetadata } from '@/shared/types/session-history'; -import { i18nService } from '@/infrastructure/i18n'; +import { i18nService, useI18n } from '@/infrastructure/i18n'; import './ArchivedSessionsConfig.scss'; const log = createLogger('ArchivedSessionsConfig'); @@ -156,6 +156,7 @@ const ArchivedRow: React.FC = ({ const ArchivedSessionsConfig: React.FC = () => { const { t } = useTranslation('common'); + const { t: tSettings } = useI18n('settings'); const { openedWorkspacesList } = useWorkspaceContext(); const [loading, setLoading] = useState(true); @@ -483,6 +484,7 @@ const ArchivedSessionsConfig: React.FC = () => { {loadFailures.map(failure => ( diff --git a/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx b/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx index 485965ad9a..8fe3012dc9 100644 --- a/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx +++ b/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx @@ -606,6 +606,16 @@ const KeyboardShortcutsTab: React.FC = () => { filteredByScope('filetree').length > 0 || filteredByScope('git').length > 0; + const scopeDescriptions: Record = { + app: t('keyboard.scopeDescriptions.app'), + chat: t('keyboard.scopeDescriptions.chat'), + editor: t('keyboard.scopeDescriptions.editor'), + terminal: t('keyboard.scopeDescriptions.terminal'), + canvas: t('keyboard.scopeDescriptions.canvas'), + filetree: t('keyboard.scopeDescriptions.filetree'), + git: t('keyboard.scopeDescriptions.git'), + }; + const hasPendingChanges = Object.keys(pendingChanges).length > 0; const discardPendingChanges = useCallback(() => { setPendingChanges({}); @@ -699,6 +709,7 @@ const KeyboardShortcutsTab: React.FC = () => {
{showMergedTab && ( diff --git a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts index d294f2c54d..71966f3084 100644 --- a/src/web-ui/src/app/scenes/settings/settingsRegistry.ts +++ b/src/web-ui/src/app/scenes/settings/settingsRegistry.ts @@ -59,11 +59,10 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ namespaces: ['settings', 'settings/application'], searchPhrases: [ phrase('settings/application', 'applicationGroups.startupAndUpdates.title'), - phrase('settings/application', 'applicationGroups.startupAndUpdates.description'), phrase('settings/application', 'applicationGroups.windowAndNotifications.title'), - phrase('settings/application', 'applicationGroups.windowAndNotifications.description'), - phrase('settings/application', 'launchAtLogin.title'), - phrase('settings/application', 'autoUpdate.title'), + phrase('settings/application', 'launchAtLogin.toggleLabel'), + phrase('settings/application', 'preventSleep.toggleLabel'), + phrase('settings/application', 'autoUpdate.toggleLabel'), phrase('settings/application', 'notifications.title'), ], load: () => import('../../../infrastructure/config/components/ApplicationSettingsPages').then((module) => ({ @@ -139,7 +138,7 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ namespaces: ['settings', 'settings/application'], searchPhrases: [ phrase('settings/application', 'terminal.sections.terminal'), - phrase('settings/application', 'terminal.sections.terminalHint'), + phrase('settings/application', 'terminal.controls.description'), ], load: () => import('../../../infrastructure/config/components/ApplicationSettingsPages').then((module) => ({ default: module.TerminalSettingsPage, @@ -174,6 +173,8 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ phrase('settings/default-model', 'sections.defaults'), phrase('settings/default-model', 'sections.providers'), phrase('settings/default-model', 'sections.proxy'), + phrase('settings/models', 'subscriptionAuth.sectionTitle'), + phrase('settings/models', 'modelsDevCatalog.title'), phrase('settings/models', 'streamIdleTimeout.title'), ], load: () => import('../../../infrastructure/config/components/ModelSettingsPage'), @@ -189,7 +190,6 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ phrase('settings/memory', 'title'), phrase('settings/memory', 'subtitle'), phrase('settings/memory', 'sections.basic.title'), - phrase('settings/memory', 'sections.basic.description'), phrase('settings/memory', 'sections.models.title'), phrase('settings/memory', 'sections.advanced.title'), phrase('settings/memory', 'fields.memoryEnabled.label'), @@ -389,7 +389,8 @@ export const SETTINGS_PAGE_MANIFESTS: readonly SettingsPageManifest[] = [ namespaces: ['settings', 'settings/application'], searchPhrases: [ phrase('settings/application', 'logging.sections.logging'), - phrase('settings/application', 'logging.sections.loggingHint'), + phrase('settings/application', 'logging.sections.level'), + phrase('settings/application', 'logging.diagnostics.label'), ], load: () => import('../../../infrastructure/config/components/ApplicationSettingsPages').then((module) => ({ default: module.DiagnosticsSettingsPage, diff --git a/src/web-ui/src/flow_chat/_item-rhythm.scss b/src/web-ui/src/flow_chat/_item-rhythm.scss index 046811bcfd..ab0e60860b 100644 --- a/src/web-ui/src/flow_chat/_item-rhythm.scss +++ b/src/web-ui/src/flow_chat/_item-rhythm.scss @@ -7,6 +7,11 @@ margin: 0 0 var(--openbitfun-control-flow-chat-flow-item-gap) 0; } + // Side-docked reasoning occupies the next item's gutter, not a transcript row. + > .flow-thinking-item[data-thinking-attachment='side'] { + margin-bottom: 0; + } + // Defensive fallback for a card renderer that returns no content. Known // hidden items are removed before composition, including their wrappers. > .flowchat-flow-item:has(> .flow-tool-card-wrapper:empty) { diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 5b921c9966..99e416daee 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -133,7 +133,6 @@ import { PendingQueuePanel } from './PendingQueuePanel'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { openBtwSessionInAuxPane, selectActiveBtwSessionTab } from '../services/btwSessionPane'; import { resolveSessionRelationship } from '../utils/sessionMetadata'; -import { isProjectedSessionEmpty } from '../utils/flowChatTurnIdentity'; import { canSwitchSessionMainAgent, isChatInputActionVisibleForTarget, @@ -233,6 +232,9 @@ import { isSessionInUseError } from '@/infrastructure/api/errors/TauriCommandErr import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; import { isBtwSessionDraft } from '../utils/modelSelectionTarget'; +import { hasSessionStarted, isSessionBindingLocked } from '../utils/sessionLifecycle'; +import { prepareSessionDraftForCommand } from '../services/sessionDraftService'; +import { useSessionWorkspaceSelection } from '../hooks/useSessionWorkspaceSelection'; import { SubagentAvatar, resolveSubagentNameKey } from '../subagent-identity'; import { sessionLineageLifecycleForSession } from '../utils/sessionLineage'; import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI'; @@ -592,6 +594,8 @@ export const ChatInput: React.FC = ({ const [activeTurnPermissionMode, setActiveTurnPermissionMode] = useState(null); const [isHarnessSessionCreating, setIsHarnessSessionCreating] = useState(false); + const [isWorkspaceSubmitting, setIsWorkspaceSubmitting] = useState(false); + const workspaceSubmittingRef = useRef(false); const permissionModeRequestGenerationRef = useRef(0); const permissionModeLifecycleRef = useRef<{ sessionId: string | null; @@ -697,14 +701,10 @@ export const ChatInput: React.FC = ({ const isBtwDraftTarget = isBtwSessionDraft(effectiveTargetSession); const btwDraftSettingsInherited = isBtwDraftTarget && Boolean(peer?.peerMode.active) && peer?.currentPeerCapabilities?.btwInitialModelSelectionV1 !== true; - const effectiveTargetSessionHasTurns = effectiveTargetSession - ? !isProjectedSessionEmpty(effectiveTargetSession) - : false; // A submission keeps the session started even if every surviving Turn is // later rolled back. Before that first submission, the composer intentionally // stays expanded instead of collapsing as the empty draft is measured. - const effectiveTargetSessionStarted = effectiveTargetSessionHasTurns - || Boolean(effectiveTargetSession?.lastSubmittedMode?.trim()); + const effectiveTargetSessionStarted = effectiveTargetSession ? hasSessionStarted(effectiveTargetSession) : false; const isNewSessionComposer = !effectiveTargetSessionStarted; const dispatchObserverJob = dispatchJobStore(state => { const jobId = effectiveTargetSession?.config.dispatchJobId; @@ -1120,25 +1120,32 @@ export const ChatInput: React.FC = ({ const hasRegisteredWorkspace = Boolean( registration && Object.prototype.hasOwnProperty.call(registration, 'workspacePath'), ); + const { openedWorkspaces } = useWorkspaceContext(); + const workspaceSelection = useSessionWorkspaceSelection( + effectiveTargetSession, isWorkspaceSubmitting || !!derivedState?.isProcessing, + ); + const draftWorkspaceId = !hasRegisteredWorkspace ? workspaceSelection.draftWorkspaceId : undefined; + const draftTargetWorkspace = draftWorkspaceId ? workspaceSelection.selectedWorkspace : undefined; const workspacePath = hasRegisteredWorkspace ? (registration?.workspacePath || '').trim() + : draftWorkspaceId ? draftTargetWorkspace?.rootPath ?? '' : conversationScope ? (currentSession?.workspacePath ?? '') : currentWorkspacePath; const workspaceName = hasRegisteredWorkspace ? (workspacePath ? path.basename(workspacePath) : '') + : draftWorkspaceId ? draftTargetWorkspace?.name ?? '' : conversationScope ? (workspacePath ? path.basename(workspacePath) : '') : currentWorkspaceName; const sessionBoundWorkspacePath = ( - (!hasRegisteredWorkspace && effectiveTargetSession?.workspacePath) + (!draftWorkspaceId && !hasRegisteredWorkspace && effectiveTargetSession?.workspacePath) || workspacePath || '' ).trim(); const workspacePathRef = useRef(sessionBoundWorkspacePath); workspacePathRef.current = sessionBoundWorkspacePath; - const { openedWorkspaces } = useWorkspaceContext(); const contextWorkspace = useMemo(() => ( - effectiveTargetSession + draftWorkspaceId ? draftTargetWorkspace : effectiveTargetSession ? findWorkspaceForSession(effectiveTargetSession, openedWorkspaces.values()) : workspace ?? undefined - ), [effectiveTargetSession, openedWorkspaces, workspace]); + ), [draftWorkspaceId, draftTargetWorkspace, effectiveTargetSession, openedWorkspaces, workspace]); // Workspace record the session's own state and configuration are addressed // with. A worktree-isolated session belongs to the project it was started // from: its worktree record exists for execution and is usually not an open @@ -1153,17 +1160,18 @@ export const ChatInput: React.FC = ({ // Workspace record the input addresses, or the context workspace while no // session exists yet. An empty string means the targeted session has no // record; it must not fall back to the context. - const inputWorkspaceId = effectiveTargetSession + const inputWorkspaceId = draftWorkspaceId ?? (effectiveTargetSession ? sessionOwningId ?? '' - : contextWorkspace?.id; + : contextWorkspace?.id); // Workspace record of the directory the session actually runs in. Git state // and dispatch baselines describe that checkout, not the owning project. - const executionWorkspaceId = effectiveTargetSession + const executionWorkspaceId = draftWorkspaceId ?? (effectiveTargetSession ? sessionWorkspaceId(effectiveTargetSession) ?? '' - : contextWorkspace?.id; + : contextWorkspace?.id); const sessionBoundRemoteConnectionId = ( hasRegisteredWorkspace ? registration?.remoteConnectionId + : draftWorkspaceId ? draftTargetWorkspace?.connectionId : ( effectiveTargetSession?.remoteConnectionId || effectiveTargetSession?.config?.remoteConnectionId @@ -1172,14 +1180,19 @@ export const ChatInput: React.FC = ({ )?.trim() || undefined; const chatStripRepositoryPath = useMemo(() => { - const fromSession = hasRegisteredWorkspace + const fromSession = hasRegisteredWorkspace || draftWorkspaceId ? '' : (effectiveTargetSession?.workspacePath || '').trim(); const fromContext = (workspacePath || '').trim(); return fromSession || fromContext; - }, [hasRegisteredWorkspace, workspacePath, effectiveTargetSession?.workspacePath]); + }, [hasRegisteredWorkspace, draftWorkspaceId, workspacePath, effectiveTargetSession?.workspacePath]); const chatStripWorkspaceLabel = useMemo(() => { + if (draftWorkspaceId) { + return draftTargetWorkspace + ? draftTargetWorkspace.name || path.basename(workspacePath) + : t('workspaceStrip.unavailableLabel'); + } const name = (workspaceName || '').trim(); const sessionPath = hasRegisteredWorkspace ? '' @@ -1233,6 +1246,9 @@ export const ChatInput: React.FC = ({ workspace?.id, workspaceName, workspacePath, + draftWorkspaceId, + draftTargetWorkspace, + t, ]); const [tokenUsage, setTokenUsage] = React.useState( @@ -2476,6 +2492,11 @@ export const ChatInput: React.FC = ({ setActiveTurnPermissionMode(null); return undefined; } + if (effectiveTargetSession?.draft) { + setSessionPermissionMode(effectiveTargetSession.draft.permissionMode ?? null); + setSessionPermissionModeUnread(false); + return undefined; + } void (async () => { try { const permissionSessionId = isBtwDraftTarget @@ -2525,6 +2546,7 @@ export const ChatInput: React.FC = ({ effectiveTargetSession?.remoteConnectionId, effectiveTargetSession?.remoteSshHost, effectiveTargetSession?.parentSessionId, + effectiveTargetSession?.draft, isBtwDraftTarget, isAcpTargetSession, t, @@ -2538,6 +2560,19 @@ export const ChatInput: React.FC = ({ return; } const targetSessionId = effectiveTargetSessionId; + const store = FlowChatStore.getInstance(); + const draftSession = store.getState().sessions.get(targetSessionId); + if (draftSession?.draft) { + if (workspaceSubmittingRef.current || draftSession.draft.phase !== 'editing') return; + store.setState(state => { + const current = state.sessions.get(targetSessionId); + return current?.draft ? { ...state, sessions: new Map(state.sessions).set(targetSessionId, { + ...current, draft: { ...current.draft, permissionMode: nextMode }, + }) } : state; + }); + setSessionPermissionMode(nextMode); + return; + } const targetTurnId = activePermissionTurnIdRef.current; const generation = ++permissionModeRequestGenerationRef.current; const previousMode = sessionPermissionMode; @@ -2769,8 +2804,9 @@ export const ChatInput: React.FC = ({ * Checking worktree isolation only arms the empty session. The first prompt * materializes the worktree after it has visibly been submitted. */ - const remoteWorkspaceSession = - !isLocalWorkspaceSession(effectiveTargetSession, workspace); + const remoteWorkspaceSession = draftWorkspaceId + ? !draftTargetWorkspace || !['normal', 'assistant'].includes(draftTargetWorkspace.workspaceKind) + : !isLocalWorkspaceSession(effectiveTargetSession, workspace); const worktreeControl = useMemo(() => { if (!effectiveTargetSessionId || !effectiveTargetSession) return undefined; @@ -2790,7 +2826,7 @@ export const ChatInput: React.FC = ({ const locked = isSessionWorktreeBindingLocked( effectiveTargetSession, - !!derivedState?.isProcessing, + !!derivedState?.isProcessing || isWorkspaceSubmitting, ); return { @@ -2803,6 +2839,7 @@ export const ChatInput: React.FC = ({ .get(effectiveTargetSessionId); if ( !latestSession + || workspaceSubmittingRef.current || isSessionWorktreeBindingLocked(latestSession, false) ) { notificationService.error(tWorktrees('strip.toggleLocked')); @@ -2823,13 +2860,17 @@ export const ChatInput: React.FC = ({ remoteWorkspaceSession, tWorktrees, caps.worktreeBaselineLocked, + isWorkspaceSubmitting, ]); const handleSelectDispatchTarget = useCallback(async (selection: DispatchSelection) => { try { + if (draftWorkspaceId && !draftTargetWorkspace) { + throw new Error(t('workspaceStrip.unavailable')); + } await FlowChatManager.getInstance().createChatSession( { - ...flowChatSessionConfigForCurrentWorkspace(workspace), + ...flowChatSessionConfigForCurrentWorkspace(draftTargetWorkspace ?? workspace), dispatchTargetRequest: selection.request, dispatchTarget: selection.target, // Not asked for while picking a target: a dispatch session starts on @@ -2853,9 +2894,12 @@ export const ChatInput: React.FC = ({ log.error('Failed to create dispatched session projection', { error }); notificationService.error(t('chatInput.dispatch.createFailed')); } - }, [effectiveSendAgentType, permissionMode, t, workspace]); + }, [draftWorkspaceId, draftTargetWorkspace, effectiveSendAgentType, permissionMode, t, workspace]); - const harnessProfileLocked = effectiveTargetSessionStarted; + const harnessProfileLocked = effectiveTargetSession + ? isSessionBindingLocked(effectiveTargetSession, isWorkspaceSubmitting) + || !!effectiveTargetSession.draft && effectiveTargetSession.draft.phase !== 'editing' + : false; const dispatchControl = useMemo(() => { if ( registration || @@ -2887,7 +2931,8 @@ export const ChatInput: React.FC = ({ sourceWorkspacePath: workspacePath || undefined, locked: isNonLocalDispatchTarget(target) || - effectiveTargetSessionHasTurns || + effectiveTargetSessionStarted || + isWorkspaceSubmitting || !!derivedState?.isProcessing, onSelectTarget: handleSelectDispatchTarget, syncableJobId, @@ -2900,7 +2945,8 @@ export const ChatInput: React.FC = ({ effectiveTargetSession?.config.dispatchJobId, effectiveTargetSession?.config.dispatchJobState, effectiveTargetSession?.config.dispatchTarget, - effectiveTargetSessionHasTurns, + effectiveTargetSessionStarted, + isWorkspaceSubmitting, dispatchObserverJob?.baselineWorktreeMissing, dispatchObserverJob?.baselineWorktreePath, dispatchObserverJob?.branch, @@ -3818,11 +3864,12 @@ export const ChatInput: React.FC = ({ setSlashCommandState({ isActive: false, kind: 'all', query: '', selectedIndex: 0 }); try { + const commandSession = await prepareSessionDraftForCommand(effectiveTargetSession); await agentAPI.runInitAgentsMd({ sessionId: effectiveTargetSessionId, - workspacePath: effectiveTargetSession.workspacePath, - remoteConnectionId: effectiveTargetSession.remoteConnectionId, - remoteSshHost: effectiveTargetSession.remoteSshHost, + workspacePath: commandSession.workspacePath, + remoteConnectionId: commandSession.remoteConnectionId, + remoteSshHost: commandSession.remoteSshHost, }); } catch (error) { log.error('Failed to trigger /init', { @@ -3916,6 +3963,8 @@ export const ChatInput: React.FC = ({ setSlashCommandState({ isActive: false, kind: 'all', query: '', selectedIndex: 0 }); try { + const commandSession = FlowChatStore.getInstance().getState().sessions.get(effectiveTargetSessionId); + if (commandSession?.draft) await prepareSessionDraftForCommand(commandSession); await agentAPI.reloadSessionContext({ sessionId: effectiveTargetSessionId, target: parsed.target, @@ -3991,11 +4040,12 @@ export const ChatInput: React.FC = ({ const originalPendingLargePastes = { ...pendingLargePastesRef.current }; try { + const commandSession = await prepareSessionDraftForCommand(effectiveTargetSession); const prepared = await prepareReviewLaunchFromSlashCommand( message, - effectiveTargetSession.workspacePath, - effectiveTargetSession.remoteConnectionId, - effectiveTargetSession.workspaceId, + commandSession.workspacePath, + commandSession.remoteConnectionId, + commandSession.workspaceId, ); if (prepared.mode === 'strict' && prepared.requiresConsent) { const confirmed = await confirmDeepReviewLaunch(prepared.runManifest, { @@ -4021,7 +4071,7 @@ export const ChatInput: React.FC = ({ const launched = await launchPreparedReviewSession({ parentSessionId: effectiveTargetSessionId, - workspacePath: effectiveTargetSession.workspacePath, + workspacePath: commandSession.workspacePath, displayMessage: message, prepared, childSessionName: t('chatInput.reviewThreadTitle'), @@ -4588,6 +4638,7 @@ export const ChatInput: React.FC = ({ const sessionModeSelectionTarget = useMemo(() => effectiveTargetSessionId && effectiveTargetSession ? { sessionId: effectiveTargetSessionId, + draft: !!effectiveTargetSession.draft, workspacePath: sessionProjectWorkspacePath(effectiveTargetSession), remoteConnectionId: effectiveTargetSession.remoteConnectionId || @@ -4697,7 +4748,7 @@ export const ChatInput: React.FC = ({ setIsHarnessSessionCreating(true); try { - const newSessionId = await FlowChatManager.getInstance().createChatSession( + const newSessionId = await FlowChatManager.getInstance().createChatDraft( flowChatSessionConfigForCurrentWorkspace(workspace), modeId, ); @@ -5161,6 +5212,7 @@ export const ChatInput: React.FC = ({ ]); const handleSendOrCancel = useCallback(async (messageOverride?: string) => { + if (workspaceSubmittingRef.current) return; if (!derivedState) return; if (caps.transferInFlight) return; if (isInterruptedTurnRecoveryInFlight) return; @@ -5320,87 +5372,100 @@ export const ChatInput: React.FC = ({ // cannot block cancellation or other controls that do not start a turn. if (!modelAvailability.canSend) return; - const confirmed = await confirmPromptCacheGuardIfNeeded(); - if (!confirmed || !submissionScope.isCurrent() || !isTargetCurrent(submissionSessionId)) { + if (draftWorkspaceId && !draftTargetWorkspace) { + notificationService.error(t('workspaceStrip.unavailable')); return; } + workspaceSubmittingRef.current = true; + setIsWorkspaceSubmitting(true); + try { + const confirmed = await confirmPromptCacheGuardIfNeeded(); + if (!confirmed || !submissionScope.isCurrent() || !isTargetCurrent(submissionSessionId)) { + return; + } - // Add to history before clearing (session-scoped) - if (effectiveTargetSessionId) { - addToHistory(effectiveTargetSessionId, message); - } - setHistoryIndex(-1); - setSavedDraft(''); + // Add to history before clearing (session-scoped) + if (effectiveTargetSessionId) { + addToHistory(effectiveTargetSessionId, message); + } + setHistoryIndex(-1); + setSavedDraft(''); - clearComposerForSubmission({ - clearValue: () => dispatchInput({ type: 'CLEAR_VALUE' }), - clearContexts, - clearPendingLargePastes, - // Clear the machine queue too; otherwise queuedInput→input sync puts - // the submitted text back into the composer. - clearQueuedInput: () => setQueuedInput(null), - }); - const clearedComposerRevision = submissionSessionId - ? composerMutationRevision(submissionSessionId) - : 0; - const clearedStoredDraft = submissionSessionId - ? sessionComposerStore.getState().getDraft(submissionSessionId) - : null; + clearComposerForSubmission({ + clearValue: () => dispatchInput({ type: 'CLEAR_VALUE' }), + clearContexts, + clearPendingLargePastes, + // Clear the machine queue too; otherwise queuedInput→input sync puts + // the submitted text back into the composer. + clearQueuedInput: () => setQueuedInput(null), + }); + const clearedComposerRevision = submissionSessionId + ? composerMutationRevision(submissionSessionId) + : 0; + const clearedStoredDraft = submissionSessionId + ? sessionComposerStore.getState().getDraft(submissionSessionId) + : null; - try { - await submitThroughChatInputRegistration( - registration, - { - text: message, - displayText: originalMessage, - contexts: submittedContexts, - composerPresentation: persistedComposerPresentation, - sessionId: effectiveTargetSessionId || undefined, - workspacePath: workspacePath || undefined, - }, - () => sendMessage(message, { - displayMessage: originalMessage, - composerPresentation: persistedComposerPresentation, - composerDraft: { - value: originalMessage, - pendingLargePastes: originalPendingLargePastes, + try { + await submitThroughChatInputRegistration( + registration, + { + text: message, + displayText: originalMessage, + contexts: submittedContexts, + composerPresentation: persistedComposerPresentation, + sessionId: effectiveTargetSessionId || undefined, + workspacePath: workspacePath || undefined, }, - clearContextsOnSuccess: false, - }), - ); - } catch (error) { - if (!submissionScope.isCurrent()) { - // A failed old-host submission may recover only its own untouched draft. - const composer = sessionComposerStore.getState(); - if (submissionSessionId && composer.getDraft(submissionSessionId, submissionScope.surfaceId) === clearedStoredDraft) { - composer.setValue(submissionSessionId, originalMessage, submissionScope.surfaceId); - composer.setContexts(submissionSessionId, submittedContexts, submissionScope.surfaceId); - composer.setPendingLargePastes(submissionSessionId, originalPendingLargePastes, submissionScope.surfaceId); + () => sendMessage(message, { + displayMessage: originalMessage, + composerPresentation: persistedComposerPresentation, + composerDraft: { + value: originalMessage, + pendingLargePastes: originalPendingLargePastes, + }, + clearContextsOnSuccess: false, + }), + ); + } catch (error) { + if (!submissionScope.isCurrent()) { + // A failed old-host submission may recover only its own untouched draft. + const composer = sessionComposerStore.getState(); + if (submissionSessionId && composer.getDraft(submissionSessionId, submissionScope.surfaceId) === clearedStoredDraft) { + composer.setValue(submissionSessionId, originalMessage, submissionScope.surfaceId); + composer.setContexts(submissionSessionId, submittedContexts, submissionScope.surfaceId); + composer.setPendingLargePastes(submissionSessionId, originalPendingLargePastes, submissionScope.surfaceId); + } + return; } - return; - } - log.error('Failed to send message', { error }); - const recoveryTarget = failedSubmissionRecoveryTarget( - submissionSessionId, - effectiveTargetSessionIdRef.current, - clearedComposerRevision, - submissionSessionId ? composerMutationRevision(submissionSessionId) : 0, - ); - if (recoveryTarget === 'current') { - dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); - replaceContexts(submittedContexts); - replacePendingLargePastes(originalPendingLargePastes); - if (derivedState?.isProcessing) { - setQueuedInput(originalMessage); + log.error('Failed to send message', { error }); + const recoveryTarget = failedSubmissionRecoveryTarget( + submissionSessionId, + effectiveTargetSessionIdRef.current, + clearedComposerRevision, + submissionSessionId ? composerMutationRevision(submissionSessionId) : 0, + ); + if (recoveryTarget === 'current') { + dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); + replaceContexts(submittedContexts); + replacePendingLargePastes(originalPendingLargePastes); + if (derivedState?.isProcessing) { + setQueuedInput(originalMessage); + } + } else if (recoveryTarget === 'stored' && submissionSessionId) { + const composer = sessionComposerStore.getState(); + composer.setValue(submissionSessionId, originalMessage); + composer.setContexts(submissionSessionId, submittedContexts); + composer.setPendingLargePastes(submissionSessionId, originalPendingLargePastes); } - } else if (recoveryTarget === 'stored' && submissionSessionId) { - const composer = sessionComposerStore.getState(); - composer.setValue(submissionSessionId, originalMessage); - composer.setContexts(submissionSessionId, submittedContexts); - composer.setPendingLargePastes(submissionSessionId, originalPendingLargePastes); } + } finally { + workspaceSubmittingRef.current = false; + setIsWorkspaceSubmitting(false); } }, [ + draftWorkspaceId, + draftTargetWorkspace, isModelSwitching, modelAvailability.canSend, targetCanSubmit, @@ -5605,7 +5670,7 @@ export const ChatInput: React.FC = ({ ? FlowChatStore.getInstance().getState().sessions.get(currentSessionId)?.mode : undefined; const sessionConfig = flowChatSessionConfigForCurrentWorkspace(workspace); - await FlowChatManager.getInstance().createChatSession(sessionConfig, sessionMode); + await FlowChatManager.getInstance().createChatDraft(sessionConfig, sessionMode); } catch (error) { log.error('Failed to create new session from boost menu', { error }); } @@ -6187,6 +6252,13 @@ export const ChatInput: React.FC = ({ ); const workspaceStrip = workspaceStripVisible ? ( { + if (!workspaceSubmittingRef.current) workspaceSelection.workspaceControl.onSelect(workspaceId); + }, + } : undefined} workspaceId={executionWorkspaceId ?? ''} repositoryPath={chatStripRepositoryPath} workspaceLabel={chatStripWorkspaceLabel} @@ -6205,7 +6277,8 @@ export const ChatInput: React.FC = ({ } : { mode: permissionMode, - disabled: isBtwDraftTarget, + disabled: isBtwDraftTarget || isWorkspaceSubmitting + || !!effectiveTargetSession?.draft && effectiveTargetSession.draft.phase !== 'editing', saving: permissionModeSaving, scopeLabel: t('chatInput.permissionMode.sessionScope'), overridden: permissionModeOverridden, @@ -6950,7 +7023,8 @@ export const ChatInput: React.FC = ({ externalSelection={dispatchModelSelection} modeDefaultModelId={targetModeInfo?.model} persistSharedModeDefault={!isBtwDraftTarget && Boolean(targetModeInfo && targetModeInfo.source !== 'external')} - disabled={isInterruptedTurnRecoveryInFlight || btwDraftSettingsInherited} + disabled={isInterruptedTurnRecoveryInFlight || btwDraftSettingsInherited || isWorkspaceSubmitting + || !!effectiveTargetSession?.draft && effectiveTargetSession.draft.phase !== 'editing'} disabledReason={btwDraftSettingsInherited ? t('selection.inheritedSettings') : undefined} reasoningTriggerPresentation="label" /> diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx index cddd829288..f9542bad9c 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.test.tsx @@ -7,6 +7,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatInputWorkspaceStrip } from './ChatInputWorkspaceStrip'; +import type { WorkspaceInfo } from '@/shared/types'; const mocks = vi.hoisted(() => ({ refreshBasic: vi.fn(async () => undefined), @@ -242,9 +243,8 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { ).toBe('main'); }); - it('switches the active workspace from the strip menu when several are open', async () => { - mocks.useOptionalWorkspaceContext.mockReturnValue({ - openedWorkspacesList: [ + it('selects the draft target without switching the global workspace', async () => { + const options = [ { id: 'ws-1', name: 'OpenBitFun', @@ -270,22 +270,15 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { workspaceKind: 'assistant', assistantId: 'assistant-4', }, - ], - activeWorkspace: { - id: 'ws-1', - name: 'OpenBitFun', - rootPath: 'D:/workspace/OpenBitFun', - workspaceKind: 'normal', - }, - primaryAssistantWorkspaceId: 'ws-3', - setActiveWorkspace: mocks.setActiveWorkspace, - }); + ] as WorkspaceInfo[]; + const onSelect = vi.fn(); await act(async () => { root.render( ); }); @@ -327,8 +320,41 @@ describe('ChatInputWorkspaceStrip git refresh behavior', () => { other?.click(); }); - expect(mocks.setActiveWorkspace).toHaveBeenCalledWith('ws-2'); + expect(onSelect).toHaveBeenCalledWith('ws-2'); + expect(mocks.setActiveWorkspace).not.toHaveBeenCalled(); + expect(document.querySelector('[data-testid="chat-input-workspace-menu"]')).toBeNull(); + }); + + it('closes a stale directory menu when first submission locks its target', async () => { + const options = ['a', 'b'].map(id => ({ id, name: id, workspaceKind: 'normal' })) as WorkspaceInfo[]; + const onSelect = vi.fn(); + const render = (locked: boolean) => ; + await act(async () => { root.render(render(false)); }); + await act(async () => { container.querySelector('[data-testid="chat-input-workspace-trigger"]')?.click(); }); + expect(document.querySelector('[data-testid="chat-input-workspace-menu"]')).not.toBeNull(); + await act(async () => { root.render(render(true)); }); + expect(container.querySelector('[data-testid="chat-input-workspace-trigger"]')).toBeNull(); expect(document.querySelector('[data-testid="chat-input-workspace-menu"]')).toBeNull(); + expect(onSelect).not.toHaveBeenCalled(); + expect(mocks.setActiveWorkspace).not.toHaveBeenCalled(); + }); + + it('can replace a closed target with the only remaining workspace', async () => { + const onSelect = vi.fn(); + const options = [{ id: 'a', name: 'A', rootPath: '/a', workspaceKind: 'normal' }] as WorkspaceInfo[]; + await act(async () => root.render( + , + )); + const trigger = container.querySelector('[data-testid="chat-input-workspace-trigger"]'); + expect(trigger).not.toBeNull(); + await act(async () => trigger?.click()); + await act(async () => { + document.querySelector('[data-testid="chat-input-workspace-option-a"]')?.click(); + }); + expect(onSelect).toHaveBeenCalledWith('a'); + expect(mocks.setActiveWorkspace).not.toHaveBeenCalled(); }); it('splits the situation from the contract for the next turn', async () => { diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx index d79d740cfb..e1bdd0f28f 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.tsx @@ -22,10 +22,10 @@ import type { SessionExecutionTarget } from '@/infrastructure/api/service-api/Wo import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { getWorkspaceDisplayName, - useOptionalWorkspaceContext, } from '@/infrastructure/contexts/WorkspaceContext'; import { useI18n } from '@/infrastructure/i18n'; import { WorkspaceKind } from '@/shared/types'; +import type { SessionWorkspaceControl } from '../hooks/useSessionWorkspaceSelection'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { DispatchResultDialog } from '@/features/dispatch/DispatchResultDialog'; import { DispatchTargetPicker } from '@/features/dispatch/DispatchTargetPicker'; @@ -39,6 +39,8 @@ export interface ChatInputWorkspaceStripProps { workspaceId: string; /** Resolved display name (workspace title or folder basename). */ workspaceLabel: string; + /** The composer owns draft selection; this control never navigates the shell. */ + workspaceControl?: SessionWorkspaceControl; /** Session usage report (/usage) — context ring on the right rail. */ usageReport?: { visible: boolean; @@ -144,6 +146,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( repositoryPath, workspaceId, workspaceLabel, + workspaceControl, usageReport, permissionControl, deferPassiveGitRefresh = false, @@ -155,7 +158,6 @@ export const ChatInputWorkspaceStrip: React.FC = ( const { t } = useTranslation('flow-chat'); const { t: tWorktrees } = useI18n('worktrees'); const { t: tCommon } = useI18n('common'); - const workspaceContext = useOptionalWorkspaceContext(); const permissionRootRef = useRef(null); const permissionTriggerRef = useRef(null); const permissionMenuRef = useRef(null); @@ -405,10 +407,13 @@ export const ChatInputWorkspaceStrip: React.FC = ( : '—'); const workspaceTooltipContent = trimmedPath || label; - const switchableWorkspaces = workspaceContext?.openedWorkspacesList ?? []; - // Same rule as the shell nav switcher: a single open workspace has nothing - // to switch to, so the name stays a fact rather than offering a dead menu. - const workspaceSwitchable = !!workspaceContext && switchableWorkspaces.length > 1; + const switchableWorkspaces = workspaceControl?.options ?? []; + // A closed draft target can be replaced even when only one workspace remains. + const workspaceSwitchable = !!workspaceControl && !workspaceControl.locked + && switchableWorkspaces.some(workspace => workspace.id !== workspaceControl.selectedId); + useEffect(() => { + if (!workspaceSwitchable) setWorkspaceMenuOpen(false); + }, [workspaceSwitchable]); const worktreeToggleDisabled = !!worktreeControl?.locked; let worktreeTooltip = tWorktrees('strip.toggleOffDescription'); if (worktreeControl?.lockedReason === 'dispatch') { @@ -562,11 +567,11 @@ export const ChatInputWorkspaceStrip: React.FC = ( ); }; - // The workspace names where the session lives; with more than one workspace - // open it doubles as the switcher. Either way it wears the track's pill so + // The workspace names where the session lives; an editable draft with another + // available workspace can select its destination here. Either way it wears the track's pill so // the row keeps one rhythm — only the hover fill says whether it answers. const renderWorkspaceControl = () => { - if (!workspaceSwitchable || !workspaceContext) { + if (!workspaceSwitchable || !workspaceControl) { return ( @@ -614,15 +619,14 @@ export const ChatInputWorkspaceStrip: React.FC = ( autoFocusFirstItem > {switchableWorkspaces.map(workspace => { - const isActive = workspace.id === workspaceContext.activeWorkspace?.id; + const isActive = workspace.id === workspaceControl.selectedId; const workspaceName = getWorkspaceDisplayName(workspace); const workspacePath = workspace.rootPath?.trim(); const isAssistantWorkspace = workspace.workspaceKind === WorkspaceKind.Assistant; const isPrimaryAssistantWorkspace = ( isAssistantWorkspace && ( - workspace.id === workspaceContext.primaryAssistantWorkspaceId - || (!workspaceContext.primaryAssistantWorkspaceId && !workspace.assistantId) + !workspace.assistantId ) ); const workspaceDetail = isAssistantWorkspace @@ -648,7 +652,7 @@ export const ChatInputWorkspaceStrip: React.FC = ( event.stopPropagation(); setWorkspaceMenuOpen(false); if (!isActive) { - void workspaceContext.setActiveWorkspace(workspace.id); + workspaceControl.onSelect(workspace.id); } }} > diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts index 4821afe5d4..62b696e2fa 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts @@ -265,7 +265,7 @@ describe('composer context track layout', () => { // their real path, while assistants expose their product role instead of // leaking the internal assistant workspace directory. expect(component).toContain('workspace.rootPath?.trim()'); - expect(component).toContain('workspaceContext.primaryAssistantWorkspaceId'); + expect(component).toContain('workspace.assistantId'); expect(component).toContain("'workspaceStrip.primaryAssistant'"); expect(component).toContain("'workspaceStrip.personalAssistant'"); expect(component).toContain('__workspace-option-detail'); @@ -427,10 +427,7 @@ describe('composer context track layout', () => { const component = readLocalFile('ChatInput.tsx'); expect(component).toContain( - 'const effectiveTargetSessionStarted = effectiveTargetSessionHasTurns', - ); - expect(component).toContain( - "|| Boolean(effectiveTargetSession?.lastSubmittedMode?.trim());", + 'const effectiveTargetSessionStarted = effectiveTargetSession ? hasSessionStarted(effectiveTargetSession) : false;', ); expect(component).toContain( 'const isNewSessionComposer = !effectiveTargetSessionStarted;', @@ -445,7 +442,7 @@ describe('composer context track layout', () => { /const measureIsMultiLine = useCallback[\s\S]*?if \(isNewSessionComposer && !compactComposer\) \{\s*setIsMultiLine\(true\);\s*return;/, ); expect(component).toContain( - 'const harnessProfileLocked = effectiveTargetSessionStarted;', + 'isSessionBindingLocked(effectiveTargetSession, isWorkspaceSubmitting)', ); }); diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx index 60d6bcb2f3..3528196c8f 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx @@ -128,6 +128,8 @@ export const FlowToolCard: React.FC = React.memo(({ data-openbitfun-component="flow-tool-card" data-openbitfun-part="root" data-openbitfun-state={permissionPending ? 'permission-pending' : undefined} + data-thinking-handoff-priority={permissionPending || effectiveToolItem.status === 'error' + || effectiveToolItem.toolResult?.success === false ? 'immediate' : undefined} data-testid={toolCardTestId} data-tool-name={effectiveToolItem.toolName} data-tool-card-id={toolItem.id} diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx b/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx index a76ebafa7e..75a6823dad 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import React, { act } from 'react'; +import React, { act, useState } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; import { AppearanceCompiler } from '@/infrastructure/appearance/compiler/AppearanceCompiler'; @@ -8,6 +8,10 @@ import { AppearanceRegistry } from '@/infrastructure/appearance/registry/Appeara import { APPEARANCE_SCHEMA_VERSION, type AppearancePackage } from '@/infrastructure/appearance/types'; import { WelcomePanel } from './WelcomePanel'; import { welcomePanelAppearanceDescriptor } from './WelcomePanel.appearance'; +import { ChatInputWorkspaceStrip } from './ChatInputWorkspaceStrip'; +import { useSessionWorkspaceSelection } from '../hooks/useSessionWorkspaceSelection'; +import type { Session } from '../types/flow-chat'; +import type { WorkspaceInfo } from '@/shared/types'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -17,6 +21,20 @@ const gitApiMock = vi.hoisted(() => ({ getRepositoryBasic: vi.fn(), })); +const workspaceMocks = vi.hoisted(() => ({ + currentWorkspace: { id: 'workspace-1', name: 'OpenBitFun', rootPath: 'D:/workspace/OpenBitFun', workspaceKind: 'normal' }, + openedWorkspaces: new Map(), + selectDraftWorkspace: vi.fn(), + openWorkspace: vi.fn(), + switchWorkspace: vi.fn(), + switchLeftPanelTab: vi.fn(), + gitScopes: [] as string[], +})); + +vi.mock('../services/FlowChatManager', () => ({ + FlowChatManager: { getInstance: () => workspaceMocks }, +})); + vi.mock('react-i18next', () => ({ initReactI18next: { type: '3rdParty', init: () => {} }, useTranslation: () => ({ @@ -30,21 +48,19 @@ vi.mock('../../infrastructure/api', () => ({ vi.mock('../../app/hooks/useApp', () => ({ useApp: () => ({ - switchLeftPanelTab: vi.fn(), + switchLeftPanelTab: workspaceMocks.switchLeftPanelTab, }), })); vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ + getWorkspaceDisplayName: (workspace: WorkspaceInfo) => workspace.name, useWorkspaceContext: () => ({ hasWorkspace: true, - currentWorkspace: { - id: 'workspace-1', - name: 'OpenBitFun', - rootPath: 'D:/workspace/OpenBitFun', - }, - openedWorkspacesList: [], - openWorkspace: vi.fn(), - switchWorkspace: vi.fn(), + currentWorkspace: workspaceMocks.currentWorkspace, + openedWorkspaces: workspaceMocks.openedWorkspaces, + openedWorkspacesList: [...workspaceMocks.openedWorkspaces.values()], + openWorkspace: workspaceMocks.openWorkspace, + switchWorkspace: workspaceMocks.switchWorkspace, }), })); @@ -61,7 +77,10 @@ vi.mock('@/app/scenes/my-agent/useAgentIdentityDocument', () => ({ // instead of hitting gitAPI directly. Return deterministic state so the // render tests do not race against real timers or Tauri APIs. vi.mock('@/tools/git/hooks/useGitState', () => ({ - useGitState: () => ({ + useGitState: ({ repositoryPath, debugSource }: { repositoryPath: { workspaceId: string }; debugSource: string }) => { + const workspaceId = repositoryPath.workspaceId; + if (debugSource === 'welcome_panel') workspaceMocks.gitScopes.push(workspaceId); + return { state: null, isLoading: false, error: null, @@ -69,26 +88,61 @@ vi.mock('@/tools/git/hooks/useGitState', () => ({ refreshBasic: vi.fn(), refreshStatus: vi.fn(), refreshDetailed: vi.fn(), - isRepository: true, + isRepository: !!workspaceId, repositoryTrustRequired: false, - currentBranch: 'main', + currentBranch: workspaceId === 'workspace-2' ? 'feature/other' : 'main', ahead: 0, behind: 0, hasChanges: false, staged: [], - unstaged: [], + unstaged: workspaceId === 'workspace-2' ? [{ path: 'changed.ts' }] : [], untracked: [], conflicts: [], branches: undefined, commits: undefined, - }), + }; + }, })); +function newDraft(): Session { + return { + sessionId: 'draft-session', mode: 'Standard', workspaceId: 'workspace-1', + workspacePath: workspaceMocks.currentWorkspace.rootPath, + config: { workspaceId: 'workspace-1' }, historyState: 'new', dialogTurns: [], + status: 'idle', createdAt: 1, lastActiveAt: 1, error: null, + draft: { workspaceId: 'workspace-1', phase: 'editing', turnId: 'first-turn' }, + }; +} + +/** Exercise the two real controls with the shared session selection adapter. */ +function DraftWorkspaceSurfaces() { + const [session, setSession] = useState(newDraft); + const { selectedWorkspace, workspaceControl } = useSessionWorkspaceSelection(session); + workspaceMocks.selectDraftWorkspace.mockImplementation((sessionId: string, workspaceId: string) => { + expect(sessionId).toBe(session.sessionId); + setSession(current => ({ ...current, draft: { ...current.draft!, workspaceId } })); + }); + return <> + + + {session.workspaceId} + ; +} + describe('WelcomePanel Git summary loading', () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { + vi.clearAllMocks(); + workspaceMocks.selectDraftWorkspace.mockReset(); + workspaceMocks.gitScopes.length = 0; + workspaceMocks.openedWorkspaces = new Map([ + ['workspace-1', workspaceMocks.currentWorkspace as WorkspaceInfo], + ['workspace-2', { id: 'workspace-2', name: 'Other project', rootPath: 'D:/workspace/Other', workspaceKind: 'normal' } as WorkspaceInfo], + ]); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -124,6 +178,71 @@ describe('WelcomePanel Git summary loading', () => { expect(container.querySelector('img[src^="/panda_full_"]')).toBeNull(); }); + it('keeps the welcome copy, Git scope and strip selection in sync without activating a workspace', async () => { + await act(async () => root.render()); + const welcomeTrigger = () => container.querySelector('[data-openbitfun-product-part="workspaceAction"]')!; + const stripTrigger = () => container.querySelector('[data-testid="chat-input-workspace-trigger"]')!; + await act(async () => stripTrigger().click()); + await act(async () => document.querySelector('[data-testid="chat-input-workspace-option-workspace-2"]')!.click()); + + expect(welcomeTrigger().textContent).toContain('Other project'); + expect(welcomeTrigger().title).toBe('D:/workspace/Other'); + expect(stripTrigger().textContent).toContain('Other project'); + expect(container.querySelector('[data-openbitfun-product-part="gitAction"]')?.textContent).toContain('feature/other'); + expect(workspaceMocks.gitScopes.at(-1)).toBe('workspace-2'); + expect(container.querySelector('[data-openbitfun-product-part="narrative"]')?.textContent).toContain('welcome.waitingToStage'); + // A preview of B must not navigate to the shell's Git panel for A. + expect(container.querySelector('[data-openbitfun-product-part="gitAction"]')?.disabled).toBe(true); + + await act(async () => welcomeTrigger().click()); + const menu = document.querySelector('[data-openbitfun-product-part="workspaceMenu"]')!; + expect(menu.textContent).not.toContain('header.newProject'); + expect(menu.querySelector('[aria-checked="true"]')?.textContent).toContain('Other project'); + await act(async () => menu.querySelector('[data-openbitfun-product-part="workspaceItem"]')!.click()); + + expect(welcomeTrigger().textContent).toContain('OpenBitFun'); + expect(stripTrigger().textContent).toContain('OpenBitFun'); + expect(workspaceMocks.gitScopes.at(-1)).toBe('workspace-1'); + expect(container.querySelector('[data-testid="canonical-workspace"]')?.textContent).toBe('workspace-1'); + expect(workspaceMocks.selectDraftWorkspace.mock.calls).toEqual([ + ['draft-session', 'workspace-2'], ['draft-session', 'workspace-1'], + ]); + expect(workspaceMocks.switchWorkspace).not.toHaveBeenCalled(); + expect(workspaceMocks.openWorkspace).not.toHaveBeenCalled(); + expect(workspaceMocks.switchLeftPanelTab).not.toHaveBeenCalled(); + }); + + it('closes the welcome selector when first submission binds the draft', async () => { + const session = newDraft(); + await act(async () => root.render()); + await act(async () => container.querySelector('[data-openbitfun-product-part="workspaceAction"]')!.click()); + expect(document.querySelector('[data-openbitfun-product-part="workspaceMenu"]')).not.toBeNull(); + await act(async () => root.render()); + expect(document.querySelector('[data-openbitfun-product-part="workspaceMenu"]')).toBeNull(); + expect(container.querySelector('[data-openbitfun-product-part="workspaceAction"]')?.disabled).toBe(true); + expect(workspaceMocks.switchWorkspace).not.toHaveBeenCalled(); + }); + + it('shows an unavailable draft target without displaying the active project or its Git state', async () => { + const session = newDraft(); + session.draft!.workspaceId = 'closed-workspace'; + await act(async () => root.render()); + const trigger = container.querySelector('[data-openbitfun-product-part="workspaceAction"]')!; + expect(trigger.textContent).toContain('workspaceStrip.unavailableLabel'); + expect(trigger.disabled).toBe(false); + expect(container.querySelector('[data-openbitfun-product-part="gitAction"]')).toBeNull(); + expect(workspaceMocks.gitScopes.at(-1)).toBe(''); + }); + + it('keeps an existing empty session bound to its own workspace', async () => { + const session = { ...newDraft(), draft: undefined, workspaceId: 'workspace-2' }; + await act(async () => root.render()); + const trigger = container.querySelector('[data-openbitfun-product-part="workspaceAction"]')!; + expect(trigger.textContent).toContain('Other project'); + expect(trigger.disabled).toBe(true); + expect(workspaceMocks.gitScopes.at(-1)).toBe('workspace-2'); + }); + it('portals the workspace menu outside the scrollable welcome panel', async () => { await act(async () => { root.render(); diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx index 7ef3f90c6d..4839f0605f 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx @@ -16,6 +16,9 @@ import { useAgentIdentityDocument } from '@/app/scenes/my-agent/useAgentIdentity import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { useGitState } from '@/tools/git/hooks/useGitState'; +import { useSessionWorkspaceSelection } from '../hooks/useSessionWorkspaceSelection'; +import type { Session } from '../types/flow-chat'; +import { sessionWorkspaceId } from '../utils/sessionWorkspace'; import './WelcomePanel.css'; import './WelcomePanelSurface.scss'; @@ -24,6 +27,7 @@ const log = createLogger('WelcomePanel'); interface WelcomePanelProps { onQuickAction?: (command: string) => void; className?: string; + session?: Session | null; sessionMode?: string; /** Owning workspace ID of the session being welcomed; selects the assistant identity document. */ workspaceId?: string; @@ -33,9 +37,10 @@ interface WelcomePanelProps { export const WelcomePanel: React.FC = ({ onQuickAction, className = '', - sessionMode, - workspaceId, - workspacePath = '', + session, + sessionMode: initialSessionMode, + workspaceId: initialWorkspaceId, + workspacePath: initialWorkspacePath = '', }) => { const { t } = useTranslation('flow-chat'); const { t: tCommon } = useTranslation('common'); @@ -47,12 +52,23 @@ export const WelcomePanel: React.FC = ({ const { switchLeftPanelTab } = useApp(); const { - hasWorkspace, + hasWorkspace: hasActiveWorkspace, currentWorkspace, + openedWorkspaces, openedWorkspacesList, openWorkspace, switchWorkspace, } = useWorkspaceContext(); + const { selectedWorkspace, workspaceControl } = useSessionWorkspaceSelection(session); + const workspace = session ? selectedWorkspace + : initialWorkspaceId ? openedWorkspaces.get(initialWorkspaceId) : currentWorkspace; + const workspaceId = session ? workspaceControl.selectedId : initialWorkspaceId ?? workspace?.id; + const workspacePath = session?.draft ? selectedWorkspace?.rootPath ?? '' + : session?.workspacePath ?? initialWorkspacePath; + const hasWorkspace = session ? Boolean(workspaceId) : hasActiveWorkspace; + const sessionMode = session?.mode ?? initialSessionMode; + const workspaceLabel = workspace?.name || (session + ? t('workspaceStrip.unavailableLabel') : t('shared:features.workspace')); const sessionModeLower = (sessionMode || '').toLowerCase(); const isCoworkSession = sessionModeLower === 'cowork'; const isClawSession = sessionModeLower === 'claw'; @@ -61,7 +77,9 @@ export const WelcomePanel: React.FC = ({ // branch / worktree changes (including external ones picked up by the // GitStateManager poll). When there is no workspace or we are in a // cowork/claw session we pass a blank scope so the hook stays idle. - const activeWsId = !isCoworkSession && !isClawSession ? currentWorkspace?.id : undefined; + const activeWsId = !isCoworkSession && !isClawSession && workspace + ? session?.draft?.workspaceId ?? (session ? sessionWorkspaceId(session) : workspace.id) + : undefined; const gitScope = activeWsId ? { workspaceId: activeWsId } : { workspaceId: '' }; const { isRepository, @@ -83,14 +101,14 @@ export const WelcomePanel: React.FC = ({ // Derive the same shape the old loadGitState produced so the render and // narrative helpers below do not have to change. const gitState = useMemo(() => { - if (!isRepository || !currentBranch) return null; + if (!activeWsId || !isRepository || !currentBranch) return null; return { currentBranch, unstagedFiles: (unstaged?.length || 0) + (untracked?.length || 0), stagedFiles: staged?.length || 0, unpushedCommits: ahead || 0, }; - }, [isRepository, currentBranch, ahead, staged, unstaged, untracked]); + }, [activeWsId, isRepository, currentBranch, ahead, staged, unstaged, untracked]); const identityWorkspace = useMemo( () => (isClawSession && workspaceId ? { id: workspaceId, rootPath: workspacePath } : null), @@ -123,9 +141,17 @@ export const WelcomePanel: React.FC = ({ const aiPartnerKey = isCoworkSession ? 'welcome.aiPartnerCowork' : isClawSession ? 'welcome.aiPartnerClaw' : null; const otherWorkspaces = useMemo( - () => openedWorkspacesList.filter(ws => ws.id !== currentWorkspace?.id), - [openedWorkspacesList, currentWorkspace?.id], + () => (session ? workspaceControl.options : openedWorkspacesList).filter(ws => ws.id !== workspaceId), + [session, workspaceControl.options, openedWorkspacesList, workspaceId], ); + const canSwitchWorkspace = session + ? !workspaceControl.locked && otherWorkspaces.length > 0 : true; + // The shell Git panel still follows its active workspace. A draft preview of + // another project must not open that unrelated panel or activate the project. + const canNavigateGit = !!activeWsId && activeWsId === currentWorkspace?.id; + useEffect(() => { + setWorkspaceDropdownOpen(false); + }, [session?.sessionId, workspaceId, canSwitchWorkspace]); const workspaceMenuLayout = useAnchoredPopoverPosition({ open: workspaceDropdownOpen, anchorRef: workspaceTriggerRef, @@ -137,8 +163,8 @@ export const WelcomePanel: React.FC = ({ }); const handleGitClick = useCallback(() => { - switchLeftPanelTab('git'); - }, [switchLeftPanelTab]); + if (canNavigateGit) switchLeftPanelTab('git'); + }, [canNavigateGit, switchLeftPanelTab]); const isGitClean = useMemo( () => !!gitState && gitState.unstagedFiles === 0 && gitState.stagedFiles === 0 && gitState.unpushedCommits === 0, @@ -167,6 +193,7 @@ export const WelcomePanel: React.FC = ({ data-openbitfun-product-part="gitAction" className="welcome-panel__inline-btn" onClick={handleGitClick} + disabled={!canNavigateGit} > {label} @@ -176,7 +203,7 @@ export const WelcomePanel: React.FC = ({ {t('welcome.period')} ); - }, [gitState, handleGitClick, t]); + }, [gitState, handleGitClick, canNavigateGit, t]); useEffect(() => { if (!workspaceDropdownOpen) return; @@ -205,9 +232,14 @@ export const WelcomePanel: React.FC = ({ }, [workspaceDropdownOpen]); const handleSwitchWorkspace = useCallback(async (ws: WorkspaceInfo) => { - try { setWorkspaceDropdownOpen(false); await switchWorkspace(ws); } + setWorkspaceDropdownOpen(false); + if (session) { + workspaceControl.onSelect(ws.id); + return; + } + try { await switchWorkspace(ws); } catch (err) { log.warn('Failed to switch workspace', err); } - }, [switchWorkspace]); + }, [session, workspaceControl, switchWorkspace]); const handleOpenOtherFolder = useCallback(async () => { try { @@ -287,13 +319,13 @@ export const WelcomePanel: React.FC = ({ labelBehavior="static" variant="text" leadingIcon={} - trailingIcon={ + trailingIcon={canSwitchWorkspace ? ( - } + ) : undefined} ref={workspaceTriggerRef} type="button" data-openbitfun-product-component="welcome-panel" @@ -301,14 +333,14 @@ export const WelcomePanel: React.FC = ({ data-openbitfun-state={workspaceDropdownOpen ? 'open' : undefined} className={`welcome-panel__inline-btn welcome-panel__inline-btn--interactive${workspaceDropdownOpen ? ' welcome-panel__inline-btn--active' : ''}`} onClick={() => setWorkspaceDropdownOpen(v => !v)} - disabled={isSelectingWorkspace} - title={currentWorkspace?.rootPath} + disabled={isSelectingWorkspace || !canSwitchWorkspace} + title={workspace?.rootPath} aria-haspopup="menu" aria-expanded={workspaceDropdownOpen} > - {currentWorkspace?.name || t('shared:features.workspace')} + {workspaceLabel} - {workspaceDropdownOpen && createOverlayPortal( + {workspaceDropdownOpen && canSwitchWorkspace && createOverlayPortal( = ({ autoFocusFirstItem aria-label={t('shared:features.workspace')} > - } onClick={() => { void handleCreateWorkspace(); }} > {tCommon('header.newProject')} - - {(hasWorkspace || otherWorkspaces.length > 0) && } - {hasWorkspace && currentWorkspace && ( + } + {!session && (hasWorkspace || otherWorkspaces.length > 0) && } + {hasWorkspace && workspace && ( = ({ leading={} metadata={} > - {currentWorkspace.name} + {workspace.name} )} {otherWorkspaces.length > 0 && ( <> - {hasWorkspace && currentWorkspace && } + {hasWorkspace && workspace && } {otherWorkspaces.map(ws => ( = ({ data-openbitfun-product-part="gitAction" className="welcome-panel__inline-btn" onClick={handleGitClick} + disabled={!canNavigateGit} > {gitState.currentBranch} diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.appearance.ts b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.appearance.ts index 7e63b6bc7c..0318b9d5bd 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.appearance.ts +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.appearance.ts @@ -1,4 +1,12 @@ import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const fileEditGroupAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'file-edit-group', + parts: [ + { id: 'root' }, { id: 'header' }, { id: 'summary' }, { id: 'controls' }, + { id: 'contentWrapper' }, { id: 'content' }, { id: 'item' }, + ], + states: [{ id: 'expanded', selector: { kind: 'self', suffix: '[data-openbitfun-state~="expanded"]' } }], +}; export const exploreGroupAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'explore-group', parts: [ diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx index 806cd9972d..8b809346ee 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx @@ -141,6 +141,7 @@ const ExportContent: React.FC = ({ dialogTurn, expandThinkin thinkingItem={{ ...thinkingItem, isStreaming: false, + status: 'completed', }} isLastItem={false} forceExpanded={expandThinking} diff --git a/src/web-ui/src/flow_chat/components/modern/FileEditGroupView.tsx b/src/web-ui/src/flow_chat/components/modern/FileEditGroupView.tsx new file mode 100644 index 0000000000..702a745644 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FileEditGroupView.tsx @@ -0,0 +1,31 @@ +import React, { useMemo } from 'react'; +import { FlowGroup, type FlowGroupProps } from '@openbitfun/ui/flow-chat'; +import { useI18n } from '@/infrastructure/i18n'; +import { useSnapshotState } from '@/tools/snapshot_system/hooks/useSnapshotState'; +import { FileEditGroupContext } from '../../grouping/FileEditGroupContext'; +import { useFileOperationDiffStats } from '../../tool-cards/useFileOperationDiffStats'; +import { sumFileOperationDiffStats } from '../../tool-cards/fileOperationDiffStats'; +import type { FlowItem, FlowToolItem } from '../../types/flow-chat'; + +/** Resolve totals before deferred member cards mount, and share the exact values with them. */ +export const FileEditGroupView = React.forwardRef(function FileEditGroupView({ items, sessionId, fileRevision, ...props }, ref) { + const { t, formatNumber } = useI18n('flow-chat'); + const { surfaceEpoch, snapshotsAvailable } = useSnapshotState(sessionId); + const tools = useMemo(() => items.filter((item): item is FlowToolItem => item.type === 'tool'), [items]); + const diffStats = useFileOperationDiffStats(tools, { sessionId, surfaceEpoch, snapshotsAvailable }); + const total = sumFileOperationDiffStats(diffStats.values()); + const additions = formatNumber(total.additions); + const deletions = formatNumber(total.deletions); + const revisionLabels = useMemo(() => new Map(tools.map((item, index) => + [item.id, t('fileEditGroup.revision', { count: formatNumber(index + 1) })])), [tools, t, formatNumber]); + const context = useMemo(() => ({ revisionLabels, diffStats }), [revisionLabels, diffStats]); + + return + + ; +}); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowGroupLifecycle.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowGroupLifecycle.test.tsx index 46278d332a..72c9b789f6 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowGroupLifecycle.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowGroupLifecycle.test.tsx @@ -9,6 +9,10 @@ import type { FlowTextItem, FlowThinkingItem, FlowToolItem } from '../../types/f import { requestDeferredContentItem } from '@openbitfun/flow-chat-presentation/deferred-content'; const state = vi.hoisted(() => ({ choices: new Map(), permissions: new Set(), revealing: false })); +vi.mock('@/tools/snapshot_system/hooks/useSnapshotState', () => ({ + useSnapshotState: () => ({ surfaceEpoch: 0, snapshotsAvailable: false }), +})); +vi.mock('../../session-drivers/sessionFileNavigation', () => ({ hasSessionFileProvider: () => false })); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key, formatNumber: String }) })); vi.mock('./FlowChatContext', () => ({ useFlowChatContext: () => ({ sessionId: 'session', onExpandGroup: (id: string) => state.choices.set(id, true) }), @@ -20,11 +24,12 @@ vi.mock('../FlowToolCard', () => ({ FlowToolCard: ({ toolItem }: { toolItem: Flo {expanded && Output}
; } })); vi.mock('../../tool-cards/ModelThinkingDisplay', () => ({ - ModelThinkingDisplay: ({ thinkingItem, withinGroup, hidden, forceExpanded }: { - thinkingItem: FlowThinkingItem; withinGroup: boolean; hidden?: boolean; forceExpanded?: boolean; + ModelThinkingDisplay: ({ thinkingItem, withinGroup, hidden, forceExpanded, retainForGroupCollapse }: { + thinkingItem: FlowThinkingItem; withinGroup: boolean; hidden?: boolean; forceExpanded?: boolean; retainForGroupCollapse?: boolean; }) => { useReportTypewriterReveal(thinkingItem.id, state.revealing); - return ; + return ; }, })); vi.mock('../FlowTextBlock', () => ({ FlowTextBlock: ({ textItem }: { textItem: FlowTextItem }) => { @@ -60,6 +65,31 @@ function group(phase: FlowGroupData['phase'], category: FlowGroupCategory = 'exp toolCall: { id: 'call', input: {} } } as FlowToolItem] }; } +it('renders one shared file header and keeps failed revisions visible with explicit collapse available', () => { + const data = group('collecting', 'file-edit'); + data.allItems = ['one', 'two'].map(id => ({ id, type: 'tool', toolName: 'Edit', timestamp: 1, status: 'completed', + toolCall: { id, input: { file_path: '/remote/src/App.tsx', old_string: 'old', new_string: 'new' } } })); + const render = () => act(() => root.render()); + render(); + const node = container.querySelector('[data-testid="chat-file-edit-group"]')!; + const header = container.querySelector('[data-openbitfun-part="summary"]')!; + expect(header.textContent).toBe('fileEditGroup.expandedApp.tsx'); + expect(node.getAttribute('data-expanded')).toBe('true'); + expect(container.querySelector('[data-openbitfun-part="controls"]')).toBeNull(); + data.phase = 'settled'; + render(); + expect(container.querySelector('[data-openbitfun-part="summary"]')).toBe(header); + expect(header.textContent).toBe('fileEditGroup.labelApp.tsx'); + expect(node.getAttribute('data-expanded')).toBe('false'); + data.allItems = [data.allItems[0], { ...data.allItems[1], status: 'error' }]; + render(); + expect(node.getAttribute('data-expanded')).toBe('true'); + expect(node.textContent).toContain('fileEditGroup.failed'); + state.choices.set('group', false); + render(); + expect(node.getAttribute('data-expanded')).toBe('false'); +}); + it.each(['explore', 'context', 'interface'] as const)('%s stays open through each completion and closes once at the group boundary', category => { const render = (data: FlowGroupData) => act(() => root.render()); render(group('collecting', category, true)); @@ -95,7 +125,10 @@ it.each(['thinking', 'text'] as const)('waits for collected %s to finish reveali render('collecting'); render('settled'); expect(container.querySelector('[data-flow-group]')?.getAttribute('data-expanded')).toBe('true'); - if (type === 'thinking') expect(container.querySelector('[data-parent-scroll]')?.getAttribute('data-parent-scroll')).toBe('true'); + if (type === 'thinking') { + expect(container.querySelector('[data-parent-scroll]')?.getAttribute('data-parent-scroll')).toBe('true'); + expect(container.querySelector('[data-parent-collapse]')?.getAttribute('data-parent-collapse')).toBe('true'); + } state.revealing = false; render('settled'); expect(container.querySelector('[data-flow-group]')?.getAttribute('data-expanded')).toBe('false'); @@ -148,9 +181,11 @@ it('mounts the live tail and preserves it through completion without mounting th expect(tail).not.toBeNull(); expect(container.querySelector('[data-call="call-0"]')).toBeNull(); expect(container.querySelectorAll('[data-call]').length).toBeLessThanOrEqual(48); - render({ ...data, allItems: data.allItems.map(item => ({ ...item, status: 'completed' })) }); + render({ ...data, phase: 'settled', allItems: data.allItems.map(item => ({ ...item, status: 'completed' })) }); expect(container.querySelector('[data-call="call-400"]')).toBe(active); expect(container.querySelector('[data-call="call-999"]')).toBe(tail); + expect(container.querySelector('[data-call="call-0"]')).toBeNull(); + expect(container.querySelectorAll('[data-call]').length).toBeLessThanOrEqual(48); }); function selectFilter(label: string, value: string) { diff --git a/src/web-ui/src/flow_chat/components/modern/FlowGroupRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/FlowGroupRenderer.tsx index f24d18da7a..31f043f7f1 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowGroupRenderer.tsx @@ -14,6 +14,7 @@ import { getFlowGroupCategory, getFlowGroupDisclosureChoice, isFlowGroupExpanded import { TypewriterRevealGateProvider } from '../../hooks/TypewriterRevealGate'; import { useCreateTypewriterRevealGate, useTypewriterRevealGate } from '../../hooks/typewriterRevealGateContext'; import { flowGroupPolicies } from '../../grouping/policies'; +import { fileEditGroupStatus, fileEditTarget } from '../../grouping/fileEdits'; import { isFlowGroupMemberActive } from '../../grouping/lifecycle'; import { filterFlowGroupItems, flowGroupToolCounts, flowGroupToolLabels, type FlowGroupStatusFilter } from '../../grouping/browse'; import { FlowTextBlock } from '../FlowTextBlock'; @@ -26,6 +27,7 @@ import { toolCapsuleStateKey } from '../../tool-cards/toolCapsuleModel'; import { isToolCapsule } from '../../tool-cards/toolCardMetadata'; import { getConcurrentCapsuleRows, type ConcurrentCapsuleRow } from '../../tool-cards/toolCapsuleLayout'; import { useFlowChatContext, useFlowChatVolatileContext } from './FlowChatContext'; +import { FileEditGroupView } from './FileEditGroupView'; import { buildFlowGroupRenderSegments, estimateFlowGroupItemsHeight } from './flowGroupRenderSegments'; import './ExploreRegion.scss'; @@ -37,7 +39,8 @@ export interface FlowGroupRendererProps { } interface FlowGroupView { - render: (props: FlowGroupProps & React.RefAttributes) => React.ReactNode; + render: (props: FlowGroupProps & React.RefAttributes, + context: { items: readonly FlowItem[]; sessionId?: string }) => React.ReactNode; Item: React.ComponentType>; } @@ -45,6 +48,10 @@ interface FlowGroupView { // same public anatomy; adding a category does not fork disclosure or motion. // The legacy explore identity now owns every exploration/execution collection. const flowGroupViews = { + 'file-edit': { + render: (props, context) => , + Item: props =>
, + }, explore: { render: props => } data-openbitfun-component="explore-group" data-openbitfun-part="root" data-testid="chat-explore-group" />, @@ -79,6 +86,7 @@ export const FlowGroupRenderer: React.FC = React.memo(({ onCollapseGroup, onExpandGroup, sessionId, + onFileViewRequest, } = useFlowChatContext(); const { groupStates, exploreGroupStates, expandedToolCapsules, groupReceiveFeedback, pendingPermissionToolCallIds } = useFlowChatVolatileContext(); @@ -101,6 +109,8 @@ export const FlowGroupRenderer: React.FC = React.memo(({ const parallelRows = useMemo(() => getConcurrentCapsuleRows(allItems), [allItems]); const renderSegments = useMemo(() => buildFlowGroupRenderSegments(allItems), [allItems]); const toolItemCount = useMemo(() => allItems.filter(item => item.type === 'tool').length, [allItems]); + const filePath = category === 'file-edit' ? fileEditTarget(allItems.find(item => item.type === 'tool')) : undefined; + const fileStatus = useMemo(() => fileEditGroupStatus(category === 'file-edit' ? allItems : []), [category, allItems]); const [query, setQuery] = useState(''); const [toolFilter, setToolFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); @@ -128,7 +138,12 @@ export const FlowGroupRenderer: React.FC = React.memo(({ const hasPendingPermission = allItems.some(item => item.type === 'tool' && pendingPermissionToolCallIds?.has((item as FlowToolItem).toolCall.id)); const isExpanded = hasOpenCapsule || hasPendingPermission || defaultExpanded + || (disclosureChoice === undefined && (fileStatus.failed > 0 || fileStatus.stopped > 0)) || (disclosureChoice === undefined && revealGate.isAnyRevealing); + // Once sealed work is complete, the parent owns the single closing motion. + // Keep revealed thinking at its current height while the reveal gate drains. + const retainForGroupCollapse = disclosureChoice !== true && !hasOpenCapsule && !hasPendingPermission + && (data.phase === 'settled' || (data.phase === undefined && !isGroupStreaming)); const previousExpanded = useRef(isExpanded); useLayoutEffect(() => { if (previousExpanded.current !== isExpanded) dispatchToolCardToggle(); @@ -160,12 +175,26 @@ export const FlowGroupRenderer: React.FC = React.memo(({ const groupProps = { ref: cardRootRef, placement, 'data-tool-card-id': groupId, expanded: isExpanded, + 'data-thinking-continuation': '', + 'data-thinking-handoff-priority': hasPendingPermission || allItems.some(item => item.status === 'error' + || (item.type === 'tool' && (item as FlowToolItem).toolResult?.success === false)) ? 'immediate' : undefined, streaming: isGroupStreaming, itemCount: toolItemCount, 'data-flow-group-phase': data.phase, ...summaryPresentation, ...policy.attributes(allItems), onExpandedChange: handleExpandedChange, // The transcript owns scrolling; individual completions do not close or pulse the group. receiveFeedback: data.phase === undefined ? groupReceiveFeedback?.get(groupId) : undefined, - browser: { + fileRevision: filePath ? { + path: filePath, label: filePath.split('/').pop() || filePath, + countLabel: t('fileEditGroup.label', { count: formatNumber(toolItemCount) }), + expandedLabel: t('fileEditGroup.expanded'), + status: fileStatus.failed ? 'error' as const : fileStatus.running ? 'running' as const : fileStatus.stopped ? 'stopped' as const : undefined, + statusLabel: fileStatus.failed ? t('fileEditGroup.failed', { count: formatNumber(fileStatus.failed) }) + : fileStatus.running ? t('fileEditGroup.running') + : fileStatus.stopped ? t('fileEditGroup.stopped', { count: formatNumber(fileStatus.stopped) }) : undefined, + openFile: onFileViewRequest ? { label: t('toolCards.file.openFullCodeHint'), + onPress: () => onFileViewRequest(filePath, filePath.split('/').pop() || filePath) } : undefined, + } : undefined, + browser: category === 'file-edit' ? undefined : { query, onQueryChange: setQuery, searchLabel: t('groupBrowser.search'), clearSearchLabel: t('groupBrowser.clearSearch'), filterLabel: t('groupBrowser.filters'), @@ -193,10 +222,13 @@ export const FlowGroupRenderer: React.FC = React.memo(({ isLastItem={isLastGroupInTurn && idx === allItems.length - 1} hidden={!browse.visibleIds.has(item.id)} forceThinkingExpanded={expandedThinkingItemIds?.includes(item.id) || browse.matchingThinkingIds.has(item.id)} + revealStreamingContent={browse.matchingThinkingIds.has(item.id)} + retainForGroupCollapse={retainForGroupCollapse} Item={view.Item} /> ); - const startAtTail = !browsing && disclosureChoice !== true && (data.phase === 'collecting' || data.phase === 'settling' || isGroupStreaming); + // Completion must not materialize an unvisited head while the tail closes. + const startAtTail = !browsing && disclosureChoice !== true; const visibleSegments = renderSegments.filter(segment => segment.items.some(item => browse.visibleIds.has(item.id))); const initialSegments = new Set((startAtTail ? visibleSegments.slice(-2) : visibleSegments.slice(0, 2)).map(segment => segment.key)); const segments = renderSegments.map(segment => ({ @@ -217,7 +249,7 @@ export const FlowGroupRenderer: React.FC = React.memo(({ - }); + }, { items: allItems, sessionId }); }); /** @@ -229,12 +261,14 @@ interface FlowGroupItemRendererProps { turnId: string; isLastItem?: boolean; forceThinkingExpanded?: boolean; + revealStreamingContent?: boolean; + retainForGroupCollapse?: boolean; hidden?: boolean; capsuleRow?: ConcurrentCapsuleRow; Item: FlowGroupView['Item']; } -const FlowGroupItemRenderer = React.memo(({ item, turnId, isLastItem, forceThinkingExpanded, hidden, capsuleRow, Item }) => { +const FlowGroupItemRenderer = React.memo(({ item, turnId, isLastItem, forceThinkingExpanded, revealStreamingContent, retainForGroupCollapse, hidden, capsuleRow, Item }) => { const { onToolConfirm, onToolReject, @@ -279,7 +313,9 @@ const FlowGroupItemRenderer = React.memo(({ item, tu case 'thinking': { const thinkingItem = item as FlowThinkingItem; return ( -