mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-16 20:46:08 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0afd5798ab | |||
| fcb80d6cd6 | |||
| 441e0c5b7c | |||
| 0a6b02ccb5 |
@@ -18,7 +18,7 @@ jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
# skip pre-release tags (containing '-') on auto-trigger; always run on workflow_dispatch
|
||||
# 跳过预发布 tag(含 "-");workflow_dispatch 始终执行
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || !contains(github.ref_name, '-') }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -26,8 +26,16 @@ jobs:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: lobe-linux-x64
|
||||
bun_target: bun-linux-x64
|
||||
- os: ubuntu-latest
|
||||
target: lobe-linux-arm64
|
||||
bun_target: bun-linux-arm64
|
||||
- os: macos-latest
|
||||
target: lobe-macos-x64
|
||||
bun_target: bun-darwin-x64
|
||||
- os: macos-latest
|
||||
target: lobe-macos-arm64
|
||||
bun_target: bun-darwin-arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -44,7 +52,11 @@ jobs:
|
||||
- name: Build binary
|
||||
run: |
|
||||
mkdir -p dist
|
||||
bun build ./apps/cli/src/index.ts --compile --minify --outfile ./dist/${{ matrix.target }}
|
||||
bun build ./apps/cli/src/index.ts \
|
||||
--compile \
|
||||
--minify \
|
||||
--target ${{ matrix.bun_target }} \
|
||||
--outfile ./dist/${{ matrix.target }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -71,5 +83,7 @@ jobs:
|
||||
tag_name: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
files: |
|
||||
./dist/lobe-linux-x64/lobe-linux-x64
|
||||
./dist/lobe-linux-arm64/lobe-linux-arm64
|
||||
./dist/lobe-macos-x64/lobe-macos-x64
|
||||
./dist/lobe-macos-arm64/lobe-macos-arm64
|
||||
./apps/cli/install.sh
|
||||
|
||||
+6
-5
@@ -1,10 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
REPO="lobehub/lobe-chat"
|
||||
# 从本仓库的 Release 下载预编译二进制
|
||||
REPO="lobehub/lobehub"
|
||||
BIN_NAME="lh"
|
||||
|
||||
# Detect OS
|
||||
# 检测操作系统
|
||||
case "$(uname -s)" in
|
||||
Linux) OS="linux" ;;
|
||||
Darwin) OS="macos" ;;
|
||||
@@ -14,7 +15,7 @@ case "$(uname -s)" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# Detect architecture
|
||||
# 检测 CPU 架构
|
||||
case "$(uname -m)" in
|
||||
x86_64) ARCH="x64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
@@ -44,7 +45,7 @@ fi
|
||||
|
||||
chmod +x "$TMP"
|
||||
|
||||
# Choose install directory: prefer /usr/local/bin, fall back to ~/.local/bin
|
||||
# 选择安装目录:优先 /usr/local/bin,否则退回 ~/.local/bin
|
||||
USE_SUDO=0
|
||||
if [ -w "/usr/local/bin" ]; then
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
@@ -59,7 +60,7 @@ else
|
||||
printf ' export PATH="%s:$PATH"\n' "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
# Install binary and create symlinks
|
||||
# 安装二进制并创建 lobe / lobehub 别名
|
||||
if [ "$USE_SUDO" = "1" ]; then
|
||||
sudo cp "$TMP" "${INSTALL_DIR}/${BIN_NAME}"
|
||||
sudo chmod +x "${INSTALL_DIR}/${BIN_NAME}"
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"displayItems": "Display Items",
|
||||
"duplicateLoading": "Copying Topic...",
|
||||
"duplicateSuccess": "Topic Copied Successfully",
|
||||
"failedStatusTip": "This run hit an error — open it to take a look.",
|
||||
"favorite": "Favorite",
|
||||
"filter.filter": "Filter",
|
||||
"filter.groupMode.byProject": "By project",
|
||||
@@ -43,6 +44,7 @@
|
||||
"groupTitle.byStatus.completed": "Completed",
|
||||
"groupTitle.byStatus.failed": "Failed",
|
||||
"groupTitle.byStatus.paused": "Paused",
|
||||
"groupTitle.byStatus.pending": "Needs attention",
|
||||
"groupTitle.byStatus.running": "Running",
|
||||
"groupTitle.byStatus.waitingForHuman": "Awaiting input",
|
||||
"groupTitle.byTime.month": "This Month",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"displayItems": "显示条目",
|
||||
"duplicateLoading": "话题复制中…",
|
||||
"duplicateSuccess": "话题复制成功",
|
||||
"failedStatusTip": "当前执行遇到了错误,点击查看详情",
|
||||
"favorite": "收藏",
|
||||
"filter.filter": "筛选",
|
||||
"filter.groupMode.byProject": "按项目",
|
||||
@@ -43,6 +44,7 @@
|
||||
"groupTitle.byStatus.completed": "已完成",
|
||||
"groupTitle.byStatus.failed": "已失败",
|
||||
"groupTitle.byStatus.paused": "已暂停",
|
||||
"groupTitle.byStatus.pending": "待处理",
|
||||
"groupTitle.byStatus.running": "进行中",
|
||||
"groupTitle.byStatus.waitingForHuman": "等待处理",
|
||||
"groupTitle.byTime.month": "本月",
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('TopicModel - Query', () => {
|
||||
updatedAt: new Date('2023-01-01'),
|
||||
userId,
|
||||
},
|
||||
// null status is treated as `active` (rank 2)
|
||||
// null status is treated as `active` (rank 3)
|
||||
{ id: 'active', sessionId, updatedAt: new Date('2023-09-01'), userId },
|
||||
{
|
||||
id: 'running-old',
|
||||
@@ -87,6 +87,15 @@ describe('TopicModel - Query', () => {
|
||||
updatedAt: new Date('2023-03-01'),
|
||||
userId,
|
||||
},
|
||||
// failed shares the top "pending" bucket with waitingForHuman, so it
|
||||
// ranks just below it and above running/active
|
||||
{
|
||||
id: 'failed',
|
||||
sessionId,
|
||||
status: 'failed',
|
||||
updatedAt: new Date('2023-04-01'),
|
||||
userId,
|
||||
},
|
||||
{
|
||||
id: 'completed',
|
||||
sessionId,
|
||||
@@ -101,9 +110,10 @@ describe('TopicModel - Query', () => {
|
||||
expect(result.items.map((t) => t.id)).toEqual([
|
||||
'fav', // favorite, rank-independent
|
||||
'waiting', // waitingForHuman = 0
|
||||
'running-new', // running = 1, newer first within the bucket
|
||||
'failed', // failed = 1
|
||||
'running-new', // running = 2, newer first within the bucket
|
||||
'running-old',
|
||||
'active', // null status → active = 2
|
||||
'active', // null status → active = 3
|
||||
'completed', // completed = 5
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -115,18 +115,21 @@ export interface ListTopicsForMemoryExtractorCursor {
|
||||
}
|
||||
|
||||
// Status priority for the sidebar "group by status" ordering. Lower rank =
|
||||
// higher in the list. A NULL / unknown status falls through to `active` (2),
|
||||
// higher in the list. A NULL / unknown status falls through to `active` (3),
|
||||
// matching the client which treats a missing status as active. Keep this in
|
||||
// sync with `STATUS_GROUP_ORDER` in `@lobechat/utils` (client-side bucketing).
|
||||
// sync with `STATUS_GROUP_ORDER` / `resolveStatusBucket` in `@lobechat/utils`
|
||||
// (client-side bucketing): `waitingForHuman` and `failed` both collapse into the
|
||||
// top `pending` bucket, so they must float to the top here too — otherwise a
|
||||
// failed topic could fall off the first page and vanish from the pending group.
|
||||
const STATUS_SORT_RANK = sql`CASE ${topics.status}
|
||||
WHEN 'waitingForHuman' THEN 0
|
||||
WHEN 'running' THEN 1
|
||||
WHEN 'active' THEN 2
|
||||
WHEN 'paused' THEN 3
|
||||
WHEN 'failed' THEN 4
|
||||
WHEN 'failed' THEN 1
|
||||
WHEN 'running' THEN 2
|
||||
WHEN 'active' THEN 3
|
||||
WHEN 'paused' THEN 4
|
||||
WHEN 'completed' THEN 5
|
||||
WHEN 'archived' THEN 6
|
||||
ELSE 2 END`;
|
||||
ELSE 3 END`;
|
||||
|
||||
// Favorites always float to the top; the rest are ordered by the requested
|
||||
// strategy. `status` adds the priority bucket before the recency tiebreaker.
|
||||
|
||||
@@ -232,7 +232,7 @@ describe('groupTopicsByStatus', () => {
|
||||
expect(groupTopicsByStatus([], 'updatedAt')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should order groups by fixed priority: waitingForHuman, running, then active', () => {
|
||||
it('should order groups by fixed priority: pending, running, then active', () => {
|
||||
const topics = [
|
||||
createTopic('a', 'active'),
|
||||
createTopic('r', 'running'),
|
||||
@@ -241,7 +241,30 @@ describe('groupTopicsByStatus', () => {
|
||||
|
||||
const result = groupTopicsByStatus(topics, 'updatedAt');
|
||||
|
||||
expect(result.map((g) => g.id)).toEqual(['waitingForHuman', 'running', 'active']);
|
||||
expect(result.map((g) => g.id)).toEqual(['pending', 'running', 'active']);
|
||||
});
|
||||
|
||||
it('should collapse waitingForHuman and failed into the pending bucket', () => {
|
||||
const topics = [
|
||||
createTopic('w', 'waitingForHuman', 2),
|
||||
createTopic('f', 'failed', 1),
|
||||
createTopic('a', 'active'),
|
||||
];
|
||||
|
||||
const result = groupTopicsByStatus(topics, 'updatedAt');
|
||||
|
||||
expect(result.map((g) => g.id)).toEqual(['pending', 'active']);
|
||||
expect(result[0].children.map((t) => t.id)).toEqual(['w', 'f']);
|
||||
});
|
||||
|
||||
it('should bucket an unread completion as pending while read completions stay completed', () => {
|
||||
const topics = [createTopic('unread', 'completed'), createTopic('read', 'completed')];
|
||||
|
||||
const result = groupTopicsByStatus(topics, 'updatedAt', undefined, new Set(['unread']));
|
||||
|
||||
expect(result.map((g) => g.id)).toEqual(['pending', 'completed']);
|
||||
expect(result[0].children.map((t) => t.id)).toEqual(['unread']);
|
||||
expect(result[1].children.map((t) => t.id)).toEqual(['read']);
|
||||
});
|
||||
|
||||
it('should bucket topics without a status as active', () => {
|
||||
@@ -263,7 +286,7 @@ describe('groupTopicsByStatus', () => {
|
||||
|
||||
const result = groupTopicsByStatus(topics, 'updatedAt');
|
||||
|
||||
expect(result.map((g) => g.id)).toEqual(['waitingForHuman', 'paused', 'completed']);
|
||||
expect(result.map((g) => g.id)).toEqual(['pending', 'paused', 'completed']);
|
||||
});
|
||||
|
||||
it('should sort topics inside a group by the chosen field desc', () => {
|
||||
@@ -288,11 +311,11 @@ describe('groupTopicsByStatus', () => {
|
||||
expect(result[1].children.map((t) => t.id)).toEqual(['idle']);
|
||||
});
|
||||
|
||||
it('should keep a loading topic in waitingForHuman (it outranks the running overlay)', () => {
|
||||
it('should keep a loading topic in pending (it outranks the running overlay)', () => {
|
||||
const topics = [createTopic('waiting', 'waitingForHuman')];
|
||||
|
||||
const result = groupTopicsByStatus(topics, 'updatedAt', new Set(['waiting']));
|
||||
|
||||
expect(result.map((g) => g.id)).toEqual(['waitingForHuman']);
|
||||
expect(result.map((g) => g.id)).toEqual(['pending']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,54 +151,71 @@ export const groupTopicsByProject = (
|
||||
});
|
||||
};
|
||||
|
||||
// Status-based grouping. Fixed priority order: topics awaiting a human come
|
||||
// first, then running, then active; the remaining states fall below. Topics
|
||||
// without a status are treated as `active`. The group `id` is the raw status
|
||||
// value so the sidebar can resolve its title via `groupTitle.byStatus.<id>`.
|
||||
// The display buckets for status grouping. These are NOT raw `ChatTopicStatus`
|
||||
// values: the three states that need the user's attention — awaiting a human,
|
||||
// failed, and an unread completion — collapse into a single `pending` bucket so
|
||||
// the sidebar surfaces "needs attention" in one place. The remaining buckets map
|
||||
// 1:1 to a status. The group `id` resolves its title via `groupTitle.byStatus.<id>`.
|
||||
export type TopicStatusBucket =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'active'
|
||||
| 'paused'
|
||||
| 'completed'
|
||||
| 'archived';
|
||||
|
||||
// Fixed priority order: `pending` (needs attention) comes first, then running,
|
||||
// then active; the remaining states fall below. Topics without a status are
|
||||
// treated as `active`.
|
||||
//
|
||||
// The server orders the query by the same priority (see `STATUS_SORT_RANK` in
|
||||
// `@lobechat/database` topic model) so the right page is fetched; this only
|
||||
// re-buckets that already-ordered page for display. Keep the two in sync. The
|
||||
// one client-only nuance is `loadingTopicIds` (a topic streaming right now),
|
||||
// which the server can't know about — see `resolveStatusBucket`.
|
||||
export const STATUS_GROUP_ORDER: ChatTopicStatus[] = [
|
||||
'waitingForHuman',
|
||||
// The server orders the query by the underlying status priority (see
|
||||
// `STATUS_SORT_RANK` in `@lobechat/database` topic model) so the right page is
|
||||
// fetched; this only re-buckets that already-ordered page for display. The
|
||||
// client-only nuances are `loadingTopicIds` (a topic streaming right now) and
|
||||
// `unreadTopicIds` (a completion not yet read), which the server can't know
|
||||
// about — see `resolveStatusBucket`.
|
||||
export const STATUS_GROUP_ORDER: TopicStatusBucket[] = [
|
||||
'pending',
|
||||
'running',
|
||||
'active',
|
||||
'paused',
|
||||
'failed',
|
||||
'completed',
|
||||
'archived',
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the bucket a topic belongs to. Mirrors the icon precedence in the
|
||||
* sidebar `TopicItem`: `waitingForHuman` wins, then a topic that is actively
|
||||
* streaming on this client (`loadingTopicIds`, a transient client-only state
|
||||
* the server can't see) or persisted as `running` lands in `running`, then the
|
||||
* persisted status, defaulting to `active`.
|
||||
* sidebar `TopicItem`: anything needing attention (`waitingForHuman`, `failed`,
|
||||
* or an unread completion in `unreadTopicIds`) lands in `pending`; then a topic
|
||||
* actively streaming on this client (`loadingTopicIds`, a transient client-only
|
||||
* state the server can't see) or persisted as `running` lands in `running`; then
|
||||
* the persisted status, defaulting to `active`.
|
||||
*/
|
||||
const resolveStatusBucket = (
|
||||
topic: ChatTopic,
|
||||
loadingTopicIds?: ReadonlySet<string>,
|
||||
): ChatTopicStatus => {
|
||||
if (topic.status === 'waitingForHuman') return 'waitingForHuman';
|
||||
unreadTopicIds?: ReadonlySet<string>,
|
||||
): TopicStatusBucket => {
|
||||
if (topic.status === 'waitingForHuman' || topic.status === 'failed') return 'pending';
|
||||
if (unreadTopicIds?.has(topic.id)) return 'pending';
|
||||
if (loadingTopicIds?.has(topic.id) || topic.status === 'running') return 'running';
|
||||
const status = topic.status ?? 'active';
|
||||
return STATUS_GROUP_ORDER.includes(status) ? status : 'active';
|
||||
const status: ChatTopicStatus = topic.status ?? 'active';
|
||||
if (status === 'paused' || status === 'completed' || status === 'archived') return status;
|
||||
return 'active';
|
||||
};
|
||||
|
||||
export const groupTopicsByStatus = (
|
||||
topics: ChatTopic[],
|
||||
field: 'createdAt' | 'updatedAt',
|
||||
loadingTopicIds?: ReadonlySet<string>,
|
||||
unreadTopicIds?: ReadonlySet<string>,
|
||||
): GroupedTopic[] => {
|
||||
if (!topics.length) return [];
|
||||
|
||||
const groupsMap = new Map<ChatTopicStatus, ChatTopic[]>();
|
||||
const groupsMap = new Map<TopicStatusBucket, ChatTopic[]>();
|
||||
|
||||
for (const topic of topics) {
|
||||
const id = resolveStatusBucket(topic, loadingTopicIds);
|
||||
const id = resolveStatusBucket(topic, loadingTopicIds, unreadTopicIds);
|
||||
const existing = groupsMap.get(id);
|
||||
if (existing) {
|
||||
existing.push(topic);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Github } from '@lobehub/icons';
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { FolderIcon, GitBranchIcon } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface DirIconProps {
|
||||
/** Detected repo type — drives the glyph: GitHub mark, git branch, or a plain
|
||||
* folder when the type is unknown. */
|
||||
repoType?: 'git' | 'github';
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** Directory icon shared by every cwd surface (local / device / settings pickers,
|
||||
* topic rows) so a `github` dir looks the same everywhere. */
|
||||
const DirIcon = memo<DirIconProps>(({ repoType, size = 16 }) => {
|
||||
const iconStyle = { color: cssVar.colorTextTertiary, flex: 'none' as const };
|
||||
if (repoType === 'github') return <Github size={size} style={iconStyle} />;
|
||||
return (
|
||||
<Icon icon={repoType === 'git' ? GitBranchIcon : FolderIcon} size={size} style={iconStyle} />
|
||||
);
|
||||
});
|
||||
|
||||
DirIcon.displayName = 'DirIcon';
|
||||
|
||||
export default DirIcon;
|
||||
@@ -63,6 +63,7 @@ const styles = createStaticStyles(({ css }) => {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
@@ -72,6 +73,7 @@ const styles = createStaticStyles(({ css }) => {
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
white-space: nowrap;
|
||||
|
||||
transition: background 0.2s;
|
||||
|
||||
@@ -81,6 +83,7 @@ const styles = createStaticStyles(({ css }) => {
|
||||
}
|
||||
`,
|
||||
separator: css`
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 10px;
|
||||
background: ${cssVar.colorSplit};
|
||||
@@ -89,6 +92,7 @@ const styles = createStaticStyles(({ css }) => {
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
|
||||
@@ -118,6 +122,7 @@ const styles = createStaticStyles(({ css }) => {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
@@ -127,6 +132,7 @@ const styles = createStaticStyles(({ css }) => {
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
white-space: nowrap;
|
||||
|
||||
transition: background 0.2s;
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
@@ -40,6 +41,7 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
white-space: nowrap;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
@@ -48,6 +50,12 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
buttonLabel: css`
|
||||
overflow: hidden;
|
||||
max-width: 120px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
check: css`
|
||||
flex: none;
|
||||
margin-inline-start: auto;
|
||||
@@ -292,6 +300,12 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
|
||||
const storedTarget = agencyConfig?.executionTarget;
|
||||
const boundDeviceId = agencyConfig?.boundDeviceId;
|
||||
|
||||
// Heterogeneous agents (Claude Code / Codex — remote types already early-return
|
||||
// below) bring their own toolchain and must execute somewhere, so `'none'`
|
||||
// (plain chat, no execution environment) isn't a valid target for them: hide
|
||||
// the option and never fall back to / honour a stale stored `'none'`.
|
||||
const isHetero = !!heteroType;
|
||||
|
||||
const { data: devices, isLoading } = lambdaQuery.device.listDevices.useQuery(undefined, {
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -303,8 +317,13 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
|
||||
const gatewayDeviceInfo = useElectronStore((s) => s.gatewayDeviceInfo);
|
||||
const currentDeviceId = isDesktop ? gatewayDeviceInfo?.deviceId : undefined;
|
||||
|
||||
// Effective target: falls back to local on desktop, no device on web.
|
||||
const executionTarget: DeviceExecutionTarget = storedTarget ?? (isDesktop ? 'local' : 'none');
|
||||
// Effective target: falls back to local on desktop, sandbox for heterogeneous
|
||||
// agents on web (they must execute somewhere), otherwise no device on web. A
|
||||
// hetero agent never resolves to `'none'`, even if one was previously stored.
|
||||
const fallbackTarget: DeviceExecutionTarget = isDesktop ? 'local' : isHetero ? 'sandbox' : 'none';
|
||||
const storedOrFallback = storedTarget ?? fallbackTarget;
|
||||
const executionTarget: DeviceExecutionTarget =
|
||||
isHetero && storedOrFallback === 'none' ? fallbackTarget : storedOrFallback;
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (target: DeviceExecutionTarget, deviceId?: string) => {
|
||||
@@ -401,13 +420,15 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<OptionRow
|
||||
active={isActive('none')}
|
||||
desc={t('heteroAgent.executionTarget.noneDesc')}
|
||||
icon={<Icon icon={MonitorOffIcon} size={14} />}
|
||||
label={t('heteroAgent.executionTarget.none')}
|
||||
onClick={() => void handleSelect('none')}
|
||||
/>
|
||||
{isHetero ? null : (
|
||||
<OptionRow
|
||||
active={isActive('none')}
|
||||
desc={t('heteroAgent.executionTarget.noneDesc')}
|
||||
icon={<Icon icon={MonitorOffIcon} size={14} />}
|
||||
label={t('heteroAgent.executionTarget.none')}
|
||||
onClick={() => void handleSelect('none')}
|
||||
/>
|
||||
)}
|
||||
{isDesktop ? (
|
||||
<OptionRow
|
||||
active={isActive('local')}
|
||||
@@ -468,7 +489,7 @@ const HeteroDeviceSwitcher = memo<HeteroDeviceSwitcherProps>(({ agentId }) => {
|
||||
>
|
||||
<div className={styles.button}>
|
||||
{chipIcon}
|
||||
<span>{chipLabel}</span>
|
||||
<span className={styles.buttonLabel}>{chipLabel}</span>
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
@@ -45,6 +45,7 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
@@ -54,6 +55,7 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
white-space: nowrap;
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import { deviceSelectors, useDeviceStore } from '@/store/device';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
|
||||
import { openAddWorkingDirModal } from './AddWorkingDirModal';
|
||||
import { renderDirIcon } from './dirIcon';
|
||||
import DirIcon from './DirIcon';
|
||||
import { useCommitWorkingDirectory } from './useCommitWorkingDirectory';
|
||||
import { useMigrateDeviceRecents } from './useMigrateDeviceRecents';
|
||||
|
||||
@@ -37,6 +37,7 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
@@ -46,6 +47,7 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
white-space: nowrap;
|
||||
|
||||
transition: background 0.2s;
|
||||
|
||||
@@ -53,6 +55,12 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
background: ${cssVar.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
buttonLabel: css`
|
||||
overflow: hidden;
|
||||
max-width: 140px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`,
|
||||
chooseFolderItem: css`
|
||||
cursor: pointer;
|
||||
|
||||
@@ -322,7 +330,7 @@ const WorkingDirectoryPicker = memo<WorkingDirectoryPickerProps>(({ agentId }) =
|
||||
key={entry.path}
|
||||
onClick={() => void pick(entry)}
|
||||
>
|
||||
{renderDirIcon(entry.repoType)}
|
||||
<DirIcon repoType={entry.repoType} />
|
||||
<Flexbox flex={1} style={{ minWidth: 0 }}>
|
||||
<div className={styles.dirName}>{getDirName(entry.path)}</div>
|
||||
<div className={styles.dirPath}>{entry.path}</div>
|
||||
@@ -366,11 +374,11 @@ const WorkingDirectoryPicker = memo<WorkingDirectoryPickerProps>(({ agentId }) =
|
||||
const trigger = (
|
||||
<div className={styles.button}>
|
||||
{selectedDir ? (
|
||||
renderDirIcon(recents.find((r) => r.path === selectedDir)?.repoType)
|
||||
<DirIcon repoType={recents.find((r) => r.path === selectedDir)?.repoType} />
|
||||
) : (
|
||||
<Icon icon={FolderIcon} size={14} />
|
||||
)}
|
||||
<span>{displayName}</span>
|
||||
<span className={styles.buttonLabel}>{displayName}</span>
|
||||
<Icon icon={ChevronDownIcon} size={12} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Github } from '@lobehub/icons';
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { FolderIcon, GitBranchIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Render the directory icon for a detected repo type: GitHub mark, git branch,
|
||||
* or a plain folder when the type is unknown. Shared by every cwd picker so a
|
||||
* `github` dir looks the same in the local, device, and settings views. */
|
||||
export const renderDirIcon = (repoType?: 'git' | 'github'): ReactNode => {
|
||||
const iconStyle = { color: cssVar.colorTextTertiary, flex: 'none' as const };
|
||||
if (repoType === 'github') return <Github size={16} style={iconStyle} />;
|
||||
return (
|
||||
<Icon icon={repoType === 'git' ? GitBranchIcon : FolderIcon} size={16} style={iconStyle} />
|
||||
);
|
||||
};
|
||||
@@ -17,6 +17,26 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
padding-block: 0;
|
||||
padding-inline: 4px;
|
||||
`,
|
||||
// Left cluster (mode + device + working directory + git) is the variable-width
|
||||
// part. It shrinks first and, once its long labels have truncated as far as
|
||||
// they can, scrolls horizontally instead of wrapping each chip's text. The
|
||||
// scrollbar is hidden — trackpad / wheel still works.
|
||||
leftGroup: css`
|
||||
scrollbar-width: none;
|
||||
|
||||
overflow: auto hidden;
|
||||
flex: 1;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
// Right cluster (approval mode + context window) stays pinned and intact.
|
||||
rightGroup: css`
|
||||
flex: none;
|
||||
`,
|
||||
}));
|
||||
|
||||
const RuntimeConfig = memo(() => {
|
||||
@@ -43,12 +63,12 @@ const RuntimeConfig = memo(() => {
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
|
||||
{/* Left: chat-mode switcher + (agent-only) execution device + working directory */}
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Flexbox horizontal align={'center'} className={styles.leftGroup} gap={4}>
|
||||
<ModeSelector />
|
||||
{enableAgentMode && <WorkspaceControls agentId={agentId} />}
|
||||
</Flexbox>
|
||||
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Flexbox horizontal align={'center'} className={styles.rightGroup} gap={4}>
|
||||
{enableAgentMode && <ApprovalMode />}
|
||||
{showContextWindow && <ContextWindow />}
|
||||
</Flexbox>
|
||||
|
||||
@@ -47,6 +47,12 @@ export interface NavItemProps extends Omit<BlockProps, 'children' | 'title'> {
|
||||
actions?: ReactNode;
|
||||
active?: boolean;
|
||||
contextMenuItems?: GenericItemType[] | (() => GenericItemType[]);
|
||||
/**
|
||||
* Optional second line rendered under the title (e.g. a topic's project
|
||||
* directory). When set, the row grows to fit both lines; when omitted the
|
||||
* layout is byte-identical to a single-line row.
|
||||
*/
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
extra?: ReactNode;
|
||||
/**
|
||||
@@ -58,6 +64,12 @@ export interface NavItemProps extends Omit<BlockProps, 'children' | 'title'> {
|
||||
loading?: boolean;
|
||||
slots?: NavItemSlots;
|
||||
title: ReactNode;
|
||||
/**
|
||||
* Override the title text color. Defaults to colorText when active and
|
||||
* colorTextSecondary otherwise. Pass cssVar.colorText to keep a row's title
|
||||
* fully emphasized regardless of active state (e.g. topic titles).
|
||||
*/
|
||||
titleColor?: string;
|
||||
}
|
||||
|
||||
const NavItem = memo<NavItemProps>(
|
||||
@@ -70,6 +82,8 @@ const NavItem = memo<NavItemProps>(
|
||||
icon,
|
||||
iconSize = 18,
|
||||
title,
|
||||
titleColor,
|
||||
description,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
@@ -78,7 +92,7 @@ const NavItem = memo<NavItemProps>(
|
||||
...rest
|
||||
}) => {
|
||||
const iconColor = active ? cssVar.colorText : cssVar.colorTextDescription;
|
||||
const textColor = active ? cssVar.colorText : cssVar.colorTextSecondary;
|
||||
const textColor = titleColor ?? (active ? cssVar.colorText : cssVar.colorTextSecondary);
|
||||
const variant = active ? 'filled' : 'borderless';
|
||||
|
||||
const { titlePrefix, iconPostfix } = slots || {};
|
||||
@@ -98,7 +112,8 @@ const NavItem = memo<NavItemProps>(
|
||||
className={cx(styles.container, className)}
|
||||
clickable={!disabled}
|
||||
gap={8}
|
||||
height={36}
|
||||
height={description ? undefined : 36}
|
||||
paddingBlock={description ? 4 : undefined}
|
||||
paddingInline={4}
|
||||
variant={variant}
|
||||
onClick={(e) => {
|
||||
@@ -126,15 +141,24 @@ const NavItem = memo<NavItemProps>(
|
||||
{iconPostfix}
|
||||
<Flexbox horizontal align={'center'} flex={1} gap={8} style={{ overflow: 'hidden' }}>
|
||||
{titlePrefix}
|
||||
<Text
|
||||
color={textColor}
|
||||
style={{ flex: 1 }}
|
||||
ellipsis={{
|
||||
tooltipWhenOverflow: true,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Flexbox flex={1} gap={1} style={{ overflow: 'hidden' }}>
|
||||
<Text color={textColor} ellipsis={{ tooltipWhenOverflow: true }}>
|
||||
{title}
|
||||
</Text>
|
||||
{description}
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Text
|
||||
color={textColor}
|
||||
style={{ flex: 1 }}
|
||||
ellipsis={{
|
||||
tooltipWhenOverflow: true,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Flexbox
|
||||
horizontal
|
||||
align={'center'}
|
||||
|
||||
@@ -27,6 +27,7 @@ export default {
|
||||
'displayItems': 'Display Items',
|
||||
'duplicateLoading': 'Copying Topic...',
|
||||
'duplicateSuccess': 'Topic Copied Successfully',
|
||||
'failedStatusTip': 'This run hit an error — open it to take a look.',
|
||||
'favorite': 'Favorite',
|
||||
'filter.filter': 'Filter',
|
||||
'filter.groupMode.byProject': 'By project',
|
||||
@@ -44,6 +45,7 @@ export default {
|
||||
'groupTitle.byStatus.completed': 'Completed',
|
||||
'groupTitle.byStatus.failed': 'Failed',
|
||||
'groupTitle.byStatus.paused': 'Paused',
|
||||
'groupTitle.byStatus.pending': 'Needs attention',
|
||||
'groupTitle.byStatus.running': 'Running',
|
||||
'groupTitle.byStatus.waitingForHuman': 'Awaiting input',
|
||||
'groupTitle.byTime.month': 'This Month',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChatTopicMetadata, ChatTopicStatus } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
|
||||
import { Flexbox, Icon, Skeleton, Tag, Text, Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar, keyframes, useTheme } from 'antd-style';
|
||||
import { CheckCircle2, Hand, HashIcon, MessageSquareDashed } from 'lucide-react';
|
||||
import { CheckCircle2, Hand, HashIcon, MessageSquareDashed, TriangleAlert } from 'lucide-react';
|
||||
import { memo, Suspense, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -9,6 +9,7 @@ import DotsLoading from '@/components/DotsLoading';
|
||||
import RingLoadingIcon from '@/components/RingLoading';
|
||||
import { SESSION_CHAT_TOPIC_URL } from '@/const/url';
|
||||
import { isDesktop } from '@/const/version';
|
||||
import DirIcon from '@/features/ChatInput/RuntimeConfig/DirIcon';
|
||||
import NavItem from '@/features/NavPanel/components/NavItem';
|
||||
import { getPlatformIcon } from '@/routes/(main)/agent/channel/const';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
@@ -83,214 +84,252 @@ const cancelPendingSingleClick = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Last non-empty path segment — the folder name. Also yields the repo name for
|
||||
// a web github URL (".../owner/repo" → "repo").
|
||||
const getDirName = (path: string) => path.split('/').findLast(Boolean) || path;
|
||||
|
||||
interface TopicItemProps {
|
||||
active?: boolean;
|
||||
fav?: boolean;
|
||||
id?: string;
|
||||
metadata?: ChatTopicMetadata;
|
||||
/**
|
||||
* Show the topic's project directory as a second line under the title. Used by
|
||||
* the by-status grouping, where the row otherwise carries no project context
|
||||
* (by-project mode already puts the directory in the group header).
|
||||
*/
|
||||
showWorkingDirectory?: boolean;
|
||||
status?: ChatTopicStatus | null;
|
||||
threadId?: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, metadata, status }) => {
|
||||
const { t } = useTranslation('topic');
|
||||
const { isDarkMode } = useTheme();
|
||||
const activeAgentId = useAgentStore((s) => s.activeAgentId);
|
||||
// Heterogeneous agents (Claude Code, Codex, …) don't have the chat-style
|
||||
// topic semantics, so skip the default `#` placeholder icon for their rows.
|
||||
const isHeterogeneousAgent = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
|
||||
const addTab = useElectronStore((s) => s.addTab);
|
||||
const TopicItem = memo<TopicItemProps>(
|
||||
({ id, title, fav, active, threadId, metadata, status, showWorkingDirectory }) => {
|
||||
const { t } = useTranslation('topic');
|
||||
const { isDarkMode } = useTheme();
|
||||
const activeAgentId = useAgentStore((s) => s.activeAgentId);
|
||||
// Heterogeneous agents (Claude Code, Codex, …) don't have the chat-style
|
||||
// topic semantics, so skip the default `#` placeholder icon for their rows.
|
||||
const isHeterogeneousAgent = useAgentStore(agentSelectors.isCurrentAgentHeterogeneous);
|
||||
const addTab = useElectronStore((s) => s.addTab);
|
||||
|
||||
const loadingRingColor = isDarkMode
|
||||
? cssVar.colorWarningBorder
|
||||
: `color-mix(in srgb, ${cssVar.colorWarning} 45%, transparent)`;
|
||||
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;
|
||||
return SESSION_CHAT_TOPIC_URL(activeAgentId, id);
|
||||
}, [activeAgentId, id]);
|
||||
// Construct href for cmd+click support
|
||||
const href = useMemo(() => {
|
||||
if (!activeAgentId || !id) return undefined;
|
||||
return SESSION_CHAT_TOPIC_URL(activeAgentId, id);
|
||||
}, [activeAgentId, id]);
|
||||
|
||||
const [editing, isLoading] = useChatStore((s) => [
|
||||
id ? s.topicRenamingId === id : false,
|
||||
id ? s.topicLoadingIds.includes(id) : false,
|
||||
]);
|
||||
const [editing, isLoading] = useChatStore((s) => [
|
||||
id ? s.topicRenamingId === id : false,
|
||||
id ? s.topicLoadingIds.includes(id) : false,
|
||||
]);
|
||||
|
||||
const isUnreadCompleted = useChatStore(
|
||||
id ? operationSelectors.isTopicUnreadCompleted(id) : () => false,
|
||||
);
|
||||
|
||||
const {
|
||||
focusTopicPopup,
|
||||
navigateToTopic,
|
||||
isInAgentSubRoute,
|
||||
isInTopicContextRoute,
|
||||
routeTopicId,
|
||||
urlTopicId,
|
||||
} = useTopicNavigation();
|
||||
const isRouteTopicActive = Boolean(id && routeTopicId === id && isInTopicContextRoute);
|
||||
const isTopicActive = Boolean(
|
||||
(active || isRouteTopicActive) && !threadId && (!isInAgentSubRoute || isRouteTopicActive),
|
||||
);
|
||||
|
||||
const shouldShowThreadList = Boolean(id && id === urlTopicId);
|
||||
|
||||
const toggleEditing = useCallback(
|
||||
(visible?: boolean) => {
|
||||
useChatStore.setState({ topicRenamingId: visible && id ? id : '' });
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (editing) return;
|
||||
if (isDesktop) {
|
||||
cancelPendingSingleClick();
|
||||
pendingSingleClickTimer = setTimeout(() => {
|
||||
pendingSingleClickTimer = null;
|
||||
void navigateToTopic(id);
|
||||
}, 250);
|
||||
} else {
|
||||
void navigateToTopic(id);
|
||||
}
|
||||
}, [editing, id, navigateToTopic]);
|
||||
|
||||
const handleDoubleClick = useCallback(async () => {
|
||||
if (!id || !activeAgentId || !isDesktop) return;
|
||||
cancelPendingSingleClick();
|
||||
if (await focusTopicPopup(id)) {
|
||||
void navigateToTopic(id, { skipPopupFocus: true });
|
||||
return;
|
||||
}
|
||||
addTab(SESSION_CHAT_TOPIC_URL(activeAgentId, id));
|
||||
void navigateToTopic(id);
|
||||
}, [id, activeAgentId, addTab, focusTopicPopup, navigateToTopic]);
|
||||
|
||||
const { dropdownMenu } = useTopicItemDropdownMenu({
|
||||
fav,
|
||||
id,
|
||||
status,
|
||||
title,
|
||||
});
|
||||
|
||||
const isCompleted = status === 'completed';
|
||||
const isRunning = status === 'running';
|
||||
const isWaitingForHuman = status === 'waitingForHuman';
|
||||
|
||||
const hasUnread = id && isUnreadCompleted;
|
||||
const unreadIcon = (
|
||||
<span className={styles.unreadWrapper}>
|
||||
<span className={styles.unreadRipple} />
|
||||
<span className={styles.unreadDot} />
|
||||
</span>
|
||||
);
|
||||
|
||||
// For default topic (no id)
|
||||
if (!id) {
|
||||
return (
|
||||
<NavItem
|
||||
active={Boolean(active && !isInAgentSubRoute && !isInTopicContextRoute)}
|
||||
icon={
|
||||
isLoading ? (
|
||||
<RingLoadingIcon
|
||||
ringColor={loadingRingColor}
|
||||
size={14}
|
||||
style={{ color: cssVar.colorWarning }}
|
||||
/>
|
||||
) : (
|
||||
<Icon color={cssVar.colorTextDescription} icon={MessageSquareDashed} size={'small'} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Flexbox horizontal align={'center'} flex={1} gap={6}>
|
||||
{t('defaultTitle')}
|
||||
<Tag
|
||||
size={'small'}
|
||||
style={{
|
||||
color: cssVar.colorTextDescription,
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{t('temp')}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
const isUnreadCompleted = useChatStore(
|
||||
id ? operationSelectors.isTopicUnreadCompleted(id) : () => false,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox data-testid="topic-item" style={{ position: 'relative' }}>
|
||||
<NavItem
|
||||
actions={<Actions dropdownMenu={dropdownMenu} />}
|
||||
active={isTopicActive}
|
||||
contextMenuItems={dropdownMenu}
|
||||
disabled={editing}
|
||||
href={href}
|
||||
title={title === '...' ? <DotsLoading gap={3} size={4} /> : title}
|
||||
icon={(() => {
|
||||
if (isWaitingForHuman) {
|
||||
return <Icon icon={Hand} size={'small'} style={{ color: cssVar.colorInfo }} />;
|
||||
}
|
||||
if (isLoading || isRunning) {
|
||||
return (
|
||||
const {
|
||||
focusTopicPopup,
|
||||
navigateToTopic,
|
||||
isInAgentSubRoute,
|
||||
isInTopicContextRoute,
|
||||
routeTopicId,
|
||||
urlTopicId,
|
||||
} = useTopicNavigation();
|
||||
const isRouteTopicActive = Boolean(id && routeTopicId === id && isInTopicContextRoute);
|
||||
const isTopicActive = Boolean(
|
||||
(active || isRouteTopicActive) && !threadId && (!isInAgentSubRoute || isRouteTopicActive),
|
||||
);
|
||||
|
||||
const shouldShowThreadList = Boolean(id && id === urlTopicId);
|
||||
|
||||
const toggleEditing = useCallback(
|
||||
(visible?: boolean) => {
|
||||
useChatStore.setState({ topicRenamingId: visible && id ? id : '' });
|
||||
},
|
||||
[id],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (editing) return;
|
||||
if (isDesktop) {
|
||||
cancelPendingSingleClick();
|
||||
pendingSingleClickTimer = setTimeout(() => {
|
||||
pendingSingleClickTimer = null;
|
||||
void navigateToTopic(id);
|
||||
}, 250);
|
||||
} else {
|
||||
void navigateToTopic(id);
|
||||
}
|
||||
}, [editing, id, navigateToTopic]);
|
||||
|
||||
const handleDoubleClick = useCallback(async () => {
|
||||
if (!id || !activeAgentId || !isDesktop) return;
|
||||
cancelPendingSingleClick();
|
||||
if (await focusTopicPopup(id)) {
|
||||
void navigateToTopic(id, { skipPopupFocus: true });
|
||||
return;
|
||||
}
|
||||
addTab(SESSION_CHAT_TOPIC_URL(activeAgentId, id));
|
||||
void navigateToTopic(id);
|
||||
}, [id, activeAgentId, addTab, focusTopicPopup, navigateToTopic]);
|
||||
|
||||
const { dropdownMenu } = useTopicItemDropdownMenu({
|
||||
fav,
|
||||
id,
|
||||
status,
|
||||
title,
|
||||
});
|
||||
|
||||
const isCompleted = status === 'completed';
|
||||
const isFailed = status === 'failed';
|
||||
const isRunning = status === 'running';
|
||||
const isWaitingForHuman = status === 'waitingForHuman';
|
||||
|
||||
// By-status grouping mixes topics from different projects, so surface each
|
||||
// topic's working directory as a muted second line. Data is already on the
|
||||
// topic (`metadata.workingDirectory`) — no fetch. On web it's a github repo
|
||||
// URL; on desktop a local path shown with a plain folder icon.
|
||||
const workingDirectory = metadata?.workingDirectory;
|
||||
const workingDirectoryNode =
|
||||
showWorkingDirectory && workingDirectory ? (
|
||||
<Flexbox horizontal align={'center'} gap={4} style={{ overflow: 'hidden' }}>
|
||||
<DirIcon repoType={isDesktop ? undefined : 'github'} size={12} />
|
||||
<Text ellipsis fontSize={11} style={{ color: cssVar.colorTextDescription }}>
|
||||
{getDirName(workingDirectory)}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
) : undefined;
|
||||
|
||||
const hasUnread = id && isUnreadCompleted;
|
||||
const unreadIcon = (
|
||||
<span className={styles.unreadWrapper}>
|
||||
<span className={styles.unreadRipple} />
|
||||
<span className={styles.unreadDot} />
|
||||
</span>
|
||||
);
|
||||
|
||||
// For default topic (no id)
|
||||
if (!id) {
|
||||
return (
|
||||
<NavItem
|
||||
active={Boolean(active && !isInAgentSubRoute && !isInTopicContextRoute)}
|
||||
titleColor={cssVar.colorText}
|
||||
icon={
|
||||
isLoading ? (
|
||||
<RingLoadingIcon
|
||||
ringColor={loadingRingColor}
|
||||
size={14}
|
||||
style={{ color: cssVar.colorWarning }}
|
||||
/>
|
||||
);
|
||||
) : (
|
||||
<Icon color={cssVar.colorTextDescription} icon={MessageSquareDashed} size={'small'} />
|
||||
)
|
||||
}
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<Icon
|
||||
icon={CheckCircle2}
|
||||
title={
|
||||
<Flexbox horizontal align={'center'} flex={1} gap={6}>
|
||||
{t('defaultTitle')}
|
||||
<Tag
|
||||
size={'small'}
|
||||
style={{ color: cssVar.colorTextDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (hasUnread) return unreadIcon;
|
||||
if (metadata?.bot?.platform) {
|
||||
const ProviderIcon = getPlatformIcon(metadata.bot!.platform);
|
||||
if (ProviderIcon) {
|
||||
return <ProviderIcon color={cssVar.colorTextDescription} size={16} />;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Icon
|
||||
icon={HashIcon}
|
||||
size={'small'}
|
||||
style={{
|
||||
color: cssVar.colorTextDescription,
|
||||
// Heterogeneous agents (Claude Code, Codex, …) have no chat-style
|
||||
// topic semantics, so suppress the `#` glyph while keeping its
|
||||
// box so the title stays aligned with sibling rows.
|
||||
visibility: isHeterogeneousAgent ? 'hidden' : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => void handleDoubleClick()}
|
||||
/>
|
||||
<Editing id={id} title={title} toggleEditing={toggleEditing} />
|
||||
{shouldShowThreadList && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flexbox gap={8} paddingBlock={8} paddingInline={24} width={'100%'}>
|
||||
<Skeleton.Button active size={'small'} style={{ height: 18, width: '100%' }} />
|
||||
<Skeleton.Button active size={'small'} style={{ height: 18, width: '100%' }} />
|
||||
style={{
|
||||
color: cssVar.colorTextDescription,
|
||||
fontSize: 10,
|
||||
}}
|
||||
>
|
||||
{t('temp')}
|
||||
</Tag>
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
<ThreadList topicId={id} />
|
||||
</Suspense>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox data-testid="topic-item" style={{ position: 'relative' }}>
|
||||
<NavItem
|
||||
actions={<Actions dropdownMenu={dropdownMenu} />}
|
||||
active={isTopicActive}
|
||||
contextMenuItems={dropdownMenu}
|
||||
description={workingDirectoryNode}
|
||||
disabled={editing}
|
||||
href={href}
|
||||
title={title === '...' ? <DotsLoading gap={3} size={4} /> : title}
|
||||
titleColor={cssVar.colorText}
|
||||
icon={(() => {
|
||||
if (isWaitingForHuman) {
|
||||
return <Icon icon={Hand} size={'small'} style={{ color: cssVar.colorInfo }} />;
|
||||
}
|
||||
if (isLoading || isRunning) {
|
||||
return (
|
||||
<RingLoadingIcon
|
||||
ringColor={loadingRingColor}
|
||||
size={14}
|
||||
style={{ color: cssVar.colorWarning }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isFailed) {
|
||||
return (
|
||||
<Tooltip title={t('failedStatusTip')}>
|
||||
<Icon icon={TriangleAlert} size={'small'} style={{ color: cssVar.colorError }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<Icon
|
||||
icon={CheckCircle2}
|
||||
size={'small'}
|
||||
style={{ color: cssVar.colorTextDescription }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (hasUnread) return unreadIcon;
|
||||
if (metadata?.bot?.platform) {
|
||||
const ProviderIcon = getPlatformIcon(metadata.bot!.platform);
|
||||
if (ProviderIcon) {
|
||||
return <ProviderIcon color={cssVar.colorTextDescription} size={16} />;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Icon
|
||||
icon={HashIcon}
|
||||
size={'small'}
|
||||
style={{
|
||||
color: cssVar.colorTextDescription,
|
||||
// Heterogeneous agents (Claude Code, Codex, …) have no chat-style
|
||||
// topic semantics, so suppress the `#` glyph while keeping its
|
||||
// box so the title stays aligned with sibling rows.
|
||||
visibility: isHeterogeneousAgent ? 'hidden' : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => void handleDoubleClick()}
|
||||
/>
|
||||
<Editing id={id} title={title} toggleEditing={toggleEditing} />
|
||||
{shouldShowThreadList && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flexbox gap={8} paddingBlock={8} paddingInline={24} width={'100%'}>
|
||||
<Skeleton.Button active size={'small'} style={{ height: 18, width: '100%' }} />
|
||||
<Skeleton.Button active size={'small'} style={{ height: 18, width: '100%' }} />
|
||||
</Flexbox>
|
||||
}
|
||||
>
|
||||
<ThreadList topicId={id} />
|
||||
</Suspense>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default TopicItem;
|
||||
|
||||
+6
-6
@@ -3,13 +3,12 @@ import { cssVar } from 'antd-style';
|
||||
import {
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
CircleAlert,
|
||||
CircleDot,
|
||||
Hand,
|
||||
Loader,
|
||||
type LucideIcon,
|
||||
PauseCircle,
|
||||
Star,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
@@ -17,17 +16,17 @@ import TopicItem from '../../List/Item';
|
||||
import { type GroupItemComponentProps } from '../GroupedAccordion';
|
||||
|
||||
// Map each status-group id to its icon + color, mirroring the per-topic status
|
||||
// glyphs in `List/Item`. `favorite` is the synthetic group split out by
|
||||
// `buildGroupedTopics`, so it gets a star.
|
||||
// glyphs in `List/Item`. `pending` collapses the attention-needing states
|
||||
// (awaiting input / failed / unread completion) into one group; `favorite` is
|
||||
// the synthetic group split out by `buildGroupedTopics`, so it gets a star.
|
||||
const STATUS_ICON: Record<string, { color: string; icon: LucideIcon }> = {
|
||||
active: { color: cssVar.colorTextTertiary, icon: CircleDot },
|
||||
archived: { color: cssVar.colorTextDescription, icon: Archive },
|
||||
completed: { color: cssVar.colorTextDescription, icon: CheckCircle2 },
|
||||
failed: { color: cssVar.colorError, icon: XCircle },
|
||||
favorite: { color: cssVar.colorWarning, icon: Star },
|
||||
paused: { color: cssVar.colorTextDescription, icon: PauseCircle },
|
||||
pending: { color: cssVar.colorWarning, icon: CircleAlert },
|
||||
running: { color: cssVar.colorWarning, icon: Loader },
|
||||
waitingForHuman: { color: cssVar.colorInfo, icon: Hand },
|
||||
};
|
||||
|
||||
const GroupItem = memo<GroupItemComponentProps>(({ group, activeTopicId, activeThreadId }) => {
|
||||
@@ -55,6 +54,7 @@ const GroupItem = memo<GroupItemComponentProps>(({ group, activeTopicId, activeT
|
||||
<Flexbox gap={1} paddingBlock={1}>
|
||||
{children.map((topic) => (
|
||||
<TopicItem
|
||||
showWorkingDirectory
|
||||
active={activeTopicId === topic.id}
|
||||
fav={topic.favorite}
|
||||
id={topic.id}
|
||||
|
||||
+26
-3
@@ -14,6 +14,7 @@ import { agentByIdSelectors } from '@/store/agent/selectors';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
bar: css`
|
||||
container: runtimebar / inline-size;
|
||||
padding-block: 0;
|
||||
padding-inline: 4px;
|
||||
`,
|
||||
@@ -21,6 +22,7 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
cursor: default;
|
||||
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
@@ -30,6 +32,27 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
|
||||
font-size: 12px;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
white-space: nowrap;
|
||||
`,
|
||||
// On a narrow bar the "full access" badge collapses to just its icon — the
|
||||
// hover tooltip still spells it out. Saves a chunk of horizontal space that
|
||||
// the truncating workspace cluster can use instead.
|
||||
fullAccessLabel: css`
|
||||
@container runtimebar (width < 600px) {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
// Mirror RuntimeConfig: the workspace cluster shrinks then scrolls horizontally
|
||||
// (hidden scrollbar) instead of wrapping each chip's text on narrow screens.
|
||||
leftGroup: css`
|
||||
scrollbar-width: none;
|
||||
overflow: auto hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
@@ -46,7 +69,7 @@ const WorkingDirectoryBar = memo(() => {
|
||||
if (!agentId) return null;
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Flexbox horizontal align={'center'} className={styles.leftGroup} gap={4}>
|
||||
<WorkspaceControls alwaysShowWorkspace agentId={agentId} />
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
@@ -65,13 +88,13 @@ const WorkingDirectoryBar = memo(() => {
|
||||
const fullAccessBadge = (
|
||||
<div className={styles.fullAccess}>
|
||||
<Icon icon={CircleAlertIcon} size={14} />
|
||||
<span>{tChat('heteroAgent.fullAccess.label')}</span>
|
||||
<span className={styles.fullAccessLabel}>{tChat('heteroAgent.fullAccess.label')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align={'center'} className={styles.bar} justify={'space-between'}>
|
||||
<Flexbox horizontal align={'center'} gap={4}>
|
||||
<Flexbox horizontal align={'center'} className={styles.leftGroup} gap={4}>
|
||||
<WorkspaceControls alwaysShowWorkspace agentId={agentId} />
|
||||
</Flexbox>
|
||||
<Tooltip title={tChat('heteroAgent.fullAccess.tooltip')}>{fullAccessBadge}</Tooltip>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { ChatTopicStatus } from '@lobechat/types';
|
||||
import { Flexbox, Icon, Skeleton, Tag } from '@lobehub/ui';
|
||||
import { Flexbox, Icon, Skeleton, Tag, Tooltip } from '@lobehub/ui';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { CheckCircle2, Hand, HashIcon, Loader2Icon, MessageSquareDashed } from 'lucide-react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
Hand,
|
||||
HashIcon,
|
||||
Loader2Icon,
|
||||
MessageSquareDashed,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import { AnimatePresence, m } from 'motion/react';
|
||||
import { memo, Suspense, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -148,6 +155,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, stat
|
||||
});
|
||||
|
||||
const isCompleted = status === 'completed';
|
||||
const isFailed = status === 'failed';
|
||||
const isRunning = status === 'running';
|
||||
const isWaitingForHuman = status === 'waitingForHuman';
|
||||
|
||||
@@ -198,6 +206,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, stat
|
||||
return (
|
||||
<NavItem
|
||||
active={active}
|
||||
titleColor={cssVar.colorText}
|
||||
icon={
|
||||
isLoading ? (
|
||||
<Icon spin color={cssVar.colorWarning} icon={Loader2Icon} size={'small'} />
|
||||
@@ -233,6 +242,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, stat
|
||||
disabled={editing}
|
||||
href={!editing ? href : undefined}
|
||||
title={title === '...' ? <DotsLoading gap={3} size={4} /> : title}
|
||||
titleColor={cssVar.colorText}
|
||||
icon={(() => {
|
||||
if (isWaitingForHuman) {
|
||||
return <Icon icon={Hand} size={'small'} style={{ color: cssVar.colorInfo }} />;
|
||||
@@ -242,6 +252,13 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, stat
|
||||
<Icon spin icon={Loader2Icon} size={'small'} style={{ color: cssVar.colorWarning }} />
|
||||
);
|
||||
}
|
||||
if (isFailed) {
|
||||
return (
|
||||
<Tooltip title={t('failedStatusTip')}>
|
||||
<Icon icon={TriangleAlert} size={'small'} style={{ color: cssVar.colorError }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<Icon
|
||||
|
||||
@@ -9,7 +9,7 @@ import { FolderOpenIcon, FolderPlusIcon, XIcon } from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { renderDirIcon } from '@/features/ChatInput/RuntimeConfig/dirIcon';
|
||||
import DirIcon from '@/features/ChatInput/RuntimeConfig/DirIcon';
|
||||
import { lambdaQuery } from '@/libs/trpc/client';
|
||||
import { electronSystemService } from '@/services/electron/system';
|
||||
import { nextWorkingDirs } from '@/store/device';
|
||||
@@ -231,7 +231,7 @@ const DeviceDetailPanel = memo<DeviceDetailPanelProps>(({ device, isCurrent, onC
|
||||
renderItem={(item: { id: string; repoType?: 'git' | 'github' }) => (
|
||||
<SortableList.Item className={styles.recentItem} id={item.id} variant={'filled'}>
|
||||
<SortableList.DragHandle />
|
||||
{renderDirIcon(item.repoType)}
|
||||
<DirIcon repoType={item.repoType} />
|
||||
<Text className={styles.path} title={item.id}>
|
||||
{item.id}
|
||||
</Text>
|
||||
|
||||
@@ -20,6 +20,7 @@ import type React from 'react';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AddSkillButton from '@/features/SkillStore/SkillList/AddSkillButton';
|
||||
import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
|
||||
import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
@@ -332,6 +333,7 @@ const SkillList = memo<SkillListProps>(
|
||||
return (
|
||||
<Center className={styles.container} paddingBlock={48}>
|
||||
<Empty description={t('tab.skillDesc')} icon={SkillsIcon} title={t('tab.skillEmpty')} />
|
||||
<AddSkillButton />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -559,6 +561,9 @@ const SkillList = memo<SkillListProps>(
|
||||
renderUserAgentSkills(),
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<AddSkillButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { Button, DropdownMenu, Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { GithubIcon } from '@lobehub/ui/icons';
|
||||
import { Button, Icon } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { ChevronDown, FileArchive, Grid2x2Plus, Link, Store } from 'lucide-react';
|
||||
import { Plus, Store } from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AddConnectorModal } from '@/features/Connectors';
|
||||
import ImportFromGithubModal from '@/features/SkillStore/SkillList/ImportFromGithubModal';
|
||||
import ImportFromUrlModal from '@/features/SkillStore/SkillList/ImportFromUrlModal';
|
||||
import UploadSkillModal from '@/features/SkillStore/SkillList/UploadSkillModal';
|
||||
import NavHeader from '@/features/NavHeader';
|
||||
import { createSkillStoreModal } from '@/features/SkillStore';
|
||||
import { useToolStore } from '@/store/tool';
|
||||
@@ -89,9 +85,6 @@ const Page = memo(() => {
|
||||
const [selected, setSelected] = useState<SelectedTool | null>(null);
|
||||
const [viewMode, setViewMode] = useState<SkillViewMode>('connector');
|
||||
const [showAddConnector, setShowAddConnector] = useState(false);
|
||||
const [showUrlModal, setUrlModal] = useState(false);
|
||||
const [showGithubModal, setGithubModal] = useState(false);
|
||||
const [showUploadModal, setUploadModal] = useState(false);
|
||||
|
||||
// Data sources for auto-select
|
||||
const builtinTools = useToolStore((s) => s.builtinTools, isEqual);
|
||||
@@ -100,6 +93,7 @@ const Page = memo(() => {
|
||||
(s) => builtinToolSelectors.installedAllMetaList(s).map((tool) => tool.identifier),
|
||||
isEqual,
|
||||
);
|
||||
|
||||
// Auto-select first item when view changes or on load
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
@@ -153,40 +147,18 @@ const Page = memo(() => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6 }} onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu
|
||||
nativeButton={false}
|
||||
placement="bottomRight"
|
||||
items={[
|
||||
{
|
||||
icon: <Icon icon={Link} />,
|
||||
key: 'importUrl',
|
||||
label: <Flexbox gap={2}><span>{t('tab.importFromUrl')}</span><Text style={{ fontSize: 12 }} type="secondary">{t('tab.importFromUrl.desc')}</Text></Flexbox>,
|
||||
onClick: () => setUrlModal(true),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={GithubIcon} />,
|
||||
key: 'importGithub',
|
||||
label: <Flexbox gap={2}><span>{t('tab.importFromGithub')}</span><Text style={{ fontSize: 12 }} type="secondary">{t('tab.importFromGithub.desc')}</Text></Flexbox>,
|
||||
onClick: () => setGithubModal(true),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={FileArchive} />,
|
||||
key: 'uploadZip',
|
||||
label: <Flexbox gap={2}><span>{t('tab.uploadZip')}</span><Text style={{ fontSize: 12 }} type="secondary">{t('tab.uploadZip.desc')}</Text></Flexbox>,
|
||||
onClick: () => setUploadModal(true),
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
icon: <Icon icon={Grid2x2Plus} />,
|
||||
key: 'addConnector',
|
||||
label: <Flexbox gap={2}><span>{t('connector.add.title', { defaultValue: 'Add Custom Connector', ns: 'tool' })}</span></Flexbox>,
|
||||
onClick: () => setShowAddConnector(true),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button icon={Grid2x2Plus} size="small" />
|
||||
</DropdownMenu>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{viewMode === 'connector' && (
|
||||
<Button
|
||||
icon={<Icon icon={Plus} />}
|
||||
size="small"
|
||||
title={t('connector.add.title', {
|
||||
defaultValue: 'Add custom connector',
|
||||
ns: 'tool',
|
||||
})}
|
||||
onClick={() => setShowAddConnector(true)}
|
||||
/>
|
||||
)}
|
||||
<Button icon={<Icon icon={Store} />} size="small" onClick={handleOpenStore} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,9 +179,6 @@ const Page = memo(() => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ImportFromUrlModal open={showUrlModal} onOpenChange={setUrlModal} />
|
||||
<ImportFromGithubModal open={showGithubModal} onOpenChange={setGithubModal} />
|
||||
<UploadSkillModal open={showUploadModal} onOpenChange={setUploadModal} />
|
||||
<AddConnectorModal open={showAddConnector} onClose={() => setShowAddConnector(false)} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -95,6 +95,7 @@ import { markdownToTxt } from '@/utils/markdownToTxt';
|
||||
import { resolveDeviceAccessPolicy } from './deviceAccessPolicy';
|
||||
import { buildAllowedBuiltinTools, isDeviceToolIdentifier } from './deviceToolRegistry';
|
||||
import { ingestAttachment } from './ingestAttachment';
|
||||
import { resolveDeviceWorkingDirectory } from './resolveDeviceWorkingDirectory';
|
||||
import { isWorkspaceCacheFresh, upsertWorkspaceScan } from './workspaceInitCache';
|
||||
|
||||
const log = debug('lobe-server:ai-agent-service');
|
||||
@@ -333,15 +334,15 @@ export class AiAgentService {
|
||||
const device = await deviceModel.findByDeviceId(activeDeviceId);
|
||||
if (!device) return empty;
|
||||
|
||||
// The bound project root (unified precedence, mirrors hetero dispatch):
|
||||
// topic override > agent's per-device choice > device default.
|
||||
// This is the directory we scan.
|
||||
// The bound project root we scan — resolved via the shared precedence
|
||||
// helper so it cannot drift from hetero dispatch / topic backfill.
|
||||
const topic = await this.topicModel.findById(topicId);
|
||||
const boundCwd =
|
||||
topic?.metadata?.workingDirectory ||
|
||||
agencyConfig?.workingDirByDevice?.[activeDeviceId] ||
|
||||
device.defaultCwd ||
|
||||
undefined;
|
||||
const boundCwd = resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: device.defaultCwd,
|
||||
deviceId: activeDeviceId,
|
||||
topicWorkingDirectory: topic?.metadata?.workingDirectory,
|
||||
workingDirByDevice: agencyConfig?.workingDirByDevice,
|
||||
});
|
||||
if (!boundCwd) return empty;
|
||||
|
||||
const workingDirs = device.workingDirs ?? [];
|
||||
@@ -725,6 +726,7 @@ export class AiAgentService {
|
||||
|
||||
// 3. Handle topic creation: if no topicId provided, create a new topic; otherwise reuse existing
|
||||
let topicId = appContext?.topicId;
|
||||
const isNewTopic = !topicId;
|
||||
const topicBoundDeviceId = requestedDeviceId;
|
||||
if (!topicId) {
|
||||
if (resume) {
|
||||
@@ -1083,16 +1085,23 @@ export class AiAgentService {
|
||||
const boundDevice = await new DeviceModel(this.db, this.userId).findByDeviceId(
|
||||
dispatchDeviceId,
|
||||
);
|
||||
// Working-directory precedence (unified across client + server):
|
||||
// topic override > agent's per-device choice > device default.
|
||||
// An existing topic carries its pinned cwd in `metadata.workingDirectory`;
|
||||
// `initialTopicMetadata` is only populated for a brand-new topic.
|
||||
const deviceCwd =
|
||||
topic?.metadata?.workingDirectory ||
|
||||
appContext?.initialTopicMetadata?.workingDirectory ||
|
||||
agentConfig.agencyConfig?.workingDirByDevice?.[dispatchDeviceId] ||
|
||||
boundDevice?.defaultCwd ||
|
||||
undefined;
|
||||
// Resolve via the shared precedence helper so dispatch, workspace-init,
|
||||
// and the new-topic backfill below all agree on the cwd.
|
||||
const deviceCwd = resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: boundDevice?.defaultCwd,
|
||||
deviceId: dispatchDeviceId,
|
||||
initialWorkingDirectory: appContext?.initialTopicMetadata?.workingDirectory,
|
||||
topicWorkingDirectory: topic?.metadata?.workingDirectory,
|
||||
workingDirByDevice: agentConfig.agencyConfig?.workingDirByDevice,
|
||||
});
|
||||
|
||||
// A brand-new topic has no pinned cwd yet: the directory was only
|
||||
// recorded at agent level (`workingDirByDevice`) when no topic existed.
|
||||
// Persist the resolved cwd onto the topic so the sidebar groups it
|
||||
// under the right project and the next turn reuses the same directory.
|
||||
if (isNewTopic && deviceCwd && deviceCwd !== topic?.metadata?.workingDirectory) {
|
||||
await this.topicModel.updateMetadata(topicId, { workingDirectory: deviceCwd });
|
||||
}
|
||||
|
||||
// A device is the user's own persistent machine — build a
|
||||
// device-specific context instead of reusing the cloud-sandbox one
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveDeviceWorkingDirectory } from './resolveDeviceWorkingDirectory';
|
||||
|
||||
describe('resolveDeviceWorkingDirectory', () => {
|
||||
it('prefers the existing topic override above everything else', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
deviceId: 'device-1',
|
||||
initialWorkingDirectory: '/initial',
|
||||
topicWorkingDirectory: '/topic',
|
||||
workingDirByDevice: { 'device-1': '/per-device' },
|
||||
}),
|
||||
).toBe('/topic');
|
||||
});
|
||||
|
||||
it('falls back to the brand-new-topic initial metadata when no topic override', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
deviceId: 'device-1',
|
||||
initialWorkingDirectory: '/initial',
|
||||
workingDirByDevice: { 'device-1': '/per-device' },
|
||||
}),
|
||||
).toBe('/initial');
|
||||
});
|
||||
|
||||
it("uses the agent's per-device pick when no topic/initial cwd (the remote-CC new-topic case)", () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
deviceId: 'device-1',
|
||||
workingDirByDevice: { 'device-1': '/per-device' },
|
||||
}),
|
||||
).toBe('/per-device');
|
||||
});
|
||||
|
||||
it('only matches the per-device pick for the dispatched device', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
deviceId: 'device-2',
|
||||
workingDirByDevice: { 'device-1': '/per-device' },
|
||||
}),
|
||||
).toBe('/default');
|
||||
});
|
||||
|
||||
it('falls back to the device default last', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
deviceId: 'device-1',
|
||||
workingDirByDevice: {},
|
||||
}),
|
||||
).toBe('/default');
|
||||
});
|
||||
|
||||
it('returns undefined when nothing resolves', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceId: 'device-1',
|
||||
workingDirByDevice: {},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores the per-device map when no deviceId is given', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: '/default',
|
||||
workingDirByDevice: { 'device-1': '/per-device' },
|
||||
}),
|
||||
).toBe('/default');
|
||||
});
|
||||
|
||||
it('treats null/undefined inputs as absent', () => {
|
||||
expect(
|
||||
resolveDeviceWorkingDirectory({
|
||||
deviceDefaultCwd: null,
|
||||
deviceId: 'device-1',
|
||||
topicWorkingDirectory: undefined,
|
||||
workingDirByDevice: null,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Resolve the working directory for a device-bound run.
|
||||
*
|
||||
* Single source of truth for cwd precedence, shared by every server site that
|
||||
* needs it (hetero dispatch, workspace-init scan, new-topic backfill) so they
|
||||
* cannot drift. Mirrors the client picker's write rules in
|
||||
* `useCommitWorkingDirectory`:
|
||||
*
|
||||
* topic override > brand-new-topic initial metadata > agent's per-device
|
||||
* choice > device default.
|
||||
*
|
||||
* - `topicWorkingDirectory` — an existing topic's pinned cwd
|
||||
* (`topic.metadata.workingDirectory`); always wins once a conversation exists.
|
||||
* - `initialWorkingDirectory` — only populated for a brand-new topic
|
||||
* (`appContext.initialTopicMetadata.workingDirectory`, e.g. the primary repo).
|
||||
* - `workingDirByDevice[deviceId]` — the agent's per-device pick from the picker
|
||||
* when no topic existed yet.
|
||||
* - `deviceDefaultCwd` — the device's user-configured default.
|
||||
*/
|
||||
export const resolveDeviceWorkingDirectory = (params: {
|
||||
deviceDefaultCwd?: string | null;
|
||||
deviceId?: string;
|
||||
initialWorkingDirectory?: string;
|
||||
topicWorkingDirectory?: string;
|
||||
workingDirByDevice?: Record<string, string> | null;
|
||||
}): string | undefined =>
|
||||
params.topicWorkingDirectory ||
|
||||
params.initialWorkingDirectory ||
|
||||
(params.deviceId ? params.workingDirByDevice?.[params.deviceId] : undefined) ||
|
||||
params.deviceDefaultCwd ||
|
||||
undefined;
|
||||
@@ -461,5 +461,40 @@ describe('topicSelectors', () => {
|
||||
const totalChildren = grouped.reduce((sum, g) => sum + g.children.length, 0);
|
||||
expect(totalChildren).toBe(3);
|
||||
});
|
||||
|
||||
it('should place the pending group right below favorites in byStatus mode', () => {
|
||||
const state = createStateWithTopics([
|
||||
{
|
||||
id: 'fav',
|
||||
title: 'Fav',
|
||||
favorite: true,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'failed',
|
||||
title: 'Failed',
|
||||
favorite: false,
|
||||
status: 'failed',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
title: 'Active',
|
||||
favorite: false,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
const grouped = topicSelectors.groupedTopicsForSidebar(20, 'updatedAt', 'byStatus')(state);
|
||||
|
||||
// favorites stay pinned at the top; pending follows right below, then the rest
|
||||
expect(grouped.map((g) => g.id)).toEqual(['favorite', 'pending', 'active']);
|
||||
expect(grouped[1].children.map((t) => t.id)).toEqual(['failed']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,7 @@ const getGroupFn = (
|
||||
groupMode: TopicGroupMode,
|
||||
sortBy: TopicSortBy,
|
||||
loadingTopicIds?: ReadonlySet<string>,
|
||||
unreadTopicIds?: ReadonlySet<string>,
|
||||
) => {
|
||||
const field: 'createdAt' | 'updatedAt' = sortBy === 'createdAt' ? 'createdAt' : 'updatedAt';
|
||||
if (groupMode === 'byProject') {
|
||||
@@ -134,7 +135,7 @@ const getGroupFn = (
|
||||
}
|
||||
if (groupMode === 'byStatus') {
|
||||
return (topics: ChatTopic[]) =>
|
||||
groupTopicsByStatus(topics, field, loadingTopicIds).map((group) => ({
|
||||
groupTopicsByStatus(topics, field, loadingTopicIds, unreadTopicIds).map((group) => ({
|
||||
...group,
|
||||
title: t(`groupTitle.byStatus.${group.id}` as any, { ns: 'topic' }),
|
||||
}));
|
||||
@@ -152,6 +153,9 @@ const buildGroupedTopics = (
|
||||
const favTopics = topics.filter((topic) => topic.favorite);
|
||||
const unfavTopics = topics.filter((topic) => !topic.favorite);
|
||||
|
||||
// Favorites stay pinned at the very top. The "needs attention" bucket
|
||||
// (byStatus mode only) follows right below, ahead of the remaining status
|
||||
// groups, since groupTopicsByStatus emits `pending` first (STATUS_GROUP_ORDER).
|
||||
return favTopics.length > 0
|
||||
? [
|
||||
{
|
||||
@@ -177,10 +181,19 @@ const groupedTopicsForSidebar =
|
||||
(s: ChatStoreState): GroupedTopic[] => {
|
||||
const limitedTopics = displayTopicsForSidebar(pageSize, sortBy)(s);
|
||||
if (!limitedTopics) return [];
|
||||
// Topics actively streaming on this client surface under "running" even
|
||||
// though their persisted status is still active — see resolveStatusBucket.
|
||||
// Topics actively streaming on this client surface under "running", and
|
||||
// topics with an unread completion surface under "pending", even though
|
||||
// their persisted status says otherwise — see resolveStatusBucket. Both are
|
||||
// client-only states the server can't see.
|
||||
const loadingTopicIds = groupMode === 'byStatus' ? new Set(s.topicLoadingIds) : undefined;
|
||||
return buildGroupedTopics(limitedTopics, getGroupFn(groupMode, sortBy, loadingTopicIds));
|
||||
const unreadTopicIds =
|
||||
groupMode === 'byStatus'
|
||||
? new Set(Object.values(s.unreadCompletedTopicsByAgent).flatMap((set) => [...set]))
|
||||
: undefined;
|
||||
return buildGroupedTopics(
|
||||
limitedTopics,
|
||||
getGroupFn(groupMode, sortBy, loadingTopicIds, unreadTopicIds),
|
||||
);
|
||||
};
|
||||
|
||||
const hasMoreTopics = (s: ChatStoreState): boolean => {
|
||||
|
||||
Reference in New Issue
Block a user