Compare commits

..

4 Commits

Author SHA1 Message Date
ONLY-yours 0afd5798ab fix(cli): fix repo name and add all 4 platform matrix targets
- fix REPO: lobehub/lobe-chat -> lobehub/lobehub (P1)
- add lobe-linux-arm64 and lobe-macos-x64 matrix targets so all
  OS/arch combinations advertised by install.sh are actually built
  and uploaded to the release (P2)
2026-06-09 14:59:58 +08:00
ONLY-yours fcb80d6cd6 feat(cli): add binary release workflow and curl install script
- Add .github/workflows/release-cli.yml: builds standalone binaries via
  bun build --compile on ubuntu/macos runners and uploads to GitHub Release
- Add apps/cli/install.sh: POSIX-compatible curl installer that detects
  OS/arch, installs to /usr/local/bin (or ~/.local/bin fallback), and
  creates lobe + lobehub symlinks pointing to lh
2026-06-09 14:49:30 +08:00
Arvin Xu 441e0c5b7c 🐛 fix(heterogeneous-agents): refine execution target + topic sidebar attention grouping (#15574)
* 🐛 fix(heterogeneous-agents): hide "no device" execution target for hetero agents

Heterogeneous agents (Claude Code / Codex) bring their own toolchain and must
execute somewhere, so the 'none' (plain chat) execution target is invalid for
them. Hide the option in the device switcher and never resolve/display 'none'
for hetero agents — fall back to local (desktop) or sandbox (web) instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(topic): use colorText for titles and move "Needs attention" below favorites

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(chat-input): improve runtime config bar layout on narrow screens

Keep chips on a single line (no per-character wrapping), truncate long
labels (working dir / branch / device name) with ellipsis, and let the
workspace cluster scroll horizontally instead of wrapping. On a narrow
bar the hetero "full access" badge collapses to its icon (hover tooltip
still explains it) via a container query.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(topic): show project directory under topic items in by-status mode

Surface each topic's working directory as a muted second line in the
by-status grouping, where rows otherwise carry no project context. Data
is already on the topic metadata, so no extra fetch.

- NavItem: add opt-in `description` slot (single-line layout unchanged)
- DirIcon: convert `renderDirIcon` function into a memo component, add
  `size` prop, rename file to PascalCase, migrate all call sites

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:18:18 +08:00
Arvin Xu 0a6b02ccb5 💄 style(topic): show error alert icon with tooltip on failed topics (#15573)
* 💄 style(topic): show error alert icon with tooltip on failed topics

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(topic): merge attention-needing topics into one "Needs attention" group

Collapse the unread-completion, failed, and waitingForHuman states into a single
top "pending" status bucket (待处理 / Needs attention) so the sidebar surfaces
everything that needs the user's attention in one place.

- groupTopicsByStatus now buckets those three states into `pending`, taking a new
  `unreadTopicIds` set (unread completions are a client-only state).
- Server STATUS_SORT_RANK floats `failed` to the top alongside `waitingForHuman`
  so failed topics stay on the first page and don't drop out of the group.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(topic): pin the "Needs attention" group above favorites

The pending bucket already sorts above running, but the synthetic favorite group
was prepended ahead of it. Hoist pending to index 0 so attention-needing topics
sit at the very top of the sidebar, above both favorites and running.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(heterogeneous-agents): pin resolved cwd onto remote-CC new topics

Remote CC dispatched the run with the correct working directory (the
precedence chain falls back to the agent's per-device pick), but a
brand-new topic was created without `metadata.workingDirectory`, so the
sidebar grouped it under "No directory" / 无目录.

Unify the three drifting server-side cwd-precedence sites behind one
pure helper (`resolveDeviceWorkingDirectory`) and persist the resolved
cwd back onto a freshly-created topic so grouping, next-turn reuse, and
workspace-init scan all agree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:24:42 +08:00
29 changed files with 747 additions and 354 deletions
+16 -2
View File
@@ -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
View File
@@ -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}"
+2
View File
@@ -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",
+2
View File
@@ -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
]);
});
+10 -7
View File
@@ -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.
+28 -5
View File
@@ -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']);
});
});
+39 -22
View File
@@ -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} />
);
};
+22 -2
View File
@@ -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>
+35 -11
View File
@@ -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'}
+2
View File
@@ -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;
@@ -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}
@@ -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>
);
},
+15 -46
View File
@@ -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)} />
</>
);
+27 -18
View File
@@ -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']);
});
});
});
+17 -4
View File
@@ -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 => {