diff --git a/.agents/skills/verify-deepseekbot/features/README.md b/.agents/skills/verify-deepseekbot/features/README.md index 520dab909b..2748b78d07 100644 --- a/.agents/skills/verify-deepseekbot/features/README.md +++ b/.agents/skills/verify-deepseekbot/features/README.md @@ -45,7 +45,7 @@ One file per user-facing feature. Each file has four sections: `Sub-features`, ` | Disposable Memory history, restore and stale-head refusal | no | `scripts/e2e-memory-restore.mjs` | [memory-restore.md](memory-restore.md) | | Version-bound Memory images, Markdown previews and CLI export | no | `scripts/e2e-memory-images.mjs`, `scripts/e2e-memory-markdown.mjs`, `scripts/e2e-memory-formats.mjs` | [memory-images.md](memory-images.md) | -| Activity Center: approvals, Channel mute and independent control-rail indicator; page and Dock | yes (real pending requests) | `scripts/e2e-inbox-tool-approval.mjs`, `scripts/e2e-activity-live.mjs`, `scripts/e2e-activity-approve-sync.mjs`, `scripts/e2e-activity-load.mjs` | [activity-inline-approval.md](activity-inline-approval.md) | +| Activity Center: approvals, Remote Host actions, Channel mute and independent control-rail indicator; page and Dock | yes (real pending requests) | `scripts/e2e-inbox-tool-approval.mjs`, `scripts/e2e-activity-live.mjs`, `scripts/e2e-activity-approve-sync.mjs`, `scripts/e2e-activity-load.mjs` | [activity-inline-approval.md](activity-inline-approval.md) | ## Coverage and maintenance @@ -94,3 +94,4 @@ Historical failures from 2026-10-14 (not requalified by this audit): | Three-action tool approval and coherent Full Access | yes | `scripts/e2e-tool-approval-simple.mjs` | [tool-approval-simple.md](tool-approval-simple.md) | | Workspace request read/write approval and original-task continuation | yes | `scripts/e2e-workspace-request-write.mjs`, `scripts/e2e-mobile-folder-browse-web.mjs` | [workspace-request-write.md](workspace-request-write.md) | + diff --git a/.agents/skills/verify-deepseekbot/features/activity-inline-approval.md b/.agents/skills/verify-deepseekbot/features/activity-inline-approval.md index 11aca53c12..a08e982cc2 100644 --- a/.agents/skills/verify-deepseekbot/features/activity-inline-approval.md +++ b/.agents/skills/verify-deepseekbot/features/activity-inline-approval.md @@ -131,3 +131,13 @@ Seed Group messages and Human mentions through a real Bot turn using `channel_se Right-click the desktop control-rail Activity button, or focus it and press Shift+F10/ContextMenu. Verify All notifications, Needs you, Related to you, Other unread and Off as a single selection. Context-menu open must not toggle the Activity panel; Escape/selection must restore focus and outside dismissal must keep the new target's focus. Off hides only the dot: normal click and the shortcut still open Activity, and the accessible label retains true counts. Read retained replies and handled requests never light the corresponding scope. Use real ordinary messages, Human mentions/replies and a pending Group join request. Compare every indicator scope against CLI `inbox-status` messageCounts and existing Inbox queries. Mute/unmute through the Channel menu and CLI, mark the latest message read, and resolve the disposable join request; verify status refresh changes the dot while filters remain independent. Change Activity search/category and clear its filters, then change the indicator scope; neither setting may overwrite the other. Check persistence after reload, same-origin second-window synchronization, and a different origin or browser context staying independent. Invalid stored values return to All. A deliberately failed status request must show unknown rather than a fabricated zero; Off remains off through failure/recovery. Label this injected transport failure separately from real outages. Capture menu and dot states in light/dark and inspect browser errors and canonical read state. The narrow native sidebar entry is outside the control-rail preference's scope. + +## Remote Host Activity actions (#2500) + +The page and Dock group each paired Remote Host's Needs you, For you and Other unread under its Host name and a Remote mark. HumanInboxView receives a Host-scoped source for identities, commands and request cards. Approvals and questions reuse the existing cards with the shared request module; join requests, snooze and clear use shared attention-actions. Source links open the remote conversation, not an exact historical message. Search, category, history and sorting query each Host. The Bot and Channel pickers contain This Host's identities, so either filter hides remote groups. + +Use two task-local Hosts with different home basenames and ports. Pair B from A through Add remote in the header add menu. Retain the browser profile for baseline and changed captures. Give their built-in Tob different display names and use distinct DM messages to prove same-slug isolation. Use disposable ordinary Bots with deepseek-v4-pro for real pending approvals, questions, joins and group mentions. Never fabricate Inbox rows. + +Open Activity from A's rail and narrow page. Inspect all three sections, names and avatars in light/dark. Open B's approval and question details; submit a decision/answer, and verify B's canonical state changes while A has no matching write. Snooze a join, inspect Later, cancel snooze, then accept or decline it. Clear a reply and verify B's dismissed query; failures must stay visible, not look successful. Source links open B's conversation and the narrow Back path returns to Activity. Search and load-more use B's device gateway. + +Stop only B's recorded PID to inspect offline cached rows, then restart that task Profile. Revoke A's paired device from B and confirm refused rows disappear. Keep ARIA, console and canonical evidence for each exercised path; label unrun paths separately. This logical two-Host browser journey does not qualify DSH Desktop, physical-machine tailnet or native iOS/Android. Host looks are device-local by design. Full remote conversation images/cards are a separate track. diff --git a/.github/pr-screenshots/2500-t3/a-d.png b/.github/pr-screenshots/2500-t3/a-d.png new file mode 100644 index 0000000000..63496597a6 Binary files /dev/null and b/.github/pr-screenshots/2500-t3/a-d.png differ diff --git a/.github/pr-screenshots/2500-t3/a-l.png b/.github/pr-screenshots/2500-t3/a-l.png new file mode 100644 index 0000000000..c678c24ca5 Binary files /dev/null and b/.github/pr-screenshots/2500-t3/a-l.png differ diff --git a/.github/pr-screenshots/2500-t3/b-d.png b/.github/pr-screenshots/2500-t3/b-d.png new file mode 100644 index 0000000000..5667fee4c6 Binary files /dev/null and b/.github/pr-screenshots/2500-t3/b-d.png differ diff --git a/.github/pr-screenshots/2500-t3/b-l.png b/.github/pr-screenshots/2500-t3/b-l.png new file mode 100644 index 0000000000..f0493dfb39 Binary files /dev/null and b/.github/pr-screenshots/2500-t3/b-l.png differ diff --git a/.github/pr-screenshots/2500-t3/e-d.png b/.github/pr-screenshots/2500-t3/e-d.png new file mode 100644 index 0000000000..5b2d1f64c9 Binary files /dev/null and b/.github/pr-screenshots/2500-t3/e-d.png differ diff --git a/.github/pr-screenshots/2500-t3/e-l.png b/.github/pr-screenshots/2500-t3/e-l.png new file mode 100644 index 0000000000..b6be0b9625 Binary files /dev/null and b/.github/pr-screenshots/2500-t3/e-l.png differ diff --git a/.github/pr-screenshots/2500-t3/f-d.png b/.github/pr-screenshots/2500-t3/f-d.png new file mode 100644 index 0000000000..049498bd4e Binary files /dev/null and b/.github/pr-screenshots/2500-t3/f-d.png differ diff --git a/.github/pr-screenshots/2500-t3/f-l.png b/.github/pr-screenshots/2500-t3/f-l.png new file mode 100644 index 0000000000..ec27a542cb Binary files /dev/null and b/.github/pr-screenshots/2500-t3/f-l.png differ diff --git a/.github/pr-screenshots/2500-t3/open.png b/.github/pr-screenshots/2500-t3/open.png new file mode 100644 index 0000000000..c11836a776 Binary files /dev/null and b/.github/pr-screenshots/2500-t3/open.png differ diff --git a/.github/pr-screenshots/2500-t3/p-d.png b/.github/pr-screenshots/2500-t3/p-d.png new file mode 100644 index 0000000000..7d345d6154 Binary files /dev/null and b/.github/pr-screenshots/2500-t3/p-d.png differ diff --git a/.github/pr-screenshots/2500-t3/p-l.png b/.github/pr-screenshots/2500-t3/p-l.png new file mode 100644 index 0000000000..8b0407d7ae Binary files /dev/null and b/.github/pr-screenshots/2500-t3/p-l.png differ diff --git a/.github/pr-screenshots/2500-t3/s-d.png b/.github/pr-screenshots/2500-t3/s-d.png new file mode 100644 index 0000000000..2f340ba69b Binary files /dev/null and b/.github/pr-screenshots/2500-t3/s-d.png differ diff --git a/.github/pr-screenshots/2500-t3/s-l.png b/.github/pr-screenshots/2500-t3/s-l.png new file mode 100644 index 0000000000..2f62758af2 Binary files /dev/null and b/.github/pr-screenshots/2500-t3/s-l.png differ diff --git a/changes/2500-remote-activity.md b/changes/2500-remote-activity.md new file mode 100644 index 0000000000..c79463d8e4 --- /dev/null +++ b/changes/2500-remote-activity.md @@ -0,0 +1,17 @@ +--- +section: Added +--- + +## en + +- Activity Center groups paired Remote Hosts' requests, replies and unread conversations by Host. Remote names and avatars stay scoped to their Host; approvals, questions, join requests, snooze and clear act on that Host, and source links open its conversation ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). +- Remote read and submission failures show a short translated retry hint while keeping loaded items and diagnostic causes. Decisions already handled on another device or expired do not count as a successful submission ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). +- Unreadable remote activity items show a retry hint while valid items remain usable; retrying a failed conversation open targets the same Host ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). +- Switching remote Hosts or Channels clears the previous conversation's unsent draft in Activity Center and the conversation panel, preventing it from being sent to the new destination ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). + +## zh + +- 活动中心按 Host 展示已配对远程的请求、回复和未读会话,使用各自的名称与头像;审批、问题、加入请求、稍后处理和清除均发送到对应 Host,来源链接打开其会话([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 +- 远程读取和提交失败时保留已有项目与诊断原因,并显示简短的本地化重试提示;已被另一设备处理或已过期的决定不再计为本次提交成功([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 +- 远程活动中无法读取的项目会显示重试提示,有效项目仍可操作;打开会话失败后,重试仍指向同一 Host([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 +- 活动中心和会话面板切换远程 Host 或频道时会清空原会话尚未发送的草稿,避免误发给新来源([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index 02a65b8450..f6fcf46027 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -1138,7 +1138,7 @@ Chats shares its persisted device-local conversation filter with Activity. The n The application-defined browser `RemoteHosts` owner keeps its pairings and selected Host tab in device-local storage. Each Remote Host reuses the phone's Host client and conversation module through the device gateway; This Host never relays the Remote Host's credential. The shared `createInbox(client, { activity: true })` supplies each Remote Host's Human Inbox and unread snapshot. The first browser subscriber starts the client and Inbox, and the last stops them while retaining the last Inbox snapshot. Authenticated avatar fetches publish an immutable URL-to-object-URL map; removal, roster replacement and final unsubscribe release obsolete objects and discard late fetch results. The storage-key rename imports existing browser pairings and the selected tab. See [ADR-0242](../adr/0242-a-browser-pairs-other-computers-through-their-device-gateway.md) and [ADR-0243](../adr/0243-every-client-reads-every-host-through-one-host-feed.md). -This scaffold does not yet display remote avatars, activity, unread counts or Activity Center items. This Host still reads its existing bridge/store until those Host Feed slices migrate. Device Notification Preference remains a subsequent device-owned slice; the browser has no closed-page notification path. +Activity Center consumes each Remote Host's shared Inbox through `RemoteHosts.activity`, reusing the existing `HumanInboxView` for Host groups. Its Host-scoped source supplies identity, pagination, source navigation and commands; it never receives This Host's `BridgeActions`. `createHostActivityActions` adapts the shared attention and request operations to the existing approval and question cards. Each operation resolves the current pairing before calling its Host client, and confirmed changes refresh that Host's Inbox. Filtered observers share the Host client and stop with their last subscriber. This Host still reads its existing bridge/store until that Host Feed slice migrates. Device Notification Preference remains a subsequent device-owned slice; the browser has no closed-page notification path. ### Version-bound Memory image previews (#2214, first Web slice) diff --git a/packages/client/src/client/activity-center-panel.tsx b/packages/client/src/client/activity-center-panel.tsx index e939726740..5b0c825314 100644 --- a/packages/client/src/client/activity-center-panel.tsx +++ b/packages/client/src/client/activity-center-panel.tsx @@ -1,12 +1,13 @@ import { ActivityInboxHeader } from './activity-inbox-menu.js'; import type { BridgeActions } from './actions.js'; import { ActivityCenterView } from './activity-center-view.js'; -import { ACTIVITY_CENTER_PANEL, type DockPanel } from './dock-panels.js'; +import { ACTIVITY_CENTER_PANEL, type DockPanel, type DockPanelRegistry } from './dock-panels.js'; import type { BotHarnessTranslate } from './locale.js'; export function createActivityCenterPanel( actions: BridgeActions, t: BotHarnessTranslate, + registry?: DockPanelRegistry, ): DockPanel { return { id: ACTIVITY_CENTER_PANEL, @@ -16,6 +17,8 @@ export function createActivityCenterPanel( order: -100, defaultPosition: { zone: 'right' }, headerAction: () => , - component: () => , + component: () => ( + + ), }; } diff --git a/packages/client/src/client/activity-center-view.tsx b/packages/client/src/client/activity-center-view.tsx index 1b229c92d5..9c3fe9b27e 100644 --- a/packages/client/src/client/activity-center-view.tsx +++ b/packages/client/src/client/activity-center-view.tsx @@ -1,5 +1,9 @@ import { ActivityInboxHeader } from './activity-inbox-menu.js'; -import { useMemo, type ReactElement } from 'react'; +import { useMemo, useSyncExternalStore, type ReactElement } from 'react'; +import { conversationKey, remoteHosts } from './remote-hosts.js'; +import { RemoteConversationView } from './remote-conversation-view.js'; +import { ActivityHostHeading, RemoteActivityGroup } from './remote-activity.js'; +import type { DockPanelRegistry } from './dock-panels.js'; import type { BridgeActions } from './actions.js'; import { HumanInboxView } from './human-inbox-view.js'; import { zhTranslate, type BotHarnessTranslate } from './locale.js'; @@ -21,12 +25,16 @@ export function ActivityCenterView({ actions, t = zhTranslate, placement = 'page', + registry, }: { actions: BridgeActions; t?: BotHarnessTranslate; placement?: 'page' | 'panel'; + registry?: DockPanelRegistry | undefined; }): ReactElement { const panel = placement === 'panel'; + const sources = remoteHosts(); + const remote = useSyncExternalStore(sources.subscribe, sources.getSnapshot); const { feed, query, pages, collapsed } = useActivityFeed(actions); const scoped = useMemo( () => @@ -48,6 +56,8 @@ export function ActivityCenterView({ detach?.(); }; }, [actions, panel, feed]); + if (!panel && !registry?.available() && remote.open !== undefined) + return ; return (
)}
+ {remote.hosts.length > 0 ? ( + + ) : null} {feed.visible().map((section) => { const page = pages[section]; const count = @@ -101,6 +114,17 @@ export function ActivityCenterView({ /> ); })} + {remote.hosts.map((host) => ( + + ))}
); diff --git a/packages/client/src/client/activity-inbox-styles.ts b/packages/client/src/client/activity-inbox-styles.ts index d41d67f638..b86b65a613 100644 --- a/packages/client/src/client/activity-inbox-styles.ts +++ b/packages/client/src/client/activity-inbox-styles.ts @@ -2,13 +2,13 @@ import { color, radius, space, text } from './ds/tokens.stylex.js'; export const ACTIVITY_INBOX_CSS = ` .bh-activity-triage { min-width: 0; } -.bh-activity-triage .bh-activity-sections > .bh-human-inbox { min-width: 0; padding: 0; } +.bh-activity-triage .bh-activity-sections .bh-human-inbox { min-width: 0; padding: 0; } .bh-activity-triage .bh-activity-inbox { width: 100%; max-width: none; min-width: 0; margin: 0; padding: ${space.x1}; box-sizing: border-box; } .bh-activity-triage.bh-activity-center-panel .bh-activity-inbox { padding: 0; } .bh-activity-triage .bh-activity-center-header { display: flex; align-items: center; justify-content: space-between; padding: ${space.x1} ${space.x3}; } .bh-activity-triage .bh-activity-center-header h1 { margin: 0; } .bh-activity-triage .bh-human-inbox-workspace { display: block; } -.bh-activity-triage .bh-human-inbox-detail { grid-column: 1 / -1; min-width: 0; } +.bh-activity-triage .bh-human-inbox-detail { position: relative; grid-column: 1 / -1; min-width: 0; } .bh-activity-header-actions { margin: 0; padding: 0; height: auto; min-width: 0; } .bh-activity-header-actions:has(.bh-search-open) { flex: 1; } .bh-activity-header-actions .bh-search-open { width: 100%; margin-inline: 0; } diff --git a/packages/client/src/client/bot-main.tsx b/packages/client/src/client/bot-main.tsx index 05d4625a29..da60f9851d 100644 --- a/packages/client/src/client/bot-main.tsx +++ b/packages/client/src/client/bot-main.tsx @@ -3280,7 +3280,7 @@ function ConversationView({ state.selection === undefined ? ( ) : state.selection.kind === 'inbox' ? ( - + ) : ( conversationPane ); @@ -3517,7 +3517,7 @@ function BotMainContent({ const workspace = useContext(WorkspaceContext); if (!workspace && state.selection === undefined) return ; if (!workspace && state.selection?.kind === 'inbox') - return ; + return ; const scopeKey = state.selection === undefined ? 'welcome' diff --git a/packages/client/src/client/bridge.ts b/packages/client/src/client/bridge.ts index 4057c2a25b..b842ae8d0f 100644 --- a/packages/client/src/client/bridge.ts +++ b/packages/client/src/client/bridge.ts @@ -3889,7 +3889,7 @@ export async function loadBotAttention( return parseBotAttentionPage(await unwrap(call, 'botAttention', { slug, limit, cursor, state })); } -function parseHumanAttentionPage(value: unknown): HumanAttentionPage { +export function parseHumanAttentionPage(value: unknown): HumanAttentionPage { const row = asRecord(value); const raw = row?.['items']; if (!Array.isArray(raw)) throw new Error('invalid Human attention page'); diff --git a/packages/client/src/client/channel-dock.tsx b/packages/client/src/client/channel-dock.tsx index 801147318b..bb4092531b 100644 --- a/packages/client/src/client/channel-dock.tsx +++ b/packages/client/src/client/channel-dock.tsx @@ -1,6 +1,7 @@ import { mountWorkspaceChrome, updateWorkspaceChrome } from './workspace-chrome.js'; import { useRemoteHosts } from './host-tabs.js'; import { RemoteConversationView } from './remote-conversation-view.js'; +import { conversationKey } from './remote-hosts.js'; import { AppearancePanelHeader } from './appearance-panel-header.js'; import { AppearanceContext } from './appearance-context.js'; import { PanelAppearanceSettings } from './appearance-panel-settings.js'; @@ -328,7 +329,8 @@ function workspaceState(dock: DockContextValue, id: string): SubpanelState | und function ChannelBodyPanel(): ReactElement { const dock = useDock(); const remote = useRemoteHosts().open; - if (remote !== undefined) return ; + if (remote !== undefined) + return ; const panels: Record = { [MESSAGE_LEAF]: { label: dock.t('workspace.messages'), content: }, }; diff --git a/packages/client/src/client/host-activity-actions.ts b/packages/client/src/client/host-activity-actions.ts new file mode 100644 index 0000000000..b18f0b9c04 --- /dev/null +++ b/packages/client/src/client/host-activity-actions.ts @@ -0,0 +1,143 @@ +import { + allowAutoRejectionOnce, + clearAttentionItem, + decideJoinRequest, + snoozeAttentionItem, +} from '../../../../apps/mobile/src/host/attention-actions.js'; +import type { HostClient } from '../../../../apps/mobile/src/host/client.js'; +import { + answerQuestion, + decideApproval, + loadApproval, + loadQuestion, + type DecisionResult, + type RequestRef, +} from '../../../../apps/mobile/src/host/requests.js'; +import type { BridgeActions } from './actions.js'; +import { + ignoreHumanAssignmentReport, + loadTimelinePage, + markReadPosition, + type BridgeCall, +} from './bridge.js'; +import type { HumanInboxCommands } from './human-inbox-view.js'; +import type { ChannelMessage, HumanAttentionItem } from './store.js'; + +export type HostRequestActions = Pick< + BridgeActions, + | 'toolApprovalStatus' + | 'toolApprovalState' + | 'toolApprovalExecutionState' + | 'decideToolApproval' + | 'userQuestionStatus' + | 'answerUserQuestion' +>; + +export interface HostActivityActions extends HumanInboxCommands, HostRequestActions { + message(item: HumanAttentionItem, signal?: AbortSignal): Promise; +} + +export function createHostActivityActions(options: { + client(): HostClient; + items(): readonly HumanAttentionItem[]; + refresh(): Promise; + open(channelId: string): Promise; + requestUnavailable: string; +}): HostActivityActions { + const refs = new Map(); + const ref = (channelId: string, messageId: string, retained = true): RequestRef => { + const key = JSON.stringify([channelId, messageId]); + const item = options + .items() + .find((item) => item.channelId === channelId && item.messageId === messageId); + if (item !== undefined) refs.set(key, { channelId, messageId, botSlug: item.botSlug }); + const value = item !== undefined || retained ? refs.get(key) : undefined; + if (value === undefined) throw new Error('Activity request is unavailable'); + return value; + }; + const call: BridgeCall = async (method, args, signal) => { + signal?.throwIfAborted(); + const value = await options.client().call(method, args); + signal?.throwIfAborted(); + return { ok: true, value }; + }; + const changed = async (run: () => Promise): Promise => { + await run(); + await options.refresh(); + }; + const request = async (run: () => Promise): Promise => { + try { + return await run(); + } catch (cause) { + throw new Error(options.requestUnavailable, { cause }); + } + }; + const decided = async (run: () => Promise): Promise => { + const result = await request(run); + await options.refresh(); + if (result !== 'accepted') throw new Error(options.requestUnavailable); + }; + const approval = (channelId: string, messageId: string) => + request(() => loadApproval(options.client(), ref(channelId, messageId))); + return { + refreshHumanInbox: options.refresh, + openChannelAtMessage: (channelId) => options.open(channelId), + async decideGroupJoin(channelId, requestId, accept) { + await changed(() => decideJoinRequest(options.client(), channelId, requestId, accept)); + return true; + }, + dismissHumanInbox: (item) => changed(() => clearAttentionItem(options.client(), item)), + snoozeHumanInbox: (item, until) => + changed(() => + snoozeAttentionItem(options.client(), item, until === null ? null : new Date(until)), + ), + allowAutoRejectionOnce: (item) => + changed(() => allowAutoRejectionOnce(options.client(), item.id)), + markRead: (channelId, messageId) => changed(() => markReadPosition(call, channelId, messageId)), + ignoreHumanReport: (sourceEventId) => + changed(() => ignoreHumanAssignmentReport(call, sourceEventId)), + async message(item, signal) { + if (item.channelId === undefined || item.messageId === undefined) + throw new Error('Activity message is unavailable'); + ref(item.channelId, item.messageId); + const { page } = await loadTimelinePage( + call, + item.channelId, + { direction: 'around', around: item.messageId, olderLimit: 0, newerLimit: 0 }, + signal, + ); + const message = page.entries.find((entry) => entry.id === item.messageId); + if (message?.author.kind !== 'bot' || message.author.slug !== item.botSlug) + throw new Error('Activity request author changed'); + return message; + }, + async toolApprovalStatus(channelId, messageId) { + return (await approval(channelId, messageId)).status === 'pending' ? 'pending' : 'expired'; + }, + async toolApprovalState(channelId, messageId) { + const value = await approval(channelId, messageId); + return { + status: value.status === 'pending' ? 'pending' : 'expired', + ...(value.expiresAt === undefined ? {} : { expiresAt: value.expiresAt }), + }; + }, + async toolApprovalExecutionState(channelId, messageId) { + return (await approval(channelId, messageId)).execution; + }, + async decideToolApproval(channelId, messageId, outcome) { + if (outcome === 'allowed-always-exact' || outcome === 'allowed-always-all') + throw new Error('This approval choice is unavailable'); + await decided(() => + decideApproval(options.client(), ref(channelId, messageId, false), outcome), + ); + }, + async userQuestionStatus(channelId, messageId) { + const value = await request(() => loadQuestion(options.client(), ref(channelId, messageId))); + return value.status === 'decided' ? 'answered' : value.status; + }, + answerUserQuestion: (channelId, messageId, answers) => + decided(() => + answerQuestion(options.client(), ref(channelId, messageId, false), { answers }), + ), + }; +} diff --git a/packages/client/src/client/human-inbox-auto-rejection.tsx b/packages/client/src/client/human-inbox-auto-rejection.tsx index 9b90630ccf..f0b8d0c426 100644 --- a/packages/client/src/client/human-inbox-auto-rejection.tsx +++ b/packages/client/src/client/human-inbox-auto-rejection.tsx @@ -25,7 +25,7 @@ export function AllowAutoRejectionButton({ onError, }: { item: HumanAttentionItem; - actions: BridgeActions; + actions: Pick; t: BotHarnessTranslate; onDone?: () => void; onError: () => void; diff --git a/packages/client/src/client/human-inbox-controls.tsx b/packages/client/src/client/human-inbox-controls.tsx index 11e79ec3ba..aac5d82cab 100644 --- a/packages/client/src/client/human-inbox-controls.tsx +++ b/packages/client/src/client/human-inbox-controls.tsx @@ -13,12 +13,17 @@ export function HumanInboxSourceButton({ channels, t, onClick, + localIdentity = true, }: { item: HumanAttentionItem; - bots: readonly BotSummary[]; - channels: readonly ChannelSummary[]; + bots: readonly Pick< + BotSummary, + 'slug' | 'displayName' | 'avatar' | 'appearance' | 'avatarSeed' + >[]; + channels: readonly Pick[]; t: BotHarnessTranslate; onClick: () => void; + localIdentity?: boolean; }): ReactElement { const channel = channels.find((value) => value.id === item.channelId); const bot = bots.find((value) => value.slug === item.botSlug); @@ -48,6 +53,7 @@ export function HumanInboxSourceButton({ ) : ( item.channelId !== undefined && item.messageId !== undefined; +export type HumanInboxCommands = Pick< + BridgeActions, + | 'decideGroupJoin' + | 'refreshHumanInbox' + | 'ignoreHumanReport' + | 'markRead' + | 'dismissHumanInbox' + | 'snoozeHumanInbox' + | 'decideToolApproval' + | 'openChannelAtMessage' + | 'allowAutoRejectionOnce' +>; + +export interface HumanInboxSource { + commands: HumanInboxCommands; + detail(item: HumanAttentionItem, onClose: () => void): ReactElement; + botName(slug: string): string; + isDirect(channelId: string | undefined): boolean; + open(item: HumanAttentionItem): Promise; + sourceButton(item: HumanAttentionItem, onOpen: () => void): ReactNode; + refresh(): Promise; + loadMore(): Promise; +} + export function HumanInboxView({ actions, t = zhTranslate, @@ -68,14 +92,17 @@ export function HumanInboxView({ emptyMessage, sectionHeading, collapsed = false, + hostSource, }: { - actions: BridgeActions; inboxOverride?: import('./store.js').HumanInboxState; sectionHeading?: ReactNode; emptyMessage?: string | undefined; collapsed?: boolean; t?: BotHarnessTranslate | undefined; -}): ReactElement { +} & ( + | { actions: BridgeActions; hostSource?: never } + | { actions?: never; hostSource: HumanInboxSource } +)): ReactElement { const state = useClientStore( keepClientState, inboxOverride === undefined ? inboxInputs : overrideInputs, @@ -108,11 +135,20 @@ export function HumanInboxView({ const root = useRef(null); const keyHandler = useRef<(event: KeyboardEvent) => void>(() => undefined); const selectable = selectionMode; + const commands = hostSource?.commands ?? actions!; const selectedItems = inbox.items.filter((item) => selected.has(itemKey(item))); const openAction = (item: HumanAttentionItem): void => { + if ( + hostSource !== undefined && + item.kind !== 'tool-approval' && + item.kind !== 'user-question' + ) { + void hostSource.open(item).catch(() => setActionError(t('humanInbox.failed'))); + return; + } if (item.kind === 'private-input') { - void actions + void commands .openChannelAtMessage(item.channelId!, item.messageId!) .catch(() => setActionError(t('humanInbox.failed'))); return; @@ -121,23 +157,25 @@ export function HumanInboxView({ }; const botName = (slug: string): string => - state.bots.find((bot) => bot.slug === slug)?.displayName ?? slug; + hostSource === undefined + ? (state.bots.find((bot) => bot.slug === slug)?.displayName ?? slug) + : hostSource.botName(slug); const bots = [...state.bots].sort((left, right) => left.displayName.localeCompare(right.displayName), ); const openRepairBotInbox = (item: HumanAttentionItem): Promise => - openRepairBotInboxFor(actions, item); + hostSource === undefined ? openRepairBotInboxFor(actions, item) : hostSource.open(item); const openSource = (item: HumanAttentionItem): Promise => - openHumanAttentionSource(actions, item); + hostSource === undefined ? openHumanAttentionSource(actions, item) : hostSource.open(item); const decide = async (item: HumanAttentionItem, accept: boolean): Promise => { if (item.requestId === undefined || item.channelId === undefined) return; setBusyId(item.id); setActionError(undefined); try { - if (!(await actions.decideGroupJoin(item.channelId, item.requestId, accept))) + if (!(await commands.decideGroupJoin(item.channelId, item.requestId, accept))) throw new Error(t('humanInbox.failed')); - await actions.refreshHumanInbox(); + await commands.refreshHumanInbox(); } catch { setActionError(t('humanInbox.failed')); } finally { @@ -155,10 +193,10 @@ export function HumanInboxView({ setActionError(undefined); try { if (item.kind === 'assignment-report') { - await actions.ignoreHumanReport(item.sourceEventId!); - await actions.refreshHumanInbox(); + await commands.ignoreHumanReport(item.sourceEventId!); + await commands.refreshHumanInbox(); } else { - await actions.markRead(item.channelId!, item.messageId!); + await commands.markRead(item.channelId!, item.messageId!); } } catch { setActionError(t('humanInbox.failed')); @@ -168,23 +206,24 @@ export function HumanInboxView({ }; const settle = async (item: HumanAttentionItem): Promise => { - if (!acknowledgeable(item)) await actions.dismissHumanInbox(item); + if (!acknowledgeable(item)) await commands.dismissHumanInbox(item); else if (item.kind === 'assignment-report') { if (item.sourceEventId === undefined) throw new Error(t('humanInbox.failed')); - await actions.ignoreHumanReport(item.sourceEventId); - await actions.refreshHumanInbox(); + await commands.ignoreHumanReport(item.sourceEventId); + await commands.refreshHumanInbox(); } else { if (item.channelId === undefined || item.messageId === undefined) throw new Error(t('humanInbox.failed')); - await actions.markRead(item.channelId, item.messageId); + await commands.markRead(item.channelId, item.messageId); } }; - const settleOne = async (item: HumanAttentionItem): Promise => { + const settleOne = async (item: HumanAttentionItem, clear = false): Promise => { setBusyId(item.id); setActionError(undefined); try { - await settle(item); + if (clear) await commands.dismissHumanInbox(item); + else await settle(item); if (replySource !== undefined && itemKey(replySource) === itemKey(item)) setReplySource(undefined); } catch { @@ -198,7 +237,7 @@ export function HumanInboxView({ setBusyId(item.id); setActionError(undefined); try { - await actions.snoozeHumanInbox(item, until); + await commands.snoozeHumanInbox(item, until); if (replySource !== undefined && itemKey(replySource) === itemKey(item)) setReplySource(undefined); } catch { @@ -223,7 +262,7 @@ export function HumanInboxView({ setSelected(new Set()); setBatchBusy(false); if (failed > 0) setActionError(t('humanInbox.batch.failed', { count: String(failed) })); - await actions.refreshHumanInbox().catch(() => undefined); + await commands.refreshHumanInbox().catch(() => undefined); }; const toggleSelected = (item: HumanAttentionItem): void => @@ -309,7 +348,11 @@ export function HumanInboxView({ const itemTitle = (item: HumanAttentionItem): string => item.kind === 'channel-unread' - ? state.channels.find((channel) => channel.id === item.channelId)?.type === 'dm' + ? ( + hostSource === undefined + ? state.channels.find((channel) => channel.id === item.channelId)?.type === 'dm' + : hostSource.isDirect(item.channelId) + ) ? botName(item.botSlug) : (item.channelName ?? botName(item.botSlug)) : item.kind === 'group-join-request' @@ -342,7 +385,9 @@ export function HumanInboxView({ : botName(item.botSlug); const renderSource = (source: HumanAttentionItem, onClose: () => void): ReactElement => - source.kind === 'auto-rejection' ? ( + hostSource !== undefined ? ( + hostSource.detail(source, onClose) + ) : source.kind === 'auto-rejection' ? (

{inbox.error}

- @@ -502,7 +550,7 @@ export function HumanInboxView({ disabled={batchBusy} onClick={() => void runBatch((item) => - actions.decideToolApproval( + commands.decideToolApproval( item.channelId!, item.messageId!, 'allowed-once', @@ -519,7 +567,7 @@ export function HumanInboxView({ disabled={batchBusy} onClick={() => void runBatch((item) => - actions.decideToolApproval( + commands.decideToolApproval( item.channelId!, item.messageId!, 'rejected', @@ -559,6 +607,7 @@ export function HumanInboxView({ ? retained.rows : inbox.items.map((item) => ({ item, leaving: false })) ).map(({ item, leaving }) => + hostSource === undefined && item.kind === 'tool-approval' && item.category === 'action' && item.channelId && @@ -702,132 +751,166 @@ export function HumanInboxView({ ) : null}
- {item.kind === 'channel-unread' || - item.kind === 'channel-reply' || - item.kind === 'channel-mention' || - item.kind === 'tool-approval' || - item.kind === 'user-question' || - item.kind === 'private-input' || - item.kind === 'workspace-grant-request' || - item.kind === 'assignment-waiting-human' || - item.kind === 'assignment-blocked' ? ( - item.kind === 'workspace-grant-request' && item.category !== 'handled' ? ( - - ) : ( + <> + {item.kind === 'channel-unread' || + item.kind === 'channel-reply' || + item.kind === 'channel-mention' || + item.kind === 'tool-approval' || + item.kind === 'user-question' || + item.kind === 'private-input' || + item.kind === 'workspace-grant-request' || + item.kind === 'assignment-waiting-human' || + item.kind === 'assignment-blocked' ? ( + hostSource === undefined && + item.kind === 'workspace-grant-request' && + item.category !== 'handled' ? ( + + ) : ( + + ) + ) : null} + {item.category === 'handled' ? ( - ) - ) : null} - {item.category === 'handled' ? ( - - ) : null} - {item.kind === 'auto-rejection' ? ( - { - if (replySource !== undefined && itemKey(replySource) === itemKey(item)) - setReplySource(undefined); - }} - onError={() => setActionError(t('humanInbox.failed'))} - /> - ) : null} - {item.kind === 'group-join-request' ? ( - <> + ) : null} + {item.kind === 'auto-rejection' ? ( + { + if ( + replySource !== undefined && + itemKey(replySource) === itemKey(item) + ) + setReplySource(undefined); + }} + onError={() => setActionError(t('humanInbox.failed'))} + /> + ) : null} + {item.kind === 'group-join-request' ? ( + <> + + + + ) : item.kind === 'bot-dm-message' || + item.kind === 'assignment-report' || + item.kind === 'channel-unread' || + ((item.kind === 'channel-reply' || item.kind === 'channel-mention') && + item.isUnread) ? ( + ) : null} + {(selectable || hostSource !== undefined) && + canSnoozeHumanInboxItem(item) ? ( + setSnoozeMenu(open ? itemKey(item) : undefined)} + onSnooze={(until) => void snooze(item, until)} + /> + ) : null} + {hostSource === undefined ? ( + + void openSource(item).catch(() => + setActionError(t('humanInbox.failed')), + ) + } + /> + ) : ( + hostSource.sourceButton( + item, + () => + void openSource(item).catch(() => + setActionError(t('humanInbox.failed')), + ), + ) + )} + {hostSource !== undefined && item.category !== 'handled' ? ( - - ) : item.kind === 'bot-dm-message' || - item.kind === 'assignment-report' || - item.kind === 'channel-unread' || - ((item.kind === 'channel-reply' || item.kind === 'channel-mention') && - item.isUnread) ? ( - - ) : null} - {selectable && canSnoozeHumanInboxItem(item) ? ( - setSnoozeMenu(open ? itemKey(item) : undefined)} - onSnooze={(until) => void snooze(item, until)} - /> - ) : null} - - void openSource(item).catch(() => setActionError(t('humanInbox.failed'))) - } - /> + ) : null} +
{replySource?.id === item.id ? (
@@ -843,7 +926,11 @@ export function HumanInboxView({ diff --git a/packages/client/src/client/index.ts b/packages/client/src/client/index.ts index 5f9020ce24..2ecb0d7cd9 100644 --- a/packages/client/src/client/index.ts +++ b/packages/client/src/client/index.ts @@ -530,7 +530,7 @@ export function apply(ctx: ClientContext): void { ...createChannelSidebarBuiltins(t, prefs, nativeSessions).map((entry) => channelSidebar.register(entry), ), - dockPanels.register(createActivityCenterPanel(actions, t)), + dockPanels.register(createActivityCenterPanel(actions, t, dockPanels)), dockPanels.register(createMarketPanel(actions, dockPanels, t)), ]; }; diff --git a/packages/client/src/client/locale.ts b/packages/client/src/client/locale.ts index 02e4dfad87..68c63aad22 100644 --- a/packages/client/src/client/locale.ts +++ b/packages/client/src/client/locale.ts @@ -3919,6 +3919,7 @@ export const zh = { 'remoteHosts.look.color': '颜色', 'remoteHosts.look.noColor': '不设颜色', 'remoteHosts.look.save': '保存', + 'remoteHosts.remote': '远程', 'remoteHosts.add': '添加远程', 'remoteHosts.add.description': '在远程 Host 的「设置 → 设备 → 配对手机」里复制配对链接,粘贴到这里。本机和远程需要在同一个 Tailscale 网络里。', @@ -8266,6 +8267,7 @@ My relationship with you, established lore and topics to avoid: [fill in]. 'remoteHosts.look.color': 'Color', 'remoteHosts.look.noColor': 'No color', 'remoteHosts.look.save': 'Save', + 'remoteHosts.remote': 'Remote', 'remoteHosts.add': 'Add remote', 'remoteHosts.add.description': 'On the remote host, copy the link from Settings → Devices → Pair a phone and paste it here. Both hosts must be on the same Tailscale network.', diff --git a/packages/client/src/client/remote-activity.tsx b/packages/client/src/client/remote-activity.tsx new file mode 100644 index 0000000000..25baf40392 --- /dev/null +++ b/packages/client/src/client/remote-activity.tsx @@ -0,0 +1,409 @@ +import { useMemo, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'; +import * as stylex from '@stylexjs/stylex'; +import { + IconChevronDownOutlineMedium, + IconChevronRightOutlineMedium, + Button, +} from '@deepseek-ai/dsh-client-ui-primitives'; +import type { InboxSnapshot } from '../../../../apps/mobile/src/host/inbox.js'; +import { isAvatarAppearance } from '../../../core/src/bots/avatar-appearance.js'; +import { parseHumanAttentionPage } from './bridge.js'; +import { ACTIVITY_SECTIONS, type ActivityQuery, type ActivitySection } from './activity-feed.js'; +import { CHANNEL_BODY_PANEL } from './channel-dock-layout.js'; +import type { DockPanelRegistry } from './dock-panels.js'; +import { color, space, text } from './ds/tokens.stylex.js'; +import { HumanInboxView } from './human-inbox-view.js'; +import { HumanInboxSourceButton } from './human-inbox-controls.js'; +import { createHostActivityActions, type HostActivityActions } from './host-activity-actions.js'; +import { useMountedResource } from './mounted-resource.js'; +import { ToolApprovalCard } from './tool-approval-card.js'; +import { UserQuestionCard } from './user-question-card.js'; +import type { BotHarnessTranslate } from './locale.js'; +import type { RemoteHost, RemoteHosts, RemoteConversationTarget } from './remote-hosts.js'; +import type { ChannelMessage, HumanAttentionItem, HumanInboxState } from './store.js'; + +const styles = stylex.create({ + heading: { + display: 'flex', + alignItems: 'center', + gap: space.x3, + margin: 0, + padding: space.x3, + color: color.fg, + fontSize: text.titleSize, + lineHeight: text.titleLine, + fontWeight: text.strong, + }, + mark: { color: color.subtle, fontSize: text.metaSize, fontWeight: text.regular }, + note: { + margin: 0, + paddingInline: space.x3, + paddingBlock: space.x2, + color: color.subtle, + fontSize: text.metaSize, + lineHeight: text.metaLine, + }, +}); +const titles = { + action: 'activityInbox.needsYou', + replies: 'activityInbox.forYou', + unread: 'humanInbox.unread', + handled: 'humanInbox.handled', +} as const; + +export function remoteActivityTarget( + host: RemoteHost, + item: HumanAttentionItem, +): RemoteConversationTarget { + const bot = host.snapshot.roster?.bots.find((bot) => bot.slug === item.botSlug); + const group = host.snapshot.roster?.channels.find((channel) => channel.id === item.channelId); + return { + hostId: host.host.hostId, + title: group?.name ?? bot?.displayName ?? item.channelName ?? item.botSlug, + ...(item.channelId === undefined ? { botSlug: item.botSlug } : { channelId: item.channelId }), + }; +} + +function parseRemoteActivityItems(value: unknown): { + items: HumanAttentionItem[]; + failed: boolean; +} { + if (!Array.isArray(value)) return { items: [], failed: true }; + let failed = false; + const items = value.flatMap((item) => { + try { + return parseHumanAttentionPage({ items: [item] }).items; + } catch { + failed = true; + return []; + } + }); + return { items, failed }; +} + +export function remoteActivityPage( + inbox: InboxSnapshot, + section: ActivitySection, + t: BotHarnessTranslate, +): HumanInboxState { + const parsed = + inbox.status === 'ready' + ? parseRemoteActivityItems(inbox.sections[section]) + : { items: [], failed: false }; + return { + category: section, + sort: 'newest', + botSlug: undefined, + channelId: undefined, + snoozed: false, + items: parsed.items, + status: inbox.status, + error: + parsed.failed || + inbox.status === 'error' || + (inbox.status === 'ready' && inbox.stale !== undefined) + ? t('humanInbox.failed') + : undefined, + nextCursor: inbox.status === 'ready' ? inbox.cursors?.[section] : undefined, + unreadCount: 0, + hasAction: false, + channelUnread: inbox.status === 'ready' ? (inbox.unread ?? {}) : {}, + }; +} + +export function ActivityHostHeading({ + name, + remote = false, + t, +}: { + name: string; + remote?: boolean; + t: BotHarnessTranslate; +}): ReactElement { + return ( +

+ {name} + {remote ? {t('remoteHosts.remote')} : null} +

+ ); +} + +function RemoteActivityDetail({ + item, + actions, + t, + onClose, + onOpen, +}: { + item: HumanAttentionItem; + actions: HostActivityActions; + t: BotHarnessTranslate; + onClose(): void; + onOpen(): Promise; +}): ReactElement { + const [message, setMessage] = useState(); + const [failed, setFailed] = useState<'request' | 'open'>(); + const opening = useRef(false); + const [attempt, setAttempt] = useState(0); + const request = item.kind === 'tool-approval' || item.kind === 'user-question'; + const mount = useMountedResource(() => { + if (!request) return; + const controller = new AbortController(); + setFailed(undefined); + void actions.message(item, controller.signal).then( + (value) => { + if (!controller.signal.aborted) setMessage(value); + }, + () => { + if (!controller.signal.aborted) setFailed('request'); + }, + ); + return () => controller.abort(); + }, [actions, item.id, attempt]); + const open = async (): Promise => { + if (opening.current) return; + opening.current = true; + setFailed(undefined); + try { + await onOpen(); + } catch { + setFailed('open'); + } finally { + opening.current = false; + } + }; + return ( +
+
+ +
+ {failed ? ( +

+ {t('humanInbox.failed')}{' '} + +

+ ) : request && message === undefined ? ( +

{t('humanInbox.loading')}

+ ) : null} + {message?.toolApprovalRequest ? ( + + ) : message?.userQuestionRequest ? ( + + ) : ( +

{item.summary}

+ )} + +
+ ); +} + +export function RemoteActivityGroup({ + host, + sources, + avatars, + registry, + query, + t, +}: { + host: RemoteHost; + sources: RemoteHosts; + avatars: ReadonlyMap; + registry?: DockPanelRegistry | undefined; + query: ActivityQuery; + t: BotHarnessTranslate; +}): ReactElement | null { + const filtered = + query.search !== '' || + query.sort !== 'default' || + query.snoozed || + query.category === 'handled'; + const observation = useMemo( + () => + sources.activity( + host.host.hostId, + filtered + ? { + activity: true, + search: query.search, + sort: query.sort, + section: query.snoozed ? 'snoozed' : query.category === 'handled' ? 'handled' : 'all', + } + : undefined, + )!, + [sources, host.host.hostId, filtered, query.search, query.sort, query.snoozed, query.category], + ); + const inbox = useSyncExternalStore(observation.subscribe, observation.getSnapshot); + const currentInbox = useRef(inbox); + currentInbox.current = inbox; + const scopedActions = useMemo( + () => + createHostActivityActions({ + requestUnavailable: t('humanInbox.failed'), + client: () => { + const client = sources.client(host.host.hostId); + if (client === undefined || client.getSnapshot().connection.status === 'refused') + throw new Error(t('humanInbox.failed')); + return client; + }, + items: () => { + const value = currentInbox.current; + return value.status === 'ready' + ? Object.values(value.sections).flatMap( + (items) => parseRemoteActivityItems(items).items, + ) + : []; + }, + refresh: async () => { + await observation.getInbox()?.refresh(); + }, + open: async (channelId) => { + const current = sources + .getSnapshot() + .hosts.find((entry) => entry.host.hostId === host.host.hostId); + if (current === undefined || current.snapshot.connection.status === 'refused') + throw new Error(t('humanInbox.failed')); + sources.open({ + hostId: current.host.hostId, + channelId, + title: + current.snapshot.roster?.channels.find((channel) => channel.id === channelId)?.name ?? + current.host.hostName, + }); + registry?.open(CHANNEL_BODY_PANEL); + }, + }), + [sources, host.host.hostId, observation, registry, t], + ); + const [collapsed, setCollapsed] = useState>>({}); + if (query.botSlug !== undefined || query.channelId !== undefined) return null; + const refused = host.snapshot.connection.status === 'refused'; + const bots = (host.snapshot.roster?.bots ?? []).map((bot) => ({ + slug: bot.slug, + displayName: bot.displayName, + ...(bot.avatarUrl !== undefined && avatars.has(bot.avatarUrl) + ? { avatar: avatars.get(bot.avatarUrl)! } + : {}), + ...(isAvatarAppearance(bot.appearance) ? { appearance: bot.appearance } : {}), + ...(bot.avatarSeed === undefined ? {} : { avatarSeed: bot.avatarSeed }), + })); + const channels = (host.snapshot.roster?.channels ?? []).map((channel) => ({ + id: channel.id, + name: channel.name, + type: 'group' as const, + })); + const open = async (item: HumanAttentionItem) => { + const current = sources + .getSnapshot() + .hosts.find((entry) => entry.host.hostId === host.host.hostId); + if (current === undefined || current.snapshot.connection.status === 'refused') + throw new Error(t('humanInbox.failed')); + sources.open(remoteActivityTarget(current, item)); + registry?.open(CHANNEL_BODY_PANEL); + }; + return ( +
+ + {host.snapshot.connection.status !== 'connected' ? ( +

+ {t(refused ? 'remoteHosts.refused' : 'remoteHosts.offline', { name: host.host.hostName })} +

+ ) : null} + {refused + ? null + : (query.category === 'all' ? ACTIVITY_SECTIONS : [query.category]).map((section) => { + const page = remoteActivityPage(inbox, section, t); + const count = + section === 'unread' + ? page.items.reduce((sum, item) => sum + (item.unreadCount ?? 0), 0) + : page.items.length; + return ( + ( + open(item)} + /> + ), + botName: (slug) => bots.find((bot) => bot.slug === slug)?.displayName ?? slug, + isDirect: (channelId) => !channels.some((channel) => channel.id === channelId), + open, + sourceButton: (item, onOpen) => ( + + ), + refresh: async () => { + await observation.getInbox()?.refresh(); + }, + loadMore: async () => { + await observation.getInbox()?.loadMore?.(section); + }, + }} + sectionHeading={ + + } + /> + ); + })} +
+ ); +} diff --git a/packages/client/src/client/remote-hosts.ts b/packages/client/src/client/remote-hosts.ts index e00b083ecd..91bf015403 100644 --- a/packages/client/src/client/remote-hosts.ts +++ b/packages/client/src/client/remote-hosts.ts @@ -13,6 +13,8 @@ import { createInbox, type Inbox, type InboxSnapshot, + observeInbox, + type InboxQuery, } from '../../../../apps/mobile/src/host/inbox.js'; import { DEVICE_PREFIX, @@ -64,6 +66,8 @@ export interface RemoteHosts { close(): void; conversation(target: RemoteConversationTarget): Conversation | undefined; markRead(target: RemoteConversationTarget, messageId: string): Promise; + activity(hostId: string, query?: InboxQuery): ReturnType | undefined; + client(hostId: string): HostClient | undefined; } export interface HostStorage { @@ -130,6 +134,7 @@ export function createRemoteHosts(options: { const { storage, carrier, loadImage, releaseImage } = options; const listeners = new Set<() => void>(); const clients = new Map(); + const activity = new Map>(); const conversations = new Map(); const images = new Map(); const loading = new Map(); @@ -246,6 +251,7 @@ export function createRemoteHosts(options: { entry.release(); stop(entry); clients.delete(hostId); + activity.delete(hostId); for (const [key, conversation] of conversations) if ((JSON.parse(key) as string[])[0] === hostId) { conversation.stop(); @@ -258,6 +264,25 @@ export function createRemoteHosts(options: { return { getSnapshot: () => snapshot, + client: (hostId) => clients.get(hostId)?.client, + activity(hostId, query) { + const entry = clients.get(hostId); + if (entry === undefined) return undefined; + if (query !== undefined) return observeInbox(entry.client, query); + let observed = activity.get(hostId); + if (observed === undefined) { + observed = { + getSnapshot: () => entry.inbox?.feed.getSnapshot() ?? entry.retained, + getInbox: () => entry.inbox?.feed, + subscribe(listener) { + return entry.inbox?.feed.subscribe(listener) ?? (() => {}); + }, + watching: () => entry.inbox !== undefined, + }; + activity.set(hostId, observed); + } + return observed; + }, subscribe(listener) { const first = listeners.size === 0; listeners.add(listener); diff --git a/packages/client/src/client/tool-approval-card.tsx b/packages/client/src/client/tool-approval-card.tsx index 031698acc4..a1064231b7 100644 --- a/packages/client/src/client/tool-approval-card.tsx +++ b/packages/client/src/client/tool-approval-card.tsx @@ -27,10 +27,15 @@ export function ToolApprovalCard({ companionTarget, channelId: sourceChannelId, surface = 'chat', + hostScoped = false, }: { surface?: 'chat' | 'activity'; message: ChannelMessage; - actions: BridgeActions; + actions: Pick< + BridgeActions, + 'toolApprovalStatus' | 'toolApprovalState' | 'toolApprovalExecutionState' | 'decideToolApproval' + >; + hostScoped?: boolean; channelId?: string | undefined; decision?: | 'allowed-once' @@ -195,6 +200,7 @@ export function ToolApprovalCard({ setError(undefined); const channelId = cardChannelId; if ( + !hostScoped && !companionTarget && surface !== 'activity' && store.getSnapshot().selection?.kind !== 'inbox' && diff --git a/packages/client/src/client/user-question-card.tsx b/packages/client/src/client/user-question-card.tsx index ed8a89cc95..090081a559 100644 --- a/packages/client/src/client/user-question-card.tsx +++ b/packages/client/src/client/user-question-card.tsx @@ -16,9 +16,11 @@ export function UserQuestionCard({ t, companionTarget, channelId: sourceChannelId, + hostScoped = false, }: { message: ChannelMessage; - actions: BridgeActions; + actions: Pick; + hostScoped?: boolean; channelId?: string | undefined; resolution?: 'answered' | 'cancelled' | undefined; t: BotHarnessTranslate; @@ -102,6 +104,7 @@ export function UserQuestionCard({ if (companionTarget && !qualified()) return; const channelId = cardChannelId; if ( + !hostScoped && !companionTarget && store.getSnapshot().selection?.kind !== 'inbox' && store.getSnapshot().conversation.channel?.id !== channelId diff --git a/packages/client/test/human-inbox-keyboard-batch.test.ts b/packages/client/test/human-inbox-keyboard-batch.test.ts index 44cf31c9da..7169ece63b 100644 --- a/packages/client/test/human-inbox-keyboard-batch.test.ts +++ b/packages/client/test/human-inbox-keyboard-batch.test.ts @@ -224,3 +224,632 @@ describe('Human Inbox snooze', () => { expect(humanInboxSnoozeUntil('tomorrow', friday)!.getDate()).toBe(17); }); }); + +import { RemoteActivityGroup, remoteActivityPage } from '../src/client/remote-activity.js'; +import { createRemoteHosts, REMOTE_HOSTS_STORAGE_KEY } from '../src/client/remote-hosts.js'; +import { FakeHost } from '../../../apps/mobile/test/fake-host.js'; +import { createDockPanelRegistry } from '../src/client/dock-panels.js'; +import { en, zhTranslate } from '../src/client/locale.js'; +import type { InboxSnapshot } from '../../../apps/mobile/src/host/inbox.js'; +import { bindTobPresentation } from '../src/client/tob-presentation.js'; + +async function remoteFixture(host: FakeHost) { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + host.methods.set('channelUnreadCounts', () => ({ channels: [] })); + const sources = createRemoteHosts({ + carrier: host.carrier, + storage: { + getItem: (key) => + key === REMOTE_HOSTS_STORAGE_KEY + ? JSON.stringify([ + { + hostId: host.hostId, + hostName: 'Remote QA', + routes: ['http://127.0.0.1:47001'], + deviceId: 'device-1', + credential: host.credential, + }, + ]) + : null, + setItem: () => {}, + removeItem: () => {}, + }, + }); + const release = sources.subscribe(() => {}); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + const element = document.createElement('div'); + document.body.append(element); + const root = createRoot(element); + const registry = createDockPanelRegistry(); + return { + sources, + element, + registry, + async render() { + await act(async () => + root.render( + createElement(RemoteActivityGroup, { + host: sources.getSnapshot().hosts[0]!, + sources, + avatars: new Map(), + registry, + t: zhTranslate, + query: { + category: 'all', + sort: 'default', + search: '', + snoozed: false, + botSlug: undefined, + channelId: undefined, + }, + }), + ), + ); + }, + async close() { + await act(async () => root.unmount()); + release(); + element.remove(); + }, + }; +} + +it('isolates invalid remote items from the shared Inbox and retries the affected section', async () => { + const host = new FakeHost(); + const valid = approval('tob'); + let invalid = true; + host.methods.set('humanAttention', (args) => ({ + queryVersion: 1, + items: + args.category === 'action' + ? [ + valid, + ...(invalid + ? [{ ...valid, id: 'unsupported', kind: 'future-request' }, { id: 'broken' }] + : []), + ] + : [], + })); + host.methods.set('channelTimeline', () => ({ + revision: 1, + page: { + entries: [ + { + id: valid.messageId, + at: valid.createdAt, + author: { kind: 'bot', slug: 'tob' }, + body: valid.summary, + toolApprovalRequest: { + sessionId: 'remote-session', + callId: 'remote-call', + toolName: 'bash', + role: 'orchestrator', + cwd: '', + input: '{}', + allowAlways: false, + }, + }, + ], + olderCursor: null, + newerCursor: null, + hasOlder: false, + hasNewer: false, + }, + })); + host.methods.set('toolApprovalStatus', () => ({ status: 'pending' })); + host.methods.set('toolApprovalDecide', () => ({ accepted: true })); + const fixture = await remoteFixture(host); + try { + expect(fixture.sources.getSnapshot().hosts[0]?.inbox).toMatchObject({ + status: 'ready', + sections: { action: expect.arrayContaining([{ id: 'broken' }]) }, + }); + await fixture.render(); + expect(fixture.element.querySelectorAll('.bh-human-inbox-row')).toHaveLength(1); + expect(fixture.element.querySelectorAll('[role="alert"]')).toHaveLength(1); + expect(fixture.element.querySelector('[role="alert"]')?.textContent).toContain( + zhTranslate('humanInbox.failed'), + ); + await act(async () => + fixture.element.querySelector('.bh-human-inbox-row-open')!.click(), + ); + const allow = () => + [...fixture.element.querySelectorAll('button')].find( + (n) => n.textContent?.trim() === zhTranslate('approval.allowOnce'), + ); + await act(async () => { + await vi.waitFor(() => expect(allow()?.disabled).toBe(false)); + }); + await act(async () => allow()!.click()); + expect(host.calls.filter((c) => c.method === 'toolApprovalDecide')).toEqual([ + { + method: 'toolApprovalDecide', + args: { channelId: valid.channelId, messageId: valid.messageId, outcome: 'allowed-once' }, + }, + ]); + invalid = false; + await act(async () => + [...fixture.element.querySelectorAll('button')] + .find((n) => n.textContent?.trim() === zhTranslate('activityInbox.retry'))! + .click(), + ); + expect(fixture.element.querySelector('[role="alert"]')).toBeNull(); + } finally { + await fixture.close(); + } +}); + +it('retries a non-request detail open once against the same Host', async () => { + const host = new FakeHost(); + const item = { ...approval('tob'), kind: 'workspace-grant-request' }; + host.methods.set('humanAttention', (args) => ({ + queryVersion: 1, + items: args.category === 'action' ? [item] : [], + })); + const fixture = await remoteFixture(host); + const open = vi.spyOn(fixture.sources, 'open').mockImplementationOnce(() => { + throw new Error('Host open failed'); + }); + const openPanel = vi.fn(); + const detach = fixture.registry.attach({ open: openPanel, close: () => {} }); + try { + await fixture.render(); + await act(async () => + fixture.element.querySelector('.bh-human-inbox-row-open')!.click(), + ); + await act(async () => + [...fixture.element.querySelectorAll('.bh-human-inbox-reply button')] + .find((n) => n.textContent?.trim() === zhTranslate('humanInbox.open'))! + .dispatchEvent(new MouseEvent('click', { bubbles: true })), + ); + expect(fixture.element.querySelector('[role="alert"]')?.textContent).toContain( + zhTranslate('humanInbox.failed'), + ); + expect(open).toHaveBeenCalledTimes(1); + expect(openPanel).not.toHaveBeenCalled(); + await act(async () => + [...fixture.element.querySelectorAll('.bh-human-inbox-reply button')] + .find((n) => n.textContent?.trim() === zhTranslate('activityInbox.retry'))! + .dispatchEvent(new MouseEvent('click', { bubbles: true })), + ); + expect(fixture.element.querySelector('[role="alert"]')).toBeNull(); + expect(open).toHaveBeenCalledTimes(2); + expect(open.mock.calls[1]?.[0]).toEqual(open.mock.calls[0]?.[0]); + expect(openPanel).toHaveBeenCalledExactlyOnceWith('bh:channel-body'); + } finally { + detach(); + open.mockRestore(); + await fixture.close(); + } +}); + +it.each(['en', 'zh'] as const)( + 'localizes remote failures in %s without losing stale data', + (locale) => { + const english: Readonly> = en; + const t = locale === 'zh' ? zhTranslate : (key: string) => english[key] ?? key; + const raw = 'Host HTTP 502 returned non-JSON upstream response'; + const failed: InboxSnapshot = { status: 'error', error: raw }; + expect(remoteActivityPage(failed, 'action', t)).toMatchObject({ + status: 'error', + error: t('humanInbox.failed'), + items: [], + }); + const retained: InboxSnapshot = { + status: 'ready', + stale: raw, + sections: { action: [approval('tob')], replies: [], unread: [], handled: [] }, + cursors: { action: 'next-page' }, + }; + expect(remoteActivityPage(retained, 'action', t)).toMatchObject({ + status: 'ready', + error: t('humanInbox.failed'), + items: [approval('tob')], + nextCursor: 'next-page', + }); + expect(retained.stale).toBe(raw); + expect(failed.error).toBe(raw); + expect( + remoteActivityPage({ ...retained, stale: undefined }, 'action', t).error, + ).toBeUndefined(); + }, +); + +it('renders the remote identity and opens its Host even when a local Tob has the same slug', async () => { + const host = new FakeHost(); + let unread = 2; + host.methods.set('humanAttention', (args) => ({ + queryVersion: 1, + items: args.category === 'action' ? [approval('tob')] : [], + })); + host.methods.set('channelUnreadCounts', () => ({ + channels: unread ? [{ channelId: 'dm-tob', unreadCount: unread }] : [], + })); + host.methods.set('channelMarkRead', () => { + unread = 0; + return { accepted: true }; + }); + host.methods.set('list', () => ({ + bots: [ + { + slug: 'tob', + displayName: 'Remote Tob', + aggregateState: 'idle', + avatar: '/api/botharness/avatar/tob', + }, + ], + })); + const sources = createRemoteHosts({ + carrier: host.carrier, + storage: { + getItem: (key) => + key === REMOTE_HOSTS_STORAGE_KEY + ? JSON.stringify([ + { + hostId: host.hostId, + hostName: 'Remote Studio', + routes: ['http://127.0.0.1:47001'], + deviceId: 'device-1', + credential: host.credential, + }, + ]) + : null, + setItem: () => {}, + removeItem: () => {}, + }, + loadImage: async () => 'blob:remote-tob', + }); + const release = sources.subscribe(() => {}); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + await vi.waitFor(() => expect(sources.getSnapshot().avatars.size).toBe(1)); + const bot = { + slug: 'tob', + displayName: 'Local Tob', + roles: [], + aggregateState: 'idle', + workspaces: [], + createdAt: '', + tob: { defaultName: true, defaultAvatar: true }, + }; + store.setRoster([bot], []); + const unbind = bindTobPresentation({ + bot: () => bot, + icon: () => 'bot', + subscribe: () => () => {}, + }); + const element = document.createElement('div'); + document.body.append(element); + const root = createRoot(element); + const call = vi.fn(async () => ({ ok: true as const, value: {} })); + const registry = createDockPanelRegistry(); + const openPanel = vi.fn(); + const detach = registry.attach({ open: openPanel, close: () => {} }); + try { + await act(async () => + root.render( + createElement(RemoteActivityGroup, { + host: sources.getSnapshot().hosts[0]!, + sources, + avatars: sources.getSnapshot().avatars, + registry, + t: zhTranslate, + query: { + category: 'all', + sort: 'default', + botSlug: undefined, + channelId: undefined, + snoozed: false, + search: '', + }, + }), + ), + ); + expect(element.textContent).toContain('Remote Studio'); + expect(element.textContent).toContain('远程'); + expect(element.textContent).toContain('Remote Tob'); + expect(element.textContent).not.toContain('Local Tob'); + expect(element.querySelector('img')?.getAttribute('src')).toBe('blob:remote-tob'); + await act(async () => + element.querySelector('.bh-human-inbox-source-link')!.click(), + ); + expect(sources.getSnapshot().open).toEqual({ + hostId: host.hostId, + channelId: 'dm-tob', + title: 'Remote Tob', + }); + expect(openPanel).toHaveBeenCalledExactlyOnceWith('bh:channel-body'); + expect(call).not.toHaveBeenCalled(); + expect(host.calls.filter(({ method }) => method === 'channelMarkRead')).toEqual([]); + const activity = sources.activity(host.hostId)!; + expect(activity.getSnapshot()).toMatchObject({ status: 'ready', unread: { 'dm-tob': 2 } }); + await act(async () => sources.markRead(sources.getSnapshot().open!, 'remote-last')); + expect(host.calls.filter(({ method }) => method === 'channelMarkRead')).toEqual([ + { method: 'channelMarkRead', args: { channelId: 'dm-tob', messageId: 'remote-last' } }, + ]); + const refreshed = activity.getSnapshot(); + expect(refreshed.status).toBe('ready'); + expect(refreshed.status === 'ready' ? refreshed.unread : undefined).toEqual({}); + expect(refreshed).toBe(sources.getSnapshot().hosts[0]?.inbox); + } finally { + await act(async () => root.unmount()); + detach(); + unbind(); + release(); + } +}); + +it.each([ + ...(['tool-approval', 'user-question'] as const).flatMap((kind) => + (['accepted', 'decided', 'expired', 'http', 'non-json', 'network'] as const).flatMap((result) => + (kind === 'tool-approval' && (result === 'decided' || result === 'expired') + ? [false, true] + : [false] + ).map((batch) => ({ + kind, + result, + batch, + precheck: false, + recovery: false, + })), + ), + ), + ...(['http', 'non-json', 'network'] as const).flatMap((result) => + [false, true].map((recovery) => ({ + kind: 'tool-approval' as const, + result, + batch: false, + precheck: true, + recovery, + })), + ), +])('remote $kind $result batch=$batch pre=$precheck recover=$recovery', async (scenario) => { + const { kind, result, batch, precheck, recovery } = scenario; + const host = new FakeHost(); + const transportFailure = result === 'http' || result === 'non-json' || result === 'network'; + const submissions: typeof host.calls = []; + let checking = false; + let precheckFailures = 0; + let pending = true; + const item = { ...approval('tob'), kind }; + const message = { + id: item.messageId!, + at: item.createdAt, + author: { kind: 'bot', slug: 'tob' }, + body: item.summary, + ...(kind === 'tool-approval' + ? { + toolApprovalRequest: { + sessionId: 'remote-session', + callId: 'remote-call', + toolName: 'bash', + role: 'orchestrator', + cwd: '/qa', + input: '{"command":"echo QA"}', + allowAlways: false, + }, + } + : { + userQuestionRequest: { + sessionId: 'remote-session', + questions: [ + { + id: 'q', + question: 'Which marker?', + options: [{ label: 'Blue' }, { label: 'Green' }], + }, + ], + }, + }), + }; + host.methods.set('channelTimeline', () => ({ + revision: 1, + page: { + entries: [ + message, + ...(!pending && result === 'decided' + ? [ + { + id: 'remote-decision', + at: item.createdAt, + author: { kind: 'human' }, + body: '', + ...(kind === 'tool-approval' + ? { + toolApprovalDecision: { + requestMessageId: message.id, + outcome: 'rejected', + }, + } + : { + userQuestionResolution: { + requestMessageId: message.id, + state: 'answered', + answers: [{ id: 'q', selected: ['Green'] }], + }, + }), + }, + ] + : []), + ], + olderCursor: null, + newerCursor: null, + hasOlder: false, + hasNewer: false, + }, + })); + host.methods.set('humanAttention', (args) => ({ + queryVersion: 1, + items: (pending || result !== 'accepted') && args.category === 'action' ? [item] : [], + })); + host.methods.set('channelUnreadCounts', () => ({ channels: [] })); + host.methods.set('toolApprovalStatus', () => ({ status: pending ? 'pending' : 'expired' })); + host.methods.set('userQuestionStatus', () => ({ + status: pending ? 'pending' : result === 'expired' ? 'expired' : 'answered', + })); + const method = kind === 'tool-approval' ? 'toolApprovalDecide' : 'userQuestionAnswer'; + host.methods.set(method, () => { + pending = false; + if (result !== 'accepted') throw new Error('Request already settled'); + return { accepted: true }; + }); + const sources = createRemoteHosts({ + carrier: { + ...host.carrier, + async request(url, init) { + if ( + checking && + url.endsWith('/toolApprovalStatus') && + (!recovery || precheckFailures === 0) + ) { + precheckFailures++; + if (result === 'network') throw new TypeError('Network request failed'); + return { + status: result === 'http' ? 502 : 200, + text: async () => 'Bad gateway', + }; + } + if (url.endsWith('/' + method)) { + submissions.push({ method, args: JSON.parse(String(init.body)).payload.args }); + if (result === 'network') throw new TypeError('Network request failed'); + if (transportFailure) + return { + status: result === 'http' ? 502 : 200, + text: async () => 'Bad gateway', + }; + } + return host.carrier.request(url, init); + }, + }, + storage: { + getItem: (key) => + key === REMOTE_HOSTS_STORAGE_KEY + ? JSON.stringify([ + { + hostId: host.hostId, + hostName: 'Remote QA', + routes: ['http://127.0.0.1:47001'], + deviceId: 'device-1', + credential: host.credential, + }, + ]) + : null, + setItem: () => {}, + removeItem: () => {}, + }, + }); + const release = sources.subscribe(() => {}); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + const localSelection = store.getSnapshot().selection; + store.select({ kind: 'bot', slug: 'unrelated-local-bot' }); + const element = document.createElement('div'); + document.body.append(element); + const root = createRoot(element); + try { + await act(async () => + root.render( + createElement(RemoteActivityGroup, { + host: sources.getSnapshot().hosts[0]!, + sources, + avatars: new Map(), + t: zhTranslate, + query: { + category: 'all', + sort: 'default', + search: '', + snoozed: false, + botSlug: undefined, + channelId: undefined, + }, + }), + ), + ); + if (batch) { + await act(async () => { + [...element.querySelectorAll('button')] + .find((button) => button.textContent === zhTranslate('activityInbox.select'))! + .click(); + }); + await act(async () => + element.querySelector('.bh-human-inbox-batch input')!.click(), + ); + } else { + await act(async () => + element.querySelector('.bh-human-inbox-row-open')!.click(), + ); + } + const label = batch + ? zhTranslate('humanInbox.batch.allow', { count: '1' }) + : kind === 'tool-approval' + ? zhTranslate('approval.allowOnce') + : 'Blue'; + const button = () => + [...element.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === label, + ); + await act(async () => { + await vi.waitFor(() => expect(button()?.disabled).toBe(false)); + }); + checking = precheck; + await act(async () => button()!.click()); + expect(submissions).toEqual( + precheck + ? [] + : [ + { + method, + args: + kind === 'tool-approval' + ? { channelId: 'dm-tob', messageId: item.messageId, outcome: 'allowed-once' } + : { + channelId: 'dm-tob', + messageId: item.messageId, + answer: { answers: [{ id: 'q', selected: ['Blue'] }] }, + }, + }, + ], + ); + expect(store.getSnapshot().selection).toEqual({ kind: 'bot', slug: 'unrelated-local-bot' }); + if (transportFailure) { + await act(async () => { + await vi.waitFor(() => + expect(element.querySelector('[role="alert"]')?.textContent).toBe( + zhTranslate('humanInbox.failed'), + ), + ); + }); + if (!precheck || recovery) expect(button()?.disabled).toBe(false); + if (precheck) expect(precheckFailures).toBeGreaterThan(0); + expect(element.querySelector('.bh-request-done')).toBeNull(); + expect(element.textContent).not.toMatch(/502|JSON|gateway|Network request failed/); + } else if (result !== 'accepted') { + await act(async () => { + await vi.waitFor(() => expect(button()?.disabled ?? true).toBe(true)); + }); + expect(element.textContent).not.toContain('Request already settled'); + if (batch) + expect(element.querySelector('[role="alert"]')?.textContent).toContain( + zhTranslate('humanInbox.batch.failed', { count: '1' }), + ); + else if (kind === 'tool-approval') + expect(element.querySelector('.bh-request-done')?.getAttribute('data-state')).toBe( + 'expired', + ); + else { + expect(element.querySelector('.bh-request-done')?.getAttribute('data-state')).toBe( + result === 'decided' ? 'answered' : 'expired', + ); + expect(element.querySelector('[role="alert"]')?.textContent).toContain( + zhTranslate('humanInbox.failed'), + ); + } + expect(host.calls.filter((call) => call.method === 'humanAttention').length).toBeGreaterThan( + 3, + ); + } + } finally { + await act(async () => root.unmount()); + element.remove(); + release(); + store.select(localSelection); + } +}); diff --git a/packages/client/test/remote-conversation-source.test.tsx b/packages/client/test/remote-conversation-source.test.tsx new file mode 100644 index 0000000000..092ed62372 --- /dev/null +++ b/packages/client/test/remote-conversation-source.test.tsx @@ -0,0 +1,237 @@ +// @vitest-environment jsdom +import { act, createElement, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RemoteHosts, RemoteConversationTarget } from '../src/client/remote-hosts.js'; + +const fixture = vi.hoisted(() => ({ sources: undefined as RemoteHosts | undefined })); +vi.mock('../src/client/remote-hosts.js', async (original) => ({ + ...(await original()), + remoteHosts: () => fixture.sources!, +})); +vi.mock('@deepseek-ai/dsh-client-ui-primitives', async () => ({ + ...(await import('./human-inbox-test-controls.js')), + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: { + children?: ReactNode; + variant?: string; + size?: string; + }) => createElement('button', props, children), + MarkdownText: ({ text }: { text: string }) => createElement('span', null, text), + StateDot: () => null, + Input: () => null, +})); +vi.mock('../src/client/bot-sidebar.js', async () => { + const { useSyncExternalStore } = await import('react'); + const { store } = await import('../src/client/store.js'); + return { useClientState: () => useSyncExternalStore(store.subscribe, store.getSnapshot) }; +}); + +import { FakeHost } from '../../../apps/mobile/test/fake-host.js'; +import { createActions } from '../src/client/actions.js'; +import { ActivityCenterView } from '../src/client/activity-center-view.js'; +import { ChannelDock } from '../src/client/channel-dock.js'; +import { createRemoteHosts, REMOTE_HOSTS_STORAGE_KEY } from '../src/client/remote-hosts.js'; +import { zhTranslate } from '../src/client/locale.js'; +import { store } from '../src/client/store.js'; + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + vi.stubGlobal('PointerEvent', class extends MouseEvent {}); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); +}); +afterEach(() => vi.unstubAllGlobals()); + +const first: RemoteConversationTarget = { hostId: 'b', channelId: 'shared', title: 'First' }; +const targets = [ + { hostId: 'c', channelId: 'shared', title: 'Other Host' }, + { hostId: 'b', channelId: 'other', title: 'Other Channel' }, +]; + +async function mount(surface: 'activity' | 'dock') { + const hosts = { b: new FakeHost(), c: new FakeHost() }; + for (const host of Object.values(hosts)) { + host.methods.set('humanAttention', () => ({ items: [], queryVersion: 1 })); + host.methods.set('channelUnreadCounts', () => ({ channels: [] })); + host.methods.set('channelMarkRead', () => ({ accepted: true })); + host.channel('shared'); + host.channel('other'); + } + const routed = (url: string) => (new URL(url).hostname === 'b.test' ? hosts.b : hosts.c); + const sources = createRemoteHosts({ + carrier: { + request: (url, init) => routed(url).carrier.request(url, init), + openSocket: (url, events, headers) => routed(url).carrier.openSocket(url, events, headers), + }, + storage: { + getItem: (key) => + key === REMOTE_HOSTS_STORAGE_KEY + ? JSON.stringify( + Object.entries(hosts).map(([id, host]) => ({ + hostId: id, + hostName: id, + routes: [`https://${id}.test`], + deviceId: 'device-1', + credential: host.credential, + })), + ) + : null, + setItem: () => {}, + removeItem: () => {}, + }, + }); + fixture.sources = sources; + const release = sources.subscribe(() => {}); + await vi.waitFor(() => + expect(sources.getSnapshot().hosts.every((host) => host.inbox.status === 'ready')).toBe(true), + ); + sources.open(first); + const element = document.createElement('div'); + document.body.append(element); + const root = createRoot(element); + const actions = createActions(async () => ({ ok: true, value: { items: [] } }), store); + await act(async () => + root.render( + surface === 'activity' ? ( + + ) : ( + true} + t={zhTranslate} + storage={{ getItem: () => null, setItem: () => {}, removeItem: () => {} }} + /> + ), + ), + ); + const input = () => element.querySelector('[data-remote-composer]')!; + return { + hosts, + sources, + element, + input, + async type(body: string) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call( + input(), + body, + ); + input().dispatchEvent(new Event('input', { bubbles: true })); + }); + }, + async open(target: RemoteConversationTarget) { + await act(async () => sources.open(target)); + }, + async send() { + await act(async () => { + input().form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + }); + }, + async close() { + await act(async () => root.unmount()); + release(); + element.remove(); + fixture.sources = undefined; + }, + }; +} + +describe.each(['activity', 'dock'] as const)('%s remote conversation source', (surface) => { + it.each(targets)('does not send a previous draft to $hostId / $channelId', async (target) => { + const view = await mount(surface); + try { + await view.type('Only for B / shared'); + expect(view.input().value).toBe('Only for B / shared'); + await view.open(target); + await view.send(); + expect(view.hosts.b.calls.filter(({ method }) => method === 'channelSend')).toEqual([]); + expect(view.hosts.c.calls.filter(({ method }) => method === 'channelSend')).toEqual([]); + expect(view.input().value).toBe(''); + expect(view.element.querySelector('[data-remote-send]')!.disabled).toBe( + true, + ); + } finally { + await view.close(); + } + }); + + it('preserves editing when the same source title and messages update', async () => { + const view = await mount(surface); + try { + await view.type('Still editing'); + const input = view.input(); + await view.open({ ...first, title: 'Renamed' }); + await act(async () => { + view.hosts.b.channel('shared').append({ id: 'incoming', body: 'New message' }); + }); + expect(view.input()).toBe(input); + expect(view.input().value).toBe('Still editing'); + expect(view.element.textContent).toContain('New message'); + expect( + view.element.querySelector('[data-remote-conversation]')?.getAttribute('aria-label'), + ).toBe('Renamed'); + } finally { + await view.close(); + } + }); + + it.each(targets)( + 'keeps an old send completion in its source after switching to $hostId / $channelId', + async (target) => { + const view = await mount(surface); + let complete!: () => void; + const pending = new Promise((resolve) => { + complete = resolve; + }); + view.hosts.b.methods.set('channelSend', async (args) => { + await pending; + view.hosts.b.channel(String(args.channelId)).append({ + id: String(args.messageId), + body: String(args.body), + }); + return { accepted: true }; + }); + try { + await view.type('Send only to B / shared'); + await view.send(); + expect(view.hosts.b.calls.filter(({ method }) => method === 'channelSend')).toHaveLength(1); + await view.open(target); + await view.type('New target draft'); + await act(async () => { + complete(); + await vi.waitFor(() => + expect(view.sources.conversation(first)?.getSnapshot().messages[0]?.delivery).toBe( + 'committed', + ), + ); + }); + expect(view.input().value).toBe('New target draft'); + expect(view.element.textContent).not.toContain('Send only to B / shared'); + expect(view.hosts.b.calls.filter(({ method }) => method === 'channelSend')).toEqual([ + { + method: 'channelSend', + args: expect.objectContaining({ channelId: 'shared', body: 'Send only to B / shared' }), + }, + ]); + expect(view.hosts.c.calls.filter(({ method }) => method === 'channelSend')).toEqual([]); + expect(view.sources.getSnapshot().open).toEqual(target); + } finally { + complete(); + await view.close(); + } + }, + ); +}); diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts index 1a4d079d80..757f6d6863 100644 --- a/packages/client/test/remote-hosts.test.ts +++ b/packages/client/test/remote-hosts.test.ts @@ -2,6 +2,8 @@ import { afterEach, assert, describe, expect, it, vi } from 'vitest'; import type { HostCarrier } from '../../../apps/mobile/src/host/carrier.js'; import { FakeHost } from '../../../apps/mobile/test/fake-host.js'; +import { createHostActivityActions } from '../src/client/host-activity-actions.js'; +import type { HumanAttentionItem } from '../src/client/store.js'; import { pairingLink } from '../../core/src/devices/http.js'; import { ALL_HOSTS, @@ -370,3 +372,446 @@ describe('Host Feed lifecycle', () => { expect(sources.getSnapshot().avatars.size).toBe(0); }); }); + +it('reuses the shared activity Inbox for reads, pagination and refresh', async () => { + const { host, storage } = liveHost(); + host.methods.set('humanAttention', (args) => ({ + queryVersion: 1, + items: [ + { + id: String(args.category) + (args.cursor ? '-next' : ''), + category: args.category, + kind: 'channel-unread', + botSlug: 'ada', + summary: 'remote', + createdAt: '', + }, + ], + ...(args.cursor ? {} : { nextCursor: 'remote-cursor' }), + })); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + const observation = sources.activity(host.hostId)!; + expect(sources.activity(host.hostId)).toBe(observation); + expect(observation.getSnapshot()).toBe(sources.getSnapshot().hosts[0]?.inbox); + const reads = host.calls.length; + const off = observation.subscribe(() => {}); + expect(host.calls).toHaveLength(reads); + await observation.getInbox()!.loadMore!('unread'); + expect(observation.getSnapshot()).toMatchObject({ + sections: { unread: [{ id: 'unread' }, { id: 'unread-next' }] }, + }); + off(); + await sources.remove(host.hostId); + expect(sources.activity(host.hostId)).toBeUndefined(); +}); + +it('queries remote search and history on its own Host client and disposes the filtered Inbox', async () => { + const { host, storage } = liveHost(); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + const query = { + activity: true, + section: 'handled' as const, + search: 'Remote-only', + sort: 'oldest' as const, + }; + const observation = sources.activity(host.hostId, query)!; + expect(sources.activity(host.hostId, query)).toBe(observation); + const off = observation.subscribe(() => {}); + await vi.waitFor(() => expect(observation.getSnapshot().status).toBe('ready')); + expect(host.calls).toContainEqual({ + method: 'humanAttention', + args: { + category: 'handled', + limit: 30, + queryVersion: 1, + search: 'Remote-only', + sort: 'oldest', + }, + }); + off(); + expect(observation.getInbox()).toBeUndefined(); +}); + +it('routes decisions, questions, joins, snooze and clear to the owning Host and refuses removed Hosts', async () => { + const { host, storage } = liveHost(); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + const item: HumanAttentionItem = { + id: 'approval:shared', + category: 'action', + kind: 'tool-approval', + botSlug: 'tob', + channelId: 'dm-tob', + messageId: 'same-message', + summary: 'Remote request', + createdAt: '', + sourceEventId: 'same-event', + }; + const refresh = vi.fn(async () => {}); + const actions = createHostActivityActions({ + requestUnavailable: 'Request was not accepted', + client: () => { + const client = sources.client(host.hostId); + if (client === undefined) throw new Error('Host removed'); + return client; + }, + items: () => [item], + refresh, + open: async () => {}, + }); + host.methods.set('toolApprovalDecide', () => ({})); + host.methods.set('userQuestionAnswer', () => ({})); + host.methods.set('channelGroupJoinDecide', (args) => ({ + channel: { + joinRequests: [{ id: args.requestId, status: args.accept ? 'accepted' : 'declined' }], + }, + })); + host.methods.set('humanAttentionSnooze', () => ({ accepted: true })); + host.methods.set('humanAttentionDismiss', () => ({ accepted: true })); + host.calls.length = 0; + await actions.decideToolApproval('dm-tob', 'same-message', 'allowed-once'); + await actions.answerUserQuestion('dm-tob', 'same-message', [ + { id: 'q', selected: ['Yes'], custom: '' }, + ]); + await actions.decideGroupJoin('same-group', 'same-request', true); + await actions.snoozeHumanInbox(item, '2099-01-01T00:00:00.000Z'); + await actions.dismissHumanInbox(item); + expect(host.calls).toEqual([ + { + method: 'toolApprovalDecide', + args: { channelId: 'dm-tob', messageId: 'same-message', outcome: 'allowed-once' }, + }, + { + method: 'userQuestionAnswer', + args: { + channelId: 'dm-tob', + messageId: 'same-message', + answer: { answers: [{ id: 'q', selected: ['Yes'], custom: '' }] }, + }, + }, + { + method: 'channelGroupJoinDecide', + args: { channelId: 'same-group', requestId: 'same-request', accept: true }, + }, + { + method: 'humanAttentionSnooze', + args: { itemId: item.id, sourceKey: 'same-event', until: '2099-01-01T00:00:00.000Z' }, + }, + { method: 'humanAttentionDismiss', args: { itemId: item.id, sourceKey: 'same-event' } }, + ]); + expect(refresh).toHaveBeenCalledTimes(5); + host.methods.set('humanAttentionDismiss', () => ({ accepted: false })); + await expect(actions.dismissHumanInbox(item)).rejects.toThrow(); + expect(refresh).toHaveBeenCalledTimes(5); + await expect(actions.decideToolApproval('dm-tob', 'unowned-message', 'rejected')).rejects.toThrow( + 'Request was not accepted', + ); + await sources.remove(host.hostId); + await expect(actions.dismissHumanInbox(item)).rejects.toThrow('Host removed'); +}); + +it.each( + (['tool-approval', 'user-question'] as const).flatMap((kind) => + (['decided', 'expired'] as const).map((result) => ({ kind, result })), + ), +)( + 'does not accept a remote $kind returning $result after the precheck', + async ({ kind, result }) => { + const { host, storage } = liveHost(); + const sources = createRemoteHosts({ carrier: host.carrier, storage }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + let pending = true; + const item: HumanAttentionItem = { + id: 'request', + category: 'action', + kind, + botSlug: 'ada', + channelId: 'dm-ada', + messageId: 'request-message', + summary: '', + createdAt: '', + }; + const refresh = vi.fn(async () => {}); + const actions = createHostActivityActions({ + client: () => sources.client(host.hostId)!, + items: () => [item], + refresh, + open: async () => {}, + requestUnavailable: 'This request was not accepted', + }); + const request = { + id: item.messageId, + toolApprovalRequest: { toolName: 'bash', role: 'orchestrator', cwd: '', input: '{}' }, + userQuestionRequest: { + questions: [{ id: 'q', question: 'Choose', options: [{ label: 'Blue' }] }], + }, + }; + host.methods.set('channelTimeline', () => ({ + page: { + entries: [ + request, + ...(!pending && result === 'decided' + ? [ + { + id: 'other-device-decision', + toolApprovalDecision: { requestMessageId: item.messageId, outcome: 'rejected' }, + userQuestionResolution: { + requestMessageId: item.messageId, + state: 'answered', + answers: [{ id: 'q', selected: ['Blue'] }], + }, + }, + ] + : []), + ], + }, + })); + host.methods.set('toolApprovalStatus', () => ({ status: pending ? 'pending' : 'expired' })); + host.methods.set('userQuestionStatus', () => ({ + status: pending ? 'pending' : result === 'decided' ? 'answered' : 'expired', + })); + host.methods.set(kind === 'tool-approval' ? 'toolApprovalDecide' : 'userQuestionAnswer', () => { + pending = false; + throw new Error('Host refused after another device settled the request'); + }); + const status = () => + kind === 'tool-approval' + ? actions.toolApprovalStatus(item.channelId!, item.messageId!) + : actions.userQuestionStatus(item.channelId!, item.messageId!); + expect(await status()).toBe('pending'); + const submit = () => + kind === 'tool-approval' + ? actions.decideToolApproval(item.channelId!, item.messageId!, 'allowed-once') + : actions.answerUserQuestion(item.channelId!, item.messageId!, [ + { id: 'q', selected: ['Blue'] }, + ]); + await expect(submit()).rejects.toThrow('This request was not accepted'); + expect(refresh).toHaveBeenCalledOnce(); + expect(await status()).toBe( + kind === 'user-question' && result === 'decided' ? 'answered' : 'expired', + ); + }, +); + +it.each( + (['tool-approval', 'user-question'] as const).flatMap((kind) => + (['submit', 'refresh'] as const).map((phase) => ({ kind, phase })), + ), +)('preserves the $phase failure cause for remote $kind', async ({ kind, phase }) => { + const { host, storage } = liveHost(); + const method = kind === 'tool-approval' ? 'toolApprovalDecide' : 'userQuestionAnswer'; + const cause = new TypeError('Network request failed'); + host.methods.set(method, () => ({ accepted: true })); + const sources = createRemoteHosts({ + storage, + carrier: { + ...host.carrier, + async request(url, init) { + if (phase === 'submit' && url.endsWith('/' + method)) throw cause; + return host.carrier.request(url, init); + }, + }, + }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + const refresh = vi.fn(async () => { + throw cause; + }); + const actions = createHostActivityActions({ + client: () => sources.client(host.hostId)!, + items: () => [ + { + id: 'request', + category: 'action', + kind, + botSlug: 'ada', + channelId: 'dm-ada', + messageId: 'request-message', + summary: '', + createdAt: '', + }, + ], + refresh, + open: async () => {}, + requestUnavailable: '操作失败,请刷新后重试', + }); + const submit = () => + kind === 'tool-approval' + ? actions.decideToolApproval('dm-ada', 'request-message', 'allowed-once') + : actions.answerUserQuestion('dm-ada', 'request-message', [{ id: 'q', selected: ['Blue'] }]); + if (phase === 'submit') { + await expect(submit()).rejects.toMatchObject({ + message: '操作失败,请刷新后重试', + cause: { message: 'The Host could not be reached.' }, + }); + expect(refresh).not.toHaveBeenCalled(); + } else { + await expect(submit()).rejects.toBe(cause); + expect(refresh).toHaveBeenCalledOnce(); + expect(host.calls.filter((call) => call.method === method)).toHaveLength(1); + } +}); + +it.each(['tool-approval', 'user-question'] as const)( + 'rejects a removed %s without reusing its cached mutation reference', + async (kind) => { + const { host, storage } = liveHost(); + host.methods.set('toolApprovalStatus', () => ({ status: 'pending' })); + host.methods.set('userQuestionStatus', () => ({ status: 'pending' })); + host.methods.set('channelTimeline', () => ({ + page: { + entries: [ + { + id: 'request-message', + toolApprovalRequest: { toolName: 'bash', role: 'orchestrator', cwd: '', input: '{}' }, + userQuestionRequest: { questions: [{ id: 'q', question: 'Choose' }] }, + }, + ], + }, + })); + host.methods.set('toolApprovalDecide', () => ({ accepted: true })); + host.methods.set('userQuestionAnswer', () => ({ accepted: true })); + const sources = createRemoteHosts({ storage, carrier: host.carrier }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + let items: HumanAttentionItem[] = [ + { + id: 'request', + category: 'action', + kind, + botSlug: 'ada', + channelId: 'dm-ada', + messageId: 'request-message', + summary: '', + createdAt: '', + }, + ]; + const refresh = vi.fn(async () => {}); + const actions = createHostActivityActions({ + client: () => sources.client(host.hostId)!, + items: () => items, + refresh, + open: async () => {}, + requestUnavailable: '操作失败,请刷新后重试', + }); + const status = () => + kind === 'tool-approval' + ? actions.toolApprovalStatus('dm-ada', 'request-message') + : actions.userQuestionStatus('dm-ada', 'request-message'); + expect(await status()).toBe('pending'); + items = []; + const submit = () => + kind === 'tool-approval' + ? actions.decideToolApproval('dm-ada', 'request-message', 'allowed-once') + : actions.answerUserQuestion('dm-ada', 'request-message', [ + { id: 'q', selected: ['Blue'] }, + ]); + await expect(submit()).rejects.toMatchObject({ + message: '操作失败,请刷新后重试', + cause: { message: 'Activity request is unavailable' }, + }); + expect(refresh).not.toHaveBeenCalled(); + expect( + host.calls.filter( + ({ method }) => method === 'toolApprovalDecide' || method === 'userQuestionAnswer', + ), + ).toEqual([]); + expect(await status()).toBe('pending'); + }, +); + +it.each( + ( + [ + 'toolApprovalStatus', + 'toolApprovalState', + 'toolApprovalExecutionState', + 'userQuestionStatus', + ] as const + ).flatMap((method) => + (['status', 'timeline'] as const).flatMap((read) => + (['http', 'non-json', 'network'] as const).map((failure) => ({ method, read, failure })), + ), + ), +)( + 'localizes $method $read $failure while retaining its cause', + async ({ method, read, failure }) => { + const { host, storage } = liveHost(); + const status = method === 'userQuestionStatus' ? 'userQuestionStatus' : 'toolApprovalStatus'; + const endpoint = read === 'timeline' ? 'channelTimeline' : status; + host.methods.set(status, () => ({ status: 'pending' })); + host.methods.set('channelTimeline', () => ({ + page: { + entries: [ + { + id: 'request-message', + toolApprovalRequest: { toolName: 'bash', role: 'orchestrator', cwd: '', input: '{}' }, + userQuestionRequest: { questions: [{ id: 'q', question: 'Choose' }] }, + }, + ], + }, + })); + const sources = createRemoteHosts({ + storage, + carrier: { + ...host.carrier, + async request(url, init) { + if (url.endsWith('/' + endpoint)) { + if (failure === 'network') throw new TypeError('Network request failed'); + return { + status: failure === 'http' ? 502 : 200, + text: async () => 'Bad gateway', + }; + } + return host.carrier.request(url, init); + }, + }, + }); + releases.push(sources.subscribe(() => {})); + await vi.waitFor(() => expect(sources.getSnapshot().hosts[0]?.inbox.status).toBe('ready')); + const refresh = vi.fn(async () => {}); + const actions = createHostActivityActions({ + client: () => sources.client(host.hostId)!, + items: () => [ + { + id: 'request', + category: 'action', + kind: method === 'userQuestionStatus' ? 'user-question' : 'tool-approval', + botSlug: 'ada', + channelId: 'dm-ada', + messageId: 'request-message', + summary: '', + createdAt: '', + }, + ], + refresh, + open: async () => {}, + requestUnavailable: '操作失败,请刷新后重试', + }); + const readRequest = actions[method]; + assert(readRequest !== undefined); + await expect(readRequest('dm-ada', 'request-message')).rejects.toMatchObject({ + message: '操作失败,请刷新后重试', + cause: { + message: + failure === 'http' + ? 'The Host answered 502.' + : failure === 'non-json' + ? 'The Host did not return a JSON response.' + : 'The Host could not be reached.', + }, + }); + expect(refresh).not.toHaveBeenCalled(); + expect( + host.calls.filter( + ({ method }) => method === 'toolApprovalDecide' || method === 'userQuestionAnswer', + ), + ).toEqual([]); + }, +);