mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ 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:
parent
568389d43f
commit
d581937196
60 changed files with 914 additions and 310 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "本周",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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`);
|
||||
|
|
@ -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 }) => {
|
||||
|
|
@ -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;
|
||||
|
|
@ -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:';
|
||||
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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',
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
18
src/routes/(main)/agent/_layout/Sidebar/Topic/Filter.tsx
Normal file
18
src/routes/(main)/agent/_layout/Sidebar/Topic/Filter.tsx
Normal 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;
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
18
src/routes/(main)/group/_layout/Sidebar/Topic/Filter.tsx
Normal file
18
src/routes/(main)/group/_layout/Sidebar/Topic/Filter.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: '/',
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue