feat(cc): account card, topic filter, and CC integration polish (#13955)

* 💄 style(error): refine error page layout and stack panel

Replace Collapse with Accordion for a clickable full-row header, move
stack below action buttons as a secondary branch, and wrap in a Block
that softens to filled when collapsed and outlined when expanded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(cc): boost topic loading ring contrast in light mode

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(error): reload page on retry instead of no-op navigate

The retry button called navigate(resetPath) which often landed on the
same path and re-triggered the same error, feeling broken. Switch to
window.location.reload() so the error page actually recovers, and drop
the now-unused resetPath prop across route configs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(cc-agent): send prompt via stdin stream-json to avoid CLI arg parsing

Previously the Claude Code prompt was appended as a positional CLI arg,
so any prompt starting with `-` / `--` (dashes, 破折号) got
misinterpreted as a flag by the CC CLI's argparser.

Switch the claude-code preset to `--input-format stream-json` and write
the prompt as a newline-delimited JSON user message on stdin for all
messages (not just image-attached ones). Unifies the image and text
paths and paves the way for LOBE-7346 Phase 2 (persistent process +
native queue/interrupt).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(cc): extract per-tool inspectors into Inspector/ folder

Mirrors the Inspector/<Tool>/index.tsx convention used by builtin-tool-skills,
builtin-tool-skill-store, and builtin-tool-activator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ♻️ refactor(cc): flatten Inspector/ to per-tool tsx files

Drop the per-tool subfolder wrapper (Inspector/Edit/index.tsx → Inspector/Edit.tsx)
since each tool is a single file — no co-located assets to justify the folder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(topic): add filter with By project grouping and sort-by option

Split the legacy topicDisplayMode enum into independent topicGroupMode
(byTime / byProject / flat) and topicSortBy (createdAt / updatedAt), and
surface them from a new sidebar Filter dropdown. Adds groupTopicsByProject
so topics can be grouped by their workingDirectory, with favorites pinned
and the "no project" bucket placed last.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

*  feat(cc): show Claude Code account and subscription on profile

Add a getClaudeAuthStatus IPC that shells out to claude auth status --json,
and render the returned email + subscription tag on the CC Status Card.
The auth fetch runs independently of tool detection so a failure can't
flip the CLI card to unavailable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(home): show running spinner badge on agent/inbox avatars

Replace NavItem's generic loading state with a bottom-right spinner badge
on the avatar, so a running agent stays clearly labelled without hiding
the avatar. Inbox entries switch to per-agent isAgentRunning so only the
actively running inbox shows the badge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 💄 style(cc): default-expand Edit and Write tool renderers

Add ClaudeCodeApiName.Edit and Write to ClaudeCodeRenderDisplayControls
so their inspectors render expanded by default, matching TodoWrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🔧 chore(cc): drop default system prompt when creating Claude Code agent

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update avatar URL for Claude Code

*  test(workflow-collapse): stub ShikiLobeTheme on @lobehub/ui mock

@lobehub/editor's init code reads ShikiLobeTheme from @lobehub/ui, which
some transitive import pulls in during the test. Add the stub to match
the pattern used in WorkingSidebar/index.test.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(cc): fall back to Desktop path instead of `/` when no cwd is set

- Selector prefers desktopPath over homePath before it resolves nothing,
  so the renderer always forwards a sensible cwd.
- Main-process spawn mirrors the same fallback with app.getPath('desktop'),
  covering cases where Electron is launched from Finder (parent cwd is `/`).

Fixes LOBE-7354

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 🐛 fix(topic): use remote app origin for topic copy link

Desktop 下 window.location.origin 是 app://renderer,复制出来的链接无法分享。
改用 useAppOrigin(),与分享链接保持一致(web 用 window.location.origin,
desktop 用 electron store 的 remoteServerUrl)。

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-18 21:58:50 +08:00 committed by GitHub
parent 568389d43f
commit d581937196
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 914 additions and 310 deletions

View file

@ -67,7 +67,7 @@ import { dynamicElement, redirectElement, ErrorBoundary } from '@/utils/router';
element: dynamicElement(() => import('./chat'), 'Desktop > Chat');
element: redirectElement('/settings/profile');
errorElement: <ErrorBoundary resetPath="/chat" />;
errorElement: <ErrorBoundary />;
```
### Navigation

View file

@ -2,7 +2,7 @@ import type { ChildProcess } from 'node:child_process';
import { spawn } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import path from 'node:path';
import type { Readable, Writable } from 'node:stream';
import { app as electronApp, BrowserWindow } from 'electron';
@ -30,6 +30,8 @@ const CLI_PRESETS: Record<string, CLIPreset> = {
'claude-code': {
baseArgs: [
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
@ -37,7 +39,7 @@ const CLI_PRESETS: Record<string, CLIPreset> = {
'--permission-mode',
'bypassPermissions',
],
promptMode: 'positional',
promptMode: 'stdin',
resumeArgs: (sid) => ['--resume', sid],
},
// Future presets:
@ -134,7 +136,7 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
// ─── File cache ───
private get fileCacheDir(): string {
return join(this.app.appStoragePath, FILE_CACHE_DIR);
return path.join(this.app.appStoragePath, FILE_CACHE_DIR);
}
/**
@ -157,8 +159,8 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
): Promise<{ buffer: Buffer; mimeType: string }> {
const cacheDir = this.fileCacheDir;
const cacheKey = this.getImageCacheKey(image.id);
const metaPath = join(cacheDir, `${cacheKey}.meta`);
const dataPath = join(cacheDir, cacheKey);
const metaPath = path.join(cacheDir, `${cacheKey}.meta`);
const dataPath = path.join(cacheDir, cacheKey);
// Check cache first
try {
@ -191,11 +193,11 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
}
/**
* Build a stream-json user message with text + image content blocks.
* Build a stream-json user message with text + optional image content blocks.
*/
private async buildStreamJsonInput(
prompt: string,
imageList: ImageAttachment[],
imageList: ImageAttachment[] = [],
): Promise<string> {
const content: any[] = [{ text: prompt, type: 'text' }];
@ -260,13 +262,13 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
const preset = CLI_PRESETS[session.agentType];
if (!preset) throw new Error(`Unknown agent type: ${session.agentType}`);
const hasImages = params.imageList && params.imageList.length > 0;
const useStdin = preset.promptMode === 'stdin';
// If images are attached, prepare the stream-json input BEFORE spawning
// so any download errors are caught early.
// Build stream-json payload up-front so any image download errors
// surface before the process is spawned.
let stdinPayload: string | undefined;
if (hasImages) {
stdinPayload = await this.buildStreamJsonInput(params.prompt, params.imageList!);
if (useStdin) {
stdinPayload = await this.buildStreamJsonInput(params.prompt, params.imageList ?? []);
}
return new Promise<void>((resolve, reject) => {
@ -279,26 +281,25 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
...session.args,
];
if (hasImages) {
// With files: use stdin stream-json mode
cliArgs.push('--input-format', 'stream-json');
} else {
// Without files: use positional prompt (simple mode)
if (preset.promptMode === 'positional') {
cliArgs.push(params.prompt);
}
if (!useStdin && preset.promptMode === 'positional') {
// Positional mode: append prompt as a CLI arg (legacy / non-CC presets).
cliArgs.push(params.prompt);
}
logger.info('Spawning agent:', session.command, cliArgs.join(' '));
// Fall back to the user's Desktop so the process never inherits
// the Electron parent's cwd (which is `/` when launched from Finder).
const cwd = session.cwd || electronApp.getPath('desktop');
logger.info('Spawning agent:', session.command, cliArgs.join(' '), `(cwd: ${cwd})`);
const proc = spawn(session.command, cliArgs, {
cwd: session.cwd,
cwd,
env: { ...process.env, ...session.env },
stdio: [hasImages ? 'pipe' : 'ignore', 'pipe', 'pipe'],
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
});
// If using stdin mode, write the stream-json message and close stdin
if (hasImages && stdinPayload && proc.stdin) {
// In stdin mode, write the stream-json message and close stdin.
if (useStdin && stdinPayload && proc.stdin) {
const stdin = proc.stdin as Writable;
stdin.write(stdinPayload + '\n', () => {
stdin.end();

View file

@ -1,8 +1,15 @@
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import type { ClaudeAuthStatus } from '@lobechat/electron-client-ipc';
import type { ToolCategory, ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
const execPromise = promisify(exec);
const logger = createLogger('controllers:ToolDetectorCtr');
/**
@ -112,4 +119,19 @@ export default class ToolDetectorCtr extends ControllerModule {
priority: detector.priority,
}));
}
/**
* Get Claude Code CLI auth/account status by running `claude auth status --json`.
* Returns null if the CLI is unavailable or the command fails.
*/
@IpcMethod()
async getClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
try {
const { stdout } = await execPromise('claude auth status --json', { timeout: 5000 });
return JSON.parse(stdout.trim()) as ClaudeAuthStatus;
} catch (error) {
logger.debug('Failed to get claude auth status:', error);
return null;
}
}
}

View file

@ -1,12 +1,21 @@
import { EventEmitter } from 'node:events';
import { access, mkdtemp, readdir, readFile, rm, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PassThrough } from 'node:stream';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
const FAKE_DESKTOP_PATH = '/Users/fake/Desktop';
vi.mock('electron', () => ({
BrowserWindow: { getAllWindows: () => [] },
app: { on: vi.fn() },
app: {
getPath: vi.fn((name: string) => (name === 'desktop' ? FAKE_DESKTOP_PATH : `/fake/${name}`)),
on: vi.fn(),
},
ipcMain: { handle: vi.fn() },
}));
@ -20,7 +29,45 @@ vi.mock('@/utils/logger', () => ({
}),
}));
import HeterogeneousAgentCtr from '../HeterogeneousAgentCtr';
// Captures the most recent spawn() call so sendPrompt tests can assert on argv.
const spawnCalls: Array<{ args: string[]; command: string; options: any }> = [];
let nextFakeProc: any = null;
vi.mock('node:child_process', () => ({
spawn: (command: string, args: string[], options: any) => {
spawnCalls.push({ args, command, options });
return nextFakeProc;
},
}));
/**
* Build a fake ChildProcess that immediately exits cleanly. Records every
* stdin write on the returned `writes` array so tests can inspect the payload.
*/
const createFakeProc = () => {
const proc = new EventEmitter() as any;
const stdout = new PassThrough();
const stderr = new PassThrough();
const writes: string[] = [];
proc.stdout = stdout;
proc.stderr = stderr;
proc.stdin = {
end: vi.fn(),
write: vi.fn((chunk: string, cb?: () => void) => {
writes.push(chunk);
cb?.();
return true;
}),
};
proc.kill = vi.fn();
proc.killed = false;
// Exit asynchronously so the Promise returned by sendPrompt resolves cleanly.
setImmediate(() => {
stdout.end();
stderr.end();
proc.emit('exit', 0);
});
return { proc, writes };
};
describe('HeterogeneousAgentCtr', () => {
let appStoragePath: string;
@ -42,7 +89,9 @@ describe('HeterogeneousAgentCtr', () => {
try {
await unlink(escapePath);
} catch {}
} catch {
// best-effort cleanup
}
await (ctr as any).resolveImage({
id: `../../../${escapedTargetName}`,
@ -52,12 +101,14 @@ describe('HeterogeneousAgentCtr', () => {
const cacheEntries = await readdir(cacheDir);
expect(cacheEntries).toHaveLength(2);
expect(cacheEntries.every((entry) => /^[a-f0-9]{64}(\.meta)?$/.test(entry))).toBe(true);
expect(cacheEntries.every((entry) => /^[a-f0-9]{64}(?:\.meta)?$/.test(entry))).toBe(true);
await expect(access(escapePath)).rejects.toThrow();
try {
await unlink(escapePath);
} catch {}
} catch {
// best-effort cleanup
}
});
it('does not trust pre-seeded out-of-root traversal cache files as cache hits', async () => {
@ -68,7 +119,10 @@ describe('HeterogeneousAgentCtr', () => {
const outOfRootMetaPath = path.join(cacheDir, `${traversalId}.meta`);
await writeFile(outOfRootDataPath, 'SECRET');
await writeFile(outOfRootMetaPath, JSON.stringify({ id: traversalId, mimeType: 'text/plain' }));
await writeFile(
outOfRootMetaPath,
JSON.stringify({ id: traversalId, mimeType: 'text/plain' }),
);
const result = await (ctr as any).resolveImage({
id: traversalId,
@ -80,4 +134,83 @@ describe('HeterogeneousAgentCtr', () => {
await expect(readFile(outOfRootDataPath, 'utf8')).resolves.toBe('SECRET');
});
});
describe('sendPrompt (claude-code)', () => {
beforeEach(() => {
spawnCalls.length = 0;
});
const runSendPrompt = async (prompt: string, sessionOverrides: Record<string, any> = {}) => {
const { proc, writes } = createFakeProc();
nextFakeProc = proc;
const ctr = new HeterogeneousAgentCtr({ appStoragePath } as any);
const { sessionId } = await ctr.startSession({
agentType: 'claude-code',
command: 'claude',
...sessionOverrides,
});
await ctr.sendPrompt({ prompt, sessionId });
const { args: cliArgs, command, options } = spawnCalls[0];
return { cliArgs, command, options, writes };
};
it('passes prompt via stdin stream-json — never as a positional arg', async () => {
const prompt = '-- 这是破折号测试 --help';
const { cliArgs, writes } = await runSendPrompt(prompt);
// Prompt must never appear in argv (that is what previously broke CC's arg parser).
expect(cliArgs).not.toContain(prompt);
// Stream-json input must be wired up.
expect(cliArgs).toContain('--input-format');
expect(cliArgs).toContain('--output-format');
expect(cliArgs.filter((a) => a === 'stream-json')).toHaveLength(2);
// Exactly one stdin write, carrying the prompt as a user message JSON line.
expect(writes).toHaveLength(1);
const line = writes[0].trimEnd();
expect(line.endsWith('\n') || writes[0].endsWith('\n')).toBe(true);
const msg = JSON.parse(line);
expect(msg).toMatchObject({
message: {
content: [{ text: prompt, type: 'text' }],
role: 'user',
},
type: 'user',
});
});
it.each([
'-flag-looking-prompt',
'--help please',
'- dash at start',
'-p -- mixed',
'normal prompt with -dash- inside',
])('accepts dash-containing prompt without leaking to argv: %s', async (prompt) => {
const { cliArgs, writes } = await runSendPrompt(prompt);
expect(cliArgs).not.toContain(prompt);
expect(writes).toHaveLength(1);
const msg = JSON.parse(writes[0].trimEnd());
expect(msg.message.content[0].text).toBe(prompt);
});
it('falls back to the user Desktop when no cwd is supplied', async () => {
const { options } = await runSendPrompt('hello');
// When launched from Finder the Electron parent cwd is `/` — the
// controller must override that with the user's Desktop so CC writes
// land somewhere sensible.
expect(options.cwd).toBe(FAKE_DESKTOP_PATH);
});
it('respects an explicit cwd passed to startSession', async () => {
const explicitCwd = '/Users/fake/projects/my-repo';
const { options } = await runSendPrompt('hello', { cwd: explicitCwd });
expect(options.cwd).toBe(explicitCwd);
});
});
});

View file

@ -191,6 +191,7 @@
"analytics.telemetry.desc": "Help us improve {{appName}} with anonymous usage data",
"analytics.telemetry.title": "Send Anonymous Usage Data",
"analytics.title": "Analytics",
"ccStatus.account.label": "Account",
"ccStatus.detecting": "Detecting Claude Code CLI...",
"ccStatus.redetect": "Re-detect",
"ccStatus.title": "Claude Code CLI",

View file

@ -24,11 +24,14 @@
"duplicateLoading": "Copying Topic...",
"duplicateSuccess": "Topic Copied Successfully",
"favorite": "Favorite",
"groupMode.ascMessages": "Sort by Total Messages Ascending",
"groupMode.byTime": "Group by Created Time",
"groupMode.byUpdatedTime": "Group by Updated Time",
"groupMode.descMessages": "Sort by Total Messages Descending",
"groupMode.flat": "No Grouping",
"filter.groupMode.byProject": "By project",
"filter.groupMode.byTime": "By time",
"filter.groupMode.flat": "Flat",
"filter.organize": "Organize",
"filter.sort": "Sort by",
"filter.sortBy.createdAt": "Created time",
"filter.sortBy.updatedAt": "Updated time",
"groupTitle.byProject.noProject": "No directory",
"groupTitle.byTime.month": "This Month",
"groupTitle.byTime.today": "Today",
"groupTitle.byTime.week": "This Week",

View file

@ -191,6 +191,7 @@
"analytics.telemetry.desc": "通过匿名使用数据帮助我们改进 {{appName}}",
"analytics.telemetry.title": "发送匿名使用数据",
"analytics.title": "数据统计",
"ccStatus.account.label": "账号",
"ccStatus.detecting": "正在检测 Claude Code CLI…",
"ccStatus.redetect": "重新检测",
"ccStatus.title": "Claude Code CLI",

View file

@ -24,11 +24,14 @@
"duplicateLoading": "话题复制中…",
"duplicateSuccess": "话题复制成功",
"favorite": "收藏",
"groupMode.ascMessages": "按消息总数顺序",
"groupMode.byTime": "按创建时间分组",
"groupMode.byUpdatedTime": "按编辑时间分组",
"groupMode.descMessages": "按消息总数倒序",
"groupMode.flat": "不分组",
"filter.groupMode.byProject": "按项目",
"filter.groupMode.byTime": "按时间阶段",
"filter.groupMode.flat": "平铺",
"filter.organize": "整理",
"filter.sort": "排序",
"filter.sortBy.createdAt": "按创建时间",
"filter.sortBy.updatedAt": "按更新时间",
"groupTitle.byProject.noProject": "无目录",
"groupTitle.byTime.month": "本月",
"groupTitle.byTime.today": "今天",
"groupTitle.byTime.week": "本周",

View file

@ -0,0 +1,86 @@
'use client';
import { createEditLocalFileInspector } from '@lobechat/shared-tool-ui/inspectors';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { memo, useMemo } from 'react';
import { ClaudeCodeApiName } from '../../types';
interface CCEditArgs {
file_path?: string;
new_string?: string;
old_string?: string;
replace_all?: boolean;
}
// Mirrors `EditFileState` from `@lobechat/tool-runtime` — duplicated locally to
// keep this package free of a tool-runtime dep (it only reads the two line
// counts; the shared inspector accepts the shape via `any`).
interface SynthesizedEditState {
linesAdded: number;
linesDeleted: number;
path: string;
replacements: number;
}
/**
* LCS-based line-diff counter. Compares two snippets line-by-line and returns
* the number of added / deleted lines matches what `CodeDiff` shows in the
* render header. Cheap enough for Edit payloads (typically a handful of lines).
*/
const countLineDiff = (oldText: string, newText: string) => {
if (oldText === newText) return { linesAdded: 0, linesDeleted: 0 };
if (!oldText) return { linesAdded: newText.split('\n').length, linesDeleted: 0 };
if (!newText) return { linesAdded: 0, linesDeleted: oldText.split('\n').length };
const oldLines = oldText.split('\n');
const newLines = newText.split('\n');
const m = oldLines.length;
const n = newLines.length;
const prev = Array.from<number>({ length: n + 1 }).fill(0);
const curr = Array.from<number>({ length: n + 1 }).fill(0);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
curr[j] =
oldLines[i - 1] === newLines[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], curr[j - 1]);
}
for (let j = 0; j <= n; j++) prev[j] = curr[j];
}
const unchanged = prev[n];
return { linesAdded: n - unchanged, linesDeleted: m - unchanged };
};
const SharedInspector = createEditLocalFileInspector(ClaudeCodeApiName.Edit);
/**
* CC Edit runs remotely via Anthropic tool_use blocks, so there's no local
* runtime producing `EditFileState`. Synthesize `linesAdded` / `linesDeleted`
* from the args' `old_string` / `new_string` so the collapsed header carries
* change magnitude alongside the file path.
*/
export const EditInspector = memo<BuiltinInspectorProps<CCEditArgs, SynthesizedEditState>>(
({ args, pluginState, ...rest }) => {
const synthesized = useMemo<SynthesizedEditState | undefined>(() => {
if (pluginState) return pluginState;
if (!args?.old_string && !args?.new_string) return undefined;
const { linesAdded, linesDeleted } = countLineDiff(
args.old_string ?? '',
args.new_string ?? '',
);
return {
linesAdded,
linesDeleted,
path: args.file_path ?? '',
replacements: args.replace_all ? 0 : 1,
};
}, [args, pluginState]);
return <SharedInspector {...rest} args={args} pluginState={synthesized} />;
},
);
EditInspector.displayName = 'ClaudeCodeEditInspector';

View file

@ -4,7 +4,7 @@ import { createReadLocalFileInspector } from '@lobechat/shared-tool-ui/inspector
import type { BuiltinInspectorProps } from '@lobechat/types';
import { memo } from 'react';
import { ClaudeCodeApiName } from '../types';
import { ClaudeCodeApiName } from '../../types';
/**
* CC Read tool uses Anthropic-native args (`file_path`, `offset`, `limit`);

View file

@ -10,7 +10,7 @@ import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { ClaudeCodeApiName, type SkillArgs } from '../types';
import { ClaudeCodeApiName, type SkillArgs } from '../../types';
export const SkillInspector = memo<BuiltinInspectorProps<SkillArgs>>(
({ args, partialArgs, isArgumentsStreaming, isLoading }) => {

View file

@ -10,7 +10,7 @@ import { createStaticStyles, cssVar, cx } from 'antd-style';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { type ClaudeCodeTodoItem, type TodoWriteArgs } from '../types';
import { type ClaudeCodeTodoItem, type TodoWriteArgs } from '../../types';
const RING_SIZE = 14;
const RING_STROKE = 2;

View file

@ -10,7 +10,7 @@ import { cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { ClaudeCodeApiName, type ToolSearchArgs } from '../types';
import { ClaudeCodeApiName, type ToolSearchArgs } from '../../types';
const SELECT_PREFIX = 'select:';

View file

@ -4,7 +4,7 @@ import { createWriteLocalFileInspector } from '@lobechat/shared-tool-ui/inspecto
import type { BuiltinInspectorProps } from '@lobechat/types';
import { memo } from 'react';
import { ClaudeCodeApiName } from '../types';
import { ClaudeCodeApiName } from '../../types';
/**
* CC Write tool uses `file_path`; the shared inspector reads `path`.

View file

@ -1,30 +1,30 @@
'use client';
import {
createEditLocalFileInspector,
createGlobLocalFilesInspector,
createGrepContentInspector,
createRunCommandInspector,
} from '@lobechat/shared-tool-ui/inspectors';
import { ClaudeCodeApiName } from '../types';
import { ReadInspector } from './ReadInspector';
import { SkillInspector } from './SkillInspector';
import { TodoWriteInspector } from './TodoWriteInspector';
import { ToolSearchInspector } from './ToolSearchInspector';
import { WriteInspector } from './WriteInspector';
import { ClaudeCodeApiName } from '../../types';
import { EditInspector } from './Edit';
import { ReadInspector } from './Read';
import { SkillInspector } from './Skill';
import { TodoWriteInspector } from './TodoWrite';
import { ToolSearchInspector } from './ToolSearch';
import { WriteInspector } from './Write';
// CC's own tool names (Bash / Edit / Glob / Grep / Read / Write) are already
// the intended human-facing label, so we feed them to the shared factories as
// the "translation key" and let react-i18next's missing-key fallback echo it
// back verbatim. Keeps this package out of the plugin locale file.
//
// Bash / Edit / Glob / Grep can use the shared factories directly — Edit
// already reads `file_path`, and Glob / Grep only need `pattern`. Read and
// Write need arg mapping, so they live in their own sibling files.
// Bash / Glob / Grep can use the shared factories directly — Glob / Grep only
// need `pattern`. Edit / Read / Write need arg mapping (or synthesized plugin
// state for diff stats), so they live in their own sibling files.
export const ClaudeCodeInspectors = {
[ClaudeCodeApiName.Bash]: createRunCommandInspector(ClaudeCodeApiName.Bash),
[ClaudeCodeApiName.Edit]: createEditLocalFileInspector(ClaudeCodeApiName.Edit),
[ClaudeCodeApiName.Edit]: EditInspector,
[ClaudeCodeApiName.Glob]: createGlobLocalFilesInspector(ClaudeCodeApiName.Glob),
[ClaudeCodeApiName.Grep]: createGrepContentInspector({
noResultsKey: 'No results',

View file

@ -38,5 +38,7 @@ export const ClaudeCodeRenders = {
* `getBuiltinRenderDisplayControl` as a fallback.
*/
export const ClaudeCodeRenderDisplayControls: Record<string, RenderDisplayControl> = {
[ClaudeCodeApiName.Edit]: 'expand',
[ClaudeCodeApiName.TodoWrite]: 'expand',
[ClaudeCodeApiName.Write]: 'expand',
};

View file

@ -7,10 +7,6 @@ import type { UserPreference } from '@lobechat/types';
*/
export const CURRENT_ONBOARDING_VERSION = 1;
const DEFAULT_TOPIC_DISPLAY_MODE = 'byUpdatedTime' as NonNullable<
UserPreference['topicDisplayMode']
>;
export const DEFAULT_PREFERENCE: UserPreference = {
guide: {
moveSettingsToAvatar: true,
@ -20,6 +16,7 @@ export const DEFAULT_PREFERENCE: UserPreference = {
enableHeterogeneousAgent: false,
enableInputMarkdown: true,
},
topicDisplayMode: DEFAULT_TOPIC_DISPLAY_MODE,
topicGroupMode: 'byTime',
topicSortBy: 'updatedAt',
useCmdEnterToSend: false,
};

View file

@ -22,3 +22,16 @@ export interface ToolInfo {
name: string;
priority?: number;
}
/**
* Claude Code CLI auth status (from `claude auth status --json`)
*/
export interface ClaudeAuthStatus {
apiProvider?: string;
authMethod?: string;
email?: string;
loggedIn: boolean;
orgId?: string;
orgName?: string;
subscriptionType?: string;
}

View file

@ -48,6 +48,8 @@ import type {
export const claudeCodePreset: AgentCLIPreset = {
baseArgs: [
'-p',
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--verbose',
@ -55,7 +57,7 @@ export const claudeCodePreset: AgentCLIPreset = {
'--permission-mode',
'acceptEdits',
],
promptMode: 'positional',
promptMode: 'stdin',
resumeArgs: (sessionId) => ['--resume', sessionId],
};

View file

@ -18,10 +18,11 @@ describe('registry', () => {
describe('getPreset', () => {
it('returns preset with stream-json args for claude-code', () => {
const preset = getPreset('claude-code');
expect(preset.baseArgs).toContain('--input-format');
expect(preset.baseArgs).toContain('--output-format');
expect(preset.baseArgs).toContain('stream-json');
expect(preset.baseArgs).toContain('-p');
expect(preset.promptMode).toBe('positional');
expect(preset.promptMode).toBe('stdin');
});
it('preset has resumeArgs function', () => {

View file

@ -11,13 +11,8 @@ export type TimeGroupId =
| `${number}-${string}`
| `${number}`;
export enum TopicDisplayMode {
ByCreatedTime = 'byTime',
ByUpdatedTime = 'byUpdatedTime',
Flat = 'flat',
// AscMessages = 'ascMessages',
// DescMessages = 'descMessages',
}
export type TopicGroupMode = 'byTime' | 'byProject' | 'flat';
export type TopicSortBy = 'createdAt' | 'updatedAt';
export interface GroupedTopic {
children: ChatTopic[];

View file

@ -2,7 +2,7 @@ import type { PartialDeep } from 'type-fest';
import { z } from 'zod';
import type { Plans } from '../subscription';
import { TopicDisplayMode } from '../topic';
import type { TopicGroupMode, TopicSortBy } from '../topic';
import type { UserAgentOnboarding } from './agentOnboarding';
import type { UserOnboarding } from './onboarding';
import type { UserSettings } from './settings';
@ -74,7 +74,8 @@ export interface UserPreference {
* @deprecated Use settings.general.telemetry instead
*/
telemetry?: boolean | null;
topicDisplayMode?: TopicDisplayMode;
topicGroupMode?: TopicGroupMode;
topicSortBy?: TopicSortBy;
/**
* whether to use cmd + enter to send message
*/
@ -136,7 +137,8 @@ export const UserPreferenceSchema = z
hideSyncAlert: z.boolean().optional(),
lab: UserLabSchema.optional(),
telemetry: z.boolean().nullable(),
topicDisplayMode: z.nativeEnum(TopicDisplayMode).optional(),
topicGroupMode: z.enum(['byTime', 'byProject', 'flat']).optional(),
topicSortBy: z.enum(['createdAt', 'updatedAt']).optional(),
useCmdEnterToSend: z.boolean().optional(),
})
.partial();

View file

@ -95,3 +95,58 @@ const groupTopicsByField = (
export const groupTopicsByTime = (topics: ChatTopic[]) => groupTopicsByField(topics, 'createdAt');
export const groupTopicsByUpdatedTime = (topics: ChatTopic[]) =>
groupTopicsByField(topics, 'updatedAt');
// Project-based grouping
const NO_PROJECT_GROUP_ID = 'no-project';
const PROJECT_GROUP_PREFIX = 'project:';
// Extract the final path segment as display name; supports POSIX and Windows separators
const getProjectName = (dir: string): string => {
const segments = dir.split(/[/\\]+/).filter(Boolean);
return segments.at(-1) || dir;
};
const normalizeWorkingDirectory = (dir: string): string => dir.replace(/[/\\]+$/, '').trim();
export const groupTopicsByProject = (
topics: ChatTopic[],
field: 'createdAt' | 'updatedAt',
): GroupedTopic[] => {
if (!topics.length) return [];
const groupsMap = new Map<string, { children: ChatTopic[]; path: string }>();
for (const topic of topics) {
const raw = topic.metadata?.workingDirectory;
const normalized = raw ? normalizeWorkingDirectory(raw) : '';
const id = normalized ? `${PROJECT_GROUP_PREFIX}${normalized}` : NO_PROJECT_GROUP_ID;
const existing = groupsMap.get(id);
if (existing) {
existing.children.push(topic);
} else {
groupsMap.set(id, { children: [topic], path: normalized });
}
}
// Sort topics inside each group by chosen field desc
for (const group of groupsMap.values()) {
group.children.sort((a, b) => b[field] - a[field]);
}
const groups: GroupedTopic[] = Array.from(groupsMap.entries()).map(
([id, { children, path }]) => ({
children,
id,
title: id === NO_PROJECT_GROUP_ID ? undefined : getProjectName(path),
}),
);
// Most-recently-active project first; "no project" always last
return groups.sort((a, b) => {
if (a.id === NO_PROJECT_GROUP_ID) return 1;
if (b.id === NO_PROJECT_GROUP_ID) return -1;
const aTime = a.children[0]?.[field] ?? 0;
const bTime = b.children[0]?.[field] ?? 0;
return bTime - aTime;
});
};

View file

@ -1,6 +1,16 @@
'use client';
import { Button, Collapse, Flexbox, FluentEmoji, Highlighter } from '@lobehub/ui';
import {
Accordion,
AccordionItem,
Block,
Button,
Flexbox,
FluentEmoji,
Highlighter,
} from '@lobehub/ui';
import type { Key } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MAX_WIDTH } from '@/const/layoutTokens';
@ -9,13 +19,14 @@ export type ErrorType = Error & { digest?: string };
interface ErrorCaptureProps {
error: ErrorType;
reset: () => void;
}
const ErrorCapture = ({ error, reset }: ErrorCaptureProps) => {
const ErrorCapture = ({ error }: ErrorCaptureProps) => {
const { t } = useTranslation('error');
const hasStack = !!error?.stack;
const defaultStackKeys = typeof __CI__ !== 'undefined' && __CI__ ? ['stack'] : [];
const defaultExpandedKeys: Key[] = typeof __CI__ !== 'undefined' && __CI__ ? ['stack'] : [];
const [expandedKeys, setExpandedKeys] = useState<Key[]>(defaultExpandedKeys);
const isExpanded = expandedKeys.includes('stack');
return (
<Flexbox align={'center'} justify={'center'} style={{ minHeight: '100dvh', width: '100%' }}>
@ -37,32 +48,36 @@ const ErrorCapture = ({ error, reset }: ErrorCaptureProps) => {
{t('error.title')}
</h2>
<p style={{ marginBottom: '2em' }}>{t('error.desc')}</p>
{hasStack && (
<Collapse
defaultActiveKey={defaultStackKeys}
expandIconPlacement={'end'}
size={'small'}
style={{ marginBottom: '1em', maxWidth: '90vw', width: 560 }}
variant={'borderless'}
items={[
{
children: (
<Highlighter language={'plaintext'} padding={8} variant={'borderless'}>
{error.stack!}
</Highlighter>
),
key: 'stack',
label: t('error.stack'),
},
]}
/>
)}
<Flexbox horizontal gap={12} style={{ marginBottom: '1em' }}>
<Button onClick={() => reset()}>{t('error.retry')}</Button>
<Flexbox horizontal gap={12} style={{ marginBottom: '2em' }}>
<Button onClick={() => window.location.reload()}>{t('error.retry')}</Button>
<Button type={'primary'} onClick={() => (window.location.href = '/')}>
{t('error.backHome')}
</Button>
</Flexbox>
{hasStack && (
<Block
variant={isExpanded ? 'outlined' : 'filled'}
style={{
marginBottom: '1em',
maxWidth: '90vw',
overflow: 'hidden',
transition: 'background 0.2s, border-color 0.2s',
width: 560,
}}
>
<Accordion
expandedKeys={expandedKeys}
variant={'borderless'}
onExpandedChange={setExpandedKeys}
>
<AccordionItem indicatorPlacement={'start'} itemKey={'stack'} title={t('error.stack')}>
<Highlighter language={'plaintext'} padding={12} variant={'borderless'}>
{error.stack!}
</Highlighter>
</AccordionItem>
</Accordion>
</Block>
)}
</Flexbox>
);
};

View file

@ -27,6 +27,7 @@ vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
Icon: ({ icon: IconComponent }: { icon?: ComponentType }) =>
IconComponent ? <IconComponent /> : <div />,
ShikiLobeTheme: {},
Text: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
}));

View file

@ -208,6 +208,7 @@ export default {
'analytics.title': 'Analytics',
// Claude Code CLI status (shown on agent profile page in CC integration mode)
'ccStatus.account.label': 'Account',
'ccStatus.detecting': 'Detecting Claude Code CLI...',
'ccStatus.redetect': 'Re-detect',
'ccStatus.title': 'Claude Code CLI',

View file

@ -25,11 +25,14 @@ export default {
'duplicateLoading': 'Copying Topic...',
'duplicateSuccess': 'Topic Copied Successfully',
'favorite': 'Favorite',
'groupMode.ascMessages': 'Sort by Total Messages Ascending',
'groupMode.byTime': 'Group by Created Time',
'groupMode.byUpdatedTime': 'Group by Updated Time',
'groupMode.descMessages': 'Sort by Total Messages Descending',
'groupMode.flat': 'No Grouping',
'filter.groupMode.byProject': 'By project',
'filter.groupMode.byTime': 'By time',
'filter.groupMode.flat': 'Flat',
'filter.organize': 'Organize',
'filter.sort': 'Sort by',
'filter.sortBy.createdAt': 'Created time',
'filter.sortBy.updatedAt': 'Updated time',
'groupTitle.byProject.noProject': 'No directory',
'groupTitle.byTime.month': 'This Month',
'groupTitle.byTime.today': 'Today',
'groupTitle.byTime.week': 'This Week',

View file

@ -0,0 +1,18 @@
import { ActionIcon } from '@lobehub/ui';
import { DropdownMenu } from '@lobehub/ui/base-ui';
import { ListFilter } from 'lucide-react';
import { memo } from 'react';
import { useTopicFilterDropdownMenu } from './useFilterMenu';
const Filter = memo(() => {
const menuItems = useTopicFilterDropdownMenu();
return (
<DropdownMenu items={menuItems}>
<ActionIcon icon={ListFilter} size={'small'} />
</DropdownMenu>
);
});
export default Filter;

View file

@ -1,5 +1,5 @@
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
import { createStaticStyles, cssVar, keyframes } from 'antd-style';
import { createStaticStyles, cssVar, keyframes, useTheme } from 'antd-style';
import { HashIcon, MessageSquareDashed } from 'lucide-react';
import { memo, Suspense, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@ -81,9 +81,14 @@ interface TopicItemProps {
const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, metadata }) => {
const { t } = useTranslation('topic');
const { isDarkMode } = useTheme();
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const addTab = useElectronStore((s) => s.addTab);
const loadingRingColor = isDarkMode
? cssVar.colorWarningBorder
: `color-mix(in srgb, ${cssVar.colorWarning} 45%, transparent)`;
// Construct href for cmd+click support
const href = useMemo(() => {
if (!activeAgentId || !id) return undefined;
@ -157,7 +162,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
icon={
isLoading ? (
<RingLoadingIcon
ringColor={cssVar.colorWarningBorder}
ringColor={loadingRingColor}
size={14}
style={{ color: cssVar.colorWarning }}
/>
@ -192,11 +197,12 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
contextMenuItems={dropdownMenu}
disabled={editing}
href={href}
title={title === '...' ? <DotsLoading gap={3} size={4} /> : title}
icon={(() => {
if (isLoading) {
return (
<RingLoadingIcon
ringColor={cssVar.colorWarningBorder}
ringColor={loadingRingColor}
size={14}
style={{ color: cssVar.colorWarning }}
/>
@ -213,7 +219,6 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
<Icon icon={HashIcon} size={'small'} style={{ color: cssVar.colorTextDescription }} />
);
})()}
title={title === '...' ? <DotsLoading gap={3} size={4} /> : title}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
/>

View file

@ -20,6 +20,7 @@ import { openRenameModal } from '@/components/RenameModal';
import { isDesktop } from '@/const/version';
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
import { openShareModal } from '@/features/ShareModal';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { useElectronStore } from '@/store/electron';
@ -39,6 +40,7 @@ export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMe
const openTopicInNewWindow = useGlobalStore((s) => s.openTopicInNewWindow);
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const addTab = useElectronStore((s) => s.addTab);
const appOrigin = useAppOrigin();
const [autoRenameTopicTitle, duplicateTopic, removeTopic, favoriteTopic, updateTopicTitle] =
useChatStore((s) => [
@ -130,7 +132,7 @@ export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMe
label: t('actions.copyLink'),
onClick: () => {
if (!activeAgentId) return;
const url = `${window.location.origin}/agent/${activeAgentId}?topic=${id}`;
const url = `${appOrigin}/agent/${activeAgentId}?topic=${id}`;
navigator.clipboard.writeText(url);
message.success(t('actions.copyLinkSuccess'));
},
@ -177,6 +179,7 @@ export const useTopicItemDropdownMenu = ({ fav, id, title }: TopicItemDropdownMe
fav,
title,
activeAgentId,
appOrigin,
autoRenameTopicTitle,
duplicateTopic,
favoriteTopic,

View file

@ -12,7 +12,6 @@ import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { TopicDisplayMode } from '@/types/topic';
import AllTopicsDrawer from '../AllTopicsDrawer';
import ByTimeMode from '../TopicListContent/ByTimeMode';
@ -32,7 +31,7 @@ const TopicList = memo(() => {
s.closeAllTopicsDrawer,
]);
const [topicDisplayMode] = useUserStore((s) => [preferenceSelectors.topicDisplayMode(s)]);
const topicGroupMode = useUserStore(preferenceSelectors.topicGroupMode);
useFetchTopics(fetchParams);
@ -49,7 +48,7 @@ const TopicList = memo(() => {
}}
/>
)}
{topicDisplayMode === TopicDisplayMode.Flat ? <FlatMode /> : <ByTimeMode />}
{topicGroupMode === 'flat' ? <FlatMode /> : <ByTimeMode />}
<AllTopicsDrawer open={allTopicsDrawerOpen} onClose={closeAllTopicsDrawer} />
</>
);

View file

@ -20,7 +20,8 @@ import GroupItem from './GroupItem';
const ByTimeMode = memo(() => {
const { t } = useTranslation('topic');
const topicPageSize = useGlobalStore(systemStatusSelectors.topicPageSize);
const topicDisplayMode = useUserStore(preferenceSelectors.topicDisplayMode);
const topicSortBy = useUserStore(preferenceSelectors.topicSortBy);
const topicGroupMode = useUserStore(preferenceSelectors.topicGroupMode);
const [hasMore, isExpandingPageSize, openAllTopicsDrawer] = useChatStore((s) => [
topicSelectors.hasMoreTopics(s),
@ -30,8 +31,8 @@ const ByTimeMode = memo(() => {
const [activeTopicId, activeThreadId] = useChatStore((s) => [s.activeTopicId, s.activeThreadId]);
const groupSelector = useMemo(
() => topicSelectors.groupedTopicsForSidebar(topicPageSize, topicDisplayMode),
[topicPageSize, topicDisplayMode],
() => topicSelectors.groupedTopicsForSidebar(topicPageSize, topicSortBy, topicGroupMode),
[topicPageSize, topicSortBy, topicGroupMode],
);
const groupTopics = useChatStore(groupSelector, isEqual);
@ -40,10 +41,10 @@ const ByTimeMode = memo(() => {
s.updateSystemStatus,
]);
// Reset expanded keys when display mode changes so all groups start expanded
// Reset expanded keys when grouping changes so all groups start expanded
useEffect(() => {
updateSystemStatus({ expandTopicGroupKeys: undefined });
}, [topicDisplayMode, updateSystemStatus]);
}, [topicSortBy, topicGroupMode, updateSystemStatus]);
const expandedKeys = useMemo(() => {
return topicGroupKeys || groupTopics.map((group) => group.id);

View file

@ -12,12 +12,15 @@ import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import TopicItem from '../../List/Item';
const FlatMode = memo(() => {
const { t } = useTranslation('topic');
const topicPageSize = useGlobalStore(systemStatusSelectors.topicPageSize);
const topicSortBy = useUserStore(preferenceSelectors.topicSortBy);
const [activeTopicId, activeThreadId, hasMore, isExpandingPageSize, openAllTopicsDrawer] =
useChatStore((s) => [
@ -29,7 +32,7 @@ const FlatMode = memo(() => {
]);
const activeTopicList = useChatStore(
topicSelectors.displayTopicsForSidebar(topicPageSize),
topicSelectors.displayTopicsForSidebar(topicPageSize, topicSortBy),
isEqual,
);

View file

@ -12,7 +12,6 @@ import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { TopicDisplayMode } from '@/types/topic';
import ByTimeMode from './ByTimeMode';
import FlatMode from './FlatMode';
@ -28,7 +27,7 @@ const TopicListContent = memo(() => {
topicSelectors.isInSearchMode(s),
]);
const [topicDisplayMode] = useUserStore((s) => [preferenceSelectors.topicDisplayMode(s)]);
const topicGroupMode = useUserStore(preferenceSelectors.topicGroupMode);
useFetchTopics({ excludeTriggers: ['cron', 'eval'] });
@ -47,7 +46,7 @@ const TopicListContent = memo(() => {
}}
/>
)}
{topicDisplayMode === TopicDisplayMode.Flat ? <FlatMode /> : <ByTimeMode />}
{topicGroupMode === 'flat' ? <FlatMode /> : <ByTimeMode />}
</>
);
});

View file

@ -11,6 +11,7 @@ import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import Actions from './Actions';
import Filter from './Filter';
import List from './List';
import { useTopicActionsDropdownMenu } from './useDropdownMenu';
@ -26,10 +27,15 @@ const Topic = memo<TopicProps>(({ itemKey }) => {
return (
<AccordionItem
action={<Actions />}
itemKey={itemKey}
paddingBlock={4}
paddingInline={'8px 4px'}
action={
<Flexbox horizontal align="center" gap={2}>
<Filter />
<Actions />
</Flexbox>
}
headerWrapper={(header) => (
<ContextMenuTrigger items={dropdownMenu}>{header}</ContextMenuTrigger>
)}

View file

@ -9,9 +9,6 @@ import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { TopicDisplayMode } from '@/types/topic';
const hotArea = css`
&::before {
@ -58,31 +55,12 @@ export const useTopicActionsDropdownMenu = (
[importTopic, modal, onUploadClose, t],
);
const [topicDisplayMode, updatePreference] = useUserStore((s) => [
preferenceSelectors.topicDisplayMode(s),
s.updatePreference,
]);
const [topicPageSize, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.topicPageSize(s),
s.updateSystemStatus,
]);
return useMemo(() => {
const displayModeOrder = [
TopicDisplayMode.ByUpdatedTime,
TopicDisplayMode.ByCreatedTime,
TopicDisplayMode.Flat,
];
const displayModeItems = displayModeOrder.map((mode) => ({
icon: topicDisplayMode === mode ? <Icon icon={LucideCheck} /> : <div />,
key: mode,
label: t(`groupMode.${mode}`),
onClick: () => {
updatePreference({ topicDisplayMode: mode });
},
}));
const pageSizeOptions = [20, 40, 60, 100];
const pageSizeItems = pageSizeOptions.map((size) => ({
icon: topicPageSize === size ? <Icon icon={LucideCheck} /> : <div />,
@ -94,10 +72,6 @@ export const useTopicActionsDropdownMenu = (
}));
return [
...displayModeItems,
{
type: 'divider' as const,
},
{
children: pageSizeItems,
extra: topicPageSize,
@ -154,9 +128,7 @@ export const useTopicActionsDropdownMenu = (
},
].filter(Boolean) as MenuProps['items'];
}, [
topicDisplayMode,
topicPageSize,
updatePreference,
updateSystemStatus,
handleImport,
onUploadClose,

View file

@ -0,0 +1,54 @@
import { Icon } from '@lobehub/ui';
import type { DropdownItem } from '@lobehub/ui/base-ui';
import { LucideCheck } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import type { TopicGroupMode, TopicSortBy } from '@/types/topic';
export const useTopicFilterDropdownMenu = (): DropdownItem[] => {
const { t } = useTranslation('topic');
const [topicGroupMode, topicSortBy, updatePreference] = useUserStore((s) => [
preferenceSelectors.topicGroupMode(s),
preferenceSelectors.topicSortBy(s),
s.updatePreference,
]);
return useMemo(() => {
const groupModes: TopicGroupMode[] = ['byTime', 'byProject', 'flat'];
const sortByOptions: TopicSortBy[] = ['createdAt', 'updatedAt'];
return [
{
children: groupModes.map((mode) => ({
icon: topicGroupMode === mode ? <Icon icon={LucideCheck} /> : <div />,
key: `group-${mode}`,
label: t(`filter.groupMode.${mode}`),
onClick: () => {
updatePreference({ topicGroupMode: mode });
},
})),
key: 'organize',
label: t('filter.organize'),
type: 'group' as const,
},
{ type: 'divider' as const },
{
children: sortByOptions.map((option) => ({
icon: topicSortBy === option ? <Icon icon={LucideCheck} /> : <div />,
key: `sort-${option}`,
label: t(`filter.sortBy.${option}`),
onClick: () => {
updatePreference({ topicSortBy: option });
},
})),
key: 'sort',
label: t('filter.sort'),
type: 'group' as const,
},
];
}, [topicGroupMode, topicSortBy, updatePreference, t]);
};

View file

@ -1,7 +1,7 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { type ToolStatus } from '@lobechat/electron-client-ipc';
import { type ClaudeAuthStatus, type ToolStatus } from '@lobechat/electron-client-ipc';
import { ActionIcon, CopyButton, Flexbox, Icon, Tag, Text, Tooltip } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { CheckCircle2, Loader2Icon, RefreshCw, XCircle } from 'lucide-react';
@ -19,6 +19,11 @@ const useStyles = createStyles(({ css, token }) => ({
background: ${token.colorFillQuaternary};
`,
label: css`
min-width: 72px;
font-size: 12px;
color: ${token.colorTextTertiary};
`,
path: css`
font-family: ${token.fontFamilyCode};
font-size: 12px;
@ -30,8 +35,21 @@ const CCStatusCard = memo(() => {
const { t } = useTranslation('setting');
const { styles } = useStyles();
const [status, setStatus] = useState<ToolStatus | undefined>();
const [auth, setAuth] = useState<ClaudeAuthStatus | null>(null);
const [detecting, setDetecting] = useState(true);
// Fetched independently of `detectTool`: an auth-fetch failure must not
// flip the CLI status card to unavailable.
const fetchAuth = useCallback(async () => {
try {
const result = await toolDetectorService.getClaudeAuthStatus();
setAuth(result);
} catch (error) {
console.warn('[CCStatusCard] Failed to get claude auth status:', error);
setAuth(null);
}
}, []);
const detect = useCallback(async () => {
if (!isDesktop) return;
setDetecting(true);
@ -44,7 +62,8 @@ const CCStatusCard = memo(() => {
} finally {
setDetecting(false);
}
}, []);
void fetchAuth();
}, [fetchAuth]);
useEffect(() => {
void detect();
@ -92,6 +111,22 @@ const CCStatusCard = memo(() => {
);
};
const renderAuth = () => {
if (detecting || !status?.available || !auth?.loggedIn) return null;
return (
<Flexbox horizontal align="center" gap={8} style={{ flexWrap: 'wrap' }}>
<Text className={styles.label}>{t('ccStatus.account.label')}</Text>
{auth.email && <Text ellipsis>{auth.email}</Text>}
{auth.subscriptionType && (
<Tag color="gold" style={{ marginInlineEnd: 0 }}>
{auth.subscriptionType.toUpperCase()}
</Tag>
)}
</Flexbox>
);
};
return (
<Flexbox className={styles.card} gap={8} style={{ marginBottom: 12 }}>
<Flexbox horizontal align="center" gap={8} justify="space-between">
@ -107,6 +142,7 @@ const CCStatusCard = memo(() => {
</Tooltip>
</Flexbox>
{renderBody()}
{renderAuth()}
</Flexbox>
);
});

View file

@ -0,0 +1,18 @@
import { ActionIcon } from '@lobehub/ui';
import { DropdownMenu } from '@lobehub/ui/base-ui';
import { ListFilter } from 'lucide-react';
import { memo } from 'react';
import { useTopicFilterDropdownMenu } from './useFilterMenu';
const Filter = memo(() => {
const menuItems = useTopicFilterDropdownMenu();
return (
<DropdownMenu items={menuItems}>
<ActionIcon icon={ListFilter} size={'small'} />
</DropdownMenu>
);
});
export default Filter;

View file

@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import { isDesktop } from '@/const/version';
import { pluginRegistry } from '@/features/Electron/titlebar/RecentlyViewed/plugins';
import { useAppOrigin } from '@/hooks/useAppOrigin';
import { useAgentStore } from '@/store/agent';
import { useAgentGroupStore } from '@/store/agentGroup';
import { useChatStore } from '@/store/chat';
@ -31,6 +32,7 @@ export const useTopicItemDropdownMenu = ({
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const activeGroupId = useAgentGroupStore((s) => s.activeGroupId);
const addTab = useElectronStore((s) => s.addTab);
const appOrigin = useAppOrigin();
const [autoRenameTopicTitle, duplicateTopic, removeTopic] = useChatStore((s) => [
s.autoRenameTopicTitle,
@ -96,7 +98,7 @@ export const useTopicItemDropdownMenu = ({
label: t('actions.copyLink'),
onClick: () => {
if (!activeGroupId) return;
const url = `${window.location.origin}/group/${activeGroupId}?topic=${id}`;
const url = `${appOrigin}/group/${activeGroupId}?topic=${id}`;
navigator.clipboard.writeText(url);
message.success(t('actions.copyLinkSuccess'));
},
@ -133,6 +135,7 @@ export const useTopicItemDropdownMenu = ({
id,
activeAgentId,
activeGroupId,
appOrigin,
autoRenameTopicTitle,
duplicateTopic,
removeTopic,

View file

@ -13,7 +13,6 @@ import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { TopicDisplayMode } from '@/types/topic';
import AllTopicsDrawer from '../AllTopicsDrawer';
import ByTimeMode from '../TopicListContent/ByTimeMode';
@ -30,7 +29,7 @@ const TopicList = memo(() => {
s.closeAllTopicsDrawer,
]);
const [topicDisplayMode] = useUserStore((s) => [preferenceSelectors.topicDisplayMode(s)]);
const topicGroupMode = useUserStore(preferenceSelectors.topicGroupMode);
useFetchTopics();
@ -47,7 +46,7 @@ const TopicList = memo(() => {
}}
/>
)}
{topicDisplayMode === TopicDisplayMode.Flat ? <FlatMode /> : <ByTimeMode />}
{topicGroupMode === 'flat' ? <FlatMode /> : <ByTimeMode />}
<AllTopicsDrawer open={allTopicsDrawerOpen} onClose={closeAllTopicsDrawer} />
</>
);

View file

@ -20,7 +20,8 @@ import GroupItem from './GroupItem';
const ByTimeMode = memo(() => {
const { t } = useTranslation('topic');
const topicPageSize = useGlobalStore(systemStatusSelectors.topicPageSize);
const topicDisplayMode = useUserStore(preferenceSelectors.topicDisplayMode);
const topicSortBy = useUserStore(preferenceSelectors.topicSortBy);
const topicGroupMode = useUserStore(preferenceSelectors.topicGroupMode);
const [hasMore, isExpandingPageSize, openAllTopicsDrawer] = useChatStore((s) => [
topicSelectors.hasMoreTopics(s),
@ -30,8 +31,8 @@ const ByTimeMode = memo(() => {
const [activeTopicId, activeThreadId] = useChatStore((s) => [s.activeTopicId, s.activeThreadId]);
const groupSelector = useMemo(
() => topicSelectors.groupedTopicsForSidebar(topicPageSize, topicDisplayMode),
[topicPageSize, topicDisplayMode],
() => topicSelectors.groupedTopicsForSidebar(topicPageSize, topicSortBy, topicGroupMode),
[topicPageSize, topicSortBy, topicGroupMode],
);
const groupTopics = useChatStore(groupSelector, isEqual);
@ -40,10 +41,10 @@ const ByTimeMode = memo(() => {
s.updateSystemStatus,
]);
// Reset expanded keys when display mode changes so all groups start expanded
// Reset expanded keys when grouping changes so all groups start expanded
useEffect(() => {
updateSystemStatus({ expandTopicGroupKeys: undefined });
}, [topicDisplayMode, updateSystemStatus]);
}, [topicSortBy, topicGroupMode, updateSystemStatus]);
const expandedKeys = useMemo(() => {
return topicGroupKeys || groupTopics.map((group) => group.id);

View file

@ -12,12 +12,15 @@ import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import TopicItem from '../../List/Item';
const FlatMode = memo(() => {
const { t } = useTranslation('topic');
const topicPageSize = useGlobalStore(systemStatusSelectors.topicPageSize);
const topicSortBy = useUserStore(preferenceSelectors.topicSortBy);
const [activeTopicId, activeThreadId, hasMore, isExpandingPageSize, openAllTopicsDrawer] =
useChatStore((s) => [
@ -29,7 +32,7 @@ const FlatMode = memo(() => {
]);
const activeTopicList = useChatStore(
topicSelectors.displayTopicsForSidebar(topicPageSize),
topicSelectors.displayTopicsForSidebar(topicPageSize, topicSortBy),
isEqual,
);

View file

@ -13,7 +13,6 @@ import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { TopicDisplayMode } from '@/types/topic';
import ByTimeMode from './ByTimeMode';
import FlatMode from './FlatMode';
@ -28,7 +27,7 @@ const TopicListContent = memo(() => {
topicSelectors.isInSearchMode(s),
]);
const activeGroupId = useAgentGroupStore((s) => s.activeGroupId);
const [topicDisplayMode] = useUserStore((s) => [preferenceSelectors.topicDisplayMode(s)]);
const topicGroupMode = useUserStore(preferenceSelectors.topicGroupMode);
useFetchTopics();
@ -47,7 +46,7 @@ const TopicListContent = memo(() => {
}}
/>
)}
{topicDisplayMode === TopicDisplayMode.Flat ? <FlatMode /> : <ByTimeMode />}
{topicGroupMode === 'flat' ? <FlatMode /> : <ByTimeMode />}
</>
);
});

View file

@ -1,7 +1,7 @@
'use client';
import { AccordionItem, ContextMenuTrigger, Flexbox, Text } from '@lobehub/ui';
import React, { memo,Suspense } from 'react';
import React, { memo, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
@ -11,6 +11,7 @@ import { useChatStore } from '@/store/chat';
import { topicSelectors } from '@/store/chat/selectors';
import Actions from './Actions';
import Filter from './Filter';
import List from './List';
import { useTopicActionsDropdownMenu } from './useDropdownMenu';
@ -26,10 +27,15 @@ const Topic = memo<TopicProps>(({ itemKey }) => {
return (
<AccordionItem
action={<Actions />}
itemKey={itemKey}
paddingBlock={4}
paddingInline={'8px 4px'}
action={
<Flexbox horizontal align="center" gap={2}>
<Filter />
<Actions />
</Flexbox>
}
headerWrapper={(header) => (
<ContextMenuTrigger items={dropdownMenu}>{header}</ContextMenuTrigger>
)}

View file

@ -9,9 +9,6 @@ import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import { TopicDisplayMode } from '@/types/topic';
const hotArea = css`
&::before {
@ -58,31 +55,12 @@ export const useTopicActionsDropdownMenu = (
[importTopic, modal, onUploadClose, t],
);
const [topicDisplayMode, updatePreference] = useUserStore((s) => [
preferenceSelectors.topicDisplayMode(s),
s.updatePreference,
]);
const [topicPageSize, updateSystemStatus] = useGlobalStore((s) => [
systemStatusSelectors.topicPageSize(s),
s.updateSystemStatus,
]);
return useMemo(() => {
const displayModeOrder = [
TopicDisplayMode.ByUpdatedTime,
TopicDisplayMode.ByCreatedTime,
TopicDisplayMode.Flat,
];
const displayModeItems = displayModeOrder.map((mode) => ({
icon: topicDisplayMode === mode ? <Icon icon={LucideCheck} /> : <div />,
key: mode,
label: t(`groupMode.${mode}`),
onClick: () => {
updatePreference({ topicDisplayMode: mode });
},
}));
const pageSizeOptions = [20, 40, 60, 100];
const pageSizeItems = pageSizeOptions.map((size) => ({
icon: topicPageSize === size ? <Icon icon={LucideCheck} /> : <div />,
@ -94,10 +72,6 @@ export const useTopicActionsDropdownMenu = (
}));
return [
...displayModeItems,
{
type: 'divider' as const,
},
{
children: pageSizeItems,
extra: topicPageSize,
@ -154,9 +128,7 @@ export const useTopicActionsDropdownMenu = (
},
].filter(Boolean) as MenuProps['items'];
}, [
topicDisplayMode,
topicPageSize,
updatePreference,
updateSystemStatus,
handleImport,
onUploadClose,

View file

@ -0,0 +1,54 @@
import { Icon } from '@lobehub/ui';
import type { DropdownItem } from '@lobehub/ui/base-ui';
import { LucideCheck } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserStore } from '@/store/user';
import { preferenceSelectors } from '@/store/user/selectors';
import type { TopicGroupMode, TopicSortBy } from '@/types/topic';
export const useTopicFilterDropdownMenu = (): DropdownItem[] => {
const { t } = useTranslation('topic');
const [topicGroupMode, topicSortBy, updatePreference] = useUserStore((s) => [
preferenceSelectors.topicGroupMode(s),
preferenceSelectors.topicSortBy(s),
s.updatePreference,
]);
return useMemo(() => {
const groupModes: TopicGroupMode[] = ['byTime', 'byProject', 'flat'];
const sortByOptions: TopicSortBy[] = ['createdAt', 'updatedAt'];
return [
{
children: groupModes.map((mode) => ({
icon: topicGroupMode === mode ? <Icon icon={LucideCheck} /> : <div />,
key: `group-${mode}`,
label: t(`filter.groupMode.${mode}`),
onClick: () => {
updatePreference({ topicGroupMode: mode });
},
})),
key: 'organize',
label: t('filter.organize'),
type: 'group' as const,
},
{ type: 'divider' as const },
{
children: sortByOptions.map((option) => ({
icon: topicSortBy === option ? <Icon icon={LucideCheck} /> : <div />,
key: `sort-${option}`,
label: t(`filter.sortBy.${option}`),
onClick: () => {
updatePreference({ topicSortBy: option });
},
})),
key: 'sort',
label: t('filter.sort'),
type: 'group' as const,
},
];
}, [topicGroupMode, topicSortBy, updatePreference, t]);
};

View file

@ -46,6 +46,26 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
background: ${cssVar.colorError};
`,
runningBadge: css`
pointer-events: none;
position: absolute;
inset-block-end: -3px;
inset-inline-end: -3px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: 1.5px solid ${cssVar.colorBgContainer};
border-radius: 999px;
color: ${cssVar.colorWarning};
background: ${cssVar.colorBgContainer};
`,
wrapper: css`
position: relative;
display: inline-flex;
@ -132,7 +152,7 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
[pinned],
);
// Memoize avatar icon (show loader when updating, unread badge at bottom-right)
// Memoize avatar icon (show loader when updating, running spinner or unread badge at bottom-right)
const avatarIcon = useMemo(() => {
if (isUpdating) {
return <Icon spin color={cssVar.colorTextDescription} icon={Loader2} size={18} />;
@ -145,6 +165,17 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
/>
);
if (isLoading) {
return (
<span className={styles.wrapper}>
{avatarNode}
<span className={styles.runningBadge}>
<Icon spin icon={Loader2} size={9} />
</span>
</span>
);
}
if (unreadCount > 0) {
return (
<span className={styles.wrapper}>
@ -155,7 +186,7 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
}
return avatarNode;
}, [isUpdating, avatar, backgroundColor, unreadCount]);
}, [isUpdating, isLoading, avatar, backgroundColor, unreadCount]);
const dropdownMenu = useAgentDropdownMenu({
anchor,
@ -178,7 +209,6 @@ const AgentItem = memo<AgentItemProps>(({ item, style, className }) => {
extra={pinIcon}
icon={avatarIcon}
key={id}
loading={isLoading}
style={style}
title={titleNode}
onDoubleClick={handleDoubleClick}

View file

@ -1,7 +1,9 @@
'use client';
import { DEFAULT_INBOX_AVATAR, SESSION_CHAT_URL } from '@lobechat/const';
import { Avatar } from '@lobehub/ui';
import { Avatar, Icon } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Loader2 } from 'lucide-react';
import { type CSSProperties } from 'react';
import { memo } from 'react';
import { Link } from 'react-router-dom';
@ -14,6 +16,33 @@ import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
import { prefetchRoute } from '@/utils/router';
const styles = createStaticStyles(({ css, cssVar }) => ({
runningBadge: css`
pointer-events: none;
position: absolute;
inset-block-end: -3px;
inset-inline-end: -3px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: 1.5px solid ${cssVar.colorBgContainer};
border-radius: 999px;
color: ${cssVar.colorWarning};
background: ${cssVar.colorBgContainer};
`,
wrapper: css`
position: relative;
display: inline-flex;
`,
}));
interface InboxItemProps {
className?: string;
style?: CSSProperties;
@ -23,7 +52,9 @@ const InboxItem = memo<InboxItemProps>(({ className, style }) => {
const inboxAgentId = useAgentStore(builtinAgentSelectors.inboxAgentId);
const inboxMeta = useAgentStore(agentSelectors.getAgentMetaById(inboxAgentId!));
const isLoading = useChatStore(operationSelectors.isAgentRuntimeRunning);
const isLoading = useChatStore(
inboxAgentId ? operationSelectors.isAgentRunning(inboxAgentId) : () => false,
);
const prefetchAgent = usePrefetchAgent();
const inboxAgentTitle = inboxMeta.title || 'Lobe AI';
const inboxAgentAvatar = inboxMeta.avatar || DEFAULT_INBOX_AVATAR;
@ -33,15 +64,27 @@ const InboxItem = memo<InboxItemProps>(({ className, style }) => {
prefetchRoute(inboxUrl);
prefetchAgent(inboxAgentId!);
const avatarNode = (
<Avatar emojiScaleWithBackground avatar={inboxAgentAvatar} shape={'square'} size={24} />
);
return (
<Link aria-label={inboxAgentTitle} to={inboxUrl}>
<NavItem
className={className}
loading={isLoading}
style={style}
title={inboxAgentTitle}
icon={
<Avatar emojiScaleWithBackground avatar={inboxAgentAvatar} shape={'square'} size={24} />
isLoading ? (
<span className={styles.wrapper}>
{avatarNode}
<span className={styles.runningBadge}>
<Icon spin icon={Loader2} size={9} />
</span>
</span>
) : (
avatarNode
)
}
/>
</Link>

View file

@ -1,7 +1,9 @@
'use client';
import { DEFAULT_INBOX_AVATAR, SESSION_CHAT_URL } from '@lobechat/const';
import { Avatar } from '@lobehub/ui';
import { Avatar, Icon } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { Loader2 } from 'lucide-react';
import { memo } from 'react';
import { Link, useNavigate } from 'react-router-dom';
@ -12,16 +14,47 @@ import { useChatStore } from '@/store/chat';
import { operationSelectors } from '@/store/chat/selectors';
import { isModifierClick } from '@/utils/navigation';
const styles = createStaticStyles(({ css, cssVar }) => ({
runningBadge: css`
pointer-events: none;
position: absolute;
inset-block-end: -3px;
inset-inline-end: -3px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: 1.5px solid ${cssVar.colorBgContainer};
border-radius: 999px;
color: ${cssVar.colorWarning};
background: ${cssVar.colorBgContainer};
`,
wrapper: css`
position: relative;
display: inline-flex;
`,
}));
const InboxEntry = memo(() => {
const navigate = useNavigate();
const inboxAgentId = useAgentStore(builtinAgentSelectors.inboxAgentId);
const inboxMeta = useAgentStore(agentSelectors.getAgentMetaById(inboxAgentId!));
const isLoading = useChatStore(operationSelectors.isAgentRuntimeRunning);
const isLoading = useChatStore(
inboxAgentId ? operationSelectors.isAgentRunning(inboxAgentId) : () => false,
);
const title = inboxMeta.title || 'Lobe AI';
const avatar = inboxMeta.avatar || DEFAULT_INBOX_AVATAR;
const url = SESSION_CHAT_URL(inboxAgentId, false);
const avatarNode = <Avatar emojiScaleWithBackground avatar={avatar} shape={'square'} size={24} />;
return (
<Link
aria-label={title}
@ -33,9 +66,19 @@ const InboxEntry = memo(() => {
}}
>
<NavItem
loading={isLoading}
title={title}
icon={<Avatar emojiScaleWithBackground avatar={avatar} shape={'square'} size={24} />}
icon={
isLoading ? (
<span className={styles.wrapper}>
{avatarNode}
<span className={styles.runningBadge}>
<Icon spin icon={Loader2} size={9} />
</span>
</span>
) : (
avatarNode
)
}
/>
</Link>
);

View file

@ -214,9 +214,8 @@ export const useCreateMenuItems = () => {
},
},
avatar:
'https://registry.npmmirror.com/@lobehub/icons-static-avatar/latest/files/avatars/claude.webp',
systemRole:
'You are Claude Code, an AI coding agent. Help users with code-related tasks.',
'https://registry.npmmirror.com/@lobehub/icons-static-avatar/latest/files/avatars/claudecode.webp',
systemRole: '',
title: 'Claude Code',
},
groupId: options?.groupId,

View file

@ -1,4 +1,9 @@
import { type ToolCategory, type ToolInfo, type ToolStatus } from '@lobechat/electron-client-ipc';
import {
type ClaudeAuthStatus,
type ToolCategory,
type ToolInfo,
type ToolStatus,
} from '@lobechat/electron-client-ipc';
import { ensureElectronIpc } from '@/utils/electron/ipc';
@ -75,6 +80,13 @@ class ToolDetectorService {
getToolsInCategory = (category: ToolCategory): ToolInfo[] => {
return ensureElectronIpc().toolDetector.getToolsInCategory(category);
};
/**
* Get Claude Code CLI auth/account status
*/
getClaudeAuthStatus = async (): Promise<ClaudeAuthStatus | null> => {
return ensureElectronIpc().toolDetector.getClaudeAuthStatus();
};
}
export const toolDetectorService = new ToolDetectorService();

View file

@ -104,7 +104,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <DesktopChatLayout />,
errorElement: <ErrorBoundary resetPath="/agent" />,
errorElement: <ErrorBoundary />,
path: ':aid',
},
],
@ -130,7 +130,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <DesktopGroupLayout />,
errorElement: <ErrorBoundary resetPath="/group" />,
errorElement: <ErrorBoundary />,
path: ':gid',
},
],
@ -230,7 +230,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <CommunityLayout />,
errorElement: <ErrorBoundary resetPath="/community" />,
errorElement: <ErrorBoundary />,
path: 'community',
},
@ -264,7 +264,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <ResourceLayout />,
errorElement: <ErrorBoundary resetPath="/resource" />,
errorElement: <ErrorBoundary />,
path: 'resource',
},
@ -297,7 +297,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <SettingsLayout />,
errorElement: <ErrorBoundary resetPath="/settings" />,
errorElement: <ErrorBoundary />,
path: 'settings',
},
@ -330,7 +330,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <DesktopMemoryLayout />,
errorElement: <ErrorBoundary resetPath="/memory" />,
errorElement: <ErrorBoundary />,
path: 'memory',
},
@ -343,7 +343,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <DesktopVideoLayout />,
errorElement: <ErrorBoundary resetPath="/video" />,
errorElement: <ErrorBoundary />,
path: 'video',
},
@ -356,7 +356,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <DesktopImageLayout />,
errorElement: <ErrorBoundary resetPath="/image" />,
errorElement: <ErrorBoundary />,
path: 'image',
},
@ -405,7 +405,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <EvalLayout />,
errorElement: <ErrorBoundary resetPath="/eval" />,
errorElement: <ErrorBoundary />,
path: 'eval',
},
@ -422,7 +422,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <DesktopPageLayout />,
errorElement: <ErrorBoundary resetPath="/page" />,
errorElement: <ErrorBoundary />,
path: 'page',
},
@ -437,7 +437,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: <DesktopMainLayout />,
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/',
},
@ -459,25 +459,25 @@ export const desktopRoutes: RouteObject[] = [
// Desktop onboarding route (Electron only in .desktop.tsx)
desktopRoutes.push({
element: <DesktopOnboarding />,
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/desktop-onboarding',
});
// Web onboarding aliases redirect to the desktop-specific onboarding flow.
desktopRoutes.push({
element: redirectElement('/desktop-onboarding'),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/onboarding',
});
desktopRoutes.push({
element: redirectElement('/desktop-onboarding'),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/onboarding/agent',
});
desktopRoutes.push({
element: redirectElement('/desktop-onboarding'),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/onboarding/classic',
});

View file

@ -51,7 +51,7 @@ export const desktopRoutes: RouteObject[] = [
() => import('@/routes/(main)/agent/_layout'),
'Desktop > Chat > Layout',
),
errorElement: <ErrorBoundary resetPath="/agent" />,
errorElement: <ErrorBoundary />,
path: ':aid',
},
],
@ -86,7 +86,7 @@ export const desktopRoutes: RouteObject[] = [
() => import('@/routes/(main)/group/_layout'),
'Desktop > Group > Layout',
),
errorElement: <ErrorBoundary resetPath="/group" />,
errorElement: <ErrorBoundary />,
path: ':gid',
},
],
@ -246,7 +246,7 @@ export const desktopRoutes: RouteObject[] = [
() => import('@/routes/(main)/community/_layout'),
'Desktop > Discover > Layout',
),
errorElement: <ErrorBoundary resetPath="/community" />,
errorElement: <ErrorBoundary />,
path: 'community',
},
@ -298,7 +298,7 @@ export const desktopRoutes: RouteObject[] = [
() => import('@/routes/(main)/resource/_layout'),
'Desktop > Resource > Layout',
),
errorElement: <ErrorBoundary resetPath="/resource" />,
errorElement: <ErrorBoundary />,
path: 'resource',
},
@ -344,7 +344,7 @@ export const desktopRoutes: RouteObject[] = [
() => import('@/routes/(main)/settings/_layout'),
'Desktop > Settings > Layout',
),
errorElement: <ErrorBoundary resetPath="/settings" />,
errorElement: <ErrorBoundary />,
path: 'settings',
},
@ -398,7 +398,7 @@ export const desktopRoutes: RouteObject[] = [
() => import('@/routes/(main)/memory/_layout'),
'Desktop > Memory > Layout',
),
errorElement: <ErrorBoundary resetPath="/memory" />,
errorElement: <ErrorBoundary />,
path: 'memory',
},
@ -417,7 +417,7 @@ export const desktopRoutes: RouteObject[] = [
() => import('@/routes/(main)/(create)/video/_layout'),
'Desktop > Video > Layout',
),
errorElement: <ErrorBoundary resetPath="/video" />,
errorElement: <ErrorBoundary />,
path: 'video',
},
@ -436,7 +436,7 @@ export const desktopRoutes: RouteObject[] = [
() => import('@/routes/(main)/(create)/image/_layout'),
'Desktop > Image > Layout',
),
errorElement: <ErrorBoundary resetPath="/image" />,
errorElement: <ErrorBoundary />,
path: 'image',
},
@ -510,7 +510,7 @@ export const desktopRoutes: RouteObject[] = [
() => import('@/routes/(main)/eval/_layout'),
'Desktop > Eval > Layout',
),
errorElement: <ErrorBoundary resetPath="/eval" />,
errorElement: <ErrorBoundary />,
path: 'eval',
},
@ -533,7 +533,7 @@ export const desktopRoutes: RouteObject[] = [
() => import('@/routes/(main)/page/_layout'),
'Desktop > Page > Layout',
),
errorElement: <ErrorBoundary resetPath="/page" />,
errorElement: <ErrorBoundary />,
path: 'page',
},
@ -548,7 +548,7 @@ export const desktopRoutes: RouteObject[] = [
},
],
element: dynamicLayout(() => import('@/routes/(main)/_layout'), 'Desktop > Main > Layout'),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/',
},
// Onboarding route (outside main layout)
@ -573,7 +573,7 @@ export const desktopRoutes: RouteObject[] = [
desktopRoutes.push({
element: dynamicElement(() => import('@/routes/onboarding'), 'Desktop > Onboarding'),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/onboarding',
});
@ -582,7 +582,7 @@ desktopRoutes.push({
() => import('@/routes/onboarding/agent'),
'Desktop > Onboarding > Agent',
),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/onboarding/agent',
});
@ -591,6 +591,6 @@ desktopRoutes.push({
() => import('@/routes/onboarding/classic'),
'Desktop > Onboarding > Classic',
),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/onboarding/classic',
});

View file

@ -37,7 +37,7 @@ export const mobileRoutes: RouteObject[] = [
() => import('@/routes/(mobile)/chat/_layout'),
'Mobile > Chat > Layout',
),
errorElement: <ErrorBoundary resetPath="/agent" />,
errorElement: <ErrorBoundary />,
path: ':aid',
},
],
@ -165,7 +165,7 @@ export const mobileRoutes: RouteObject[] = [
() => import('@/routes/(mobile)/community/_layout'),
'Mobile > Discover > Layout',
),
errorElement: <ErrorBoundary resetPath="/community" />,
errorElement: <ErrorBoundary />,
path: 'community',
},
@ -214,7 +214,7 @@ export const mobileRoutes: RouteObject[] = [
() => import('@/routes/(mobile)/settings/_layout'),
'Mobile > Settings > Layout',
),
errorElement: <ErrorBoundary resetPath="/settings" />,
errorElement: <ErrorBoundary />,
path: 'settings',
},
@ -269,7 +269,7 @@ export const mobileRoutes: RouteObject[] = [
),
},
],
errorElement: <ErrorBoundary resetPath="/me" />,
errorElement: <ErrorBoundary />,
path: 'me',
},
@ -294,13 +294,13 @@ export const mobileRoutes: RouteObject[] = [
},
],
element: dynamicLayout(() => import('@/routes/(mobile)/_layout'), 'Mobile > Main > Layout'),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/',
},
// Onboarding route (outside main layout)
{
element: dynamicElement(() => import('@/routes/onboarding'), 'Mobile > Onboarding'),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/onboarding',
},
{
@ -308,7 +308,7 @@ export const mobileRoutes: RouteObject[] = [
() => import('@/routes/onboarding/agent'),
'Mobile > Onboarding > Agent',
),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/onboarding/agent',
},
{
@ -316,7 +316,7 @@ export const mobileRoutes: RouteObject[] = [
() => import('@/routes/onboarding/classic'),
'Mobile > Onboarding > Classic',
),
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/onboarding/classic',
},
...BusinessMobileRoutesWithoutMainLayout,

View file

@ -98,9 +98,8 @@ const getAgentWorkingDirectoryById =
(_s: AgentStoreState): string | undefined => {
if (!isDesktop) return;
return (
getLocalAgentWorkingDirectory(agentId) ?? globalAgentContextManager.getContext().homePath
);
const ctx = globalAgentContextManager.getContext();
return getLocalAgentWorkingDirectory(agentId) ?? ctx.desktopPath ?? ctx.homePath;
};
/**

View file

@ -1,4 +1,3 @@
import { TopicDisplayMode } from '@lobechat/types';
import dayjs from 'dayjs';
import { describe, expect, it } from 'vitest';
@ -358,13 +357,10 @@ describe('topicSelectors', () => {
activeAgentId: 'test',
});
it('should group by createdAt when displayMode is ByCreatedTime', () => {
it('should group by createdAt when sortBy is createdAt', () => {
const state = createStateWithTopics(topicsWithDifferentTimes);
const grouped = topicSelectors.groupedTopicsForSidebar(
20,
TopicDisplayMode.ByCreatedTime,
)(state);
const grouped = topicSelectors.groupedTopicsForSidebar(20, 'createdAt')(state);
// "Old but active" was created last year, so it should be in a separate group from "New and active"
expect(grouped.length).toBeGreaterThanOrEqual(2);
@ -374,13 +370,10 @@ describe('topicSelectors', () => {
expect(groupIds).toContain(dayjs(lastYear).year().toString());
});
it('should group by updatedAt when displayMode is ByUpdatedTime', () => {
it('should group by updatedAt when sortBy is updatedAt', () => {
const state = createStateWithTopics(topicsWithDifferentTimes);
const grouped = topicSelectors.groupedTopicsForSidebar(
20,
TopicDisplayMode.ByUpdatedTime,
)(state);
const grouped = topicSelectors.groupedTopicsForSidebar(20, 'updatedAt')(state);
// Both topics have updatedAt = now, so they should be in the same group
expect(grouped).toHaveLength(1);
@ -391,10 +384,7 @@ describe('topicSelectors', () => {
it('should return empty array when no topics exist', () => {
const state = merge(initialStore, { activeAgentId: 'test' });
const grouped = topicSelectors.groupedTopicsForSidebar(
20,
TopicDisplayMode.ByUpdatedTime,
)(state);
const grouped = topicSelectors.groupedTopicsForSidebar(20, 'updatedAt')(state);
expect(grouped).toEqual([]);
});
@ -410,10 +400,7 @@ describe('topicSelectors', () => {
const state = createStateWithTopics(manyTopics);
const grouped = topicSelectors.groupedTopicsForSidebar(
3,
TopicDisplayMode.ByUpdatedTime,
)(state);
const grouped = topicSelectors.groupedTopicsForSidebar(3, 'updatedAt')(state);
const totalChildren = grouped.reduce((sum, g) => sum + g.children.length, 0);
expect(totalChildren).toBe(3);

View file

@ -5,9 +5,14 @@ import {
type ChatTopic,
type ChatTopicSummary,
type GroupedTopic,
TopicDisplayMode,
type TopicGroupMode,
type TopicSortBy,
} from '@/types/topic';
import { groupTopicsByTime, groupTopicsByUpdatedTime } from '@/utils/client/topic';
import {
groupTopicsByProject,
groupTopicsByTime,
groupTopicsByUpdatedTime,
} from '@/utils/client/topic';
import { type ChatStoreState } from '../../initialState';
import { topicMapKey } from '../../utils/topicMapKey';
@ -87,26 +92,43 @@ const isUndefinedTopics = (s: ChatStoreState) => !currentTopics(s);
const isInSearchMode = (s: ChatStoreState) => s.inSearchingMode;
const isSearchingTopic = (s: ChatStoreState) => s.isSearchingTopic;
const sortTopics = (topics: ChatTopic[], sortBy: TopicSortBy): ChatTopic[] => {
const field = sortBy === 'createdAt' ? 'createdAt' : 'updatedAt';
return [...topics].sort((a, b) => b[field] - a[field]);
};
// Limit topics for sidebar display based on user's page size preference
const displayTopicsForSidebar =
(pageSize: number) =>
(pageSize: number, sortBy: TopicSortBy = 'updatedAt') =>
(s: ChatStoreState): ChatTopic[] | undefined => {
const topics = currentTopicsWithoutCron(s);
if (!topics) return undefined;
// Return only the first page worth of topics for sidebar
return topics.slice(0, pageSize);
// Favorites first, then sorted by the chosen timestamp, then page-sliced
const favTopics = topics.filter((t) => t.favorite);
const rest = topics.filter((t) => !t.favorite);
return [...sortTopics(favTopics, sortBy), ...sortTopics(rest, sortBy)].slice(0, pageSize);
};
const getGroupFn = (displayMode: TopicDisplayMode) =>
displayMode === TopicDisplayMode.ByUpdatedTime ? groupTopicsByUpdatedTime : groupTopicsByTime;
const getGroupFn = (groupMode: TopicGroupMode, sortBy: TopicSortBy) => {
if (groupMode === 'byProject') {
const field: 'createdAt' | 'updatedAt' = sortBy === 'createdAt' ? 'createdAt' : 'updatedAt';
return (topics: ChatTopic[]) =>
groupTopicsByProject(topics, field).map((group) =>
group.id === 'no-project'
? { ...group, title: t('groupTitle.byProject.noProject', { ns: 'topic' }) }
: group,
);
}
return sortBy === 'updatedAt' ? groupTopicsByUpdatedTime : groupTopicsByTime;
};
/**
* Build grouped topics from a topic list, splitting favorites into a separate group
*/
const buildGroupedTopics = (
topics: ChatTopic[],
groupFn: typeof groupTopicsByTime,
groupFn: (topics: ChatTopic[]) => GroupedTopic[],
): GroupedTopic[] => {
const favTopics = topics.filter((topic) => topic.favorite);
const unfavTopics = topics.filter((topic) => !topic.favorite);
@ -132,11 +154,11 @@ const groupedTopicsSelector =
};
const groupedTopicsForSidebar =
(pageSize: number, displayMode: TopicDisplayMode = TopicDisplayMode.ByCreatedTime) =>
(pageSize: number, sortBy: TopicSortBy = 'updatedAt', groupMode: TopicGroupMode = 'byTime') =>
(s: ChatStoreState): GroupedTopic[] => {
const limitedTopics = displayTopicsForSidebar(pageSize)(s);
const limitedTopics = displayTopicsForSidebar(pageSize, sortBy)(s);
if (!limitedTopics) return [];
return buildGroupedTopics(limitedTopics, getGroupFn(displayMode));
return buildGroupedTopics(limitedTopics, getGroupFn(groupMode, sortBy));
};
const hasMoreTopics = (s: ChatStoreState): boolean => currentTopicData(s)?.hasMore ?? false;

View file

@ -3,8 +3,9 @@ import { DEFAULT_PREFERENCE } from '@lobechat/const';
import { type UserStore } from '@/store/user';
const useCmdEnterToSend = (s: UserStore): boolean => s.preference.useCmdEnterToSend || false;
const topicDisplayMode = (s: UserStore) =>
s.preference.topicDisplayMode || DEFAULT_PREFERENCE.topicDisplayMode;
const topicGroupMode = (s: UserStore) =>
s.preference.topicGroupMode || DEFAULT_PREFERENCE.topicGroupMode!;
const topicSortBy = (s: UserStore) => s.preference.topicSortBy || DEFAULT_PREFERENCE.topicSortBy!;
const hideSyncAlert = (s: UserStore) => s.preference.hideSyncAlert;
@ -24,6 +25,7 @@ export const preferenceSelectors = {
isPreferenceInit,
shouldTriggerFileInKnowledgeBaseTip,
showUploadFileInKnowledgeBaseTip,
topicDisplayMode,
topicGroupMode,
topicSortBy,
useCmdEnterToSend,
};

View file

@ -2,7 +2,7 @@
import { ThemeProvider } from '@lobehub/ui';
import { type ComponentType, type ReactElement } from 'react';
import { lazy, memo, Suspense, useCallback, useLayoutEffect } from 'react';
import { lazy, memo, Suspense, useLayoutEffect } from 'react';
import type { RouteObject } from 'react-router-dom';
import {
createBrowserRouter,
@ -86,29 +86,8 @@ export function dynamicLayout<P = NonNullable<unknown>>(
);
}
/**
* Error boundary component for React Router
* Displays an error page and provides a reset function to navigate to a specific path
*
* @example
* import { ErrorBoundary } from '@/utils/dynamicPage';
*
* // In router config:
* {
* path: 'chat',
* errorElement: <ErrorBoundary resetPath="/chat" />
* }
*/
export interface ErrorBoundaryProps {
resetPath: string;
}
export const ErrorBoundary = ({ resetPath }: ErrorBoundaryProps) => {
export const ErrorBoundary = () => {
const error = useRouteError() as Error;
const navigate = useNavigate();
const reset = useCallback(() => {
navigate(resetPath);
}, [navigate, resetPath]);
if (typeof window !== 'undefined' && isChunkLoadError(error)) {
notifyChunkError();
@ -116,7 +95,7 @@ export const ErrorBoundary = ({ resetPath }: ErrorBoundaryProps) => {
return (
<ThemeProvider theme={{ cssVar: { key: 'lobe-vars' } }}>
<ErrorCapture error={error} reset={reset} />
<ErrorCapture error={error} />
</ThemeProvider>
);
};
@ -169,7 +148,7 @@ export function createAppRouter(routes: RouteObject[], options?: CreateAppRouter
{
children: routes,
element: <RouterRoot />,
errorElement: <ErrorBoundary resetPath="/" />,
errorElement: <ErrorBoundary />,
path: '/',
},
],