| 1 | // Actual product components and command owners; only the Desktop RPC boundary |
| 2 | // is replaced. No production mock data or identity implementation is changed. |
| 3 | import React, { useState } from "react"; |
| 4 | import { createRoot } from "react-dom/client"; |
| 5 | import { ProjectTree } from "../src/components/ProjectTree"; |
| 6 | import { HistoryPanel } from "../src/components/HistoryPanel"; |
| 7 | import { TopicbarRegion } from "../src/app-shell/TopicbarRegion"; |
| 8 | import { useProjectTopicCommands } from "../src/app-runtime/useProjectTopicCommands"; |
| 9 | import { useHistoryCommands } from "../src/app-runtime/useHistoryCommands"; |
| 10 | import { desktopProjectAdapter } from "../src/app-runtime/desktopProjectAdapter"; |
| 11 | import type { HistoryViewState } from "../src/app-runtime/historyViewProjection"; |
| 12 | import { app } from "../src/lib/bridge"; |
| 13 | import { DESKTOP_COMMANDS, type SessionSelector, type SessionOrganizationMutation, type SessionOrganizationSnapshot } from "../src/generated/desktopContract.generated"; |
| 14 | import { ApprovalModal } from "../src/components/ApprovalModal"; |
| 15 | import { useSessionOperations } from "../src/app-runtime/useSessionOperations"; |
| 16 | import { useSessionPromptCommands } from "../src/app-runtime/useSessionPromptCommands"; |
| 17 | import { useSessionControlCommands } from "../src/app-runtime/useSessionControlCommands"; |
| 18 | import { resolvePromptForTab } from "../src/lib/exactPromptSubmit"; |
| 19 | import { requestSessionCancel } from "../src/lib/inboxCancel"; |
| 20 | import { installDesktopHostStub } from "../src/__tests__/desktopHostStub"; |
| 21 | import { LocaleProvider } from "../src/lib/i18n"; |
| 22 | import { ToastProvider } from "../src/lib/toast"; |
| 23 | import type { ProjectNode, SessionMeta } from "../src/lib/types"; |
| 24 | import "../src/styles.css"; |
| 25 | |
| 26 | localStorage.setItem("reasonix-lang", "en"); |
| 27 | const root = "/fixture/independent"; |
| 28 | const topicId = "same-topic"; |
| 29 | const paginationScenario = new URLSearchParams(location.search).has("pagination"); |
| 30 | let paginationInvalidated = false; |
| 31 | const pageRequests: { cursor: string; revision: number; rejected?: boolean }[] = []; |
| 32 | let revision = 1; |
| 33 | let stale = false; |
| 34 | let archived = false; |
| 35 | const rows: ProjectNode[] = (paginationScenario ? ["a", "b", "c", "d", "e", "f", "g"] : ["a", "b"]).map((id, index) => ({ |
| 36 | key: `session-${id}`, kind: "topic", label: `Session ${id.toUpperCase()}`, root, topicId, |
| 37 | session: { hostId: "local", sessionId: id }, sessionPath: `session-id:${id}`, |
| 38 | lifecycleGeneration: 1, resultSequence: index === 0 ? 100 : 10, |
| 39 | turns: 1, turnsState: "ready", createdAt: 100, lastActivityAt: 100, children: [], |
| 40 | })); |
| 41 | const folder: ProjectNode = { key: "project-fixture", kind: "project", label: "Independent sessions", root, children: [] }; |
| 42 | const catalogStatus = () => ({ state: "ready", mode: "memory", revision, indexed: 2, total: 2, repairPending: 0, sourceCount: 2, unindexedTargetCount: 0, canRebuild: false }); |
| 43 | const listRows = () => structuredClone(rows.filter(row => !archived || stale || row.session?.sessionId !== "b")); |
| 44 | const sessions = (): SessionMeta[] => rows.filter(row => !archived || row.session?.sessionId !== "b").map(row => ({ |
| 45 | path: row.sessionPath!, sessionId: row.session!.sessionId, hostId: "local", topicId, |
| 46 | title: row.label, topicTitle: row.label, preview: row.label, turns: 1, createdAt: 100, lastActivityAt: 100, modTime: 100, |
| 47 | current: false, open: false, scope: "project", workspaceRoot: root, |
| 48 | })); |
| 49 | const calls: { method: string; id: string; title?: string }[] = []; |
| 50 | const organizationCalls: SessionOrganizationMutation[] = []; |
| 51 | const runtimeCalls: { method: string; tabId: string; promptId?: string; turnId?: string; epoch?: string }[] = []; |
| 52 | const organization: SessionOrganizationSnapshot = { revision: 1, applied: true, groups: [], order: ["ref\x00local\x00a", "ref\x00local\x00b"], manualOrderEnabled: false }; |
| 53 | const resolve = (selector: SessionSelector) => { |
| 54 | const row = rows.find(row => selector.ref ? row.session?.sessionId === selector.ref.sessionId && selector.ref.hostId === "local" : row.sessionPath === selector.sessionPath); |
| 55 | if (!row) throw new Error("fixture target_not_found"); |
| 56 | return row; |
| 57 | }; |
| 58 | const fallback = Object.fromEntries(DESKTOP_COMMANDS.map(name => [name, app[name]])); |
| 59 | const host = installDesktopHostStub({ ...fallback, |
| 60 | Platform: async () => "linux", |
| 61 | GetProjectTreeSnapshot: async () => ({ revision, projects: [folder], catalog: catalogStatus(), indexed: 2, total: 2, indexingDone: true }), |
| 62 | ListProjectTopics: async (request: { groupId?: string; cursor?: string; limit?: number }) => { |
| 63 | if (paginationScenario) { |
| 64 | const entry = { cursor: request.cursor || "", revision, rejected: false }; |
| 65 | pageRequests.push(entry); |
| 66 | if (request.cursor === "old:5") { |
| 67 | paginationInvalidated = true; revision++; entry.rejected = true; |
| 68 | throw new Error("session_operation:stale_cursor:The session list changed. Reload it."); |
| 69 | } |
| 70 | const ids = paginationInvalidated ? ["c", "a", "b", "d", "e", "f", "g"] : ["a", "b", "d", "e", "f", "g"]; |
| 71 | const start = request.cursor ? Number(request.cursor.split(":")[1]) : 0; |
| 72 | const stop = Math.min(ids.length, start + (request.limit ?? 5)); |
| 73 | const items = ids.slice(start, stop).map((id, index) => ({ ...structuredClone(rows.find(row => row.session!.sessionId === id)!), sortOrder: start + index })); |
| 74 | return { revision, items, nextCursor: stop < ids.length ? `${paginationInvalidated ? "new" : "old"}:${stop}` : "", complete: true, readyDirectories: 1, pendingDirectories: 0, failedDirectories: 0 }; |
| 75 | } |
| 76 | const members = organization.groups.find(group => group.id === request.groupId)?.sessionKeys ?? []; |
| 77 | const grouped = new Set(organization.groups.flatMap(group => group.sessionKeys ?? [])); |
| 78 | const items = listRows().filter(row => request.groupId ? members.includes(`ref\x00local\x00${row.session!.sessionId}`) : !grouped.has(`ref\x00local\x00${row.session!.sessionId}`)); |
| 79 | if (organization.manualOrderEnabled) { |
| 80 | for (const row of items) row.sortOrder = organization.order.indexOf(`ref\x00local\x00${row.session!.sessionId}`); |
| 81 | items.sort((a,b) => a.sortOrder! - b.sortOrder!); |
| 82 | } |
| 83 | return { revision, items, nextCursor: "", complete: true, readyDirectories: 1, pendingDirectories: 0, failedDirectories: 0 }; |
| 84 | }, |
| 85 | GetSessionCatalogStatus: async () => catalogStatus(), |
| 86 | GetProjectTreeRuntimeSnapshot: async () => ({ revision, topics: [] }), |
| 87 | GetTopicSummary: async () => ({ key: "", kind: "topic", label: "", children: [] }), |
| 88 | GetSessionOrganization: async () => structuredClone(organization), |
| 89 | UpdateSessionOrganization: async (_workspace: unknown, expectedRevision: number, mutation: SessionOrganizationMutation) => { |
| 90 | if (expectedRevision !== organization.revision) return { ...structuredClone(organization), applied: false }; |
| 91 | organizationCalls.push(structuredClone(mutation)); |
| 92 | const key = mutation.target ? `ref\x00local\x00${resolve(mutation.target).session!.sessionId}` : ""; |
| 93 | if (mutation.kind === "create-group") organization.groups.push({ id: mutation.groupId!, title: mutation.title!, sessionKeys: [] }); |
| 94 | else if (mutation.kind === "set-group") { |
| 95 | for (const group of organization.groups) group.sessionKeys = (group.sessionKeys ?? []).filter(item => item !== key); |
| 96 | organization.groups.find(group => group.id === mutation.groupId)?.sessionKeys?.push(key); |
| 97 | } else if (mutation.kind === "move") { |
| 98 | const anchor = `ref\x00local\x00${resolve(mutation.anchor!).session!.sessionId}`; |
| 99 | organization.order = organization.order.filter(item => item !== key); |
| 100 | organization.order.splice(organization.order.indexOf(anchor) + (mutation.position === "after" ? 1 : 0), 0, key); |
| 101 | organization.manualOrderEnabled = true; |
| 102 | } else throw new Error(`unhandled organization mutation ${mutation.kind}`); |
| 103 | organization.revision++; revision++; |
| 104 | return structuredClone(organization); |
| 105 | }, |
| 106 | ResolvePromptForTab: async (tabId: string, promptId: string, turnId: string, epoch: string) => { runtimeCalls.push({ method: "approve", tabId, promptId, turnId, epoch }); }, |
| 107 | CancelSessionForTab: async (tabId: string) => { runtimeCalls.push({ method: "stop", tabId }); return { accepted: true, alreadyIdle: false, recoveryRequired: false }; }, |
| 108 | GetProjectGroups: async () => ({ revision: 1, groups: [], applied: true }), |
| 109 | ListProjectGroups: async () => [], |
| 110 | ListTabs: async () => [], |
| 111 | RemoteConnectionStatuses: async () => [], |
| 112 | ListSessions: async () => sessions(), |
| 113 | ListHistorySessions: async () => ({ items: sessions(), nextCursor: "", revision, partial: false }), |
| 114 | GetSessionRecoveryVersions: async () => ({ items: [] }), |
| 115 | GetSessionActivityBaseline: async (selector: SessionSelector) => ({ ref: resolve(selector).session, complete: true, resultSequence: resolve(selector).resultSequence, eventVersion: "100", lifecycleGeneration: 1 }), |
| 116 | RenameTopic: async () => { throw new Error("single-session UI called forbidden topic bulk rename"); }, |
| 117 | RenameSessionTarget: async (selector: SessionSelector, title: string) => { |
| 118 | const row = resolve(selector); row.label = title; revision++; |
| 119 | calls.push({ method: "rename", id: row.session!.sessionId, title }); |
| 120 | host.emit("project-tree:changed-v2", { revision, roots: [root], reason: "metadata" }); |
| 121 | host.emit("history-index:changed-v1", { revision, indexed: 2, total: 2, pending: 0 }); |
| 122 | return { committed: true, operationId: `rename-${revision}`, lifecycleGeneration: 1, targetKey: `ref\x00local\x00${row.session!.sessionId}` }; |
| 123 | }, |
| 124 | ArchiveSessionTarget: async (selector: SessionSelector) => { |
| 125 | const row = resolve(selector); |
| 126 | if (row.session!.sessionId !== "b") throw new Error("archive targeted sibling A"); |
| 127 | archived = true; revision++; |
| 128 | calls.push({ method: "archive", id: "b" }); |
| 129 | return { committed: true, operationId: "archive-b", lifecycleGeneration: 2, targetKey: "ref\x00local\x00b" }; |
| 130 | }, |
| 131 | }); |
| 132 | (window as any).__independentEvidence = { calls, rows, organizationCalls, organization, runtimeCalls, pageRequests }; |
| 133 | |
| 134 | function Fixture() { |
| 135 | const [selected, setSelected] = useState("a"); |
| 136 | const [refresh, setRefresh] = useState(0); |
| 137 | const [mount, setMount] = useState(0); |
| 138 | const [variant, setVariant] = useState<"workbench" | "creation">("workbench"); |
| 139 | const [history, setHistory] = useState<HistoryViewState | null>(null); |
| 140 | const [showApproval, setShowApproval] = useState(false); |
| 141 | const [activityStep, setActivityStep] = useState(0); |
| 142 | const target = { tabId: `tab-${selected}`, sessionKey: selected }; |
| 143 | const resources = ["a", "b"].map(id => ({ tabId: `tab-${id}`, sessionKey: id })); |
| 144 | const operations = useSessionOperations({ visible: target, resources }); |
| 145 | const promptCommands = useSessionPromptCommands({ target, approval: { id: `approval-${selected}`, tool: "bash" }, remote: false, goal: "", toolApprovalMode: "read-only", operations, |
| 146 | reportError: error => { throw error; }, ports: { |
| 147 | isPromptCurrentForTab: (tab, _kind, id) => id === `approval-${tab.replace("tab-", "")}`, |
| 148 | approveForTab: (tab, id, allow, session, persist) => { void resolvePromptForTab(app, tab, id, "approval", { allow, session, persist }, `turn-${tab}`, `epoch-${tab}`); setShowApproval(false); }, |
| 149 | resolvePlanForTab: () => {}, resolveRecoveryForTab: () => {}, answerQuestionForTab: async () => {}, answerMCPForTab: () => {}, |
| 150 | setCollaborationModeForTab: async () => {}, clearGoalForTab: async () => {}, setRemoteComposerProfile: async () => [], |
| 151 | patchComposerProfile: () => {}, notePlanMode: () => {}, drainRemoteApprovals: () => {}, rememberRevision: () => {}, |
| 152 | } }); |
| 153 | const controlCommands = useSessionControlCommands({ activeTabId: target.tabId, resources, operations, showToast: message => { throw new Error(message); }, clearWorkspaceConflict: () => {}, ports: { |
| 154 | cancel: async () => { throw new Error("stop must retain its source tab"); }, cancelForTab: (tab, items) => requestSessionCancel(app, tab, items), |
| 155 | acceptDelivery: async () => {}, disconnectRemote: async () => {}, cancelJobForTab: async () => false, refreshBackgroundRuntimes: async () => {}, |
| 156 | } }); |
| 157 | const row = rows.find(row => row.session?.sessionId === selected)!; |
| 158 | const commands = useProjectTopicCommands({ |
| 159 | visible: { tabId: `tab-${selected}`, sessionKey: selected }, |
| 160 | topic: { id: topicId, title: row.label, target: { kind: "local", topicId, selector: { ref: row.session } } }, |
| 161 | ports: { ...desktopProjectAdapter, markChanged: () => setRefresh(value => value + 1), refreshTabs: async () => [{ id: `tab-${selected}` }], syncActive: async () => {} }, |
| 162 | navigation: { openBlank: async () => {}, enqueue: async () => {}, switchFolder: async () => {} }, reportError: error => { throw error; }, |
| 163 | }); |
| 164 | const historyCommands = useHistoryCommands({ running: false, setHistView: setHistory, |
| 165 | ports: { listSessions: async () => sessions(), deleteSession: async () => {}, renameSession: async () => { throw new Error("ambiguous rename"); }, openPage: () => {} } }); |
| 166 | return <div className={`app app--${variant}`} style={{ display: "block", height: "100vh" }}> |
| 167 | <div style={{ padding: 12, display: "flex", gap: 12 }}> |
| 168 | <button onClick={() => setHistory({ kind: "history", source: "all", sessions: sessions() })}>Open fixture history</button> |
| 169 | <button onClick={() => { document.documentElement.dataset.theme = "light"; }}>Light theme</button> |
| 170 | <button onClick={() => { document.documentElement.dataset.theme = "dark"; }}>Dark theme</button> |
| 171 | <button onClick={() => setVariant(value => value === "workbench" ? "creation" : "workbench")}>Toggle creation</button> |
| 172 | <button onClick={() => setMount(value => value + 1)}>Remount sidebar</button> |
| 173 | <button onClick={() => setShowApproval(true)}>Show selected runtime approval</button> |
| 174 | <button onClick={() => { |
| 175 | rows[1]!.resultSequence = activityStep === 1 ? 10 : 20; |
| 176 | setActivityStep(value => value + 1); revision++; setRefresh(value => value + 1); |
| 177 | host.emit("project-tree:changed-v2", { revision, roots: [root], reason: "metadata" }); |
| 178 | setMount(value => value + 1); |
| 179 | }}>Advance B activity observation</button> |
| 180 | <button onClick={() => { |
| 181 | stale = true; revision++; |
| 182 | host.emit("project-tree:runtime-changed", { revision, topics: [{ scope: "project", workspaceRoot: root, node: { ...rows[1], runtimeOnly: true, running: true, status: "thinking" } }] }); |
| 183 | setRefresh(value => value + 1); |
| 184 | }}>Inject late snapshots</button> |
| 185 | </div> |
| 186 | <TopicbarRegion view={{ automationReturn: false, automationReturnLabel: "", chromeHidden: false, brand: false, |
| 187 | sidebar: { title: "Sidebar", blocked: false, pressed: false, collapsed: false }, |
| 188 | title: { text: row.label, hover: row.label, renameLabel: "Rename selected session", editing: commands.topicbarEditing, draft: commands.topicTitleDraft, canRename: true }, |
| 189 | subtitle: { visible: false, title: "", mergeLabel: "", mergeTooltip: "" }, |
| 190 | }} commands={{ openAutomation: () => {}, toggleSidebar: () => {}, setTitleDraft: commands.setTopicTitleDraft, |
| 191 | commitRename: commands.commitActiveTopicRename, cancelRename: commands.cancelActiveTopicRename, |
| 192 | startRename: commands.startActiveTopicRename, openWorktree: () => {} }}>{null}</TopicbarRegion> |
| 193 | <aside className={`sidebar sidebar--${variant}`} style={{ width: 360, height: "calc(100vh - 120px)", position: "relative" }}> |
| 194 | <ProjectTree key={mount} variant={variant} activeScope="project" activeWorkspaceRoot={root} activeTopicId={topicId} activeSessionPath={`session-id:${selected}`} |
| 195 | refreshSignal={refresh} onOpenTopic={async (_scope, _root, _topic, path) => { setSelected(path?.replace("session-id:", "") ?? "a"); }} |
| 196 | onAddProject={async () => {}} onTopicsChanged={() => setRefresh(value => value + 1)} /> |
| 197 | </aside> |
| 198 | {showApproval && <div style={{ position: "fixed", bottom: 20, left: 400, right: 20 }}><ApprovalModal key={selected} |
| 199 | approval={{ id: `approval-${selected}`, tool: "bash", subject: `echo Session ${selected.toUpperCase()}`, fresh: true }} |
| 200 | tabId={target.tabId} onAnswer={promptCommands.handleApprovalAnswer} onStop={() => { void controlCommands.handleCancelActive(); setShowApproval(false); }} /></div>} |
| 201 | {history && <HistoryPanel sessions={history.sessions} running={false} onResume={s => { setSelected(s.sessionId!); setHistory(null); }} |
| 202 | onPreview={async () => []} onDelete={() => {}} onRename={historyCommands.onRenameHistorySession} onClose={() => setHistory(null)} />} |
| 203 | </div>; |
| 204 | } |
| 205 | createRoot(document.getElementById("root")!).render(<LocaleProvider><ToastProvider><Fixture /></ToastProvider></LocaleProvider>); |
| 206 |