diff --git a/apps/web/src/pages/workspace/artifact-pane.test.tsx b/apps/web/src/pages/workspace/artifact-pane.test.tsx index bcb9c771..1b25798a 100644 --- a/apps/web/src/pages/workspace/artifact-pane.test.tsx +++ b/apps/web/src/pages/workspace/artifact-pane.test.tsx @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import type { ProjectArtifact } from "@corbits-builder/client"; -import { ArtifactPane, LIVE_TAB, defaultTab } from "./artifact-pane.tsx"; +import { + ArtifactPane, + LIVE_TAB, + availableTab, + defaultTab, + resolveSelection, +} from "./artifact-pane.tsx"; import { APPROVAL_PACKAGE_MEDIA_TYPE } from "./approval-package-preview.tsx"; import type { DeliverableState } from "./deliverable-block.tsx"; import { writeWorkspaceApprovalsEnabled } from "../../workspace-approvals-setting.ts"; @@ -216,3 +222,51 @@ describe("default tab", () => { expect(defaultTab(false, [])).toBeNull(); }); }); + +describe("remembered tab", () => { + const plan: Deliverable = { + artifactId: "plan", + kind: "build_plan", + title: "Build plan", + version: 1, + sha256: "b".repeat(64), + }; + const have = { + liveApp: false, + manual: false, + themes: false, + draft: false, + concepts: [] as string[], + deliverables: [ready, plan], + }; + test("a remembered tab counts only while the pane can show it", () => { + expect(availableTab("build_plan", have)).toBe("build_plan"); + expect(availableTab("design-mockup", have)).toBeNull(); + expect(availableTab(LIVE_TAB, have)).toBeNull(); + expect(availableTab(LIVE_TAB, { ...have, liveApp: true })).toBe(LIVE_TAB); + expect(availableTab("user-manual", { ...have, manual: true })).toBe( + "user-manual", + ); + expect(availableTab(null, have)).toBeNull(); + }); + test("with nothing chosen yet, the remembered document opens instead of the default", () => { + expect(resolveSelection(null, true, [ready, plan], ready.kind)).toEqual({ + selectedId: null, + documentToOpen: ready.kind, + }); + // A remembered side tab is the pane's own to restore; the default stands meanwhile. + expect(resolveSelection(null, true, [ready, plan], LIVE_TAB)).toEqual({ + selectedId: LIVE_TAB, + documentToOpen: null, + }); + // One that is not filed here falls back to the default. + expect(resolveSelection(null, false, [ready, plan], "design")).toEqual({ + selectedId: null, + documentToOpen: "build_plan", + }); + // The person's own choice always wins. + expect( + resolveSelection("build_plan", true, [ready, plan], ready.kind), + ).toEqual({ selectedId: "build_plan", documentToOpen: null }); + }); +}); diff --git a/apps/web/src/pages/workspace/artifact-pane.tsx b/apps/web/src/pages/workspace/artifact-pane.tsx index c643dedd..44d2120c 100644 --- a/apps/web/src/pages/workspace/artifact-pane.tsx +++ b/apps/web/src/pages/workspace/artifact-pane.tsx @@ -54,6 +54,10 @@ import { writeDocumentPaneWidth, } from "./document-pane-width.ts"; import { LiveApp } from "./live-app.tsx"; +import { + readStoredPreference, + writeStoredPreference, +} from "../../stored-preference.ts"; import { markSeen, useSeenVersions } from "./seen-versions.ts"; import { useTipsGroup } from "./document-tips.tsx"; import { printDocument } from "./print-document.ts"; @@ -133,13 +137,48 @@ export function resolveSelection( chosenId: string | null, liveApp: boolean, deliverables: readonly Deliverable[], + remembered: string | null = null, ): { selectedId: string | null; documentToOpen: string | null } { if (chosenId !== null) return { selectedId: chosenId, documentToOpen: null }; + // The document the person was on at their last visit, once it is here, beats the default. + const kind = remembered === MOCKUP_TAB ? "design" : remembered; + if ( + kind !== null && + !isSideTab(kind) && + deliverables.some((item) => item.kind === kind) + ) + return { selectedId: null, documentToOpen: kind }; const fallback = defaultTab(liveApp, deliverables); return fallback === LIVE_TAB ? { selectedId: LIVE_TAB, documentToOpen: null } : { selectedId: null, documentToOpen: fallback }; } + +/** Where the pane keeps the tab the person was on, per project, in this browser. */ +export const paneTabKey = (projectId: string) => `sb.paneTab.${projectId}`; + +/** The remembered tab when the pane can show it now, else null. */ +export function availableTab( + id: string | null, + have: { + liveApp: boolean; + manual: boolean; + themes: boolean; + draft: boolean; + concepts: readonly string[]; + deliverables: readonly Deliverable[]; + }, +): string | null { + if (id === null) return null; + if (id === LIVE_TAB) return have.liveApp ? id : null; + if (id === MANUAL_TAB) return have.manual ? id : null; + if (id === THEMES_TAB) return have.themes ? id : null; + if (id === DRAFT_TAB) return have.draft ? id : null; + const concept = conceptOfTab(id); + if (concept !== undefined) return have.concepts.includes(concept) ? id : null; + const kind = id === MOCKUP_TAB ? "design" : id; + return have.deliverables.some((item) => item.kind === kind) ? id : null; +} const liveIds = panelTabIds(TAB_PREFIX, LIVE_TAB); const THEMES_TAB = "design-themes"; const themesIds = panelTabIds(TAB_PREFIX, THEMES_TAB); @@ -198,6 +237,7 @@ function documentStatus(item: Deliverable): string { * stay mounted so a later editor can keep its place. */ export function ArtifactPane({ + projectId, projectTitle, draft = null, manual = null, @@ -219,6 +259,8 @@ export function ArtifactPane({ approachChoice, }: { /** Names the files the panes download and share. */ + /** Names the browser's memory of the tab the person was on; without it the pane remembers nothing. */ + projectId?: string | undefined; projectTitle: string; /** The live designer's unfiled mockup, shown in its own tab until a design is filed. */ draft?: DesignFiles | null; @@ -254,24 +296,65 @@ export function ArtifactPane({ const draftShown = draft !== null; const conceptViews = concepts?.views ?? []; const firstConcept = conceptViews[0]?.label; + // The tab the person was on at their last visit to this project, until it is shown again or they + // choose another; the pane's own jumps to new work wait for that. + const memoryKey = projectId === undefined ? null : paneTabKey(projectId); + const [memory, setMemory] = useState(() => ({ + key: memoryKey, + id: memoryKey === null ? null : readStoredPreference(memoryKey), + })); + if (memory.key !== memoryKey) + setMemory({ + key: memoryKey, + id: memoryKey === null ? null : readStoredPreference(memoryKey), + }); + const remember = (id: string) => { + if (memoryKey !== null) writeStoredPreference(memoryKey, id); + }; + const restoring = memory.id !== null; + const restorable = availableTab(memory.id, { + liveApp: liveApp !== null, + manual: manual !== null, + themes: themes !== null, + draft: draftShown, + concepts: conceptViews.map((view) => view.label), + deliverables, + }); + useEffect(() => { + if (restorable === null) return; + setMemory((current) => ({ ...current, id: null })); + if (isSideTab(restorable)) { + setSide(restorable); + return; + } + // A document is opened through the page by resolveSelection; only the mockup's half is set here. + if (restorable === MOCKUP_TAB) setDesignPart("mockup"); + }, [restorable]); // The draft opens once when it first appears, so the person watches the mockup take shape. useEffect(() => { - if (draftShown) setSide(DRAFT_TAB); + if (draftShown && !restoring) { + setSide(DRAFT_TAB); + remember(DRAFT_TAB); + } }, [draftShown]); // Likewise the first concept, when concepts start. const conceptsStarted = firstConcept !== undefined; useEffect(() => { - if (firstConcept !== undefined) setSide(conceptTab(firstConcept)); + if (firstConcept !== undefined && !restoring) { + setSide(conceptTab(firstConcept)); + remember(conceptTab(firstConcept)); + } }, [conceptsStarted]); useEffect(() => { - if (openTab !== null) - setSide( - openTab.value === LIVE_TAB || - openTab.value === MANUAL_TAB || - conceptOfTab(openTab.value) !== undefined - ? openTab.value - : null, - ); + if (openTab === null) return; + setSide( + openTab.value === LIVE_TAB || + openTab.value === MANUAL_TAB || + conceptOfTab(openTab.value) !== undefined + ? openTab.value + : null, + ); + remember(openTab.value); }, [openTab]); const sideConcept = side === null ? undefined : conceptOfTab(side); const conceptShown = conceptViews.some((view) => view.label === sideConcept); @@ -289,6 +372,7 @@ export function ArtifactPane({ chosenId, liveApp !== null, deliverables, + memory.id, ); // A document is chosen through the page, which reads it; the live app the pane shows itself. useEffect(() => { @@ -298,6 +382,10 @@ export function ArtifactPane({ Record >({}); const choose = (id: string) => { + remember(id); + setMemory((current) => + current.id === null ? current : { ...current, id: null }, + ); if (isSideTab(id)) return setSide(id); setSide(null); if (id === MOCKUP_TAB || id === "design") diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 88db4919..25169095 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -1561,6 +1561,7 @@ export function StageWorkspace({ {chatFirst ? null : (