From c796d9dc520e686751a0bebe2c74f24b21efd28d Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:10:47 +0800 Subject: [PATCH 1/2] feat(mobile): mark task rows with the teammate looking after them The teammate roster row now links the open local tasks the teammate started or took over (rel 'following', same scope as the desktop sidebar mark, at most 200). Mobile task rows show the first follower's 16pt avatar beside the title and read every follower's name to screen readers. Responsibility and delegation changes invalidate the roster so the mark stays current. Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../botRemoteResourceProjection.test.ts | 12 +++++ .../ipc/botRemoteResourceProjection.ts | 26 ++++++++--- apps/desktop/src/main/localDb/ipc/bots.ts | 43 ++++++++++++++++++ .../botTaskResponsibilityAuthority.test.ts | 6 ++- .../botRemoteResourceInvalidation.ts | 30 ++++++++++++- .../src/main/maker-ipc/botWorkbenchTools.ts | 11 ++++- apps/desktop/src/main/maker-ipc/register.ts | 2 + .../src/__tests__/remoteResourceCache.test.ts | 19 +++++++- .../mobile/src/debug/teammateVisualFixture.ts | 12 +++-- .../src/device-link/remoteResourceCache.ts | 25 +++++++++++ .../mobile/src/device-link/remoteResources.ts | 3 +- apps/mobile/src/i18n/locales/en/devices.json | 1 + apps/mobile/src/i18n/locales/ja/devices.json | 1 + apps/mobile/src/i18n/locales/ko/devices.json | 1 + .../src/i18n/locales/zh-CN/devices.json | 1 + .../src/i18n/locales/zh-TW/devices.json | 1 + apps/mobile/src/session/HomeSurface.tsx | 13 +++++- apps/mobile/src/session/TaskFollowMark.tsx | 45 +++++++++++++++++++ docs/dev-rules/protocol-compatibility.md | 12 +++++ docs/product-rules/cindy-bots-runtime.md | 2 + 20 files changed, 249 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/src/session/TaskFollowMark.tsx diff --git a/apps/desktop/src/main/localDb/ipc/__tests__/botRemoteResourceProjection.test.ts b/apps/desktop/src/main/localDb/ipc/__tests__/botRemoteResourceProjection.test.ts index d98bc9a6bef..0c24675d3b8 100644 --- a/apps/desktop/src/main/localDb/ipc/__tests__/botRemoteResourceProjection.test.ts +++ b/apps/desktop/src/main/localDb/ipc/__tests__/botRemoteResourceProjection.test.ts @@ -48,6 +48,18 @@ describe('bot remote resource projection', () => { }); }); + // Mobile task rows draw the follow mark from these links; the revision must move when they do. + it('links the open tasks a teammate looks after and revises the row when they change', () => { + const before = botRemoteCollectionItemFromSource(source); + const after = botRemoteCollectionItemFromSource({ ...source, followingSessionIds: ['task-a', 'task-b'] }); + expect(after.links).toEqual([ + { rel: 'conversation', target: { kind: 'session', sessionId: 'session-1' } }, + { rel: 'following', target: { kind: 'session', sessionId: 'task-a' } }, + { rel: 'following', target: { kind: 'session', sessionId: 'task-b' } }, + ]); + expect(after.revision).not.toBe(before.revision); + }); + it('uses generic attention presentation without exposing Bot internals', () => { const projected = botRemoteCollectionItemFromSource({ ...source, diff --git a/apps/desktop/src/main/localDb/ipc/botRemoteResourceProjection.ts b/apps/desktop/src/main/localDb/ipc/botRemoteResourceProjection.ts index 0e357ddbccc..cc03dbb2a90 100644 --- a/apps/desktop/src/main/localDb/ipc/botRemoteResourceProjection.ts +++ b/apps/desktop/src/main/localDb/ipc/botRemoteResourceProjection.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + import type { RemoteCollectionItem, RemoteLocalizedText, @@ -12,6 +14,8 @@ import { isManagedBotAvatarUrl } from '../../../shared/botAvatarValue.js'; export const TEAMMATES_REMOTE_COLLECTION_ID = 'teammates'; export const BOT_REMOTE_RESOURCE_KIND = 'bot'; +/** Link to an open task this teammate looks after; controllers draw the follow mark from it. */ +export const BOT_FOLLOWING_LINK_REL = 'following'; export const TEAMMATES_TITLE: RemoteLocalizedText = { fallback: 'Teammates', @@ -91,9 +95,16 @@ function sourceRevision(source: BotRemoteResourceSource): string { source.activityAt, source.canonicalSessionId ?? '', source.needsAttention ? 1 : 0, + followingDigest(source.followingSessionIds ?? []), ].join(':'); } +/** Short stand-in for the followed task list, so revisions stay small however many tasks. */ +function followingDigest(sessionIds: readonly string[]): string { + if (!sessionIds.length) return ''; + return createHash('sha256').update(sessionIds.join('\n')).digest('hex').slice(0, 16); +} + export function botRemoteCollectionItemFromSource( source: BotRemoteResourceSource, ): RemoteCollectionItem { @@ -126,12 +137,15 @@ export function botRemoteCollectionItemFromSource( ? { badges: [{ accessibilityLabel: ATTENTION_COPY, tone: 'warning' as const }] } : {}), }, - links: source.canonicalSessionId - ? [{ - rel: 'conversation', - target: { kind: 'session', sessionId: source.canonicalSessionId }, - }] - : [], + links: [ + ...(source.canonicalSessionId + ? [{ rel: 'conversation', target: { kind: 'session' as const, sessionId: source.canonicalSessionId } }] + : []), + ...(source.followingSessionIds ?? []).map((sessionId) => ({ + rel: BOT_FOLLOWING_LINK_REL, + target: { kind: 'session' as const, sessionId }, + })), + ], revision: sourceRevision(source), }; } diff --git a/apps/desktop/src/main/localDb/ipc/bots.ts b/apps/desktop/src/main/localDb/ipc/bots.ts index d1245b72978..b1fb8e29589 100644 --- a/apps/desktop/src/main/localDb/ipc/bots.ts +++ b/apps/desktop/src/main/localDb/ipc/bots.ts @@ -1423,6 +1423,46 @@ export interface BotRemoteResourceSource { activityAt: number; currentVersion: number; updatedAt: number; + /** Open tasks this Bot looks after, for the follow mark on remote task rows. Absent = none. */ + followingSessionIds?: string[]; +} + +const MAX_FOLLOWED_TASK_LINKS = 200; + +/** + * The tasks the desktop sidebar marks as followed by this Bot (`useSessionFollowers`): + * active, local, ordinary tasks only, so archived history never grows the roster. + */ +async function readFollowedTaskIds( + db: ReturnType['drizzle'], + userDataDir: string, + botId: string, +): Promise { + let ids: string[]; + try { + ids = (await readBotResponsibilityScope(userDataDir, botId)).sessionIds; + } catch { + // A damaged store may hold releases: show no mark rather than guess (collectTaskResponsibilityScopes). + return []; + } + const followed: Array<{ id: string; updatedAt: number }> = []; + for (let start = 0; start < ids.length; start += 500) { + const rows = await db.select({ id: sessions.id, updatedAt: sessions.updatedAt }).from(sessions).where(and( + inArray(sessions.id, ids.slice(start, start + 500)), + eq(sessions.status, 'active'), + isNull(sessions.remoteHostId), + // isWorkbenchTaskSource: ordinary tasks only, never a Bot's own or a channel conversation. + inArray(sessions.source, ['desktop', 'plugin']), + or(isNull(sessions.orcaRole), ne(sessions.orcaRole, 'worker')), + )); + followed.push(...rows); + } + // Keep the roster row small: the most recently active tasks, in a stable order. + return followed + .sort((left, right) => right.updatedAt - left.updatedAt) + .slice(0, MAX_FOLLOWED_TASK_LINKS) + .map((row) => row.id) + .sort(); } async function readBotRemoteResourceSource( @@ -1443,6 +1483,8 @@ async function readBotRemoteResourceSource( .from(botProfileVersions).where(and(eq(botProfileVersions.botId, botId), eq(botProfileVersions.version, profile.currentVersion))).limit(1); const invitation = botInvitationProgress(parseJson(version?.capabilitiesJson ?? '{}').invitation); const reply = latest.role === 'assistant' ? latest : await readCanonicalChatPreview(db, canonicalSessionId, canonical?.clearedAt ?? null, true); + // Paused or archived companions show no mark, as on the desktop sidebar. + const followingSessionIds = profile.status === 'active' ? await readFollowedTaskIds(db, owner.userDataDir, botId) : []; owner.assertCurrent(); return { ...(invitation ? { invitation } : {}), @@ -1465,6 +1507,7 @@ async function readBotRemoteResourceSource( activityAt: Math.max(profile.createdAt, latest.createdAt ?? 0, canonical?.updatedAt ?? 0), currentVersion: profile.currentVersion, updatedAt: profile.updatedAt, + followingSessionIds, }; } diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botTaskResponsibilityAuthority.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botTaskResponsibilityAuthority.test.ts index f13ff2be9eb..d7f62e490de 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/botTaskResponsibilityAuthority.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/botTaskResponsibilityAuthority.test.ts @@ -76,6 +76,7 @@ function fixture(role: string) { sqlite.prepare("INSERT INTO bot_session_links VALUES ('link', ?, ?, ?, NULL)").run('caller', 'hihi', role); const save = vi.fn(async () => {}); const broadcast = vi.fn(); + const remoteBroadcast = vi.fn(); const deps = { getDbClient: () => ({ drizzle: drizzle(sqlite) }), and, desc, eq, inArray, isNotNull, isNull, botDelegations, botProfiles, botSessionLinks, sessions, @@ -84,12 +85,13 @@ function fixture(role: string) { ownerScopedUserDataPath: () => '/synthetic-owner-home', setTaskResponsibility: save, broadcastBotWorkbenchChanged: broadcast, + broadcastBotRemoteResourceChanged: remoteBroadcast, }; const api = new Function(...Object.keys(deps), js)(...Object.values(deps)) as { captureBotCreatedTaskResponsibility(id: string): Promise Promise)>; resolveWorkbenchCaller(id: string): Promise<{ ok: boolean; errorCode?: string }>; }; - return { sqlite, save, broadcast, ...api }; + return { sqlite, save, broadcast, remoteBroadcast, ...api }; } it.each(['canonical', 'thread', 'channel', 'group'])( @@ -101,6 +103,8 @@ it.each(['canonical', 'thread', 'channel', 'group'])( await record!('created'); expect(f.save).toHaveBeenCalledWith('/synthetic-owner-home', 'hihi', 'created', true, expect.any(Function)); expect(f.broadcast).toHaveBeenCalledWith('hihi'); + // Phones re-read the roster's follow links, so the new task shows the teammate's mark there too. + expect(f.remoteBroadcast).toHaveBeenCalledWith('hihi'); // Recognizing a creator must not grant a channel/group conversation workbench access. if (role === 'channel' || role === 'group') { expect(await f.resolveWorkbenchCaller('caller')).toMatchObject({ ok: false, errorCode: 'BOT_MAIN_TASK_REQUIRED' }); diff --git a/apps/desktop/src/main/maker-ipc/botRemoteResourceInvalidation.ts b/apps/desktop/src/main/maker-ipc/botRemoteResourceInvalidation.ts index 9b9d96463ff..2e070c07b56 100644 --- a/apps/desktop/src/main/maker-ipc/botRemoteResourceInvalidation.ts +++ b/apps/desktop/src/main/maker-ipc/botRemoteResourceInvalidation.ts @@ -12,7 +12,7 @@ import { } from '../device-link/broadcast-tap.js'; import { createLogger } from '../logger.js'; import { getDbClient } from '../localDb/client/current.js'; -import { botSessionLinks } from '../localDb/schema.js'; +import { botDelegations, botSessionLinks } from '../localDb/schema.js'; import { activeOwnerScopeKey, isAppSessionBoundaryPending, @@ -108,3 +108,31 @@ export function scheduleBotRemoteResourceChangedForSession( }); }); } + +/** + * A background task the teammate started is one it looks after: once the task exists, + * remote task rows need the roster's follow links again. + */ +export function scheduleBotRemoteResourceChangedForDelegation(delegationId: string, childSessionId: string | null): void { + if (!childSessionId) return; + let ownerScope: string; + try { + ownerScope = activeOwnerScopeKey(); + } catch { + return; + } + void getDbClient().drizzle + .select({ botId: botDelegations.requestingBotId }) + .from(botDelegations) + .where(eq(botDelegations.id, delegationId)) + .limit(1) + .then(([row]) => { + if (!row?.botId || !isOwnerScopeCurrent(ownerScope)) return; + broadcastBotRemoteResourceChanged(row.botId); + }) + .catch((error: unknown) => { + log.warn('resolve teammate delegation invalidation failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); +} diff --git a/apps/desktop/src/main/maker-ipc/botWorkbenchTools.ts b/apps/desktop/src/main/maker-ipc/botWorkbenchTools.ts index 657bc64c780..2150df2a09e 100644 --- a/apps/desktop/src/main/maker-ipc/botWorkbenchTools.ts +++ b/apps/desktop/src/main/maker-ipc/botWorkbenchTools.ts @@ -20,6 +20,7 @@ import { } from '../appSessionState.js'; import type { WorkbenchDelegationStatus, WorkbenchTranscriptItem } from '../../shared/botWorkbench.js'; import { broadcastBotWorkbenchChanged } from './botWorkbenchService.js'; +import { broadcastBotRemoteResourceChanged } from './botRemoteResourceInvalidation.js'; import { botLinkedProjectAccess } from './botLinkedProjects.js'; import { boundTranscript, readSessionTranscript } from './botWorkbenchTranscripts.js'; import { readTaskResponsibilities, responsibleSessionIds, setTaskResponsibility } from './botTaskResponsibility.js'; @@ -203,6 +204,8 @@ export async function captureBotCreatedTaskResponsibility(callerSessionId?: stri assertCurrent(); await setTaskResponsibility(root, botId, sessionId, true, assertCurrent); broadcastBotWorkbenchChanged(botId); + // Remote task rows draw the same mark from the teammate roster's links. + broadcastBotRemoteResourceChanged(botId); }; } @@ -219,6 +222,12 @@ export interface BotWorkbenchHostDeps { readRemoteMessages(deviceId: string, sessionId: string): Promise<{ ok: true; items: WorkbenchTranscriptItem[] } | Failure>; } +/** Desktop sidebar marks re-read the scopes; remote task rows re-read the teammate roster links. */ +function broadcastResponsibilityChanged(botId: string): void { + broadcastBotWorkbenchChanged(botId); + broadcastBotRemoteResourceChanged(botId); +} + /** * 按调用时的账号作用域组装一次工具服务:作用域在调用期间切换则中止,不做投递。 */ @@ -237,7 +246,7 @@ function createDesktopBotWorkbenchAccess(host: BotWorkbenchHostDeps): BotWorkben const result = await host.readRemoteMessages(deviceId, sessionId); return result.ok ? { ok: true as const, transcript: boundTranscript(result.items) } : result; }, - notifyChanged: broadcastBotWorkbenchChanged, + notifyChanged: broadcastResponsibilityChanged, listDelegations: listBotDelegationChildren, readResponsibleSessionIds: (botId) => readBotResponsibleSessionIds(userDataDir, botId), saveResponsibility: (botId, sessionId, responsible) => setTaskResponsibility( diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 5904b0eefb4..d3d882afe30 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -9,6 +9,7 @@ import { readChannelToolScope } from '../im/shared/channelToolScope.js'; import { verifiedOwnerRequestText, type AgentRequestReceipt } from '../../shared/forwardedRequest.js'; import { configureBotTodoDispatch, settleBotTodoForSession } from './botTodoAccess.js'; import { createBotTodoDispatch } from './botTodoDispatch.js'; +import { scheduleBotRemoteResourceChangedForDelegation } from './botRemoteResourceInvalidation.js'; import { assertBotTaskCoordination, classifySessionMessagePurpose, coordinationInput } from './botTaskCoordination.js'; import type { BotTaskCoordination, SessionMessagePurpose } from '../../shared/botTaskCoordination.js'; import { openSession, setSessionOpeningModelAdmission } from '../localDb/sessionOpening.js'; @@ -13035,6 +13036,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) broadcastSessionCreated, onChanged: (payload) => { broadcastToAllWindows(MAKER_PUSH.BOT_DELEGATION_CHANGED, payload); + scheduleBotRemoteResourceChangedForDelegation(payload.delegationId, payload.childSessionId); }, resolveInteraction: resolvePendingInteraction, hasPendingInput: (sessionId) => diff --git a/apps/mobile/src/__tests__/remoteResourceCache.test.ts b/apps/mobile/src/__tests__/remoteResourceCache.test.ts index 17b2aa6ba44..2c059af9aac 100644 --- a/apps/mobile/src/__tests__/remoteResourceCache.test.ts +++ b/apps/mobile/src/__tests__/remoteResourceCache.test.ts @@ -7,7 +7,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ default: { getAllKeys: vi.fn(async () => [...disk.keys()]), multiRemove: vi.fn(async (keys: string[]) => { keys.forEach((key) => disk.delete(key)); }), } })); -import { cacheChatRoster, readCachedChatRoster, cacheRemoteResourceHome, cacheRemoteResourceItems, clearRemoteResourceCache, isRemoteResourceUnread, markRemoteResourceRead, readRemoteResourceSnapshot, remoteResourceCacheRevision, subscribeRemoteResourceCache } from '@/device-link/remoteResourceCache'; +import { cacheChatRoster, readCachedChatRoster, cacheRemoteResourceHome, cacheRemoteResourceItems, cachedTaskFollowers, clearRemoteResourceCache, isRemoteResourceUnread, markRemoteResourceRead, readRemoteResourceSnapshot, remoteResourceCacheRevision, subscribeRemoteResourceCache } from '@/device-link/remoteResourceCache'; import { chatReadAt, chatRoomRow, type ChatSnapshot } from '@/chat/chatServerClient'; const rows = (deviceId: string, lastReplyAt: number) => [{ key: `${deviceId}:bot:writer`, host: { deviceId, deviceName: deviceId }, @@ -40,6 +40,23 @@ it('does not restore routines from an old offline snapshot while retaining compa await cacheRemoteResourceHome('alice', home); expect((await readRemoteResourceSnapshot('alice')).home.map((item) => item.id)).toEqual(['teammates']); }); +// Task rows draw the teammate mark from the roster's `following` links (desktop useSessionFollowers). +// The same task id on another computer, or a link the host withdrew, must never keep a mark. +it('finds the teammates following a task on its own computer, by name, and drops withdrawn links', async () => { + const follower = (deviceId: string, id: string, title: string, sessionIds: string[]) => ({ + key: JSON.stringify([deviceId, 'teammates', 'bot', id]), host: { deviceId, deviceName: deviceId }, + item: { ref: { collectionId: 'teammates', kind: 'bot', id }, display: { title }, revision: '1', + links: sessionIds.map((sessionId) => ({ rel: 'following', target: { kind: 'session' as const, sessionId } })) }, + }); + await cacheRemoteResourceItems('alice', 'teammates', [ + follower('home', 'lizi', 'LiZi', ['task']), follower('home', 'cindy', 'Cindy', ['task']), follower('office', 'dash', 'Dash', ['other']), + ]); + expect(cachedTaskFollowers('alice', 'home', 'task').map((row) => row.item.ref.id)).toEqual(['cindy', 'lizi']); + expect(cachedTaskFollowers('alice', 'office', 'task')).toEqual([]); + expect(cachedTaskFollowers('bob', 'home', 'task')).toEqual([]); + await cacheRemoteResourceItems('alice', 'teammates', [follower('home', 'lizi', 'LiZi', []), follower('home', 'cindy', 'Cindy', ['task'])]); + expect(cachedTaskFollowers('alice', 'home', 'task').map((row) => row.item.ref.id)).toEqual(['cindy']); +}); it('keeps device-qualified read positions and treats only later host replies as unread', async () => { await cacheRemoteResourceItems('alice', 'teammates', [...rows('home', 100), ...rows('office', 100)]); expect(isRemoteResourceUnread('alice', 'home', 'writer', 100)).toBe(false); diff --git a/apps/mobile/src/debug/teammateVisualFixture.ts b/apps/mobile/src/debug/teammateVisualFixture.ts index cb891b32baa..72d78a50f61 100644 --- a/apps/mobile/src/debug/teammateVisualFixture.ts +++ b/apps/mobile/src/debug/teammateVisualFixture.ts @@ -6,6 +6,7 @@ import { type RemoteCollectionListResponse, type RemoteResourceManifestResponse, } from '@cindy/device-link'; +import { UI_MOCK_ID_PREFIX } from '@cindy/maker-shared/ui-mock'; /** * `--mock` teammates (docs/dev-rules/ui-mock-data.md): the roster the teammate home and the task @@ -13,13 +14,16 @@ import { */ const TEAMMATES = 'teammates'; const MINUTE = 60_000; -const teammates: ReadonlyArray<{ id: string; name: string; preview: string; minutesAgo: number; avatar: RemoteCollectionItem['display']['avatar']; working?: boolean }> = [ +const teammates: ReadonlyArray<{ id: string; name: string; preview: string; minutesAgo: number; avatar: RemoteCollectionItem['display']['avatar']; working?: boolean; following?: string[] }> = [ { id: 'visual-bot-cindy', name: 'Cindy', preview: '登录页窄窗口的错位已经修好,截图放在任务里了。', minutesAgo: 2, - avatar: { kind: 'asset', value: 'cindy://avatar/preset/cindy', fallbackText: 'C' }, working: true }, + avatar: { kind: 'asset', value: 'cindy://avatar/preset/cindy', fallbackText: 'C' }, working: true, + // Task rows show the follow mark (`ui-mock` tasks): one shared with LiZi, so the mark picks by name. + following: ['visual-running', `${UI_MOCK_ID_PREFIX}rich-answer`] }, { id: 'visual-bot-dash', name: 'Dash', preview: 'Weekly numbers are in: sign-ups up 12%, churn flat.', minutesAgo: 25, avatar: { kind: 'asset', value: 'cindy://avatar/preset/dash', fallbackText: 'D' } }, { id: 'visual-bot-lizi', name: 'LiZi', preview: '这个 PR 我看完了,留了两条建议。', minutesAgo: 90, - avatar: { kind: 'asset', value: 'cindy://avatar/preset/lizi', fallbackText: 'L' } }, + avatar: { kind: 'asset', value: 'cindy://avatar/preset/lizi', fallbackText: 'L' }, + following: ['visual-running', `${UI_MOCK_ID_PREFIX}english`] }, { id: 'visual-bot-xiaoman', name: '小满', preview: '明天上午十点的会已经帮你约好。', minutesAgo: 60 * 20, avatar: { kind: 'emoji', value: '🌾', fallbackText: '小' } }, { id: 'visual-bot-research', name: 'Research Assistant', preview: 'Three sources disagree on the release date; details inside.', minutesAgo: 60 * 30, @@ -52,7 +56,7 @@ export function teammateVisualFixture(channel: string, args: unknown[], now = FI avatar: bot.avatar, ...(bot.working ? { generation: { phase: 'replying', startedAt: now - MINUTE } } : {}), }, - links: [], + links: (bot.following ?? []).map((sessionId) => ({ rel: 'following', target: { kind: 'session' as const, sessionId } })), revision: 'visual-1', })), } satisfies RemoteCollectionListResponse; diff --git a/apps/mobile/src/device-link/remoteResourceCache.ts b/apps/mobile/src/device-link/remoteResourceCache.ts index 3bc11f7fa2a..3cd9c224f46 100644 --- a/apps/mobile/src/device-link/remoteResourceCache.ts +++ b/apps/mobile/src/device-link/remoteResourceCache.ts @@ -1,4 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; +import { resolveRemoteText } from '@cindy/device-link'; import { isMobileRemoteCollectionSupported, normalizeRemoteCollectionItems, parseRemoteResourceTargets, type HostedRemoteCollectionItem, type RemoteHomeCollection } from './remoteResources'; const PREFIX = 'cindy.remoteResources.v1.'; @@ -176,6 +177,30 @@ export function cachedBotIdForSession(userId: string, collectionId: string, devi && target.kind === 'session' && target.sessionId === sessionId)); return row?.item.ref.id ?? ''; } +const NO_FOLLOWERS: readonly HostedRemoteCollectionItem[] = []; +let followIndex: { userId: string; revision: number; byTask: Map } | null = null; +/** + * Teammates whose roster row follows this task (link rel `following`), by name, as the desktop + * sidebar mark orders them. Same computer only; presentation only, never authorization. + */ +export function cachedTaskFollowers(userId: string, deviceId: string, sessionId: string): readonly HostedRemoteCollectionItem[] { + if (!userId || !deviceId) return NO_FOLLOWERS; + if (followIndex?.userId !== userId || followIndex.revision !== revision) { + const byTask = new Map(); + for (const row of snapshots.get(userId)?.items.teammates ?? []) { + if (row.item.ref.kind !== 'bot') continue; + for (const { rel, target } of row.item.links) { + if (rel !== 'following' || target.kind !== 'session') continue; + const key = JSON.stringify([row.host.deviceId, target.sessionId]); + byTask.set(key, [...(byTask.get(key) ?? []), row]); + } + } + const name = (row: HostedRemoteCollectionItem) => resolveRemoteText(row.item.display.title); + for (const rows of byTask.values()) rows.sort((left, right) => name(left).localeCompare(name(right))); + followIndex = { userId, revision, byTask }; + } + return followIndex.byTask.get(JSON.stringify([deviceId, sessionId])) ?? NO_FOLLOWERS; +} /** A cached roster row for display (name/avatar) only; never an authorization or availability signal. */ export function cachedBotItem(userId: string, collectionId: string, deviceId: string, botId: string) { return snapshots.get(userId)?.items[collectionId]?.find(({ host, item }) => host.deviceId === deviceId diff --git a/apps/mobile/src/device-link/remoteResources.ts b/apps/mobile/src/device-link/remoteResources.ts index f943c4918bf..202a1005de8 100644 --- a/apps/mobile/src/device-link/remoteResources.ts +++ b/apps/mobile/src/device-link/remoteResources.ts @@ -436,8 +436,9 @@ function normalizeRemoteCollectionItem( const display = normalizeRemoteDisplay(item?.display); const revision = boundedString(item?.revision, 1_024); if (!item || !ref || ref.collectionId !== collectionId || !display || !revision) return null; + // A teammate row carries a link per open task it follows (the desktop sends at most 200). const links = Array.isArray(item.links) - ? item.links.slice(0, 64).flatMap((link) => { + ? item.links.slice(0, 256).flatMap((link) => { const normalized = normalizeRemoteLink(link); return normalized ? [normalized] : []; }) diff --git a/apps/mobile/src/i18n/locales/en/devices.json b/apps/mobile/src/i18n/locales/en/devices.json index 434b848cce8..10cdf9261f8 100644 --- a/apps/mobile/src/i18n/locales/en/devices.json +++ b/apps/mobile/src/i18n/locales/en/devices.json @@ -330,6 +330,7 @@ "openAutomationLatest": "Open the latest session of automation {{title}}", "automationTask": "Automation {{title}}", "openConversation": "Open session {{title}}", + "followedBy": ", looked after by {{names}}", "collapse": "Collapse {{title}}", "expand": "Expand {{title}}", "taskError": "Task failed", diff --git a/apps/mobile/src/i18n/locales/ja/devices.json b/apps/mobile/src/i18n/locales/ja/devices.json index 15349e644e8..0aed0bf0c86 100644 --- a/apps/mobile/src/i18n/locales/ja/devices.json +++ b/apps/mobile/src/i18n/locales/ja/devices.json @@ -330,6 +330,7 @@ "openAutomationLatest": "自動化タスク {{title}} の最新セッションを開く", "automationTask": "自動化タスク {{title}}", "openConversation": "セッション {{title}} を開く", + "followedBy": "、担当: {{names}}", "collapse": "{{title}} を折りたたむ", "expand": "{{title}} を展開", "taskError": "タスクでエラー", diff --git a/apps/mobile/src/i18n/locales/ko/devices.json b/apps/mobile/src/i18n/locales/ko/devices.json index 34071ad5095..96783150a49 100644 --- a/apps/mobile/src/i18n/locales/ko/devices.json +++ b/apps/mobile/src/i18n/locales/ko/devices.json @@ -330,6 +330,7 @@ "openAutomationLatest": "자동화 작업 {{title}}의 최근 세션 열기", "automationTask": "자동화 작업 {{title}}", "openConversation": "세션 {{title}} 열기", + "followedBy": ", 담당: {{names}}", "collapse": "{{title}} 접기", "expand": "{{title}} 펼치기", "taskError": "작업 오류", diff --git a/apps/mobile/src/i18n/locales/zh-CN/devices.json b/apps/mobile/src/i18n/locales/zh-CN/devices.json index f11d250e46e..b4837fd4828 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/devices.json +++ b/apps/mobile/src/i18n/locales/zh-CN/devices.json @@ -330,6 +330,7 @@ "openAutomationLatest": "打开自动化 {{title}} 的最新任务", "automationTask": "自动化 {{title}}", "openConversation": "打开任务 {{title}}", + "followedBy": ",{{names}}在跟进", "collapse": "收起 {{title}}", "expand": "展开 {{title}}", "taskError": "任务出错", diff --git a/apps/mobile/src/i18n/locales/zh-TW/devices.json b/apps/mobile/src/i18n/locales/zh-TW/devices.json index b881a8bb3df..c85a98331bd 100644 --- a/apps/mobile/src/i18n/locales/zh-TW/devices.json +++ b/apps/mobile/src/i18n/locales/zh-TW/devices.json @@ -330,6 +330,7 @@ "openAutomationLatest": "開啟自動化 {{title}} 的最新任務", "automationTask": "自動化 {{title}}", "openConversation": "開啟任務 {{title}}", + "followedBy": ",{{names}}在跟進", "collapse": "收起 {{title}}", "expand": "展開 {{title}}", "taskError": "任務出錯", diff --git a/apps/mobile/src/session/HomeSurface.tsx b/apps/mobile/src/session/HomeSurface.tsx index ed0aa5d74c7..43f69a73947 100644 --- a/apps/mobile/src/session/HomeSurface.tsx +++ b/apps/mobile/src/session/HomeSurface.tsx @@ -293,6 +293,7 @@ import { AutomationTimerIcon } from '@/session/AutomationTimerIcon'; import { RenameSessionModal } from '@/session/RenameSessionModal'; import { SessionOptionsPresenter } from '@/session/SessionOptionsExpoSheet'; import { SwipeableSessionRow, type SessionSwipeControls } from '@/session/SwipeableSessionRow'; +import { TaskFollowMark, taskFollowerNames, useTaskFollowers } from '@/session/TaskFollowMark'; import { createSwipeRowRegistry } from '@/session/swipeRowRegistry'; import { useSessionListActions } from '@/session/useSessionListActions'; import { useModalFadeLifecycle } from '@/session/useModalFadeLifecycle'; @@ -4396,9 +4397,11 @@ function HomeSessionRowInner({ const prepareDisclosure = useDisclosurePrepare(); const styles = useThemedStyles(makeStyles); const { colors } = useTheme(); - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); // 运行态走订阅而非命令式读取:行已 memo 化,父层不再逐 emit 重渲染,命令式读取会 stale。 const latestItem = latestMobileSessionRow(item); + // 伙伴开的或接手的任务:标题后带第一位伙伴的头像(与桌面侧栏「在跟进」同口径)。 + const followers = useTaskFollowers(latestItem.session); const sessionIsRunning = useSessionRunning(latestItem.session.id); // 已加载消息的预览按 session 订阅。普通流式 token 只让对应的可见行更新,首页根层、 // sections 和其它任务行都保持原引用。 @@ -4453,11 +4456,14 @@ function HomeSessionRowInner({ }; const groupRowOpensPrimary = !!group && (attention || rightStatus === 'error') && !groupExpanded; const accessibilityTitle = sharedRole === 'owned' ? item.title + ', ' + t('sharedTask.roleHost') : item.title; + const followedBy = !group && followers.length + ? t('devices.list.a11y.followedBy', { names: taskFollowerNames(followers, i18n.language) }) + : ''; const accessibilityLabel = group ? groupRowOpensPrimary ? t('devices.list.a11y.openAutomationLatest', { title: accessibilityTitle }) : t('devices.list.a11y.automationTask', { title: accessibilityTitle }) - : t('devices.list.a11y.openConversation', { title: accessibilityTitle }); + : t('devices.list.a11y.openConversation', { title: accessibilityTitle }) + followedBy; const handlePress = selectionMode && onPressSelection ? onPressSelection : group @@ -4593,6 +4599,9 @@ function HomeSessionRowInner({ {showPinned ? : null} ) : null} + {!group && followers[0] ? ( + + ) : null} {rightStatus === 'time' ? ( // 任务信息(显示菜单勾选):时间 / PR / worktree / Token / 费用,按勾选顺序。 // 状态点 / spinner 优先占这一格,与桌面信息槽让位规则一致。 diff --git a/apps/mobile/src/session/TaskFollowMark.tsx b/apps/mobile/src/session/TaskFollowMark.tsx new file mode 100644 index 00000000000..494e9f7374d --- /dev/null +++ b/apps/mobile/src/session/TaskFollowMark.tsx @@ -0,0 +1,45 @@ +import { useSyncExternalStore } from 'react'; +import { View } from 'react-native'; +import { resolveRemoteText } from '@cindy/device-link'; +import { useAuth } from '@/auth/AuthContext'; +import { RemoteCompanionAvatar } from '@/components/RemoteCompanionAvatar'; +import { useDeviceLink } from '@/device-link/DeviceLinkContext'; +import { cachedTaskFollowers, subscribeRemoteResourceCache } from '@/device-link/remoteResourceCache'; +import type { HostedRemoteCollectionItem } from '@/device-link/remoteResources'; +import { iconSize } from '@/theme/tokens'; + +/** + * Teammates looking after this task: tasks they started or explicitly took over + * (cindy-bots-runtime.md, desktop `useSessionFollowers`). Read from the teammate roster's + * `following` links, so archived tasks and other computers' rosters never mark it. + */ +export function useTaskFollowers(session: { id: string; status: string; deviceLinkDeviceId?: string | null }) { + const userId = useAuth().user?.id ?? ''; + const deviceId = session.status === 'active' ? session.deviceLinkDeviceId ?? '' : ''; + const read = () => cachedTaskFollowers(userId, deviceId, session.id); + return useSyncExternalStore(subscribeRemoteResourceCache, read, read); +} + +/** Names for the row's spoken label, in the order the mark is chosen. */ +export function taskFollowerNames(followers: readonly HostedRemoteCollectionItem[], language: string): string { + const separator = /^(zh|ja)/.test(language) ? '、' : ', '; + return followers.map((row) => resolveRemoteText(row.item.display.title, language)).join(separator); +} + +/** The first teammate's 16pt avatar beside the task title; its identity, not a status. */ +export function TaskFollowMark({ follower, testID }: { follower: HostedRemoteCollectionItem; testID: string }) { + const { status, getPresenceAvailability } = useDeviceLink(); + const { host, item } = follower; + return ( + + + + ); +} diff --git a/docs/dev-rules/protocol-compatibility.md b/docs/dev-rules/protocol-compatibility.md index 01f3750bc21..e3ed002a584 100644 --- a/docs/dev-rules/protocol-compatibility.md +++ b/docs/dev-rules/protocol-compatibility.md @@ -3422,3 +3422,15 @@ namespace + id 下同一插件的有界 JSON 请求;`deviceSync: true` 是独 分享的刷新通知复用已有 `maker:provider:changed` 空载荷,只发给当前被授权的接收方,并核对 对应 host。自定义 API 不进入这套策略与新增刷新规则。详细范围及验证见 [订阅 Fast 控制交接说明](../subscription-fast-policy.md)。 + +## 伙伴名册的跟进任务链接 + +`teammates` 伙伴行的 `links` 在原 `conversation` 链接之后追加 rel `following`,`target` 为 +`{ kind: 'session', sessionId }`,表示该伙伴在跟进的本机任务(伙伴发起的委派子任务与明确接手, +扣除解除记录;来源与桌面侧栏「在跟进」相同)。只列在用伙伴、未归档、本机、普通来源且非 Orca +worker 的任务,按最近更新取前 200 条;伙伴行 `revision` 追加这组 ID 的摘要,变化时照常失效重读。 +责任记录、委派创建/状态变化都会广播伙伴远程资源变化。 + +旧控制端按未知 rel 忽略,`conversation` 仍是第一条链接,打开聊天不变;新控制端连接旧 Host +时没有该链接,任务行不显示标记。Mobile 读取的链接上限由 64 放宽到 256 以容纳这组链接。 +不增加 IPC channel、请求参数、数据库字段、push 类型或 Mobile 原生配置。 diff --git a/docs/product-rules/cindy-bots-runtime.md b/docs/product-rules/cindy-bots-runtime.md index 3206de7424c..cec7d01da28 100644 --- a/docs/product-rules/cindy-bots-runtime.md +++ b/docs/product-rules/cindy-bots-runtime.md @@ -958,6 +958,8 @@ helper 与 Orca 的写操作均使用同一逐次审批策略;Orca 的只读 创建来源;损坏记录失败关闭,该伙伴不认领任何任务;账号切换时丢弃旧读取与写入;跨进程锁 + 原子替换,容量溢出明确拒绝。 同目录、标题、最近消息都不是责任证据,不做回填。 - 侧栏「在跟进」标记只在任务 ID 对应可验证责任关系时显示伙伴头像;只认本机、在用、未隐藏的伙伴。 + 手机任务列表(首页、电脑页)在标题行右侧用 16pt 伙伴头像表达同一关系,数据来自伙伴名册的 `following` 链接(见 + protocol-compatibility.md「伙伴名册的跟进任务链接」),只标该电脑自己的伙伴;多个伙伴时显示名字排序第一位,读屏标签列出全部;归档任务不标。 #### 6.3.6 伙伴 Todo From 79e79652577a4eed1445b992f873b105228ce778 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:00:26 +0800 Subject: [PATCH 2/2] fix(mobile): keep task follow marks current after restore, on computer pages and cold start - Refresh the teammates following a task when it is archived or restored - Keep the teammate roster current on the standalone computer task page - Notify rows that read before the saved roster loaded from disk - Regenerate the design inventory Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- apps/desktop/src/main/localDb/ipc/sessions.ts | 15 +++++++ .../botRemoteResourceInvalidation.test.ts | 29 +++++++++++++- .../botRemoteResourceInvalidation.ts | 39 ++++++++++++++++++- apps/desktop/src/main/maker-ipc/register.ts | 8 +++- apps/mobile/app/devices/[deviceId].tsx | 3 ++ .../src/__tests__/remoteResourceCache.test.ts | 18 +++++++++ .../src/device-link/remoteResourceCache.ts | 6 ++- docs/design-rules/design-inventory.md | 6 +-- 8 files changed, 117 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/localDb/ipc/sessions.ts b/apps/desktop/src/main/localDb/ipc/sessions.ts index 459cab8d15a..16a42f81cd7 100644 --- a/apps/desktop/src/main/localDb/ipc/sessions.ts +++ b/apps/desktop/src/main/localDb/ipc/sessions.ts @@ -446,6 +446,13 @@ function getSafeOwnerPushStamp(): ReturnType void) | null = null; + +/** Archive / restore observer, set at registration so this module does not load teammate dependencies. */ +export function setSessionStatusPatchedListener(next: ((sessionId: string) => void) | null): void { + sessionStatusPatchedListener = next; +} + export function broadcastSessionPatched( sessionId: string, patch: Record, @@ -479,6 +486,14 @@ export function broadcastSessionPatched( error: error instanceof Error ? error.message : String(error), }); } + // Archive / restore changes which open tasks a teammate's roster row links as followed. + if (patch.status !== undefined && !isProvisionalSession(sessionId)) { + try { + sessionStatusPatchedListener?.(sessionId); + } catch { + // Teammate roster refresh never blocks the task patch itself. + } + } let windows: ReturnType = []; try { windows = BrowserWindow.getAllWindows(); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botRemoteResourceInvalidation.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botRemoteResourceInvalidation.test.ts index a7866881e3c..144916d997e 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/botRemoteResourceInvalidation.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/botRemoteResourceInvalidation.test.ts @@ -10,6 +10,8 @@ const h = vi.hoisted(() => ({ condition: undefined as SQL | undefined, ownerScope: 'owner-a', tap: vi.fn(), + realDb: false, + records: {} as Record>, })); vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })); @@ -20,9 +22,16 @@ vi.mock('../../device-link/broadcast-tap.js', () => ({ vi.mock('../../appSessionState.js', () => ({ activeOwnerScopeKey: () => h.ownerScope, isAppSessionBoundaryPending: () => h.boundaryPending, + ownerScopedUserDataPath: () => '/owner-a', +})); +vi.mock('../botTaskResponsibility.js', () => ({ + readTaskResponsibilities: async (_root: string, botId: string) => { + if (botId === 'bot-damaged') throw new Error('Invalid task responsibility store'); + return h.records[botId] ?? {}; + }, })); vi.mock('../../localDb/client/current.js', () => ({ - getDbClient: () => ({ + getDbClient: () => (h.realDb ? { drizzle: db } : { drizzle: { select: () => ({ from: () => ({ @@ -34,11 +43,14 @@ vi.mock('../../localDb/client/current.js', () => ({ })); import { + scheduleBotRemoteResourceChangedForFollowedSession, scheduleBotRemoteResourceChangedForSession, } from '../botRemoteResourceInvalidation.js'; const sqlite = new Database(':memory:'); sqlite.exec("CREATE TABLE bot_session_links (id TEXT PRIMARY KEY, bot_id TEXT, session_id TEXT, role TEXT, archived_at INTEGER); INSERT INTO bot_session_links VALUES ('c', 'bot-1', 'session-1', 'canonical', NULL), ('d', 'bot-1', 'delegated', 'delegation', NULL), ('a', 'bot-1', 'archived', 'delegation', 1), ('h', 'bot-1', 'history', 'history', NULL)"); +sqlite.exec("CREATE TABLE bot_profiles (id TEXT PRIMARY KEY, status TEXT); INSERT INTO bot_profiles VALUES ('bot-starter', 'active'), ('bot-taker', 'active'), ('bot-other', 'active'), ('bot-damaged', 'active'), ('bot-paused', 'paused')"); +sqlite.exec("CREATE TABLE bot_delegations (id TEXT PRIMARY KEY, requesting_bot_id TEXT, child_session_id TEXT); INSERT INTO bot_delegations VALUES ('d1', 'bot-starter', 'task-1')"); const db = drizzle(sqlite); afterAll(() => sqlite.close()); @@ -84,4 +96,19 @@ describe('Bot remote resource message invalidation', () => { await new Promise(resolve => setTimeout(resolve, 0)); expect(h.tap).not.toHaveBeenCalled(); }); + + // Roster rows link only open tasks: restoring one must refresh its followers' rows (#7066 review). + it('refreshes the teammates that started or took over a task whose archive state changed', async () => { + h.realDb = true; + h.records = { 'bot-taker': { 'task-1': { responsible: true, updatedAt: '2026-10-24T00:00:00Z' } }, + 'bot-paused': { 'task-1': { responsible: true, updatedAt: '2026-10-24T00:00:00Z' } } }; + try { + scheduleBotRemoteResourceChangedForFollowedSession('task-1'); + await vi.waitFor(() => expect(h.tap).toHaveBeenCalledTimes(2)); + expect(h.tap.mock.calls.map((call) => call[1].resourceRefs[0].id).sort()).toEqual(['bot-starter', 'bot-taker']); + } finally { + h.realDb = false; + h.records = {}; + } + }); }); diff --git a/apps/desktop/src/main/maker-ipc/botRemoteResourceInvalidation.ts b/apps/desktop/src/main/maker-ipc/botRemoteResourceInvalidation.ts index 2e070c07b56..53ace1c1178 100644 --- a/apps/desktop/src/main/maker-ipc/botRemoteResourceInvalidation.ts +++ b/apps/desktop/src/main/maker-ipc/botRemoteResourceInvalidation.ts @@ -12,11 +12,13 @@ import { } from '../device-link/broadcast-tap.js'; import { createLogger } from '../logger.js'; import { getDbClient } from '../localDb/client/current.js'; -import { botDelegations, botSessionLinks } from '../localDb/schema.js'; +import { botDelegations, botProfiles, botSessionLinks } from '../localDb/schema.js'; import { activeOwnerScopeKey, isAppSessionBoundaryPending, + ownerScopedUserDataPath, } from '../appSessionState.js'; +import { readTaskResponsibilities } from './botTaskResponsibility.js'; const log = createLogger('maker-ipc:bot-remote-resource'); @@ -136,3 +138,38 @@ export function scheduleBotRemoteResourceChangedForDelegation(delegationId: stri }); }); } + +/** + * Roster rows link only open tasks, so archiving or restoring a task changes the rows of the + * teammates that started it or took it over. Extra rows re-read harmlessly; a missed one + * would leave the mark stale. + */ +export function scheduleBotRemoteResourceChangedForFollowedSession(sessionId: string): void { + let ownerScope: string; + try { + ownerScope = activeOwnerScopeKey(); + } catch { + return; + } + void (async () => { + const db = getDbClient().drizzle; + const [started, bots] = await Promise.all([ + db.select({ botId: botDelegations.requestingBotId }).from(botDelegations) + .where(eq(botDelegations.childSessionId, sessionId)), + db.select({ id: botProfiles.id }).from(botProfiles).where(eq(botProfiles.status, 'active')), + ]); + const botIds = new Set(started.map((row) => row.botId)); + const root = ownerScopedUserDataPath(); + await Promise.all(bots.filter(({ id }) => !botIds.has(id)).map(async ({ id }) => { + // A damaged store follows nothing (readFollowedTaskIds), so there is no row to refresh. + const records = await readTaskResponsibilities(root, id).catch(() => ({})); + if (Object.hasOwn(records, sessionId)) botIds.add(id); + })); + if (!isOwnerScopeCurrent(ownerScope)) return; + for (const botId of botIds) broadcastBotRemoteResourceChanged(botId); + })().catch((error: unknown) => { + log.warn('resolve followed task invalidation failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); +} diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index d3d882afe30..cae95fc814e 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -9,7 +9,10 @@ import { readChannelToolScope } from '../im/shared/channelToolScope.js'; import { verifiedOwnerRequestText, type AgentRequestReceipt } from '../../shared/forwardedRequest.js'; import { configureBotTodoDispatch, settleBotTodoForSession } from './botTodoAccess.js'; import { createBotTodoDispatch } from './botTodoDispatch.js'; -import { scheduleBotRemoteResourceChangedForDelegation } from './botRemoteResourceInvalidation.js'; +import { + scheduleBotRemoteResourceChangedForDelegation, + scheduleBotRemoteResourceChangedForFollowedSession, +} from './botRemoteResourceInvalidation.js'; import { assertBotTaskCoordination, classifySessionMessagePurpose, coordinationInput } from './botTaskCoordination.js'; import type { BotTaskCoordination, SessionMessagePurpose } from '../../shared/botTaskCoordination.js'; import { openSession, setSessionOpeningModelAdmission } from '../localDb/sessionOpening.js'; @@ -508,6 +511,7 @@ import { applyAgentSwitchToSessionRow, applyAgentSwitchResumeFallbackAtomically, broadcastSessionPatched, + setSessionStatusPatchedListener, captureSessionRecycleScope, clearSessionContextInDb, createSessionRemoteHostIdReader, @@ -12927,6 +12931,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) // Phones reach groups through the Remote Resource protocol (bot-group-chat.md §8). registerBotGroupRemoteResourceProvider(() => botGroupChatServiceHolder); botDelegationServiceHolder?.dispose(); + // Archive / restore changes the open tasks a teammate's roster row links as followed. + setSessionStatusPatchedListener(scheduleBotRemoteResourceChangedForFollowedSession); botDelegationServiceHolder = createBotDelegationService({ captureRequest: captureSessionAgentRequest, readSessionExecution: id => { diff --git a/apps/mobile/app/devices/[deviceId].tsx b/apps/mobile/app/devices/[deviceId].tsx index 687fb53323a..4518a2e162b 100644 --- a/apps/mobile/app/devices/[deviceId].tsx +++ b/apps/mobile/app/devices/[deviceId].tsx @@ -83,6 +83,7 @@ import { import { useConversationSearch } from '@/session/useConversationSearch'; import { selectVisibleDeviceSessions, sessionMatchesProjectDir } from '@/session/mobileHome'; import { HomeListViewportContext, HomeSessionRow } from '@/session/HomeSurface'; +import { useTeammateRoster } from '@/session/useTeammateRoster'; import { createDisclosureListCell, ListDisclosureScope, useListDisclosureTransition } from '@/session/listDisclosureTransition'; import { RenameSessionModal } from '@/session/RenameSessionModal'; import { SessionOptionsPresenter } from '@/session/SessionOptionsExpoSheet'; @@ -145,6 +146,8 @@ const DeviceListCell = createDisclosureListCell(); function DeviceDetailScreenContent() { const viewport = useContext(HomeListViewportContext); const screenFocused = useIsFocused(); + // Task rows mark the teammate looking after them from the roster cache; keep it current here too. + useTeammateRoster(screenFocused); const screenFocusedRef = useRef(screenFocused); screenFocusedRef.current = screenFocused; const [appStateActive, setAppStateActive] = useState(AppState.currentState === 'active'); diff --git a/apps/mobile/src/__tests__/remoteResourceCache.test.ts b/apps/mobile/src/__tests__/remoteResourceCache.test.ts index 2c059af9aac..c93fa1a30ec 100644 --- a/apps/mobile/src/__tests__/remoteResourceCache.test.ts +++ b/apps/mobile/src/__tests__/remoteResourceCache.test.ts @@ -57,6 +57,24 @@ it('finds the teammates following a task on its own computer, by name, and drops await cacheRemoteResourceItems('alice', 'teammates', [follower('home', 'lizi', 'LiZi', []), follower('home', 'cindy', 'Cindy', ['task'])]); expect(cachedTaskFollowers('alice', 'home', 'task').map((row) => row.item.ref.id)).toEqual(['cindy']); }); +// A task row can ask before the disk cache answers on a cold start (#7066 review). +it('tells rows that read before the saved roster loaded once it arrives', async () => { + disk.set('cindy.remoteResources.v1.alice', JSON.stringify({ items: { teammates: [{ + key: JSON.stringify(['home', 'teammates', 'bot', 'cindy']), host: { deviceId: 'home', deviceName: 'home' }, + item: { ref: { collectionId: 'teammates', kind: 'bot', id: 'cindy' }, display: { title: 'Cindy' }, revision: '1', + links: [{ rel: 'following', target: { kind: 'session', sessionId: 'task' } }] }, + }] } })); + expect(cachedTaskFollowers('alice', 'home', 'task')).toEqual([]); + const listener = vi.fn(); + const unsubscribe = subscribeRemoteResourceCache(listener); + try { + await readRemoteResourceSnapshot('alice'); + expect(listener).toHaveBeenCalledOnce(); + expect(cachedTaskFollowers('alice', 'home', 'task').map((row) => row.item.ref.id)).toEqual(['cindy']); + } finally { + unsubscribe(); + } +}); it('keeps device-qualified read positions and treats only later host replies as unread', async () => { await cacheRemoteResourceItems('alice', 'teammates', [...rows('home', 100), ...rows('office', 100)]); expect(isRemoteResourceUnread('alice', 'home', 'writer', 100)).toBe(false); diff --git a/apps/mobile/src/device-link/remoteResourceCache.ts b/apps/mobile/src/device-link/remoteResourceCache.ts index 3cd9c224f46..8ac9d959650 100644 --- a/apps/mobile/src/device-link/remoteResourceCache.ts +++ b/apps/mobile/src/device-link/remoteResourceCache.ts @@ -76,7 +76,11 @@ export async function readRemoteResourceSnapshot(userId: string): Promise