From 0a6b02ccb50e93fbca3fcfc5f085f325c1873742 Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Tue, 9 Jun 2026 13:24:42 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20style(topic):=20show=20error=20a?= =?UTF-8?q?lert=20icon=20with=20tooltip=20on=20failed=20topics=20(#15573)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💄 style(topic): show error alert icon with tooltip on failed topics Co-Authored-By: Claude Opus 4.8 * ✨ 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 * 💄 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 * 🐛 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 --------- Co-authored-by: Claude Opus 4.8 --- locales/en-US/topic.json | 2 + locales/zh-CN/topic.json | 2 + .../__tests__/topics/topic.query.test.ts | 16 +++- packages/database/src/models/topic.ts | 17 ++-- packages/utils/src/client/topic.test.ts | 33 +++++-- packages/utils/src/client/topic.ts | 61 ++++++++----- src/locales/default/topic.ts | 2 + .../_layout/Sidebar/Topic/List/Item/index.tsx | 12 ++- .../ByStatusMode/GroupItem.tsx | 11 ++- .../_layout/Sidebar/Topic/List/Item/index.tsx | 19 +++- src/server/services/aiAgent/index.ts | 45 ++++++---- .../resolveDeviceWorkingDirectory.test.ts | 87 +++++++++++++++++++ .../aiAgent/resolveDeviceWorkingDirectory.ts | 31 +++++++ src/store/chat/slices/topic/selectors.test.ts | 35 ++++++++ src/store/chat/slices/topic/selectors.ts | 50 ++++++++--- 15 files changed, 344 insertions(+), 79 deletions(-) create mode 100644 src/server/services/aiAgent/resolveDeviceWorkingDirectory.test.ts create mode 100644 src/server/services/aiAgent/resolveDeviceWorkingDirectory.ts diff --git a/locales/en-US/topic.json b/locales/en-US/topic.json index 1282b80e90..a315d15e51 100644 --- a/locales/en-US/topic.json +++ b/locales/en-US/topic.json @@ -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", diff --git a/locales/zh-CN/topic.json b/locales/zh-CN/topic.json index ed9032264a..4e0963758b 100644 --- a/locales/zh-CN/topic.json +++ b/locales/zh-CN/topic.json @@ -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": "本月", diff --git a/packages/database/src/models/__tests__/topics/topic.query.test.ts b/packages/database/src/models/__tests__/topics/topic.query.test.ts index b0842624b6..0ebb66dc47 100644 --- a/packages/database/src/models/__tests__/topics/topic.query.test.ts +++ b/packages/database/src/models/__tests__/topics/topic.query.test.ts @@ -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 ]); }); diff --git a/packages/database/src/models/topic.ts b/packages/database/src/models/topic.ts index 8795118f30..5ef06f24c1 100644 --- a/packages/database/src/models/topic.ts +++ b/packages/database/src/models/topic.ts @@ -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. diff --git a/packages/utils/src/client/topic.test.ts b/packages/utils/src/client/topic.test.ts index 606f1b8f40..27c4deda26 100644 --- a/packages/utils/src/client/topic.test.ts +++ b/packages/utils/src/client/topic.test.ts @@ -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']); }); }); diff --git a/packages/utils/src/client/topic.ts b/packages/utils/src/client/topic.ts index dba31c648d..5f484c51ec 100644 --- a/packages/utils/src/client/topic.ts +++ b/packages/utils/src/client/topic.ts @@ -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.`. +// 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.`. +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, -): ChatTopicStatus => { - if (topic.status === 'waitingForHuman') return 'waitingForHuman'; + unreadTopicIds?: ReadonlySet, +): 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, + unreadTopicIds?: ReadonlySet, ): GroupedTopic[] => { if (!topics.length) return []; - const groupsMap = new Map(); + const groupsMap = new Map(); 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); diff --git a/src/locales/default/topic.ts b/src/locales/default/topic.ts index 0de5df0d2a..8e805e4ee7 100644 --- a/src/locales/default/topic.ts +++ b/src/locales/default/topic.ts @@ -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', diff --git a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx index 56bf25af23..8d8e99e8e1 100644 --- a/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx @@ -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(({ 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(({ id, title, fav, active, threadId, meta /> ); } + if (isFailed) { + return ( + + + + ); + } if (isCompleted) { return ( = { 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(({ group, activeTopicId, activeThreadId }) => { diff --git a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx index c249854f61..dce36e82c4 100644 --- a/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx +++ b/src/routes/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx @@ -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(({ 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(({ id, title, fav, active, threadId, stat ); } + if (isFailed) { + return ( + + + + ); + } if (isCompleted) { return ( 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 diff --git a/src/server/services/aiAgent/resolveDeviceWorkingDirectory.test.ts b/src/server/services/aiAgent/resolveDeviceWorkingDirectory.test.ts new file mode 100644 index 0000000000..84f0dbad46 --- /dev/null +++ b/src/server/services/aiAgent/resolveDeviceWorkingDirectory.test.ts @@ -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(); + }); +}); diff --git a/src/server/services/aiAgent/resolveDeviceWorkingDirectory.ts b/src/server/services/aiAgent/resolveDeviceWorkingDirectory.ts new file mode 100644 index 0000000000..df3366b95f --- /dev/null +++ b/src/server/services/aiAgent/resolveDeviceWorkingDirectory.ts @@ -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 | null; +}): string | undefined => + params.topicWorkingDirectory || + params.initialWorkingDirectory || + (params.deviceId ? params.workingDirByDevice?.[params.deviceId] : undefined) || + params.deviceDefaultCwd || + undefined; diff --git a/src/store/chat/slices/topic/selectors.test.ts b/src/store/chat/slices/topic/selectors.test.ts index 382cde5a59..d75f58f5c3 100644 --- a/src/store/chat/slices/topic/selectors.test.ts +++ b/src/store/chat/slices/topic/selectors.test.ts @@ -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']); + }); }); }); diff --git a/src/store/chat/slices/topic/selectors.ts b/src/store/chat/slices/topic/selectors.ts index 6d53f43b1c..da008bdc68 100644 --- a/src/store/chat/slices/topic/selectors.ts +++ b/src/store/chat/slices/topic/selectors.ts @@ -122,6 +122,7 @@ const getGroupFn = ( groupMode: TopicGroupMode, sortBy: TopicSortBy, loadingTopicIds?: ReadonlySet, + unreadTopicIds?: ReadonlySet, ) => { 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 => {