diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts index a47d288cf..744f0569b 100644 --- a/src/core/dashboard-rows.ts +++ b/src/core/dashboard-rows.ts @@ -12,6 +12,7 @@ import { getTerminalAdvertisedPort } from './terminal-url.js'; import { getBotBrand } from '../bot-registry.js'; import { type Brand, chatAppLink } from '../im/lark/lark-hosts.js'; import { getSessionTokenUsage, type SessionTokenUsage } from './cost-calculator.js'; +import { getIdentity } from '../im/lark/identity-cache.js'; export interface SessionRow { sessionId: string; @@ -25,6 +26,8 @@ export interface SessionRow { closedAt?: number; workingDir?: string; chatId: string; + chatType?: 'group' | 'p2p'; + chatDisplayName?: string; rootMessageId: string; threadId?: string; /** Conversation unit ('thread' = topic-anchored, 'chat' = plain chat scope). @@ -99,6 +102,20 @@ function sessionTokenUsage(s: Session, workingDir?: string): SessionTokenUsage | }); } +function directChatDisplayName(s: Session, larkAppId?: string): string | undefined { + if (s.chatType !== 'p2p') return undefined; + const persisted = String(s.chatDisplayName ?? '').trim(); + if (persisted) return persisted; + const appId = larkAppId ?? s.larkAppId; + if (!appId) return undefined; + for (const openId of [s.ownerOpenId, s.creatorOpenId, s.lastCallerOpenId]) { + if (!openId) continue; + const name = String(getIdentity(appId, openId)?.name ?? '').trim(); + if (name) return name; + } + return undefined; +} + export function composeRowFromActive(ds: DaemonSession): SessionRow { return { sessionId: ds.session.sessionId, @@ -114,6 +131,8 @@ export function composeRowFromActive(ds: DaemonSession): SessionRow { lastMessageAt: sessionLastActivityAtMs(ds.session) || ds.lastMessageAt, workingDir: ds.workingDir, chatId: ds.chatId, + chatType: ds.chatType, + chatDisplayName: directChatDisplayName(ds.session, ds.larkAppId), rootMessageId: ds.session.rootMessageId, scope: ds.session.scope, title: ds.session.title, @@ -154,6 +173,8 @@ export function composeRowFromClosed(s: Session): SessionRow { closedAt: s.closedAt ? Date.parse(s.closedAt) : undefined, workingDir: s.workingDir, chatId: s.chatId, + chatType: s.chatType, + chatDisplayName: directChatDisplayName(s, s.larkAppId), rootMessageId: s.rootMessageId, scope: s.scope, title: s.title, diff --git a/src/daemon.ts b/src/daemon.ts index 3e4ac8a0d..1b3831426 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -197,6 +197,16 @@ function findChatReplyAlias(rootId: string, chatId: string, larkAppId: string): const hit = diskSessions.find(s => s.status === 'active' && s.larkAppId === larkAppId && s.chatId === chatId && sessionHasReplyThreadAlias(s, rootId)); return hit ? { chatId: hit.chatId, sessionId: hit.sessionId } : null; } + +function setDirectChatDisplayNameFromSender( + session: Session, + chatType: 'group' | 'p2p' | undefined, + sender?: { type?: 'user' | 'bot'; name?: string }, +): void { + if (chatType !== 'p2p' || sender?.type !== 'user') return; + const name = String(sender.name ?? '').trim(); + if (name) session.chatDisplayName = name; +} /** * Per-run state for active workflow loops. * @@ -2404,9 +2414,13 @@ async function startInitialPassthroughSession(args: { const botCfg = getBot(larkAppId).config; refreshCliVersion(botCfg.cliId, botCfg.cliPathOverride); + const directChatSender = chatType === 'p2p' + ? await resolveSender(larkAppId, senderOpenId, parsed.senderType) + : undefined; const rootIdForStore = scope === 'thread' ? anchor : messageId; const session = sessionStore.createSession(chatId, rootIdForStore, commandContent.substring(0, 50), chatType); const now = Date.now(); + setDirectChatDisplayNameFromSender(session, chatType, directChatSender); session.larkAppId = larkAppId; session.ownerOpenId = ownerOpenId; session.creatorOpenId = creatorOpenId; @@ -2675,6 +2689,13 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise { const cmdRootIdForStore = scope === 'thread' ? anchor : messageId; const session = sessionStore.createSession(chatId, cmdRootIdForStore, cmdContent.substring(0, 50), chatType); const now = Date.now(); + if (chatType === 'p2p') { + setDirectChatDisplayNameFromSender( + session, + chatType, + await resolveSender(larkAppId, senderOpenId, parsed.senderType), + ); + } session.larkAppId = larkAppId; session.ownerOpenId = senderOpenId; session.ownerUnionId = senderUnionId; @@ -2761,6 +2782,7 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise { const rootIdForStore = scope === 'thread' ? anchor : messageId; const session = sessionStore.createSession(chatId, rootIdForStore, parsed.content.substring(0, 50), chatType); const now = Date.now(); + setDirectChatDisplayNameFromSender(session, chatType, newTopicSender); session.larkAppId = larkAppId; session.ownerOpenId = senderOpenId; session.ownerUnionId = senderUnionId; @@ -3365,6 +3387,13 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise if (!existingDs && threadChatId && !SESSIONLESS_DAEMON_COMMANDS.has(cmd)) { const session = sessionStore.createSession(threadChatId, anchor, cmdContent.substring(0, 50), ctxChatType); const now = Date.now(); + if (ctxChatType === 'p2p') { + setDirectChatDisplayNameFromSender( + session, + ctxChatType, + await getThreadSender(), + ); + } session.larkAppId = larkAppId; session.ownerOpenId = threadSenderOpenId; session.creatorOpenId = threadSenderOpenId; // stable creator (= dispatch orchestrator for /repo prime) — see Session.creatorOpenId @@ -3594,6 +3623,7 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise // injects it either into the immediate prompt or stashes it on // pendingSender for the deferred spawn. const autoCreateSender = await getThreadSender(); + setDirectChatDisplayNameFromSender(session, autoCreateChatType, autoCreateSender); const newDs: DaemonSession = { session, worker: null, diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 8bbcbec61..ab2acc393 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -120,11 +120,13 @@ const zh: DashboardMessages = { 'overview.noSchedules': '暂无定时任务。', 'sessions.title': '会话控制', 'sessions.subtitle': '定位飞书话题、打开 Web Terminal、关闭或恢复 CLI 会话。', - 'sessions.search': '搜索工作目录 / 标题 / ID', + 'sessions.search': '搜索群聊 / 工作目录 / 标题 / ID', 'sessions.anyStatus': '全部状态', 'sessions.adoptAny': 'adopt: 全部', 'sessions.adoptYes': 'adopt: 是', 'sessions.adoptNo': 'adopt: 否', + 'sessions.chatAny': '所在聊天: 全部', + 'sessions.showUnknownChats': '显示未知聊天', 'sessions.activeOnly': '仅活跃', 'sessions.closeSelected': '关闭选中', 'sessions.clearSelection': '取消选择', @@ -146,6 +148,10 @@ const zh: DashboardMessages = { 'sessions.bot': 'bot', 'sessions.cli': 'CLI', 'sessions.chat': '群聊', + 'sessions.location': '所在聊天', + 'sessions.groupChat': '群聊', + 'sessions.directChat': '单聊', + 'sessions.chatUnknown': '未知聊天', 'sessions.openChat': '打开群聊', 'sessions.status': '状态', 'sessions.status.starting': '启动中', @@ -1560,11 +1566,13 @@ const en: DashboardMessages = { 'overview.noSchedules': 'No schedules yet.', 'sessions.title': 'Session Control', 'sessions.subtitle': 'Locate Feishu topics, open Web Terminal, close or resume CLI sessions.', - 'sessions.search': 'Search working dir / title / IDs', + 'sessions.search': 'Search chat / working dir / title / IDs', 'sessions.anyStatus': 'Any status', 'sessions.adoptAny': 'adopt: any', 'sessions.adoptYes': 'adopt: yes', 'sessions.adoptNo': 'adopt: no', + 'sessions.chatAny': 'Chat: all', + 'sessions.showUnknownChats': 'Show unknown chats', 'sessions.activeOnly': 'Active only', 'sessions.closeSelected': 'Close selected', 'sessions.clearSelection': 'Clear', @@ -1586,6 +1594,10 @@ const en: DashboardMessages = { 'sessions.bot': 'Bot', 'sessions.cli': 'CLI', 'sessions.chat': 'Chat', + 'sessions.location': 'Chat', + 'sessions.groupChat': 'Group', + 'sessions.directChat': 'DM', + 'sessions.chatUnknown': 'Unknown chat', 'sessions.openChat': 'Open chat', 'sessions.status': 'Status', 'sessions.status.starting': 'Starting', diff --git a/src/dashboard/web/preferences.ts b/src/dashboard/web/preferences.ts index e5c69f2ed..c09837bc8 100644 --- a/src/dashboard/web/preferences.ts +++ b/src/dashboard/web/preferences.ts @@ -4,6 +4,7 @@ export type SessionsViewMode = 'kanban' | 'board' | 'table'; export const THEME_STORAGE_KEY = 'botmux.dashboard.theme'; export const SESSIONS_VIEW_STORAGE_KEY = 'botmux.dashboard.sessions.view'; +export const SESSIONS_SHOW_UNKNOWN_CHATS_STORAGE_KEY = 'botmux.dashboard.sessions.showUnknownChats'; export function normalizeThemeMode(value: unknown): ThemeMode | null { return value === 'system' || value === 'light' || value === 'dark' ? value : null; @@ -28,6 +29,15 @@ export function readStoredSessionsViewMode(storage: Storage | undefined): Sessio return normalizeSessionsViewMode(storage?.getItem(SESSIONS_VIEW_STORAGE_KEY)) ?? 'board'; } +export function readStoredSessionsShowUnknownChats(storage: Storage | undefined): boolean { + try { + const raw = storage?.getItem(SESSIONS_SHOW_UNKNOWN_CHATS_STORAGE_KEY); + return raw == null ? true : raw === '1'; + } catch { + return true; + } +} + // ── 看板列顺序(用户可拖拽/按钮自定义,从左到右)───────────────────────────── export const SESSIONS_BOARD_ORDER_STORAGE_KEY = 'botmux.dashboard.sessions.boardOrder'; export const DEFAULT_BOARD_ORDER = ['needs-you', 'starting', 'working', 'idle'] as const; @@ -67,6 +77,14 @@ export function writeStoredSessionsViewMode(storage: Storage | undefined, mode: } } +export function writeStoredSessionsShowUnknownChats(storage: Storage | undefined, show: boolean): void { + try { + storage?.setItem(SESSIONS_SHOW_UNKNOWN_CHATS_STORAGE_KEY, show ? '1' : '0'); + } catch { + // localStorage 不可用时只在当前页生效 + } +} + // ── 看板分组维度:工作流列 / 团队(筛选某团队的工作流)/ 机器人列 ───────────── export type KanbanGroupBy = 'flow' | 'team' | 'bot'; diff --git a/src/dashboard/web/sessions-page.tsx b/src/dashboard/web/sessions-page.tsx index 2e574cac1..c51117011 100644 --- a/src/dashboard/web/sessions-page.tsx +++ b/src/dashboard/web/sessions-page.tsx @@ -3,6 +3,7 @@ import { IDLE_CLEANUP_HOUR_OPTIONS } from '../session-cleanup.js'; import { mountReactPage, type PageDisposer } from './react-mount.js'; import { renderCliFilterGroup, SESSION_STATUS_OPTIONS, sessionStatusText, wireSessionsPage } from './sessions.js'; import { useT } from './react-hooks.js'; +import { readStoredSessionsShowUnknownChats } from './preferences.js'; function SortHeader(props: { sort: string; label: string }) { return {props.label}; @@ -60,7 +61,17 @@ function SessionsPage() { + + @@ -97,6 +108,7 @@ function SessionsPage() { + diff --git a/src/dashboard/web/sessions.ts b/src/dashboard/web/sessions.ts index 33843d053..9d570bf89 100644 --- a/src/dashboard/web/sessions.ts +++ b/src/dashboard/web/sessions.ts @@ -5,10 +5,12 @@ import { normalizeSessionsViewMode, readStoredBoardOrder, readStoredKanbanGroupBy, + readStoredSessionsShowUnknownChats, readStoredSessionsViewMode, type SessionsViewMode, writeStoredBoardOrder, writeStoredKanbanGroupBy, + writeStoredSessionsShowUnknownChats, writeStoredSessionsViewMode, } from './preferences.js'; import { @@ -135,6 +137,36 @@ function repoBasename(workingDir: unknown): string { return parts.at(-1) ?? value; } +function sessionChatKindLabel(s: any): string { + return s?.chatType === 'p2p' ? t('sessions.directChat') : t('sessions.groupChat'); +} + +export function sessionLocationText(s: any): string { + const chatId = String(s?.chatId ?? '').trim(); + const name = chatDisplayTitle(s); + if (name) return `${sessionChatKindLabel(s)} · ${name}`; + if (chatId) return `${sessionChatKindLabel(s)} · ${chatId}`; + return t('sessions.chatUnknown'); +} + +export function isUnknownChatSession( + s: any, + resolveTitle: (session: any) => string | null = chatDisplayTitle, +): boolean { + const chatId = String(s?.chatId ?? '').trim(); + return !!chatId && !resolveTitle(s); +} + +function sessionLocationTitle(s: any): string { + const label = sessionLocationText(s); + const chatId = String(s?.chatId ?? '').trim(); + return chatId && !label.includes(chatId) ? `${label} · ${chatId}` : label; +} + +function sessionSearchText(s: any): string { + return `${JSON.stringify(s)} ${sessionLocationText(s)} ${sessionLocationTitle(s)}`.toLowerCase(); +} + function terminalHref(s: any): string | null { if (!s.webPort) return null; // 经中心化平台访问时(本页是 HTTPS 机器子域 m-.):终端走**同源 /s/**—— @@ -269,6 +301,9 @@ export function canRestartSession(s: any): boolean { } function pageHtml(): string { + const showUnknownChatsChecked = readStoredSessionsShowUnknownChats( + typeof window === 'undefined' ? undefined : window.localStorage, + ) ? ' checked' : ''; return `
@@ -302,7 +337,11 @@ function pageHtml(): string { + ${renderCliFilterGroup()} +
@@ -332,6 +371,7 @@ function pageHtml(): string { ${th('botName', t('sessions.bot'))} ${th('cliId', t('sessions.cli'))} ${th('status', t('sessions.status'))} + ${th('chat', t('sessions.location'))} ${th('tokenIn', t('sessions.tokenIn'))} ${th('tokenOut', t('sessions.tokenOut'))} ${th('title', t('sessions.titleCol'))} @@ -536,6 +576,8 @@ export function renderSessionsPage(root: HTMLElement): () => void { export function wireSessionsPage(root: HTMLElement): () => void { const tbody = root.querySelector('#sessions-table tbody')!; const filtersForm = root.querySelector('#filters')!; + const chatSelect = root.querySelector('select[name="chat"]')!; + const showUnknownChatsInput = root.querySelector('input[name="showUnknownChats"]'); const drawer = root.querySelector('#drawer')!; const selectAllBox = root.querySelector('#select-all')!; const bulkBar = root.querySelector('#bulk-bar')!; @@ -572,6 +614,7 @@ export function wireSessionsPage(root: HTMLElement): () => void { let lastBoardHtml = ''; let lastTableHtml = ''; let lastKanbanHtml = ''; + let lastChatFilterHtml = ''; let boardAnimated = false; // 看板交互态:拖拽中的卡片 id / 标题就地编辑中 —— 两者期间都跳过看板重绘, // 否则 SSE 触发的 innerHTML 重建会把拖拽源/输入框拍没。 @@ -608,6 +651,10 @@ export function wireSessionsPage(root: HTMLElement): () => void { let idleCleanupBusy = false; let idleCleanupHours: IdleCleanupHours = 24; + if (showUnknownChatsInput) { + showUnknownChatsInput.checked = readStoredSessionsShowUnknownChats(window.localStorage); + } + function selectedIdleCleanupHours(): IdleCleanupHours { return idleCleanupHours; } @@ -871,11 +918,13 @@ export function wireSessionsPage(root: HTMLElement): () => void { function rowHtml(s: any): string { const closed = s.status === 'closed'; const checked = selected.has(s.sessionId) ? 'checked' : ''; + const location = sessionLocationText(s); return ` ${escapeHtml(botDisplayName(s))} ${escapeHtml(s.cliId ?? 'unknown')} ${statusBadgeHtml(s.status)}${lockChipHtml(s)} + ${escapeHtml(location)} ${formatTokenCount(s.tokenUsage?.in)} ${formatTokenCount(s.tokenUsage?.out)} ${escapeHtml(stripMentionPrefix(s.title ?? '').slice(0, 48))} @@ -1492,16 +1541,20 @@ export function wireSessionsPage(root: HTMLElement): () => void { const cliFilterActive = cli.length > 0 && cli.length < CLI_FILTER_OPTIONS.length; const status = f.get('status') as string; const adopt = f.get('adopt') as string; + const chat = f.get('chat') as string; const active = !!f.get('active'); + const showUnknownChats = !!f.get('showUnknownChats'); // 看板视图的「已完成」列收纳已关闭会话——「仅活跃」开关不再把它们整体 // 滤掉,否则该列永远是空的。 const keepClosed = viewMode === 'kanban'; const rows = [...store.sessions.values()] .filter(s => !cliFilterActive || cli.includes(s.cliId ?? 'unknown')) + .filter(s => showUnknownChats || !isUnknownChatSession(s)) .filter(s => !status || s.status === status) .filter(s => !adopt || (adopt === 'yes') === !!s.adopt) + .filter(s => !chat || String(s.chatId ?? '') === chat) .filter(s => !active || keepClosed || s.status !== 'closed') - .filter(s => !q || JSON.stringify(s).toLowerCase().includes(q)); + .filter(s => !q || sessionSearchText(s).includes(q)); rows.sort(compareRows); return rows; } @@ -1511,6 +1564,7 @@ export function wireSessionsPage(root: HTMLElement): () => void { if (key === 'tokenIn') return tokenCount(s.tokenUsage?.in) ?? -1; if (key === 'tokenOut') return tokenCount(s.tokenUsage?.out) ?? -1; if (key === 'adopt') return !!s.adopt; + if (key === 'chat') return sessionLocationText(s).toLowerCase(); return String(s[key] ?? '').toLowerCase(); } @@ -1593,7 +1647,31 @@ export function wireSessionsPage(root: HTMLElement): () => void { countEl.classList.toggle('cli-filter-active', checked !== boxes.length); } + function syncChatFilterOptions(): void { + const prev = chatSelect.value; + const showUnknownChats = !!filtersForm.querySelector('input[name="showUnknownChats"]')?.checked; + const options = new Map(); + for (const row of store.sessions.values()) { + const chatId = String(row.chatId ?? '').trim(); + if (!chatId) continue; + if (!showUnknownChats && isUnknownChatSession(row)) continue; + const label = sessionLocationText(row); + const existing = options.get(chatId); + if (!existing || label < existing) options.set(chatId, label); + } + const rows = [...options.entries()].sort((a, b) => a[1].localeCompare(b[1])); + const html = `${rows.map(([chatId, label]) => + `` + ).join('')}`; + if (html !== lastChatFilterHtml) { + chatSelect.innerHTML = html; + lastChatFilterHtml = html; + } + chatSelect.value = prev && options.has(prev) ? prev : ''; + } + function rerender(): void { + syncChatFilterOptions(); const rows = filtered(); for (const sid of [...selected]) { const s = store.sessions.get(sid); @@ -1607,7 +1685,7 @@ export function wireSessionsPage(root: HTMLElement): () => void { if (viewMode === 'table') { const tableHtml = rows.length ? rows.map(rowHtml).join('') - : `${t('sessions.empty')}`; + : `${t('sessions.empty')}`; if (tableHtml !== lastTableHtml) { lastTableHtml = tableHtml; tbody.innerHTML = tableHtml; @@ -1823,7 +1901,7 @@ export function wireSessionsPage(root: HTMLElement): () => void {

${escapeHtml(s.sessionId)}

${t('sessions.bot')}: ${escapeHtml(botDisplayName(s))} · ${t('sessions.cli')}: ${escapeHtml(s.cliId ?? '?')}

- ${chatDisplayTitle(s) ? `

${t('sessions.chat')}: ${escapeHtml(chatDisplayTitle(s)!)}

` : ''} +

${t('sessions.location')}: ${escapeHtml(sessionLocationText(s))}

chatId: ${escapeHtml(s.chatId ?? '')}

rootMessageId: ${escapeHtml(s.rootMessageId ?? '')}

${s.threadId ? `

threadId: ${escapeHtml(s.threadId)}

` : ''} @@ -2487,6 +2565,13 @@ export function wireSessionsPage(root: HTMLElement): () => void { }); filtersForm.addEventListener('input', rerender); + filtersForm.addEventListener('change', event => { + const target = event.target as HTMLInputElement | null; + if (target?.name === 'showUnknownChats') { + writeStoredSessionsShowUnknownChats(window.localStorage, target.checked); + rerender(); + } + }); const unsubscribeStore = store.on(rerender); // 团队看板 30s 软刷新(拉对方部署的会话快照与共享编排);页面切走后 // kanban 脱离 DOM,定时器自清。 diff --git a/src/dashboard/web/style.css b/src/dashboard/web/style.css index 9e506891e..fb070fb1e 100644 --- a/src/dashboard/web/style.css +++ b/src/dashboard/web/style.css @@ -892,6 +892,12 @@ tr[data-chat]:hover { background: var(--surface-muted); cursor: pointer; } text-align: center; font-variant-numeric: tabular-nums; } +.session-location-cell { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} button, .btn-link { diff --git a/src/dashboard/web/ui.ts b/src/dashboard/web/ui.ts index fd9b6b66d..1df8e2f3f 100644 --- a/src/dashboard/web/ui.ts +++ b/src/dashboard/web/ui.ts @@ -341,8 +341,10 @@ export function botDisplayName(s: Record): string { return String(s.botName ?? s.larkAppId ?? '-'); } -/** 会话所在群聊的标题;单聊或群列表里查不到时返回 null(由调用方回退)。 */ +/** 会话所在聊天的标题;p2p 优先使用后端行上的直聊显示名,群聊走 /api/groups 名字表。 */ export function chatDisplayTitle(s: Record): string | null { + const rowName = String(s.chatDisplayName ?? '').trim(); + if (rowName) return rowName; return (s.chatId && chatNameById.get(s.chatId)) || null; } diff --git a/src/types.ts b/src/types.ts index 2421234b8..675ff1d50 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,6 +45,10 @@ export interface Session { webPort?: number; larkAppId?: string; ownerOpenId?: string; // topic creator's open_id — for @mention in replies + /** Best-effort human-readable title for direct/p2p chats. Lark chat APIs + * often return an empty chat name for p2p, so dashboard rows carry the + * initiating user's display name here when we can resolve it. */ + chatDisplayName?: string; /** open_id of whoever created this session (the first sender), app-scoped to * this bot. UNLIKE ownerOpenId, this is set even for bot-started (foreign-bot) * sessions and is NEVER overwritten by later activity — so it stably points at diff --git a/test/dashboard-create-session.test.ts b/test/dashboard-create-session.test.ts index c8b533551..489cd1cf4 100644 --- a/test/dashboard-create-session.test.ts +++ b/test/dashboard-create-session.test.ts @@ -51,6 +51,7 @@ vi.mock('../src/core/worker-pool.js', () => ({ getCurrentCliVersion: vi.fn(() => 'test-cli-v1'), restoreUsageLimitRuntimeState: vi.fn(), setActiveSessionSafe: vi.fn(async (map: Map, k: string, ds: any) => { map.set(k, ds); }), + getActiveSessionsRegistry: vi.fn(() => null), isRelayableRealSession: vi.fn(() => false), closeSession: vi.fn(), })); @@ -77,7 +78,10 @@ vi.mock('../src/core/dashboard-events.js', () => ({ dashboardEventBus: { publish vi.mock('../src/core/dashboard-rows.js', () => ({ composeRowFromActive: vi.fn((ds: DaemonSession) => ({ sessionId: ds.session.sessionId, queued: !!ds.session.queued })), })); -vi.mock('../src/core/role-resolver.js', () => ({ resolveRole: vi.fn(() => ({ content: null, source: undefined })) })); +vi.mock('../src/core/role-resolver.js', () => ({ + resolveRole: vi.fn(() => ({ content: null, source: undefined })), + resolveRoleInjection: vi.fn(() => ({ content: null, source: undefined, injectMode: 'none' })), +})); vi.mock('../src/services/whiteboard-store.js', () => ({ whiteboardEnabled: vi.fn(() => false), getWhiteboard: vi.fn(), diff --git a/test/dashboard-preferences.test.ts b/test/dashboard-preferences.test.ts index 64f5f00c4..fd2293fcf 100644 --- a/test/dashboard-preferences.test.ts +++ b/test/dashboard-preferences.test.ts @@ -9,9 +9,11 @@ import { normalizeSkin, normalizeThemeMode, readStoredSessionsViewMode, + readStoredSessionsShowUnknownChats, readStoredSidebarMode, readStoredSkin, resolveThemeMode, + writeStoredSessionsShowUnknownChats, } from '../src/dashboard/web/preferences.js'; describe('dashboard locale preferences', () => { @@ -82,6 +84,31 @@ describe('sessions view mode preference', () => { }); }); +describe('sessions unknown chat preference', () => { + it('is enabled by default and disabled only by the explicit 0 value', () => { + const make = (value: string | null): Storage => + ({ getItem: () => value }) as unknown as Storage; + + expect(readStoredSessionsShowUnknownChats(undefined)).toBe(true); + expect(readStoredSessionsShowUnknownChats(make(null))).toBe(true); + expect(readStoredSessionsShowUnknownChats(make('0'))).toBe(false); + expect(readStoredSessionsShowUnknownChats(make('1'))).toBe(true); + }); + + it('persists the toggle as a localStorage boolean flag', () => { + const values = new Map(); + const storage = { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + } as unknown as Storage; + + writeStoredSessionsShowUnknownChats(storage, true); + expect(readStoredSessionsShowUnknownChats(storage)).toBe(true); + writeStoredSessionsShowUnknownChats(storage, false); + expect(readStoredSessionsShowUnknownChats(storage)).toBe(false); + }); +}); + describe('sidebar mode preference', () => { it('accepts expanded/collapsed and rejects junk', () => { expect(normalizeSidebarMode('expanded')).toBe('expanded'); diff --git a/test/dashboard-sessions-ui.test.ts b/test/dashboard-sessions-ui.test.ts index 64af42c42..f78433b80 100644 --- a/test/dashboard-sessions-ui.test.ts +++ b/test/dashboard-sessions-ui.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { canRestartSession, renderCliFilterGroup, restartConfirmMessage } from '../src/dashboard/web/sessions.js'; +import { + canRestartSession, + isUnknownChatSession, + renderCliFilterGroup, + restartConfirmMessage, + sessionLocationText, +} from '../src/dashboard/web/sessions.js'; describe('dashboard sessions filters', () => { it('renders CLI filters as same-name checkboxes checked by default for multi-select filtering', () => { @@ -30,4 +36,21 @@ describe('dashboard sessions filters', () => { expect(canRestartSession({ status: 'idle', adopt: true })).toBe(false); expect(canRestartSession({ status: 'starting', pendingRepo: true })).toBe(false); }); + + it('formats session location labels for group chats and direct chats', () => { + expect(sessionLocationText({ chatType: 'group', chatId: 'oc_group' })).toBe('群聊 · oc_group'); + expect(sessionLocationText({ chatType: 'p2p', chatId: 'oc_dm', chatDisplayName: '韩毅' })).toBe('单聊 · 韩毅'); + expect(sessionLocationText({ chatType: 'p2p', chatId: 'oc_dm' })).toBe('单聊 · oc_dm'); + expect(sessionLocationText({})).toBe('未知聊天'); + }); + + it('treats sessions with chatId but no resolved chat title as unknown chats', () => { + const row = { chatType: 'group', chatId: 'oc_stale' }; + const namedDirect = { chatType: 'p2p', chatId: 'oc_dm', chatDisplayName: '韩毅' }; + + expect(isUnknownChatSession(row, () => null)).toBe(true); + expect(isUnknownChatSession(row, () => 'SellerIM Agent 集中营')).toBe(false); + expect(isUnknownChatSession(namedDirect)).toBe(false); + expect(isUnknownChatSession({}, () => null)).toBe(false); + }); }); diff --git a/test/dashboard-token-usage-row.test.ts b/test/dashboard-token-usage-row.test.ts index 68328ef89..574bb7479 100644 --- a/test/dashboard-token-usage-row.test.ts +++ b/test/dashboard-token-usage-row.test.ts @@ -15,7 +15,7 @@ vi.mock('../src/core/cost-calculator.js', () => ({ })); import { getSessionTokenUsage } from '../src/core/cost-calculator.js'; -import { composeRowFromActive } from '../src/core/dashboard-rows.js'; +import { composeRowFromActive, composeRowFromClosed } from '../src/core/dashboard-rows.js'; function makeDs(): DaemonSession { return { @@ -65,4 +65,23 @@ describe('dashboard SessionRow token usage', () => { model: 'test-model', }); }); + + it('carries chatType for active and closed rows', () => { + const active = makeDs(); + active.chatType = 'p2p'; + active.session.chatType = 'p2p'; + active.session.chatDisplayName = '韩毅'; + + expect(composeRowFromActive(active).chatType).toBe('p2p'); + expect(composeRowFromActive(active).chatDisplayName).toBe('韩毅'); + expect(composeRowFromClosed({ + sessionId: 'closed-1', + chatId: 'oc_group', + chatType: 'group', + rootMessageId: 'om_root', + title: 'closed', + status: 'closed', + createdAt: new Date(1000).toISOString(), + }).chatType).toBe('group'); + }); }); diff --git a/test/prompt-builder.test.ts b/test/prompt-builder.test.ts index d8dcb67a4..3e248a1e4 100644 --- a/test/prompt-builder.test.ts +++ b/test/prompt-builder.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi } from 'vitest'; vi.mock('node:child_process', () => ({ execSync: vi.fn(() => ''), + execFile: vi.fn(), execFileSync: vi.fn(() => ''), }));