💄 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:
Arvin Xu
2026-06-09 13:24:42 +08:00
committed by GitHub
parent 5dd0f0c0c9
commit 0a6b02ccb5
15 changed files with 344 additions and 79 deletions
+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);
+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, 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
@@ -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
+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 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']);
});
});
});
+36 -14
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,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 => {