From 46df77ac3f132ff0e124f5343ac99fd5889deee0 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sun, 19 Apr 2026 21:53:22 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20style(tab-bar):=20blend=20inacti?= =?UTF-8?q?ve=20tabs=20with=20titlebar,=20show=20close=20icon=20by=20defau?= =?UTF-8?q?lt=20(#13973)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💄 style(tab-bar): blend inactive tabs with titlebar, show close icon by default Inactive tabs now use a transparent background and gain a subtle hover fill, matching Chrome's tab chrome so the titlebar feels visually unified. The close icon is always visible instead of fading in on hover, so users don't have to hunt for it on narrow tabs. Co-Authored-By: Claude Opus 4.7 (1M context) * 🐛 fix(desktop): CMD+N now actually clears active topic on agent page Previously the File → 新建话题 (CMD+N) handler only `navigate()`d to the agent base path. When the user was on `/agent/:aid?topic=xxx`, this stripped the URL param but `ChatHydration`'s URL→store updater skips `undefined` values, so `activeTopicId` in the chat store was never cleared and the subscriber would push the stale topic right back into the URL. Call `switchTopic(null)` on the store directly when an agent is active so the change propagates store→URL via the existing subscriber. Co-Authored-By: Claude Opus 4.7 (1M context) * 🐛 fix(hetero-agent): don't surface self-cancelled exits as runtime errors User-initiated cancel/stop and Electron before-quit kill the agent process with SIGINT/SIGTERM, producing non-zero exit codes (130/143/137). Mark these via session.cancelledByUs so the exit handler routes them through the complete broadcast — otherwise a user cancel or app shutdown would look like an agent failure (e.g. "Agent exited with code 143" leaking into other live CC sessions' topics). Co-Authored-By: Claude Opus 4.7 (1M context) * ✨ feat(tab-bar): show running indicator dot on tab when agent is generating Adds a useTabRunning hook that reads agent runtime state from the chat store for agent / agent-topic tabs, and renders a small gold dot over the tab avatar/icon while the conversation is generating. Other tab types stay unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) * 💄 style(claude-code): render ToolSearch select: queries as inline tags Parses select:A,B,C into individual tag chips (monospace, subtle pill background) instead of a comma-joined string, so the names of tools being loaded read more clearly. Keyword queries keep the existing single-highlight rendering. Co-Authored-By: Claude Opus 4.7 (1M context) * ✨ feat(git-status): show +N ±M -K diff badge next to branch name Surface uncommitted-file count directly in the runtime-config status bar so the dirty state is visible at a glance without opening the branch dropdown. Each segment is color-coded (added / modified / deleted) and hidden when zero; a tooltip shows the verbose breakdown. Implementation: - Backend buckets `git status --porcelain` lines into added / modified / deleted / total via X+Y status pair - New always-on useWorkingTreeStatus SWR hook (focus revalidation, 5s throttle) shared by GitStatus and BranchSwitcher — single fetch path - BranchSwitcher's "uncommitted changes: N files" now reads `total` Co-Authored-By: Claude Opus 4.7 (1M context) * 🐛 fix(assistant-group): show only delete button while tool call is in progress When the last child of an assistantGroup is a running tool call, `contentId` is undefined and the action bar fell through to a branch that dropped the `menu` and `ReactionPicker`, leaving a single copy icon with no overflow. Replace the legacy `continueGeneration / delAndRegenerate / del` bar with a del-only bar in this state — delete is the only action that makes sense before any text block is finalized. Co-Authored-By: Claude Opus 4.7 (1M context) * 🐛 fix(conversation-flow): aggregate per-step nested metadata.usage in assistantGroup After hetero-agent moved to per-step usage writes (`metadata: { usage: {...} }`), the assistantGroup virtual message stopped showing the cumulative token total across steps and instead surfaced only the last step's numbers. Root cause: splitMetadata only recognised the legacy flat shape (`metadata.totalTokens`, etc.) and didn't read the new nested shape, so each child block went into aggregateMetadata with `usage: undefined`. The sum was empty, and the final group inherited a single child's metadata.usage purely because Object.assign collapsed groupMetadata down to the last child. - splitMetadata now reads both nested (`metadata.usage` / `metadata.performance`) and flat (legacy) shapes; nested takes priority - Add `'usage'` / `'performance'` to the usage/performance field sets in parse and FlatListBuilder so the nested objects don't leak into "other metadata" - Regression test: multi-step assistantGroup chain sums child usages Co-Authored-By: Claude Opus 4.7 (1M context) * 💄 style(hetero-agent): tone down full-access badge to match left bar items The badge was shouting in colorWarning + 500 weight; reduce to colorTextSecondary at normal weight so it sits at the same visual rank as the working-dir / git buttons on the left. The CircleAlert icon still carries the warning semantics. Also force cursor:default so the non-interactive label doesn't pick up an I-beam over its text. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../main/controllers/HeterogeneousAgentCtr.ts | 26 +++++- .../desktop/src/main/controllers/SystemCtr.ts | 29 ++++++- locales/en-US/electron.json | 1 + locales/en-US/plugin.json | 1 + locales/zh-CN/electron.json | 1 + locales/zh-CN/plugin.json | 1 + .../src/client/Inspector/ToolSearch.tsx | 85 +++++++++++++++---- .../src/__tests__/parse.test.ts | 62 ++++++++++++++ packages/conversation-flow/src/parse.ts | 4 + .../src/transformation/FlatListBuilder.ts | 6 ++ .../src/transformation/MessageTransformer.ts | 22 +++-- .../electron-client-ipc/src/types/system.ts | 8 +- .../RuntimeConfig/BranchSwitcher.tsx | 10 +-- .../ChatInput/RuntimeConfig/GitStatus.tsx | 59 ++++++++++++- .../RuntimeConfig/useWorkingTreeStatus.ts | 20 +++++ .../Messages/AssistantGroup/Actions/index.tsx | 7 +- src/features/DesktopFileMenuBridge/index.tsx | 14 ++- .../Electron/titlebar/TabBar/TabItem.tsx | 33 ++++--- .../titlebar/TabBar/hooks/useTabRunning.ts | 24 ++++++ .../Electron/titlebar/TabBar/styles.ts | 32 ++++--- src/locales/default/electron.ts | 1 + src/locales/default/plugin.ts | 2 + .../WorkingDirectoryBar.tsx | 7 +- 23 files changed, 381 insertions(+), 74 deletions(-) create mode 100644 src/features/ChatInput/RuntimeConfig/useWorkingTreeStatus.ts create mode 100644 src/features/Electron/titlebar/TabBar/hooks/useTabRunning.ts diff --git a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts index 9d5f2f260b..afe590967b 100644 --- a/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts +++ b/apps/desktop/src/main/controllers/HeterogeneousAgentCtr.ts @@ -102,6 +102,14 @@ interface AgentSession { agentSessionId?: string; agentType: string; args: string[]; + /** + * True when *we* initiated the kill (cancelSession / stopSession / before-quit). + * The `exit` handler uses this to route signal-induced non-zero exits through + * the `complete` broadcast instead of surfacing them as runtime errors — + * SIGINT(130) / SIGTERM(143) / SIGKILL(137) from our own kill paths are + * intentional, not agent failures. + */ + cancelledByUs?: boolean; command: string; cwd?: string; env?: Record; @@ -362,10 +370,21 @@ export default class HeterogeneousAgentCtr extends ControllerModule { reject(err); }); - proc.on('exit', (code) => { - logger.info('Agent process exited:', { code, sessionId: session.sessionId }); + proc.on('exit', (code, signal) => { + logger.info('Agent process exited:', { code, sessionId: session.sessionId, signal }); session.process = undefined; + // If *we* killed it (cancel / stop / before-quit), treat the non-zero + // exit as a clean shutdown — surfacing it as an error would make a + // user-initiated cancel look like an agent failure, and an Electron + // shutdown affecting OTHER running CC sessions would pollute their + // topics with a misleading "Agent exited with code 143" message. + if (session.cancelledByUs) { + this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId }); + resolve(); + return; + } + if (code === 0) { this.broadcast('heteroAgentSessionComplete', { sessionId: session.sessionId }); resolve(); @@ -435,6 +454,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule { const session = this.sessions.get(params.sessionId); if (!session?.process || session.process.killed) return; + session.cancelledByUs = true; const proc = session.process; this.killProcessTree(proc, 'SIGINT'); @@ -455,6 +475,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule { if (!session) return; if (session.process && !session.process.killed) { + session.cancelledByUs = true; const proc = session.process; this.killProcessTree(proc, 'SIGTERM'); setTimeout(() => { @@ -479,6 +500,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule { electronApp.on('before-quit', () => { for (const [, session] of this.sessions) { if (session.process && !session.process.killed) { + session.cancelledByUs = true; this.killProcessTree(session.process, 'SIGTERM'); } } diff --git a/apps/desktop/src/main/controllers/SystemCtr.ts b/apps/desktop/src/main/controllers/SystemCtr.ts index 8026c19a84..5c4236a79e 100644 --- a/apps/desktop/src/main/controllers/SystemCtr.ts +++ b/apps/desktop/src/main/controllers/SystemCtr.ts @@ -390,7 +390,9 @@ export default class SystemController extends ControllerModule { } /** - * Count unstaged / staged / untracked files via `git status --porcelain`. + * Bucket dirty files into added / modified / deleted via `git status --porcelain`. + * Each file is counted once: untracked (`??`) and staged-add (`A`) → added, + * any `D` in index or working tree → deleted, everything else (`M`/`R`/`C`/`T`/`U`) → modified. */ @IpcMethod() async getGitWorkingTreeStatus(dirPath: string): Promise { @@ -400,10 +402,29 @@ export default class SystemController extends ControllerModule { cwd: dirPath, timeout: 5000, }); - const lines = stdout.split('\n').filter((line) => line.trim().length > 0); - return { clean: lines.length === 0, modified: lines.length }; + let added = 0; + let modified = 0; + let deleted = 0; + for (const line of stdout.split('\n')) { + if (line.length < 2) continue; + const x = line[0]; + const y = line[1]; + if (x === '?' && y === '?') { + added++; + } else if (x === '!' && y === '!') { + // ignored — skip + } else if (x === 'D' || y === 'D') { + deleted++; + } else if (x === 'A' || y === 'A') { + added++; + } else { + modified++; + } + } + const total = added + modified + deleted; + return { added, clean: total === 0, deleted, modified, total }; } catch { - return { clean: true, modified: 0 }; + return { added: 0, clean: true, deleted: 0, modified: 0, total: 0 }; } } diff --git a/locales/en-US/electron.json b/locales/en-US/electron.json index 7132edeffc..a984bf8616 100644 --- a/locales/en-US/electron.json +++ b/locales/en-US/electron.json @@ -106,6 +106,7 @@ "tab.closeLeftTabs": "Close Tabs to the Left", "tab.closeOtherTabs": "Close Other Tabs", "tab.closeRightTabs": "Close Tabs to the Right", + "tab.running": "Agent is running", "updater.checkingUpdate": "Checking for updates", "updater.checkingUpdateDesc": "Retrieving version information...", "updater.downloadNewVersion": "Download new version", diff --git a/locales/en-US/plugin.json b/locales/en-US/plugin.json index bc783450f6..8fe9e3cc1a 100644 --- a/locales/en-US/plugin.json +++ b/locales/en-US/plugin.json @@ -447,6 +447,7 @@ "localSystem.workingDirectory.createBranchAction": "Checkout new branch…", "localSystem.workingDirectory.current": "Current working directory", "localSystem.workingDirectory.detachedHead": "Detached HEAD at {{sha}}", + "localSystem.workingDirectory.diffStatTooltip": "Added {{added}} · Modified {{modified}} · Deleted {{deleted}}", "localSystem.workingDirectory.ghMissing": "Install and log in to the GitHub CLI (`gh`) to see linked pull requests", "localSystem.workingDirectory.newBranchPlaceholder": "feature/new-branch-name", "localSystem.workingDirectory.noRecent": "No recent directories", diff --git a/locales/zh-CN/electron.json b/locales/zh-CN/electron.json index 3562fc7b73..f214c86a94 100644 --- a/locales/zh-CN/electron.json +++ b/locales/zh-CN/electron.json @@ -106,6 +106,7 @@ "tab.closeLeftTabs": "关闭左侧标签页", "tab.closeOtherTabs": "关闭其他标签页", "tab.closeRightTabs": "关闭右侧标签页", + "tab.running": "智能体运行中", "updater.checkingUpdate": "检查新版本", "updater.checkingUpdateDesc": "正在获取版本信息…", "updater.downloadNewVersion": "下载新版本", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index 00939535a3..9ce552367e 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -447,6 +447,7 @@ "localSystem.workingDirectory.createBranchAction": "检出新分支…", "localSystem.workingDirectory.current": "当前工作目录", "localSystem.workingDirectory.detachedHead": "游离 HEAD,当前提交 {{sha}}", + "localSystem.workingDirectory.diffStatTooltip": "新增 {{added}} · 修改 {{modified}} · 删除 {{deleted}}", "localSystem.workingDirectory.ghMissing": "安装并登录 GitHub CLI(gh)即可显示关联的 Pull Request", "localSystem.workingDirectory.newBranchPlaceholder": "feature/新分支名称", "localSystem.workingDirectory.noRecent": "暂无最近目录", diff --git a/packages/builtin-tool-claude-code/src/client/Inspector/ToolSearch.tsx b/packages/builtin-tool-claude-code/src/client/Inspector/ToolSearch.tsx index bb1bbe2a60..d22463a5b8 100644 --- a/packages/builtin-tool-claude-code/src/client/Inspector/ToolSearch.tsx +++ b/packages/builtin-tool-claude-code/src/client/Inspector/ToolSearch.tsx @@ -6,7 +6,7 @@ import { shinyTextStyles, } from '@lobechat/shared-tool-ui/styles'; import type { BuiltinInspectorProps } from '@lobechat/types'; -import { cx } from 'antd-style'; +import { createStaticStyles, cx } from 'antd-style'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,44 +14,97 @@ import { ClaudeCodeApiName, type ToolSearchArgs } from '../../types'; const SELECT_PREFIX = 'select:'; +const styles = createStaticStyles(({ css, cssVar }) => ({ + baseline: css` + align-items: baseline; + `, + tag: css` + padding-block: 1px; + padding-inline: 6px; + border-radius: 4px; + + font-family: ${cssVar.fontFamilyCode}; + font-size: 12px; + color: ${cssVar.colorText}; + + background: ${cssVar.colorFillTertiary}; + `, + tagsList: css` + display: inline-flex; + flex-shrink: 1; + gap: 4px; + align-items: center; + + min-width: 0; + margin-inline-start: 6px; + + white-space: nowrap; + `, +})); + +interface ParsedQuery { + names: string[] | null; + raw: string; +} + /** - * `select:A,B,C` → `A, B, C` (names the model is loading by exact match). - * Keyword queries pass through unchanged. + * `select:A,B,C` → ['A', 'B', 'C'] (exact-name loads, rendered as tags). + * Keyword queries pass through as raw text. */ -const formatQuery = (query?: string): string | undefined => { +const parseQuery = (query?: string): ParsedQuery | undefined => { if (!query) return undefined; const trimmed = query.trim(); - if (!trimmed.toLowerCase().startsWith(SELECT_PREFIX)) return trimmed; + if (!trimmed.toLowerCase().startsWith(SELECT_PREFIX)) { + return { names: null, raw: trimmed }; + } const names = trimmed .slice(SELECT_PREFIX.length) .split(',') .map((s) => s.trim()) .filter(Boolean); - return names.length > 0 ? names.join(', ') : trimmed; + return { names: names.length > 0 ? names : null, raw: trimmed }; }; export const ToolSearchInspector = memo>( ({ args, partialArgs, isArgumentsStreaming, isLoading }) => { const { t } = useTranslation('plugin'); const label = t(ClaudeCodeApiName.ToolSearch as any); - const query = formatQuery(args?.query || partialArgs?.query); + const parsed = parseQuery(args?.query || partialArgs?.query); - if (isArgumentsStreaming && !query) { + if (isArgumentsStreaming && !parsed) { return
{label}
; } + const isShiny = isArgumentsStreaming || isLoading; + + if (parsed?.names) { + return ( +
+ {label}: + + {parsed.names.map((name, index) => ( + + {name} + + ))} + +
+ ); + } + return ( -
+
{label} - {query && ( + {parsed && ( <> : - {query} + {parsed.raw} )}
diff --git a/packages/conversation-flow/src/__tests__/parse.test.ts b/packages/conversation-flow/src/__tests__/parse.test.ts index 0bd1f2ca40..ef268a076f 100644 --- a/packages/conversation-flow/src/__tests__/parse.test.ts +++ b/packages/conversation-flow/src/__tests__/parse.test.ts @@ -473,6 +473,68 @@ describe('parse', () => { const result = parse(input as any[]); expect(result.flatList[0]?.usage).toEqual(topLevelUsage); }); + + it('should aggregate per-step nested metadata.usage across an assistantGroup chain', () => { + // Hetero-agent (Claude Code) writes per-turn usage to `metadata.usage` on + // each step assistant message. The assistantGroup virtual message must + // sum them — without this, the UI shows only one step's tokens (typically + // the last step, which gets surfaced via the lone metadata.usage that + // survived Object.assign collapse). + const step1Usage = { + inputCachedTokens: 100, + totalInputTokens: 200, + totalOutputTokens: 50, + totalTokens: 250, + }; + const step2Usage = { + inputCachedTokens: 300, + totalInputTokens: 400, + totalOutputTokens: 80, + totalTokens: 480, + }; + const input = [ + { + id: 'u1', + role: 'user' as const, + content: 'q', + createdAt: 1, + }, + { + id: 'a1', + role: 'assistant' as const, + content: '', + parentId: 'u1', + tools: [{ id: 'call-1', type: 'default', apiName: 'bash', arguments: '{}' }], + metadata: { usage: step1Usage }, + createdAt: 2, + }, + { + id: 't1', + role: 'tool' as const, + content: 'tool output', + parentId: 'a1', + tool_call_id: 'call-1', + createdAt: 3, + }, + { + id: 'a2', + role: 'assistant' as const, + content: 'final answer', + parentId: 't1', + metadata: { usage: step2Usage }, + createdAt: 4, + }, + ]; + + const result = parse(input as any[]); + const group = result.flatList.find((m) => m.role === 'assistantGroup'); + expect(group?.usage).toEqual({ + inputCachedTokens: 400, + totalInputTokens: 600, + totalOutputTokens: 130, + totalTokens: 730, + }); + }); }); describe('Performance', () => { diff --git a/packages/conversation-flow/src/parse.ts b/packages/conversation-flow/src/parse.ts index 719fd2c399..c2fcaa96cc 100644 --- a/packages/conversation-flow/src/parse.ts +++ b/packages/conversation-flow/src/parse.ts @@ -70,12 +70,16 @@ export function parse(messages: Message[], messageGroups?: MessageGroupMetadata[ 'outputImageTokens', 'outputReasoningTokens', 'outputTextTokens', + // Nested canonical shape — executors write `metadata.usage` / `metadata.performance` + // as objects; treat them as part of the usage/performance set alongside the legacy flat keys. + 'performance', 'rejectedPredictionTokens', 'totalInputTokens', 'totalOutputTokens', 'totalTokens', 'tps', 'ttft', + 'usage', ]); helperMaps.messageMap.forEach((message, id) => { diff --git a/packages/conversation-flow/src/transformation/FlatListBuilder.ts b/packages/conversation-flow/src/transformation/FlatListBuilder.ts index 2251256e83..12e901cb0f 100644 --- a/packages/conversation-flow/src/transformation/FlatListBuilder.ts +++ b/packages/conversation-flow/src/transformation/FlatListBuilder.ts @@ -812,12 +812,15 @@ export class FlatListBuilder { 'outputImageTokens', 'outputReasoningTokens', 'outputTextTokens', + // Nested canonical shape — see splitMetadata + 'performance', 'rejectedPredictionTokens', 'totalInputTokens', 'totalOutputTokens', 'totalTokens', 'tps', 'ttft', + 'usage', ]); Object.entries(assistant.metadata).forEach(([key, value]) => { @@ -955,12 +958,15 @@ export class FlatListBuilder { 'outputImageTokens', 'outputReasoningTokens', 'outputTextTokens', + // Nested canonical shape — see splitMetadata + 'performance', 'rejectedPredictionTokens', 'totalInputTokens', 'totalOutputTokens', 'totalTokens', 'tps', 'ttft', + 'usage', ]); Object.entries(message.metadata).forEach(([key, value]) => { diff --git a/packages/conversation-flow/src/transformation/MessageTransformer.ts b/packages/conversation-flow/src/transformation/MessageTransformer.ts index b6eb6ad1a2..84f5cc12b5 100644 --- a/packages/conversation-flow/src/transformation/MessageTransformer.ts +++ b/packages/conversation-flow/src/transformation/MessageTransformer.ts @@ -31,7 +31,15 @@ export class MessageTransformer { } /** - * Split metadata into usage and performance objects + * Split metadata into usage and performance objects. + * + * Supports two storage shapes: + * - **Nested** (canonical): `metadata.usage = {...}`, `metadata.performance = {...}` + * — written by hetero-agent / Gateway executors. + * - **Flat** (legacy): `metadata.totalTokens`, `metadata.ttft`, etc — older write paths + * that splatted token fields directly onto metadata. + * + * Nested takes priority; flat fields fill in any missing keys (transition state). */ splitMetadata(metadata?: any): { performance?: ModelPerformance; @@ -39,8 +47,10 @@ export class MessageTransformer { } { if (!metadata) return {}; - const usage: ModelUsage = {}; - const performance: ModelPerformance = {}; + const usage: ModelUsage = { ...metadata.usage }; + const performance: ModelPerformance = { ...metadata.performance }; + let hasUsage = Object.keys(usage).length > 0; + let hasPerformance = Object.keys(performance).length > 0; const usageFields = [ 'acceptedPredictionTokens', @@ -63,18 +73,16 @@ export class MessageTransformer { 'totalTokens', ] as const; - let hasUsage = false; usageFields.forEach((field) => { - if (metadata[field] !== undefined) { + if (metadata[field] !== undefined && (usage as any)[field] === undefined) { (usage as any)[field] = metadata[field]; hasUsage = true; } }); const performanceFields = ['duration', 'latency', 'tps', 'ttft'] as const; - let hasPerformance = false; performanceFields.forEach((field) => { - if (metadata[field] !== undefined) { + if (metadata[field] !== undefined && (performance as any)[field] === undefined) { (performance as any)[field] = metadata[field]; hasPerformance = true; } diff --git a/packages/electron-client-ipc/src/types/system.ts b/packages/electron-client-ipc/src/types/system.ts index cb8d2eae31..997d36b23c 100644 --- a/packages/electron-client-ipc/src/types/system.ts +++ b/packages/electron-client-ipc/src/types/system.ts @@ -58,9 +58,15 @@ export interface GitBranchListItem { } export interface GitWorkingTreeStatus { + /** Untracked + staged-as-added files */ + added: number; clean: boolean; - /** Count of modified / staged / untracked files (each file counted once) */ + /** Files marked deleted in either index or working tree */ + deleted: number; + /** Modified / renamed / copied / type-changed / unmerged files */ modified: number; + /** Total dirty files (each file counted once) — sum of added + modified + deleted */ + total: number; } export interface GitCheckoutResult { diff --git a/src/features/ChatInput/RuntimeConfig/BranchSwitcher.tsx b/src/features/ChatInput/RuntimeConfig/BranchSwitcher.tsx index 2a4c54509b..56bea4e670 100644 --- a/src/features/ChatInput/RuntimeConfig/BranchSwitcher.tsx +++ b/src/features/ChatInput/RuntimeConfig/BranchSwitcher.tsx @@ -24,6 +24,8 @@ import useSWR from 'swr'; import { message } from '@/components/AntdStaticMethods'; import { electronSystemService } from '@/services/electron/system'; +import { useWorkingTreeStatus } from './useWorkingTreeStatus'; + const styles = createStaticStyles(({ css }) => ({ branchLabel: css` overflow: hidden; @@ -176,11 +178,7 @@ const BranchSwitcher = memo( () => electronSystemService.listGitBranches(path), { revalidateOnFocus: false, shouldRetryOnError: false }, ); - const { data: workingStatus, mutate: mutateWorkingStatus } = useSWR( - open ? ['git-status', path] : null, - () => electronSystemService.getGitWorkingTreeStatus(path), - { revalidateOnFocus: false }, - ); + const { data: workingStatus, mutate: mutateWorkingStatus } = useWorkingTreeStatus(path); const [isRefreshing, setIsRefreshing] = useState(false); const handleRefresh = useCallback(async () => { @@ -324,7 +322,7 @@ const BranchSwitcher = memo( {isCurrent && workingStatus && !workingStatus.clean && (
{t('localSystem.workingDirectory.uncommittedChanges', { - count: workingStatus.modified, + count: workingStatus.total, })}
)} diff --git a/src/features/ChatInput/RuntimeConfig/GitStatus.tsx b/src/features/ChatInput/RuntimeConfig/GitStatus.tsx index 311d73d074..1d8424cb60 100644 --- a/src/features/ChatInput/RuntimeConfig/GitStatus.tsx +++ b/src/features/ChatInput/RuntimeConfig/GitStatus.tsx @@ -8,6 +8,7 @@ import { electronSystemService } from '@/services/electron/system'; import BranchSwitcher from './BranchSwitcher'; import { useGitInfo } from './useGitInfo'; +import { useWorkingTreeStatus } from './useWorkingTreeStatus'; const styles = createStaticStyles(({ css }) => ({ branchLabel: css` @@ -16,6 +17,26 @@ const styles = createStaticStyles(({ css }) => ({ text-overflow: ellipsis; white-space: nowrap; `, + diffStat: css` + display: inline-flex; + flex-shrink: 0; + gap: 4px; + align-items: center; + + margin-inline-start: 2px; + + font-variant-numeric: tabular-nums; + line-height: 1; + `, + diffStatAdded: css` + color: ${cssVar.colorSuccess}; + `, + diffStatDeleted: css` + color: ${cssVar.colorError}; + `, + diffStatModified: css` + color: ${cssVar.colorWarning}; + `, prTrigger: css` cursor: pointer; @@ -72,6 +93,7 @@ interface GitStatusProps { const GitStatus = memo(({ path, isGithub }) => { const { t } = useTranslation('plugin'); const { data, mutate } = useGitInfo(path, isGithub); + const { data: workingStatus, mutate: mutateWorkingStatus } = useWorkingTreeStatus(path); const [switcherOpen, setSwitcherOpen] = useState(false); const handleOpenPr = useCallback(() => { @@ -97,13 +119,45 @@ const GitStatus = memo(({ path, isGithub }) => { ? t('localSystem.workingDirectory.ghMissing') : undefined; + const diffStat = + workingStatus && !workingStatus.clean ? ( + + {workingStatus.added > 0 && ( + +{workingStatus.added} + )} + {workingStatus.modified > 0 && ( + ±{workingStatus.modified} + )} + {workingStatus.deleted > 0 && ( + -{workingStatus.deleted} + )} + + ) : null; + + const diffStatTooltip = + workingStatus && !workingStatus.clean + ? t('localSystem.workingDirectory.diffStatTooltip', { + added: workingStatus.added, + deleted: workingStatus.deleted, + modified: workingStatus.modified, + }) + : undefined; + const branchTrigger = (
{data.branch} + {diffStat}
); + const wrappedBranchTrigger = + diffStat && diffStatTooltip ? ( + {branchTrigger} + ) : ( + branchTrigger + ); + return ( <>
@@ -117,12 +171,13 @@ const GitStatus = memo(({ path, isGithub }) => { onOpenChange={setSwitcherOpen} onAfterCheckout={() => { void mutate(); + void mutateWorkingStatus(); }} onExternalRefresh={async () => { - await mutate(); + await Promise.all([mutate(), mutateWorkingStatus()]); }} > - {branchTrigger} + {wrappedBranchTrigger} )} {data.pullRequest && ( diff --git a/src/features/ChatInput/RuntimeConfig/useWorkingTreeStatus.ts b/src/features/ChatInput/RuntimeConfig/useWorkingTreeStatus.ts new file mode 100644 index 0000000000..c0a850f7be --- /dev/null +++ b/src/features/ChatInput/RuntimeConfig/useWorkingTreeStatus.ts @@ -0,0 +1,20 @@ +import { isDesktop } from '@lobechat/const'; + +import { useClientDataSWR } from '@/libs/swr'; +import { electronSystemService } from '@/services/electron/system'; + +/** + * Working-tree dirty-file breakdown for the current cwd. + * Always-on (not gated by dropdown open state) so the status bar can show a + * +N ~M -K badge. Revalidates on window focus, throttled to 5s — git status + * is local & cheap, but we still don't need sub-second freshness. + */ +export const useWorkingTreeStatus = (dirPath?: string) => { + const key = isDesktop && dirPath ? ['git-working-tree-status', dirPath] : null; + + return useClientDataSWR(key, () => electronSystemService.getGitWorkingTreeStatus(dirPath!), { + focusThrottleInterval: 5 * 1000, + revalidateOnFocus: true, + shouldRetryOnError: false, + }); +}; diff --git a/src/features/Conversation/Messages/AssistantGroup/Actions/index.tsx b/src/features/Conversation/Messages/AssistantGroup/Actions/index.tsx index a59fb86538..0aa7227e7c 100644 --- a/src/features/Conversation/Messages/AssistantGroup/Actions/index.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/Actions/index.tsx @@ -22,7 +22,7 @@ const DEFAULT_MENU: MessageActionSlot[] = [ 'regenerate', 'del', ]; -const EMPTY_GROUP_BAR: MessageActionSlot[] = ['continueGeneration', 'delAndRegenerate', 'del']; +const IN_PROGRESS_BAR: MessageActionSlot[] = ['del']; interface GroupActionsProps { actionsConfig?: MessageActionsConfig; @@ -39,9 +39,10 @@ export const GroupActionsBar = memo( [contentBlock, data, id], ); - // Empty group (no assistant content) — only allows continuing / reset / delete + // No finalized text block yet (group is either empty or last child is a + // still-running tool call). Only delete is meaningful here. if (!contentId) { - return ; + return ; } const defaultBar = data.tools ? DEFAULT_BAR_WITH_TOOLS : DEFAULT_BAR; diff --git a/src/features/DesktopFileMenuBridge/index.tsx b/src/features/DesktopFileMenuBridge/index.tsx index 979a59af7e..aa00fabf07 100644 --- a/src/features/DesktopFileMenuBridge/index.tsx +++ b/src/features/DesktopFileMenuBridge/index.tsx @@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom'; import { useCreateMenuItems } from '@/routes/(main)/home/_layout/hooks/useCreateMenuItems'; import { useAgentStore } from '@/store/agent'; import { builtinAgentSelectors } from '@/store/agent/selectors'; +import { useChatStore } from '@/store/chat'; /** * Bridge component for handling File menu actions from Electron main process @@ -20,11 +21,16 @@ const DesktopFileMenuBridge = () => { const activeAgentId = useAgentStore((s) => s.activeAgentId); // Handle create new topic from File menu - // If currently in an agent page, create a new topic for the current agent - // Otherwise, navigate to inbox agent + // If currently in an agent page, clear the active topic via the store — + // navigating to the same path won't clear `activeTopicId` because + // ChatHydration's URL→store updater skips `undefined` values. + // If not in an agent page, navigate to inbox agent. const handleCreateNewTopic = useCallback(() => { - const targetAgentId = activeAgentId || inboxAgentId; - navigate(SESSION_CHAT_URL(targetAgentId, false)); + if (activeAgentId) { + useChatStore.getState().switchTopic(null); + return; + } + navigate(SESSION_CHAT_URL(inboxAgentId, false)); }, [activeAgentId, inboxAgentId, navigate]); // Handle create new agent from File menu diff --git a/src/features/Electron/titlebar/TabBar/TabItem.tsx b/src/features/Electron/titlebar/TabBar/TabItem.tsx index 99e549d9ce..28229f3500 100644 --- a/src/features/Electron/titlebar/TabBar/TabItem.tsx +++ b/src/features/Electron/titlebar/TabBar/TabItem.tsx @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next'; import { type ResolvedPageData } from '@/features/Electron/titlebar/RecentlyViewed/types'; import { electronStylish } from '@/styles/electron'; +import { useTabRunning } from './hooks/useTabRunning'; import { useStyles } from './styles'; interface TabItemProps { @@ -45,6 +46,7 @@ const TabItem = memo( const styles = useStyles; const { t } = useTranslation('electron'); const id = item.reference.id; + const isRunning = useTabRunning(item.reference); const handleClick = useCallback(() => { if (!isActive) { @@ -99,23 +101,26 @@ const TabItem = memo( onClick={handleClick} > {item.avatar ? ( - + + + {isRunning && } + ) : ( - item.icon && + item.icon && ( + + + {isRunning && } + + ) )} {item.title} - + ); diff --git a/src/features/Electron/titlebar/TabBar/hooks/useTabRunning.ts b/src/features/Electron/titlebar/TabBar/hooks/useTabRunning.ts new file mode 100644 index 0000000000..c003e36fa8 --- /dev/null +++ b/src/features/Electron/titlebar/TabBar/hooks/useTabRunning.ts @@ -0,0 +1,24 @@ +import { + type AgentParams, + type AgentTopicParams, + type PageReference, +} from '@/features/Electron/titlebar/RecentlyViewed/types'; +import { useChatStore } from '@/store/chat'; +import { operationSelectors } from '@/store/chat/selectors'; + +/** + * Whether the agent runtime is generating in this tab's conversation context. + * Only chat tabs (agent / agent-topic) can be "running"; other tab types return false. + */ +export const useTabRunning = (reference: PageReference): boolean => + useChatStore((s) => { + if (reference.type === 'agent') { + const { agentId } = reference.params as AgentParams; + return operationSelectors.isAgentRuntimeRunningByContext({ agentId, topicId: null })(s); + } + if (reference.type === 'agent-topic') { + const { agentId, topicId } = reference.params as AgentTopicParams; + return operationSelectors.isAgentRuntimeRunningByContext({ agentId, topicId })(s); + } + return false; + }); diff --git a/src/features/Electron/titlebar/TabBar/styles.ts b/src/features/Electron/titlebar/TabBar/styles.ts index 88b39429d7..51ee2ec063 100644 --- a/src/features/Electron/titlebar/TabBar/styles.ts +++ b/src/features/Electron/titlebar/TabBar/styles.ts @@ -1,16 +1,32 @@ import { createStaticStyles } from 'antd-style'; export const useStyles = createStaticStyles(({ css, cssVar }) => ({ + avatarWrapper: css` + position: relative; + flex-shrink: 0; + line-height: 0; + `, closeIcon: css` flex-shrink: 0; color: ${cssVar.colorTextTertiary}; - opacity: 0; - transition: opacity 0.15s ${cssVar.motionEaseOut}; &:hover { color: ${cssVar.colorText}; } `, + runningDot: css` + position: absolute; + inset-block-end: -2px; + inset-inline-end: -2px; + + width: 8px; + height: 8px; + border: 1.5px solid ${cssVar.colorBgLayout}; + border-radius: 50%; + + background: ${cssVar.gold}; + box-shadow: 0 0 6px ${cssVar.gold}; + `, container: css` flex: 1; min-width: 0; @@ -33,16 +49,12 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({ font-size: 12px; - background-color: ${cssVar.colorFillTertiary}; + background-color: transparent; transition: background-color 0.15s ${cssVar.motionEaseInOut}; &:hover { - background-color: ${cssVar.colorFillSecondary}; - } - - &:hover .closeIcon { - opacity: 1; + background-color: ${cssVar.colorFillTertiary}; } `, tabActive: css` @@ -51,10 +63,6 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({ &:hover { background-color: ${cssVar.colorFill}; } - - & .closeIcon { - opacity: 1; - } `, tabIcon: css` flex-shrink: 0; diff --git a/src/locales/default/electron.ts b/src/locales/default/electron.ts index 6689e4306e..0f98616fcc 100644 --- a/src/locales/default/electron.ts +++ b/src/locales/default/electron.ts @@ -32,6 +32,7 @@ export default { 'tab.closeLeftTabs': 'Close Tabs to the Left', 'tab.closeOtherTabs': 'Close Other Tabs', 'tab.closeRightTabs': 'Close Tabs to the Right', + 'tab.running': 'Agent is running', 'proxy.auth': 'Authentication Required', 'proxy.authDesc': 'If the proxy server requires a username and password', 'proxy.authSettings': 'Authentication Settings', diff --git a/src/locales/default/plugin.ts b/src/locales/default/plugin.ts index acd828115f..e016775acb 100644 --- a/src/locales/default/plugin.ts +++ b/src/locales/default/plugin.ts @@ -454,6 +454,8 @@ export default { 'localSystem.workingDirectory.current': 'Current working directory', 'localSystem.workingDirectory.chooseDifferentFolder': 'Choose a different folder', 'localSystem.workingDirectory.detachedHead': 'Detached HEAD at {{sha}}', + 'localSystem.workingDirectory.diffStatTooltip': + 'Added {{added}} · Modified {{modified}} · Deleted {{deleted}}', 'localSystem.workingDirectory.ghMissing': 'Install and log in to the GitHub CLI (`gh`) to see linked pull requests', 'localSystem.workingDirectory.newBranchPlaceholder': 'feature/new-branch-name', diff --git a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx index 74955526b2..8d3f8b4bcb 100644 --- a/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx +++ b/src/routes/(main)/agent/features/Conversation/HeterogeneousChatInput/WorkingDirectoryBar.tsx @@ -48,17 +48,18 @@ const styles = createStaticStyles(({ css }) => ({ } `, fullAccess: css` + cursor: default; + display: flex; gap: 6px; align-items: center; padding-block: 2px; - padding-inline: 6px; + padding-inline: 4px; border-radius: 4px; font-size: 12px; - font-weight: 500; - color: ${cssVar.colorWarning}; + color: ${cssVar.colorTextSecondary}; `, }));