From 7a37d629f052a3b5753b96e2e10996e113d23744 Mon Sep 17 00:00:00 2001 From: Krzysztof Cieslak Date: Thu, 10 Sep 2026 14:44:51 +0000 Subject: [PATCH] Clarify first-document creation --- apps/web/src/hosted.tsx | 7 +- apps/web/src/navigation-chrome.test.ts | 10 +- apps/web/src/navigation-model.test.ts | 80 ++++- apps/web/src/navigation-model.ts | 37 ++- apps/web/src/navigation-shell.tsx | 151 ++++++--- apps/web/src/navigation.css | 36 +++ apps/web/src/new-document-dialog.tsx | 122 ++++++++ apps/web/src/project-sidebar.tsx | 56 +++- apps/web/src/research-sidebar.test.ts | 3 +- apps/web/src/use-document-creation.ts | 102 +++++++ docs/channels.md | 13 + e2e/document-creation.e2e.ts | 408 +++++++++++++++++++++++++ e2e/document-navigation.e2e.ts | 30 +- e2e/github.ts | 13 +- 14 files changed, 961 insertions(+), 107 deletions(-) create mode 100644 apps/web/src/new-document-dialog.tsx create mode 100644 apps/web/src/use-document-creation.ts create mode 100644 e2e/document-creation.e2e.ts diff --git a/apps/web/src/hosted.tsx b/apps/web/src/hosted.tsx index 8919fb8f..8133eca5 100644 --- a/apps/web/src/hosted.tsx +++ b/apps/web/src/hosted.tsx @@ -379,13 +379,17 @@ function DocumentRouteSwap( (layer): layer is DocumentRouteLayer => layer !== undefined, ); let motion = motionContract("content-swap"); + let { onDocumentLoaded, onDocumentRouteSettled } = useNavigationDocument(); let ready = useCallback(( key: DocumentRouteIdentity, resolution?: DocumentRouteResolution, ) => { if (resolution) aliases.current.set(resolution.routeKey, key); dispatch({ key, resolution, type: "ready" }); - }, []); + if (requestedRoute.current.key === key) { + onDocumentRouteSettled(requestedRoute.current.routeKey); + } + }, [onDocumentRouteSettled]); let metadataPath = useCallback(( key: DocumentRouteIdentity, metadataRouteKey: DocumentRouteIdentity, @@ -399,7 +403,6 @@ function DocumentRouteSwap( } onCanonicalPath(pathname); }, [onCanonicalPath]); - let { onDocumentLoaded } = useNavigationDocument(); let published = useRef(undefined); useEffect(() => { let resolution = state.current.resolution; diff --git a/apps/web/src/navigation-chrome.test.ts b/apps/web/src/navigation-chrome.test.ts index 3bf3456b..328259df 100644 --- a/apps/web/src/navigation-chrome.test.ts +++ b/apps/web/src/navigation-chrome.test.ts @@ -38,8 +38,7 @@ describe("the Figma navigation chrome", () => { test("uses a pen for document creation and a plus for adding a Project", () => { let markup = renderToStaticMarkup(createElement(ProjectSidebar, { canCreateDocument: true, - creatingNewDocument: false, - creatingProjectIds: new Set(), + pendingCreations: new Map(), onAccount: () => {}, onAddProject: () => {}, onCollapse: () => {}, @@ -104,8 +103,7 @@ describe("the Figma navigation chrome", () => { test("exposes whether the account menu is open", () => { let props = { canCreateDocument: false, - creatingNewDocument: false, - creatingProjectIds: new Set(), + pendingCreations: new Map(), onAccount: () => {}, onAddProject: () => {}, onCollapse: () => {}, @@ -132,8 +130,7 @@ describe("the Figma navigation chrome", () => { test("offers explicit pagination when a Project has more documents", () => { let markup = renderToStaticMarkup(createElement(ProjectSidebar, { canCreateDocument: true, - creatingNewDocument: false, - creatingProjectIds: new Set(), + pendingCreations: new Map(), onAccount: () => {}, onAddProject: () => {}, onCollapse: () => {}, @@ -158,6 +155,7 @@ describe("the Figma navigation chrome", () => { })); expect(markup).toContain('aria-label="Load more documents in testing-sql-transcripts"'); + expect(markup).not.toContain("No documents yet."); }); test("keeps the document header to one project icon and document trigger", () => { diff --git a/apps/web/src/navigation-model.test.ts b/apps/web/src/navigation-model.test.ts index e60ea3d1..27e10810 100644 --- a/apps/web/src/navigation-model.test.ts +++ b/apps/web/src/navigation-model.test.ts @@ -2,10 +2,9 @@ import { describe, expect, it } from "bun:test"; import { activeProject, - beginProjectCreation, canManageProject, + documentCreationTarget, documentDestination, - finishProjectCreation, isDocumentWorkspaceRoute, landingDocument, navigationMode, @@ -209,13 +208,6 @@ describe("navigation model", () => { }); }); - it("keeps one Project creating while another creation settles", () => { - let creating = beginProjectCreation(new Set(), "R_one"); - creating = beginProjectCreation(creating, "R_two"); - - expect([...finishProjectCreation(creating, "R_one")]).toEqual(["R_two"]); - }); - it("allows mutations only for push or admin navigation repositories", () => { let viewerProject = { ...projects[1]!.project, @@ -247,3 +239,73 @@ describe("navigation model", () => { expect(canManageProject(adminProject)).toBe(true); }); }); + +function project(id: string, permission: "push" | "admin" | "pull" = "push") { + return { + repositoryId: id, + repositoryOwner: "acme", + repositoryName: id, + position: 0, + available: true, + repository: { + id, + owner: "acme", + name: id, + fullName: `acme/${id}`, + permissions: { pull: true, push: permission === "push", admin: permission === "admin" }, + }, + }; +} + +describe("document creation targets", () => { + let first = project("first"); + let second = project("second", "admin"); + let viewer = project("viewer", "pull"); + let unavailable = { ...project("unavailable"), available: false }; + + it("waits for navigation and unresolved current-document context", () => { + expect(documentCreationTarget(undefined, undefined)).toEqual({ type: "loading" }); + expect(documentCreationTarget([first], undefined, true)).toEqual({ type: "loading" }); + expect(documentCreationTarget([first], first, true)).toEqual({ + type: "project", + project: first, + }); + }); + + it("creates in the sole writable project without any document catalogue", () => { + expect(documentCreationTarget([viewer, first, unavailable], undefined)).toEqual({ + type: "project", + project: first, + }); + expect(documentCreationTarget([viewer, second], viewer)).toEqual({ + type: "project", + project: second, + }); + }); + + it("prefers the current project and asks only when the target is ambiguous", () => { + expect(documentCreationTarget([first, second], second)).toEqual({ + type: "project", + project: second, + }); + expect(documentCreationTarget([first, viewer, second, unavailable], undefined)).toEqual({ + type: "choose", + projects: [first, second], + }); + }); + + it("uses current navigation permissions rather than stale document context", () => { + let revoked = { + ...first, + repository: { ...first.repository, permissions: viewer.repository.permissions }, + }; + expect(documentCreationTarget([revoked, second], first)).toEqual({ + type: "project", + project: second, + }); + expect(documentCreationTarget([viewer, unavailable], unavailable)).toEqual({ + type: "unavailable", + }); + expect(documentCreationTarget([], undefined)).toEqual({ type: "unavailable" }); + }); +}); diff --git a/apps/web/src/navigation-model.ts b/apps/web/src/navigation-model.ts index 2191841f..8a83c035 100644 --- a/apps/web/src/navigation-model.ts +++ b/apps/web/src/navigation-model.ts @@ -46,6 +46,27 @@ export function canManageProject(project: NavigationProject): boolean { && (project.repository.permissions.push || project.repository.permissions.admin); } +export type DocumentCreationTarget = + | { type: "loading" } + | { type: "project"; project: NavigationProject } + | { type: "choose"; projects: NavigationProject[] } + | { type: "unavailable" }; + +export function documentCreationTarget( + projects: NavigationProject[] | undefined, + current: NavigationProject | undefined, + resolvingDocument = false, +): DocumentCreationTarget { + if (!projects || (resolvingDocument && !current)) return { type: "loading" }; + let eligible = projects.filter(project => project.available && canManageProject(project)); + let active = eligible.find(project => project.repositoryId === current?.repositoryId); + if (active) return { type: "project", project: active }; + if (eligible.length === 1) return { type: "project", project: eligible[0]! }; + return eligible.length > 1 + ? { type: "choose", projects: eligible } + : { type: "unavailable" }; +} + export function documentDestination( projects: ProjectDocuments[], documentId: string, @@ -92,22 +113,6 @@ export function researchChildNavigation( return { destination: researchChildDestination(parent, child), opener }; } -export function beginProjectCreation( - creating: ReadonlySet, - projectId: string, -): Set { - return new Set(creating).add(projectId); -} - -export function finishProjectCreation( - creating: ReadonlySet, - projectId: string, -): Set { - let next = new Set(creating); - next.delete(projectId); - return next; -} - export function landingDocument( projects: ProjectDocuments[], lastDocumentId?: string, diff --git a/apps/web/src/navigation-shell.tsx b/apps/web/src/navigation-shell.tsx index b66c77b8..627f0b4e 100644 --- a/apps/web/src/navigation-shell.tsx +++ b/apps/web/src/navigation-shell.tsx @@ -25,10 +25,9 @@ import { NavigationFocusScope } from "./navigation-focus"; import { motionImmediately } from "./motion-input"; import { activeProject, - beginProjectCreation, canManageProject, + documentCreationTarget, documentDestination, - finishProjectCreation, isDocumentWorkspaceRoute, landingDocument, NAVIGATION_MEDIA, @@ -44,6 +43,7 @@ import { import { clearRepositoryCache } from "./repository-cache"; import { TerminalAlert } from "./terminal-alert"; import { useProjectDocuments } from "./use-project-documents"; +import { useDocumentCreation } from "./use-document-creation"; import type { Research } from "@chopin/protocol"; import type { ResearchOpener } from "@chopin/editor"; @@ -88,6 +88,9 @@ let ProjectSidebar = lazy(() => let AddProjectDialog = lazy(() => import("./add-project-dialog").then(module => ({ default: module.AddProjectDialog })) ); +let NewDocumentDialog = lazy(() => + import("./new-document-dialog").then(module => ({ default: module.NewDocumentDialog })) +); let DocumentSearchDialog = lazy(() => import("./document-search-dialog").then(module => ({ default: module.DocumentSearchDialog })) ); @@ -109,6 +112,7 @@ let NavigationDocument = createContext<{ onDocumentAction: (documentId: string, action: DocumentAction) => void; onDocumentDeleted: (documentId: string) => void; onDocumentLoaded: (channel: Api.Channel, routeKey: DocumentRouteIdentity) => Promise; + onDocumentRouteSettled: (routeKey: DocumentRouteIdentity) => void; onRepositoryAccessChanged: () => void; onResearchChildOpen: ( parentId: string, @@ -121,6 +125,7 @@ let NavigationDocument = createContext<{ onDocumentAction() {}, onDocumentDeleted() {}, async onDocumentLoaded() {}, + onDocumentRouteSettled() {}, onRepositoryAccessChanged() {}, onResearchChildOpen() {}, onResearchChildPublished() {}, @@ -272,14 +277,12 @@ export function NavigationShell( let [catalogueMode, setCatalogueMode] = useState<"active" | "archived">("active"); let [dialog, setDialog] = useState< | "add" + | "new" | "search" | { channel: Api.Channel; type: "delete" | "rename" } >(); let [accountOpen, setAccountOpen] = useState(false); - let creatingProjectIds = useRef>(new Set()); - let [creatingProjectIdsForView, setCreatingProjectIdsForView] = useState>( - () => new Set(), - ); + let [settledRouteKey, setSettledRouteKey] = useState(); let [focusProjectId, setFocusProjectId] = useState(); let [width, resize] = useSidebarWidth(); let mode = useNavigationMode(); @@ -320,6 +323,8 @@ export function NavigationShell( } = useProjectDocuments(navigation, catalogueMode === "archived"); let routeKey = isDocumentWorkspaceRoute(route) ? documentRouteIdentity(route) + : route.page === "repository" + ? `repository:${route.owner}/${route.repository}` : route.page; let currentRouteKey = useRef(routeKey); currentRouteKey.current = routeKey; @@ -476,12 +481,6 @@ export function NavigationShell( void refresh(false); }, [refresh]); - useEffect(() => { - if (route.page !== "repositories" || !navigation) return; - let destination = landingDocument(projects, navigation.lastDocumentId); - if (destination) navigate(documentDestination(projects, destination), { replace: true }); - }, [navigate, navigation, projects, route.page]); - useEffect(() => { localStorage.setItem(`${SIDEBAR_STORAGE_KEY}:collapsed`, String(collapsed)); }, [collapsed]); @@ -512,20 +511,6 @@ export function NavigationShell( return () => cancelAnimationFrame(frame); }, [focusProjectId, projects]); - let startProjectCreation = (projectId: string): boolean => { - if (creatingProjectIds.current.has(projectId)) return false; - let next = beginProjectCreation(creatingProjectIds.current, projectId); - creatingProjectIds.current = next; - setCreatingProjectIdsForView(next); - return true; - }; - - let completeProjectCreation = (projectId: string) => { - let next = finishProjectCreation(creatingProjectIds.current, projectId); - creatingProjectIds.current = next; - setCreatingProjectIdsForView(next); - }; - let navigateToDocument = (documentId: string, path?: string) => { setError(undefined); setDialog(undefined); @@ -533,27 +518,41 @@ export function NavigationShell( navigate(documentDestination(projects, documentId, path)); }; - let createDocument = async (project: Api.NavigationProject) => { - if (!canManageProject(project) || !startProjectCreation(project.repositoryId)) return; - try { - let created = await Api.createChannel(project.repositoryOwner, project.repositoryName); - upsertDocument(created.channel); - navigateToDocument( - created.channel.id, - documentPath( - created.repository.owner, - created.repository.name, - created.channel.slug, - ), - ); - } catch (reason) { - setError({ reason, retry: "refresh" }); - } finally { - completeProjectCreation(project.repositoryId); - } + let creation = useDocumentCreation({ + routeKey, + onCreated: upsertDocument, + onNavigate: navigateToDocument, + onAccessChanged: () => void refresh(), + }); + useEffect(() => { + if (route.page !== "repositories" || !navigation) return; + // Catalogue updates from earlier creations must not compete with an explicit creation. + if (creation.pending.size > 0 || creation.error) return; + let destination = landingDocument(projects, navigation.lastDocumentId); + if (destination) navigate(documentDestination(projects, destination), { replace: true }); + }, [creation.error, creation.pending.size, navigate, navigation, projects, route.page]); + let createDocument = (project: Api.NavigationProject) => { + let current = navigationRef.current?.projects.find(value => + value.repositoryId === project.repositoryId + ); + if (current) void creation.create(current); }; + let retryProject = navigation?.projects.find(project => + project.repositoryId === creation.error?.project.repositoryId + && project.available && canManageProject(project) + ); + let documentRouteSettled = useCallback((key: DocumentRouteIdentity) => { + if (currentRouteKey.current !== key) return; + setSettledRouteKey(key); + creation.settled(key); + }, [creation.settled]); let active = activeProject(projects, currentDocumentId, resolvedChannel?.repositoryId); + let creationTarget = documentCreationTarget( + navigation?.projects, + active, + isDocumentWorkspaceRoute(route) && settledRouteKey !== routeKey, + ); let currentChannel = projects.flatMap(project => project.documents.channels) .find(channel => channel.id === currentDocumentId) ?? resolvedChannel; let currentChannelRef = useRef(undefined); @@ -603,8 +602,9 @@ export function NavigationShell( }; }, [revalidateCatalogues]); let newDocument = () => { - if (active && canManageProject(active)) void createDocument(active); - else showDialog("add"); + if (creationTarget.type === "loading") return; + if (creationTarget.type === "project") createDocument(creationTarget.project); + else showDialog("new"); }; let showDialog = useCallback((next: NonNullable) => { @@ -697,6 +697,7 @@ export function NavigationShell( onDocumentChanged: documentChanged, onDocumentDeleted: documentDeleted, onDocumentLoaded: documentLoaded, + onDocumentRouteSettled: documentRouteSettled, onRepositoryAccessChanged: repositoryAccessChanged, onResearchChildOpen: researchChildOpen, onResearchChildPublished: researchChildPublished, @@ -705,6 +706,7 @@ export function NavigationShell( documentChanged, documentDeleted, documentLoaded, + documentRouteSettled, repositoryAccessChanged, researchChildOpen, researchChildPublished, @@ -767,9 +769,13 @@ export function NavigationShell( )} accountMenuOpen={accountOpen} - canCreateDocument={!active || canManageProject(active)} - creatingProjectIds={creatingProjectIdsForView} - creatingNewDocument={!!active && creatingProjectIdsForView.has(active.repositoryId)} + canCreateDocument={creationTarget.type !== "loading"} + pendingCreations={creation.pending} + newDocumentPhase={creationTarget.type === "loading" + ? "loading" + : creationTarget.type === "project" + ? creation.pending.get(creationTarget.project.repositoryId) + : undefined} currentDocumentId={currentDocumentId} onAccount={() => setAccountOpen(open => !open)} onAddProject={() => showDialog("add")} @@ -792,6 +798,30 @@ export function NavigationShell( ); let content = ( <> + {!sidebarVisible && !drawerOpen && presentedDialog !== "new" && creation.pending.size > 0 && ( +
+ {[...creation.pending].map(([id, phase]) => ( +

+ {phase === "creating" ? "Creating document…" : "Opening document…"}{" "} + {navigation?.projects.find(project => project.repositoryId === id)?.repositoryName} +

+ ))} +
+ )} + {creation.error && presentedDialog !== "new" && ( + + {creation.error.message} + {retryProject && ( + + )} + + )} {error !== undefined && ( {error.reason instanceof Error @@ -873,6 +903,8 @@ export function NavigationShell( onAdded={project => { catalogueRefreshes.current.set(project.repositoryId, Date.now()); setFocusProjectId(project.repositoryId); + setCollapsed(false); + if (mode === "drawer") setDrawerOpen(true); void refresh(); }} onDismiss={() => setDialog(undefined)} @@ -881,6 +913,27 @@ export function NavigationShell( )} + {dialogMotion && presentedDialog === "new" && ( + + + showDialog("add")} + onCreate={createDocument} + onDismiss={() => { + setDialog(undefined); + if (mode === "drawer") { + requestAnimationFrame(() => drawerOpener.current?.focus()); + } + }} + onRetry={retryProject ? () => createDocument(retryProject) : undefined} + pending={creation.pending} + projects={navigation?.projects ?? []} + /> + + + )} {dialogMotion && presentedDialog === "search" && ( diff --git a/apps/web/src/navigation.css b/apps/web/src/navigation.css index e73e8352..9177691f 100644 --- a/apps/web/src/navigation.css +++ b/apps/web/src/navigation.css @@ -437,6 +437,27 @@ color: var(--color-text-primary); } +.project-sidebar-empty { + padding: 4px 8px 8px 32px; + font-size: var(--text-sm); + color: var(--color-text-tertiary); +} + +.project-sidebar-empty-action { + margin-top: 4px; + color: var(--color-brand); + text-decoration: underline; +} + +.project-sidebar-action-pending { + opacity: 1; +} + +:is(.project-sidebar-primary-action, .project-sidebar-action, .project-sidebar-empty-action):disabled { + cursor: default; + color: var(--color-text-quaternary); +} + .project-sidebar-account { width: 100%; justify-content: flex-start; @@ -554,6 +575,21 @@ box-shadow: var(--shadow-overlay); } +.navigation-creation-status { + position: absolute; + top: calc(var(--document-shell-header-height) + 8px); + left: 16px; + right: 16px; + z-index: 15; + border-radius: var(--radius-md); + background: var(--color-page); + padding: 8px 12px; + box-shadow: var(--shadow-resting); + font-size: var(--text-sm); + color: var(--color-text-tertiary); + pointer-events: none; +} + .navigation-error { position: absolute; left: 16px; diff --git a/apps/web/src/new-document-dialog.tsx b/apps/web/src/new-document-dialog.tsx new file mode 100644 index 00000000..ee0c40ad --- /dev/null +++ b/apps/web/src/new-document-dialog.tsx @@ -0,0 +1,122 @@ +import { useId, useRef, useState } from "react"; + +import { NavigationDialog } from "./navigation-dialog"; +import { canManageProject } from "./navigation-model"; +import { TerminalAlert } from "./terminal-alert"; + +import type * as Api from "./api"; +import type { NavigationDialogMotion } from "./navigation-dialog"; +import type { DocumentCreationPhase } from "./use-document-creation"; + +export function NewDocumentDialog( + { projects, pending, error, motion, onCreate, onAddProject, onDismiss, onRetry }: { + projects: Api.NavigationProject[]; + pending: ReadonlyMap; + error?: string; + motion: NavigationDialogMotion; + onCreate: (project: Api.NavigationProject) => void; + onAddProject: () => void; + onDismiss: () => void; + onRetry?: () => void; + }, +) { + let input = useRef(null); + let searchId = useId(); + let [query, setQuery] = useState(""); + let eligible = projects.filter(project => project.available && canManageProject(project)) + .sort((first, second) => first.position - second.position); + let normalized = query.trim().toLocaleLowerCase(); + let visible = eligible.filter(project => + `${project.repositoryOwner}/${project.repositoryName}`.toLocaleLowerCase().includes(normalized) + ); + return ( + 0 ? input : undefined} + motion={motion} + onDismiss={onDismiss} + title="New document" + > + {error && ( + + {error} + {onRetry && ( + + )} + + )} + {eligible.length > 0 + ? ( + <> +

+ Choose a project for your new document. +

+ + setQuery(event.target.value)} + placeholder="Search projects" + ref={input} + value={query} + /> +
+ {visible.length === 0 && ( +

No matching projects.

+ )} + {visible.map(project => { + let phase = pending.get(project.repositoryId); + return ( +
+ +
+ {phase === "creating" + ? "Creating document…" + : phase + ? "Opening document…" + : ""} +
+
+ ); + })} +
+ + ) + : ( +
+

+ {projects.length === 0 + ? "Add a project to create your first document." + : "You need write access to an available project to create a document."} +

+ + + Manage repository access + +
+ )} +
+ ); +} diff --git a/apps/web/src/project-sidebar.tsx b/apps/web/src/project-sidebar.tsx index ed994ef0..8fd84422 100644 --- a/apps/web/src/project-sidebar.tsx +++ b/apps/web/src/project-sidebar.tsx @@ -15,6 +15,7 @@ import { ArchiveIcon, ChevronIcon, DocumentIcon, SearchIcon } from "@chopin/icon import type * as Api from "./api"; import type { DocumentAction } from "./document-actions-menu"; import type { ProjectDocuments } from "./document-actions"; +import type { DocumentCreationPhase } from "./use-document-creation"; import type { ReactNode } from "react"; export function NavigationIcon( @@ -55,7 +56,7 @@ export function documentGroups( function Project( { archiveMode, - creatingProjectIds, + pendingCreations, currentDocumentId, entry, expanded, @@ -65,7 +66,7 @@ function Project( onToggle, }: { archiveMode: boolean; - creatingProjectIds: ReadonlySet; + pendingCreations: ReadonlyMap; currentDocumentId?: string; entry: ProjectDocuments; expanded: boolean; @@ -79,11 +80,27 @@ function Project( let groups = documentGroups(documents.channels, archiveMode); let label = project.repository?.name ?? project.repositoryName; let canManage = canManageProject(project); - let creating = creatingProjectIds.has(project.repositoryId); + let phase = pendingCreations.get(project.repositoryId); let contentId = useId(); let collapseMotion = motionContract("collapse"); let projectContent = ( <> + {!archiveMode && documents.status === "ready" && documents.channels.length === 0 + && !documents.nextCursor && ( +
+

No documents yet.

+ {project.available && canManage && ( + + )} +
+ )} {documents.status === "unavailable" && (

Access unavailable

)} @@ -229,16 +246,21 @@ function Project( {!archiveMode && project.available && canManage && ( )} +
+ {phase === "creating" ? "Creating document…" : phase ? "Opening document…" : ""} +
; + newDocumentPhase?: DocumentCreationPhase | "loading"; + pendingCreations: ReadonlyMap; currentDocumentId?: string; onAccount: () => void; onAddProject: () => void; @@ -319,13 +341,23 @@ export function ProjectSidebar( : ( <>