mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
💄 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:
parent
6ca5fc4bdc
commit
46df77ac3f
23 changed files with 381 additions and 74 deletions
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@
|
|||
"tab.closeLeftTabs": "关闭左侧标签页",
|
||||
"tab.closeOtherTabs": "关闭其他标签页",
|
||||
"tab.closeRightTabs": "关闭右侧标签页",
|
||||
"tab.running": "智能体运行中",
|
||||
"updater.checkingUpdate": "检查新版本",
|
||||
"updater.checkingUpdateDesc": "正在获取版本信息…",
|
||||
"updater.downloadNewVersion": "下载新版本",
|
||||
|
|
|
|||
|
|
@ -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": "暂无最近目录",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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]) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
20
src/features/ChatInput/RuntimeConfig/useWorkingTreeStatus.ts
Normal file
20
src/features/ChatInput/RuntimeConfig/useWorkingTreeStatus.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
24
src/features/Electron/titlebar/TabBar/hooks/useTabRunning.ts
Normal file
24
src/features/Electron/titlebar/TabBar/hooks/useTabRunning.ts
Normal 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;
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
`,
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue