💄 style(tab-bar): blend inactive tabs with titlebar, show close icon by default (#13973)

* 💄 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) <noreply@anthropic.com>

* 🐛 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) <noreply@anthropic.com>

* 🐛 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) <noreply@anthropic.com>

*  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) <noreply@anthropic.com>

* 💄 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) <noreply@anthropic.com>

*  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) <noreply@anthropic.com>

* 🐛 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) <noreply@anthropic.com>

* 🐛 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) <noreply@anthropic.com>

* 💄 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-19 21:53:22 +08:00 committed by GitHub
parent 6ca5fc4bdc
commit 46df77ac3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 381 additions and 74 deletions

View file

@ -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<string, string>;
@ -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');
}
}

View file

@ -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<GitWorkingTreeStatus> {
@ -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 };
}
}

View file

@ -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",

View file

@ -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",

View file

@ -106,6 +106,7 @@
"tab.closeLeftTabs": "关闭左侧标签页",
"tab.closeOtherTabs": "关闭其他标签页",
"tab.closeRightTabs": "关闭右侧标签页",
"tab.running": "智能体运行中",
"updater.checkingUpdate": "检查新版本",
"updater.checkingUpdateDesc": "正在获取版本信息…",
"updater.downloadNewVersion": "下载新版本",

View file

@ -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 CLIgh即可显示关联的 Pull Request",
"localSystem.workingDirectory.newBranchPlaceholder": "feature/新分支名称",
"localSystem.workingDirectory.noRecent": "暂无最近目录",

View file

@ -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<BuiltinInspectorProps<ToolSearchArgs>>(
({ 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 <div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>{label}</div>;
}
const isShiny = isArgumentsStreaming || isLoading;
if (parsed?.names) {
return (
<div
className={cx(
inspectorTextStyles.root,
styles.baseline,
isShiny && shinyTextStyles.shinyText,
)}
>
<span>{label}:</span>
<span className={styles.tagsList}>
{parsed.names.map((name, index) => (
<span className={styles.tag} key={`${index}-${name}`}>
{name}
</span>
))}
</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<div className={cx(inspectorTextStyles.root, isShiny && shinyTextStyles.shinyText)}>
<span>{label}</span>
{query && (
{parsed && (
<>
<span>: </span>
<span className={highlightTextStyles.primary}>{query}</span>
<span className={highlightTextStyles.primary}>{parsed.raw}</span>
</>
)}
</div>

View file

@ -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', () => {

View file

@ -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) => {

View file

@ -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]) => {

View file

@ -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;
}

View file

@ -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 {

View file

@ -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<BranchSwitcherProps>(
() => 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<BranchSwitcherProps>(
{isCurrent && workingStatus && !workingStatus.clean && (
<div className={styles.itemMeta}>
{t('localSystem.workingDirectory.uncommittedChanges', {
count: workingStatus.modified,
count: workingStatus.total,
})}
</div>
)}

View file

@ -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<GitStatusProps>(({ 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<GitStatusProps>(({ path, isGithub }) => {
? t('localSystem.workingDirectory.ghMissing')
: undefined;
const diffStat =
workingStatus && !workingStatus.clean ? (
<span className={styles.diffStat}>
{workingStatus.added > 0 && (
<span className={styles.diffStatAdded}>+{workingStatus.added}</span>
)}
{workingStatus.modified > 0 && (
<span className={styles.diffStatModified}>±{workingStatus.modified}</span>
)}
{workingStatus.deleted > 0 && (
<span className={styles.diffStatDeleted}>-{workingStatus.deleted}</span>
)}
</span>
) : null;
const diffStatTooltip =
workingStatus && !workingStatus.clean
? t('localSystem.workingDirectory.diffStatTooltip', {
added: workingStatus.added,
deleted: workingStatus.deleted,
modified: workingStatus.modified,
})
: undefined;
const branchTrigger = (
<div className={styles.trigger}>
<Icon icon={GitBranchIcon} size={12} />
<span className={styles.branchLabel}>{data.branch}</span>
{diffStat}
</div>
);
const wrappedBranchTrigger =
diffStat && diffStatTooltip ? (
<Tooltip title={diffStatTooltip}>{branchTrigger}</Tooltip>
) : (
branchTrigger
);
return (
<>
<div className={styles.separator} />
@ -117,12 +171,13 @@ const GitStatus = memo<GitStatusProps>(({ path, isGithub }) => {
onOpenChange={setSwitcherOpen}
onAfterCheckout={() => {
void mutate();
void mutateWorkingStatus();
}}
onExternalRefresh={async () => {
await mutate();
await Promise.all([mutate(), mutateWorkingStatus()]);
}}
>
{branchTrigger}
{wrappedBranchTrigger}
</BranchSwitcher>
)}
{data.pullRequest && (

View file

@ -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,
});
};

View file

@ -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<GroupActionsProps>(
[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 <MessageActionBar bar={actionsConfig?.bar ?? EMPTY_GROUP_BAR} ctx={ctx} />;
return <MessageActionBar bar={IN_PROGRESS_BAR} ctx={ctx} />;
}
const defaultBar = data.tools ? DEFAULT_BAR_WITH_TOOLS : DEFAULT_BAR;

View file

@ -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

View file

@ -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<TabItemProps>(
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<TabItemProps>(
onClick={handleClick}
>
{item.avatar ? (
<Avatar
emojiScaleWithBackground
avatar={item.avatar}
background={item.backgroundColor}
shape="square"
size={16}
/>
<span className={styles.avatarWrapper}>
<Avatar
emojiScaleWithBackground
avatar={item.avatar}
background={item.backgroundColor}
shape="square"
size={16}
/>
{isRunning && <span aria-label={t('tab.running')} className={styles.runningDot} />}
</span>
) : (
item.icon && <Icon className={styles.tabIcon} icon={item.icon} size="small" />
item.icon && (
<span className={styles.avatarWrapper}>
<Icon className={styles.tabIcon} icon={item.icon} size="small" />
{isRunning && <span aria-label={t('tab.running')} className={styles.runningDot} />}
</span>
)
)}
<span className={styles.tabTitle}>{item.title}</span>
<ActionIcon
className={cx('closeIcon', styles.closeIcon)}
icon={X}
size="small"
onClick={handleClose}
/>
<ActionIcon className={styles.closeIcon} icon={X} size="small" onClick={handleClose} />
</Flexbox>
</ContextMenuTrigger>
);

View file

@ -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;
});

View file

@ -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;

View file

@ -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',

View file

@ -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',

View file

@ -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};
`,
}));