mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
💄 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>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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, 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';
|
||||
|
||||
@@ -175,6 +175,7 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
||||
});
|
||||
|
||||
const isCompleted = status === 'completed';
|
||||
const isFailed = status === 'failed';
|
||||
const isRunning = status === 'running';
|
||||
const isWaitingForHuman = status === 'waitingForHuman';
|
||||
|
||||
@@ -243,6 +244,13 @@ const TopicItem = memo<TopicItemProps>(({ id, title, fav, active, threadId, meta
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isFailed) {
|
||||
return (
|
||||
<Tooltip title={t('failedStatusTip')}>
|
||||
<Icon icon={TriangleAlert} size={'small'} style={{ color: cssVar.colorError }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<Icon
|
||||
|
||||
+5
-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 }) => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -242,6 +250,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
|
||||
|
||||
@@ -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 pin the pending group to the very top, above 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);
|
||||
|
||||
// pending floats above the favorite group; favorite stays above the rest
|
||||
expect(grouped.map((g) => g.id)).toEqual(['pending', 'favorite', 'active']);
|
||||
expect(grouped[0].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,16 +153,28 @@ const buildGroupedTopics = (
|
||||
const favTopics = topics.filter((topic) => topic.favorite);
|
||||
const unfavTopics = topics.filter((topic) => !topic.favorite);
|
||||
|
||||
return favTopics.length > 0
|
||||
? [
|
||||
{
|
||||
children: favTopics,
|
||||
id: 'favorite',
|
||||
title: t('favorite', { ns: 'topic' }),
|
||||
},
|
||||
...groupFn(unfavTopics),
|
||||
]
|
||||
: groupFn(topics);
|
||||
const groups =
|
||||
favTopics.length > 0
|
||||
? [
|
||||
{
|
||||
children: favTopics,
|
||||
id: 'favorite',
|
||||
title: t('favorite', { ns: 'topic' }),
|
||||
},
|
||||
...groupFn(unfavTopics),
|
||||
]
|
||||
: groupFn(topics);
|
||||
|
||||
// The "needs attention" bucket (byStatus mode only) is pinned to the very top,
|
||||
// even above favorites, so failed / awaiting-input / unread topics are
|
||||
// impossible to miss. No-op for other group modes, which have no `pending` id.
|
||||
const pendingIndex = groups.findIndex((group) => group.id === 'pending');
|
||||
if (pendingIndex > 0) {
|
||||
const [pending] = groups.splice(pendingIndex, 1);
|
||||
groups.unshift(pending);
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const groupedTopicsSelector =
|
||||
@@ -177,10 +190,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