From 52974ac4d84022c3c0eec0c35640c0d9ce30910b Mon Sep 17 00:00:00 2001 From: Maggie Appleton <5599295+MaggieAppleton@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:54:42 +0100 Subject: [PATCH 1/3] Show where a research child came from A research child now opens with a quiet provenance line naming its parent, who started it and its source count, with the brief one click away. The parent name returns to the research card that produced it. The child's Chat starts in the parent's open or closed state and explains itself when empty. The server exposes a read-only provenance route for a child. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/research/routes.test.ts | 5 ++ apps/server/src/research/routes.ts | 20 +++++ apps/server/src/research/service.test.ts | 9 ++ apps/server/src/research/service.ts | 21 +++++ apps/server/src/storage/memory/research.ts | 10 +++ apps/server/src/storage/port.ts | 5 ++ apps/server/src/storage/postgres/research.ts | 12 +++ .../storage/research-publication-contract.ts | 3 + apps/web/src/chat/chat.tsx | 6 ++ apps/web/src/chat/transcript.tsx | 7 ++ apps/web/src/child-provenance.tsx | 83 +++++++++++++++++++ apps/web/src/document-workspace-host.tsx | 14 +++- apps/web/src/hosted.tsx | 11 ++- apps/web/src/navigation.css | 39 +++++++++ apps/web/src/research-requests.ts | 18 +++- apps/web/src/room-workspace.tsx | 7 ++ apps/web/src/workspace-model.test.ts | 8 +- apps/web/src/workspace-model.ts | 18 ++-- apps/web/src/workspace.tsx | 7 +- e2e/research-child-recovery.e2e.ts | 21 +++++ e2e/research-child-surface.e2e.ts | 33 +++----- packages/editor/src/plan-editor.tsx | 16 ++++ packages/editor/src/styles.css | 16 +++- packages/editor/src/widgets/research.tsx | 1 + packages/protocol/research.d.ts | 10 +++ 25 files changed, 356 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/child-provenance.tsx diff --git a/apps/server/src/research/routes.test.ts b/apps/server/src/research/routes.test.ts index dc2cc8aa6..cd4649384 100644 --- a/apps/server/src/research/routes.test.ts +++ b/apps/server/src/research/routes.test.ts @@ -627,6 +627,11 @@ describe("research workspace routes", () => { context.cookie, )); expect(crossChannel?.status).toBe(404); + let topLevel = await context.router.handle(request( + `/api/channels/${context.channel.id}/research-provenance`, + context.cookie, + )); + expect(topLevel?.status).toBe(404); context.access.repository = { ...context.access.repository, diff --git a/apps/server/src/research/routes.ts b/apps/server/src/research/routes.ts index b237db6fd..2533d2461 100644 --- a/apps/server/src/research/routes.ts +++ b/apps/server/src/research/routes.ts @@ -433,6 +433,26 @@ export function registerResearchWorkspaceRoutes( }, ); + router.on( + "GET", + "/api/channels/:channelId/research-provenance", + async (request, _url, params) => { + try { + let session = await auth.sessions.authenticate(request); + if (!session) return json({ error: "authentication required" }, 401); + let access = await channelAccess(auth, session, params.channelId!); + let parentId = access?.channel.parentChannelId; + let provenance = access && parentId + && await service.provenance(parentId, access.channel.id); + return provenance + ? json(provenance) + : json({ error: "research provenance not found" }, 404); + } catch (err) { + return failure(err, auth); + } + }, + ); + router.on( "GET", "/api/channels/:channelId/research-requests/:workspaceId", diff --git a/apps/server/src/research/service.test.ts b/apps/server/src/research/service.test.ts index fee67a3a0..1d55e8d56 100644 --- a/apps/server/src/research/service.test.ts +++ b/apps/server/src/research/service.test.ts @@ -497,6 +497,15 @@ describe("research workspace service", () => { let replayed = await context.restart().request(context.channelId, started.request.id); expect(replayed?.child?.id).toBe(childId); + expect(await context.service.provenance(context.channelId, childId)).toEqual({ + requestId: started.request.id, + parentChannelId: context.channelId, + brief: "Which API contracts changed?", + sourceCount: 1, + startedAt: started.request.createdAt, + startedBy: "octocat", + }); + expect(await context.service.provenance(childId, childId)).toBeUndefined(); let listed = await context.storage.channels.list(REPOSITORY_ID, 100); expect(listed.channels.filter(value => value.parentChannelId === context.channelId)) .toHaveLength(1); diff --git a/apps/server/src/research/service.ts b/apps/server/src/research/service.ts index 5430d6f03..ae27d6871 100644 --- a/apps/server/src/research/service.ts +++ b/apps/server/src/research/service.ts @@ -825,6 +825,27 @@ export class ResearchWorkspaceService { }); } + async provenance( + parentChannelId: string, + childChannelId: string, + ): Promise { + if (!this.#validId(parentChannelId) || !this.#validId(childChannelId)) return undefined; + let found = await this.#storage.research.findPublished(parentChannelId, childChannelId); + if (!found) return undefined; + let view = await this.request(parentChannelId, found.workspace.id); + if (view?.stage !== "ready" || view.child.id !== childChannelId) return undefined; + let initial = found.turns.find(value => value.kind === "initial"); + let user = initial && await this.#storage.users.get(initial.requestedBy); + return { + requestId: view.id, + parentChannelId, + brief: view.question, + sourceCount: view.child.sourceCount, + startedAt: view.createdAt, + ...(user ? { startedBy: user.login } : {}), + }; + } + async reconcile(channelId: string, workspaceId: string): Promise { if (!this.#validId(channelId) || !this.#validId(workspaceId)) return false; return this.#exclusive( diff --git a/apps/server/src/storage/memory/research.ts b/apps/server/src/storage/memory/research.ts index 2711b7159..a5ca3c133 100644 --- a/apps/server/src/storage/memory/research.ts +++ b/apps/server/src/storage/memory/research.ts @@ -848,6 +848,16 @@ export class MemoryResearchWorkspaceStore implements ResearchWorkspaceStore { return workspaceId ? this.get(channelId, workspaceId) : undefined; }; + readonly findPublished = async ( + channelId: string, + publishedChannelId: string, + ): Promise => { + let found = [...this.#workspaces.values()].find(value => + value.channelId === channelId && value.publishedChannelId === publishedChannelId + ); + return found ? this.get(channelId, found.id) : undefined; + }; + readonly findTurnByJob = async ( channelId: string, jobId: string, diff --git a/apps/server/src/storage/port.ts b/apps/server/src/storage/port.ts index 848436973..a565e2579 100644 --- a/apps/server/src/storage/port.ts +++ b/apps/server/src/storage/port.ts @@ -222,6 +222,11 @@ export interface ResearchWorkspaceStore { channelId: string, idempotencyKey: string, ): Promise; + /** The workspace in `channelId` whose report became the child document `publishedChannelId`. */ + findPublished( + channelId: string, + publishedChannelId: string, + ): Promise; findTurnByJob(channelId: string, jobId: string): Promise; } diff --git a/apps/server/src/storage/postgres/research.ts b/apps/server/src/storage/postgres/research.ts index cef46d9c1..48123ed48 100644 --- a/apps/server/src/storage/postgres/research.ts +++ b/apps/server/src/storage/postgres/research.ts @@ -1367,6 +1367,18 @@ export class PostgresResearchWorkspaceStore implements ResearchWorkspaceStore { return row ? this.get(channelId, text(row.id, "research workspace id")) : undefined; }); + readonly findPublished = ( + channelId: string, + publishedChannelId: string, + ): Promise => + this.#run("find published research workspace", async () => { + let [row] = await this.#sql<{ id: unknown }[]>` + SELECT id FROM research_workspaces + WHERE channel_id = ${channelId} AND published_channel_id = ${publishedChannelId} + `; + return row ? this.get(channelId, text(row.id, "research workspace id")) : undefined; + }); + readonly findTurnByJob = (channelId: string, jobId: string): Promise => this.#run("find research turn by job", async () => { let rows = await this.#sql< diff --git a/apps/server/src/storage/research-publication-contract.ts b/apps/server/src/storage/research-publication-contract.ts index 0560e7b0d..eb597e84e 100644 --- a/apps/server/src/storage/research-publication-contract.ts +++ b/apps/server/src/storage/research-publication-contract.ts @@ -189,6 +189,9 @@ export function researchPublicationContract(factory: StorageFactory): void { sidecar: ready.initial.sidecar, }); expect([...stored!.snapshot!.document]).toEqual([7, 8, 9]); + expect((await storage.research.findPublished(channelId, childId))?.workspace.id) + .toBe(ready.workspaceId); + expect(await storage.research.findPublished(childId, childId)).toBeUndefined(); await storage.channels.archive({ id: channelId, diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index 7459fdd95..f3182c3e5 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -96,6 +96,8 @@ export type ChatProps = { onShowDecisions?: () => void; /** Replaces the composer when this viewer cannot chat at all, such as in an archived document. */ notice?: string; + /** One calm line shown while the transcript has loaded and is still empty. */ + emptyNotice?: string; }; /** How many runs are still live, and how many are paused and resumable. */ @@ -127,6 +129,7 @@ export function Chat( sourceDestination, people = [], notice, + emptyNotice, onShowDecisions, referencesEnabled, repository, @@ -445,6 +448,9 @@ export function Chat( onRetryJob={onRetryJob} conversationPlan={conversationPlan} decisions={decisions} + empty={transcriptReady && entries.length === 0 && queue.length === 0 && !turn + ? emptyNotice + : undefined} researchOffers={researchOffers} sourceDestination={sourceDestination} entries={entries} diff --git a/apps/web/src/chat/transcript.tsx b/apps/web/src/chat/transcript.tsx index d40488543..80abcc604 100644 --- a/apps/web/src/chat/transcript.tsx +++ b/apps/web/src/chat/transcript.tsx @@ -287,6 +287,7 @@ export function Transcript( conversationPlan, conversationPlanJobs, decisions, + empty, researchOffers, entries, handle, @@ -314,6 +315,7 @@ export function Transcript( ) => Promise; onRetryJob?: (jobId: string) => Promise; decisions?: TranscriptDecisions; + empty?: string; researchOffers?: ResearchOfferControls; entries: Chat.Entry[]; handle: string; @@ -413,6 +415,11 @@ export function Transcript( className="flex min-h-full flex-col gap-4 [&>*:first-child]:mt-auto" data-chat-stack > + {empty && ( +

+ {empty} +

+ )} {groups.map(item => item.kind === "research" ? ( diff --git a/apps/web/src/child-provenance.tsx b/apps/web/src/child-provenance.tsx new file mode 100644 index 000000000..faa46fb83 --- /dev/null +++ b/apps/web/src/child-provenance.tsx @@ -0,0 +1,83 @@ +import { useEffect, useId, useState } from "react"; +import { ChevronIcon, SparkleIcon } from "@chopin/icons"; + +import { researchProvenance } from "./research-requests"; + +import type { Research } from "@chopin/protocol"; +import type { ChildParent } from "./workspace-model"; + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +/** A quiet line above a research child that names its parent, brief, and sources. */ +export function ChildProvenance({ channelId, parent }: { channelId: string; parent: ChildParent }) { + let [provenance, setProvenance] = useState(); + let [briefOpen, setBriefOpen] = useState(false); + let briefId = useId(); + useEffect(() => { + let controller = new AbortController(); + setProvenance(undefined); + setBriefOpen(false); + researchProvenance(channelId, controller.signal) + .then(value => { + if (!controller.signal.aborted) setProvenance(value); + }) + // Provenance is context, not content; the document stays usable without it. + .catch(() => {}); + return () => controller.abort(); + }, [channelId]); + if (!provenance || provenance.parentChannelId !== parent.id) return null; + let requestId = provenance.requestId; + let returnToCard = () => { + let card = document.querySelector( + `[data-workspace-room="${CSS.escape(parent.id)}"] [data-research-request="${ + CSS.escape(requestId) + }"]`, + ); + parent.onReturn(card ? { current: card } : undefined); + }; + return ( +
+

+ + + Research from{" "} + + + {provenance.startedBy && <>{" · by "}{provenance.startedBy}} + {" · "} + {provenance.sourceCount === 0 + ? "No sources" + : plural(provenance.sourceCount, "source")} + + + +

+ +
+ ); +} diff --git a/apps/web/src/document-workspace-host.tsx b/apps/web/src/document-workspace-host.tsx index e1a1d6ec4..773ce64e9 100644 --- a/apps/web/src/document-workspace-host.tsx +++ b/apps/web/src/document-workspace-host.tsx @@ -16,6 +16,7 @@ import { } from "./document-workspace-state"; import type { ComponentType } from "react"; +import type { ResearchOpener } from "@chopin/editor"; import type { ChildFocusToken } from "./anchored-child-surface"; import type { DocumentWorkspaceAction } from "./document-workspace-state"; import type { DocumentRouteIdentity } from "./document-route-swap"; @@ -102,7 +103,7 @@ export default function DocumentWorkspaceHost( routeKey: DocumentRouteIdentity, pathname: string, ) => void; - onChildClose: (parentId: string, parentPath: string) => void; + onChildClose: (parentId: string, parentPath: string, opener?: ResearchOpener) => void; onChildClosing: (parentId: string, parentPath: string) => ChildFocusToken; onParentRestored: (token: ChildFocusToken) => void; onReady: ( @@ -405,7 +406,16 @@ export default function DocumentWorkspaceHost( loaded.child, agent, user, - { label: loaded.child.channel.title, onClose: closeChild, type: "child" }, + { + label: loaded.child.channel.title, + onClose: closeChild, + parent: { + id: loaded.parent.channel.id, + label: loaded.parent.channel.title, + onReturn: opener => onChildClose(loaded.parent.channel.id, parentPath, opener), + }, + type: "child", + }, childMetadataChanged, )} key={loaded.child.channel.id} diff --git a/apps/web/src/hosted.tsx b/apps/web/src/hosted.tsx index 1dccfd2d6..3ec2b5487 100644 --- a/apps/web/src/hosted.tsx +++ b/apps/web/src/hosted.tsx @@ -221,7 +221,7 @@ function DocumentRouteSwap( }: { agent: boolean; onCanonicalPath: (pathname: string) => void; - onChildClose: (parentId: string, parentPath: string) => void; + onChildClose: (parentId: string, parentPath: string, opener?: ResearchOpener) => void; onChildClosing: (parentId: string, parentPath: string) => ChildFocusToken; onParentRestored: (token: ChildFocusToken) => void; route: DocumentRoute; @@ -429,7 +429,9 @@ export function HostedApp( if (started) childOpener.current = undefined; return { started, token: { generation: next.generation, parentId } }; }, [cancelChildFocusFrame, moveChildFocus]); - let closeChild = useCallback((parentId: string, parentPath: string) => { + let closeChild = useCallback((parentId: string, parentPath: string, opener?: ResearchOpener) => { + // A provenance link returns focus to the parent's research card instead of the original opener. + if (opener) childOpener.current = opener; let closing = beginChildClosing(parentId, parentPath); if (!closing.started) return; let action = childCloseAction(history.state, parentPath); @@ -462,13 +464,16 @@ export function HostedApp( moveChildFocus({ type: "cancel" }); return; } - let target = attempt.opener?.current; + let opener = attempt.opener?.current; + let target = opener; if (!target?.isConnected || target.closest("[inert]")) { target = parent.querySelector(`[data-document-view="plan"] h2`); } moveChildFocus({ type: "finish", token }); if (target?.isConnected && !target.closest("[inert]")) { target.focus({ preventScroll: true }); + // An opener the reader clicked is already in view; a provenance target may not be. + if (target === opener) target.scrollIntoView({ block: "nearest" }); } }); }, [cancelChildFocusFrame, moveChildFocus]); diff --git a/apps/web/src/navigation.css b/apps/web/src/navigation.css index 1af0989b5..0db2c2307 100644 --- a/apps/web/src/navigation.css +++ b/apps/web/src/navigation.css @@ -1050,6 +1050,45 @@ color: var(--color-text-primary); } +/* A research child's provenance reads like the research card's own status line. */ +.child-provenance-line { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: var(--text-sm); + color: var(--color-text-tertiary); +} + +.child-provenance-parent { + font-weight: 500; + color: var(--color-text-secondary); + text-decoration: underline; + text-decoration-color: var(--color-text-quaternary); + text-decoration-thickness: 1px; + text-underline-offset: 3px; + cursor: pointer; +} + +.child-provenance-parent:hover { + color: var(--color-text-primary); +} + +.child-provenance-toggle { + flex-shrink: 0; + gap: 0.25rem; + margin-inline-start: auto; + color: var(--color-text-tertiary); +} + +.child-provenance-brief { + margin: 0.5rem 0 0; + padding-inline-start: 2rem; + font-size: var(--text-sm); + line-height: 1.5; + color: var(--color-text-secondary); +} + @media (prefers-reduced-motion: reduce) { .motion-sidebar, .motion-drawer, diff --git a/apps/web/src/research-requests.ts b/apps/web/src/research-requests.ts index 4b36f31f1..0b32ff7a9 100644 --- a/apps/web/src/research-requests.ts +++ b/apps/web/src/research-requests.ts @@ -1,7 +1,7 @@ import type { Research } from "@chopin/protocol"; import type { ResearchOpener, ResearchStore } from "@chopin/editor"; -import { response } from "./api"; +import { ApiError, response } from "./api"; const POLL_INTERVAL = 2_000; @@ -322,3 +322,19 @@ export class ResearchRequestStore implements ResearchStore { if (this.#disposed) throw new Error("Research request store is disposed."); } } + +/** Where a research child came from; undefined for a child no research request published. */ +export async function researchProvenance( + channelId: string, + signal?: AbortSignal, +): Promise { + try { + return await response( + `/api/channels/${encodeURIComponent(channelId)}/research-provenance`, + { signal }, + ); + } catch (error) { + if (error instanceof ApiError && error.status === 404) return undefined; + throw error; + } +} diff --git a/apps/web/src/room-workspace.tsx b/apps/web/src/room-workspace.tsx index 04ef3310c..06c9e1430 100644 --- a/apps/web/src/room-workspace.tsx +++ b/apps/web/src/room-workspace.tsx @@ -22,6 +22,7 @@ import { } from "@chopin/editor"; import { Chat } from "./chat/chat"; +import { ChildProvenance } from "./child-provenance"; import { shouldShowResearchActionError, useResearchOfferLinks } from "./chat/research-offer"; import type { ResearchDraftController } from "./chat/research-draft-controller"; import { advanceConversationAnnouncement } from "./conversation-plan/announcements"; @@ -815,6 +816,9 @@ export function RoomWorkspace( : !workspaceCanEdit ? "You have read-only access to this document." : undefined} + emptyNotice={presentation.type === "child" + ? "Discuss this report here. Messages stay with the report." + : undefined} onShowDecisions={() => selectDestination("decisions")} people={peopleHere(members)} conversationPlan={conversation.state} @@ -919,6 +923,9 @@ export function RoomWorkspace( motionImmediately={settleMotionImmediately} onScrollTop={setPlanScrollTop} onState={setPlanState} + preface={presentation.type === "child" && presentation.parent + ? + : undefined} questionMotion={QUESTION_MOTION} questions={questions} readOnly={!workspaceCanEdit} diff --git a/apps/web/src/workspace-model.test.ts b/apps/web/src/workspace-model.test.ts index daddeb2db..7ae6491da 100644 --- a/apps/web/src/workspace-model.test.ts +++ b/apps/web/src/workspace-model.test.ts @@ -28,8 +28,12 @@ describe("adaptive workspace", () => { }); }); - it("starts a child with Chat collapsed without inheriting the desktop preference", () => { - expect(initialWorkspaceState(workspaceProfile(childPresentation), true)).toEqual({ + it("starts every surface with the saved desktop Chat preference", () => { + expect(initialWorkspaceState(true)).toEqual({ + chatOpen: false, + desktopChatOpen: true, + }); + expect(initialWorkspaceState(false)).toEqual({ chatOpen: false, desktopChatOpen: false, }); diff --git a/apps/web/src/workspace-model.ts b/apps/web/src/workspace-model.ts index e3063384a..3f777009a 100644 --- a/apps/web/src/workspace-model.ts +++ b/apps/web/src/workspace-model.ts @@ -1,4 +1,4 @@ -import type { DecisionView } from "@chopin/editor"; +import type { DecisionView, ResearchOpener } from "@chopin/editor"; export type WorkspaceMode = "compact" | "split"; @@ -7,7 +7,14 @@ export type WorkspaceSurface = "document" | "child"; export type WorkspacePresentation = | { type: "document" } | { childLabel: string; onChildClose: () => void; type: "parent-with-child" } - | { label: string; onClose: () => void; type: "child" }; + | { label: string; onClose: () => void; parent?: ChildParent; type: "child" }; + +/** The document a child opened from, and a way back to the place that produced it. */ +export type ChildParent = { + id: string; + label: string; + onReturn: (opener?: ResearchOpener) => void; +}; export type WorkspaceProfile = { implementation: boolean; @@ -29,13 +36,10 @@ export type WorkspaceEvent = | { type: "set-chat"; open: boolean } | { type: "set-desktop-chat"; open: boolean }; -export function initialWorkspaceState( - profile: WorkspaceProfile, - desktopChatOpen: boolean, -): WorkspaceState { +export function initialWorkspaceState(desktopChatOpen: boolean): WorkspaceState { return { chatOpen: false, - desktopChatOpen: profile.persistChat && desktopChatOpen, + desktopChatOpen, }; } diff --git a/apps/web/src/workspace.tsx b/apps/web/src/workspace.tsx index 95fa1a830..08e2daaf8 100644 --- a/apps/web/src/workspace.tsx +++ b/apps/web/src/workspace.tsx @@ -89,11 +89,8 @@ export function useWorkspaceState( let [state, dispatch] = useReducer( transitionWorkspace, undefined, - () => - initialWorkspaceState( - profile, - localStorage.getItem("chopin:pane:chat:open") !== "false", - ), + // A child opens with its parent's saved Chat preference but never saves its own. + () => initialWorkspaceState(localStorage.getItem("chopin:pane:chat:open") !== "false"), ); useEffect(() => { diff --git a/e2e/research-child-recovery.e2e.ts b/e2e/research-child-recovery.e2e.ts index 2841e0a12..d34c2a17a 100644 --- a/e2e/research-child-recovery.e2e.ts +++ b/e2e/research-child-recovery.e2e.ts @@ -534,6 +534,27 @@ test("a ready research card and its Chat notice survive reconnect without anothe await assertRecovered(); await opened.reload(); await assertRecovered(); + + await opened.getByRole("complementary", { name: "Projects" }) + .getByRole("link", { name: title, exact: true }).click(); + let surface = opened.getByRole("region", { name: `Child document: ${title}` }); + let provenance = surface.locator("[data-child-provenance]"); + let parentTitle = `Test ${room.slice(0, 8)}`; + await expect(provenance).toContainText(`Research from ${parentTitle}`); + await expect(provenance).toContainText("by e2e"); + await expect(provenance).toContainText("1 source"); + let briefToggle = provenance.getByRole("button", { name: "Brief", exact: true }); + await expect(briefToggle).toHaveAttribute("aria-expanded", "false"); + await expect(provenance.getByText(brief, { exact: true })).toBeHidden(); + await briefToggle.click(); + await expect(provenance.getByText(brief, { exact: true })).toBeVisible(); + await expect(surface.getByRole("complementary", { name: "Chat" })) + .toContainText("Discuss this report here."); + await provenance.getByRole("button", { name: parentTitle, exact: true }).click(); + await expect(surface).toHaveCount(0); + await expect( + opened.getByRole("article", { name: "Research" }).filter({ hasText: title }), + ).toBeFocused(); let response = await opened.context().request.get( `/api/channels/${room}/research-requests/${workspaceId}`, ); diff --git a/e2e/research-child-surface.e2e.ts b/e2e/research-child-surface.e2e.ts index 34b4430e9..7b90a770f 100644 --- a/e2e/research-child-surface.e2e.ts +++ b/e2e/research-child-surface.e2e.ts @@ -113,6 +113,8 @@ test("a parent-owned child keeps the parent chrome and nested geometry", async ( childTitle, CHILD_SOURCE, ); + // A child inherits the parent's Chat state; closed here so the child toolbar shows its toggle. + await page.addInitScript(() => localStorage.setItem("chopin:pane:chat:open", "false")); await join("ana"); await page.setViewportSize({ width: 1920, height: 1080 }); let parent = page.locator(`[data-workspace-room="${room}"]`); @@ -234,16 +236,10 @@ test("a child isolates chat and decisions across every parent-owned close path", let surface = page.getByRole("region", { name: `Child document: ${childTitle}` }); let parentHeader = parent.locator(".room-header"); let childClose = surface.getByRole("button", { name: `Close ${childTitle}`, exact: true }); - let childChatToggle = surface.getByRole("button", { - name: "Show chat pane", - exact: true, - }); let childChat = surface.getByRole("complementary", { name: "Chat" }); - await expect(childChatToggle).toBeVisible(); - await expect(childChat).toBeHidden(); - - await childChatToggle.click(); + // The parent has Chat open, so the child opens with its own Chat open too. await expect(childChat).toBeVisible(); + await expect(childChat.getByText("Discuss this report here.", { exact: false })).toBeVisible(); await expect(childChat).not.toContainText(parentRoomMessage); await expect(parentChat).toContainText(parentRoomMessage); let childRoomMessage = `Child room message ${room.slice(0, 8)}`; @@ -301,9 +297,8 @@ test("a child isolates chat and decisions across every parent-owned close path", await childLink.click(); await expect(surface).toBeVisible(); - await expect(childChatToggle).toBeVisible(); - await expect(childChat).toBeHidden(); - await childChatToggle.click(); + // Closing the child's Chat is not saved; it reopens with the parent's state. + await expect(childChat).toBeVisible(); await expect(childChat).not.toContainText(parentRoomMessage); await expect(parentChat).toContainText(parentRoomMessage); await expect(childChat.getByText(childRoomMessage, { exact: true })).toBeVisible(); @@ -403,8 +398,7 @@ test("an in-app child preserves and restores its mounted parent", async ({ baseU exact: true, }); let childChat = surface.getByRole("complementary", { name: "Chat" }); - await expect(childChatToggle).toBeVisible(); - await expect(childChat).toBeHidden(); + await expect(childChat).toBeVisible(); await expect(parentChat).toBeVisible(); await expect(surface.getByRole("button", { name: "Decisions", exact: true })).toBeVisible(); await expect(surface.getByRole("button", { name: "Background Work", exact: true })).toHaveCount( @@ -416,9 +410,6 @@ test("an in-app child preserves and restores its mounted parent", async ({ baseU await expect(surface.locator(`[data-workspace-room="${child.id}"]`)).toBeVisible(); await expect(surface.locator('[data-document-view="plan"]')).toBeVisible(); await expect(surface.locator('[data-document-view="decisions"]')).toBeHidden(); - await childChatToggle.click(); - await expect(childChat).toBeVisible(); - await expect(parentChat).toBeVisible(); await childChat.getByRole("button", { name: "Close sidebar", exact: true, @@ -445,8 +436,7 @@ test("an in-app child preserves and restores its mounted parent", async ({ baseU await childLink.click(); await expect(surface).toBeVisible(); - await expect(childChatToggle).toBeVisible(); - await expect(childChat).toBeHidden(); + await expect(childChat).toBeVisible(); await expect(parentChat).toBeVisible(); await page.goBack(); await expect(surface).toHaveCount(0); @@ -678,11 +668,8 @@ test("a recovered child id enters the canonical anchored child workspace", async let surface = page.getByRole("region", { name: `Child document: ${childTitle}` }); await expect(parent).toBeVisible(); await expect(surface.locator(`[data-workspace-room="${child.id}"]`)).toBeVisible(); - await expect(surface.getByRole("button", { - name: "Show chat pane", - exact: true, - })).toBeVisible(); - await expect(surface.getByRole("complementary", { name: "Chat" })).toBeHidden(); + // With no saved preference, Chat starts open in the parent and so in the child. + await expect(surface.getByRole("complementary", { name: "Chat" })).toBeVisible(); let close = surface.getByRole("button", { name: `Close ${childTitle}`, exact: true }); await expect(close).toBeVisible(); await expect( diff --git a/packages/editor/src/plan-editor.tsx b/packages/editor/src/plan-editor.tsx index d429f128a..6ea4f33d7 100644 --- a/packages/editor/src/plan-editor.tsx +++ b/packages/editor/src/plan-editor.tsx @@ -91,6 +91,8 @@ export type PlanEditorProps = { onScrollTop?: (top: number) => void; /** Whether the document has opened, for status chrome the host renders. */ onState?: (state: PlanState) => void; + /** Host-owned context shown above the document, in the prose column. */ + preface?: ReactNode; className?: string; }; @@ -112,6 +114,7 @@ export function PlanEditor( motionImmediately, onScrollTop, onState, + preface, questionMotion, questions, cardMeta, @@ -196,6 +199,18 @@ export function PlanEditor( if (element && scrollTop !== undefined) element.scrollTop = scrollTop; }, [generation, scrollTop]); + // A preface that changes height moves the document without resizing or scrolling it; overlays + // that track the prose (comment markers, rails) re-measure on scroll, so announce one. + let hasPreface = !!preface; + useEffect(() => { + let element = scroller.current; + let content = element?.querySelector(":scope > .plan-preface"); + if (!element || !content) return; + let observer = new ResizeObserver(() => element.dispatchEvent(new Event("scroll"))); + observer.observe(content); + return () => observer.disconnect(); + }, [generation, hasPreface, wire]); + useEffect(() => () => changes.dispose(), [changes]); useEffect(() => { @@ -368,6 +383,7 @@ export function PlanEditor( data-plan-scroll="" data-plan-synced={state.synced || undefined} > + {hasPreface &&
{preface}
} .plan-preface) .plan-content { + padding-block-start: 1rem; +} + /* * MDXEditor gives its placeholder the content class too. Pin its absolute box * across the wrapper so auto margins center it like the editable document; diff --git a/packages/editor/src/widgets/research.tsx b/packages/editor/src/widgets/research.tsx index a53ce7b99..179b16302 100644 --- a/packages/editor/src/widgets/research.tsx +++ b/packages/editor/src/widgets/research.tsx @@ -340,6 +340,7 @@ export function ResearchCard( Date: Thu, 8 Oct 2026 02:47:22 +0100 Subject: [PATCH 2/3] Check provenance repository identity and tidy its line Verify the parent shares the child's repository node ID before returning provenance, cover the route's success and denial cases, drop the unused start time, keep each meta segment on one line, balance the empty Chat line, and use spacing tokens for the provenance styles. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/research/routes.test.ts | 41 +++++++++++++++++++++++- apps/server/src/research/routes.ts | 7 ++-- apps/server/src/research/service.test.ts | 1 - apps/server/src/research/service.ts | 1 - apps/web/src/chat/transcript.tsx | 5 ++- apps/web/src/child-provenance.tsx | 16 ++++++--- apps/web/src/navigation.css | 18 +++++++---- packages/protocol/research.d.ts | 1 - 8 files changed, 71 insertions(+), 19 deletions(-) diff --git a/apps/server/src/research/routes.test.ts b/apps/server/src/research/routes.test.ts index cd4649384..c972a786c 100644 --- a/apps/server/src/research/routes.test.ts +++ b/apps/server/src/research/routes.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, spyOn } from "bun:test"; import { Admission } from "../auth/admission"; import { Sessions } from "../auth/session"; @@ -655,6 +655,45 @@ describe("research workspace routes", () => { expect(noPull?.status).toBe(404); }); + it("returns provenance only for a readable child whose parent shares its repository", async () => { + let context = await setup(); + let child = await createChild(childFixture(context)); + let provenance = { + requestId: "workspace-1", + parentChannelId: context.channel.id, + brief: "What changed?", + sourceCount: 2, + startedBy: "octocat", + }; + let lookup = spyOn(context.service, "provenance").mockResolvedValue(provenance); + let path = `/api/channels/${child.id}/research-provenance`; + let found = await context.router.handle(request(path, context.cookie)); + expect(found?.status).toBe(200); + expect(await found!.json()).toEqual(provenance); + expect(lookup).toHaveBeenCalledWith(context.channel.id, child.id); + + let read = context.storage.channels.get.bind(context.storage.channels); + let moved = spyOn(context.storage.channels, "get").mockImplementation(async id => { + let channel = await read(id); + return channel && id === context.channel.id + ? { ...channel, repositoryId: OTHER_REPOSITORY_ID } + : channel; + }); + expect((await context.router.handle(request(path, context.cookie)))?.status).toBe(404); + moved.mockRestore(); + + context.access.repository = { + ...context.access.repository, + permissions: { pull: false, push: false, admin: false }, + }; + expect((await context.router.handle(request(path, context.cookie)))?.status).toBe(404); + context.access.affiliated = false; + expect((await context.router.handle(request(path, context.cookie)))?.status).toBe(404); + expect((await context.router.handle(request(path)))?.status).toBe(401); + expect(lookup).toHaveBeenCalledTimes(1); + lookup.mockRestore(); + }); + it("lists repository children in one bounded response without leaking another repository", async () => { let context = await setup(); let created = await create(context); diff --git a/apps/server/src/research/routes.ts b/apps/server/src/research/routes.ts index 2533d2461..407abba5a 100644 --- a/apps/server/src/research/routes.ts +++ b/apps/server/src/research/routes.ts @@ -442,8 +442,11 @@ export function registerResearchWorkspaceRoutes( if (!session) return json({ error: "authentication required" }, 401); let access = await channelAccess(auth, session, params.channelId!); let parentId = access?.channel.parentChannelId; - let provenance = access && parentId - && await service.provenance(parentId, access.channel.id); + let parent = parentId ? await auth.storage.channels.get(parentId) : undefined; + // Repository node IDs are authoritative; a parent elsewhere is never this child's source. + let provenance = access && parent + && parent.repositoryId === access.channel.repositoryId + && await service.provenance(parent.id, access.channel.id); return provenance ? json(provenance) : json({ error: "research provenance not found" }, 404); diff --git a/apps/server/src/research/service.test.ts b/apps/server/src/research/service.test.ts index 1d55e8d56..26023b7cf 100644 --- a/apps/server/src/research/service.test.ts +++ b/apps/server/src/research/service.test.ts @@ -502,7 +502,6 @@ describe("research workspace service", () => { parentChannelId: context.channelId, brief: "Which API contracts changed?", sourceCount: 1, - startedAt: started.request.createdAt, startedBy: "octocat", }); expect(await context.service.provenance(childId, childId)).toBeUndefined(); diff --git a/apps/server/src/research/service.ts b/apps/server/src/research/service.ts index ae27d6871..5d0fe3879 100644 --- a/apps/server/src/research/service.ts +++ b/apps/server/src/research/service.ts @@ -841,7 +841,6 @@ export class ResearchWorkspaceService { parentChannelId, brief: view.question, sourceCount: view.child.sourceCount, - startedAt: view.createdAt, ...(user ? { startedBy: user.login } : {}), }; } diff --git a/apps/web/src/chat/transcript.tsx b/apps/web/src/chat/transcript.tsx index 80abcc604..2270ff7e0 100644 --- a/apps/web/src/chat/transcript.tsx +++ b/apps/web/src/chat/transcript.tsx @@ -416,7 +416,10 @@ export function Transcript( data-chat-stack > {empty && ( -

+

{empty}

)} diff --git a/apps/web/src/child-provenance.tsx b/apps/web/src/child-provenance.tsx index faa46fb83..f9b6612e1 100644 --- a/apps/web/src/child-provenance.tsx +++ b/apps/web/src/child-provenance.tsx @@ -54,11 +54,17 @@ export function ChildProvenance({ channelId, parent }: { channelId: string; pare {parent.label} - {provenance.startedBy && <>{" · by "}{provenance.startedBy}} - {" · "} - {provenance.sourceCount === 0 - ? "No sources" - : plural(provenance.sourceCount, "source")} + {provenance.startedBy && ( + <> + {" "} + · by {provenance.startedBy} + + )}{" "} + + · {provenance.sourceCount === 0 + ? "No sources" + : plural(provenance.sourceCount, "source")} +