···11-import { useState } from 'react';
22-import { WorkspaceTiling } from '../tiling/WorkspaceTiling';
33-44-const INITIAL_NOTE = `# Welcome to Textile
55-66-This is a **local-first** note. The editor uses Markdown and is built for a smooth typing experience.
77-88-- Edit this buffer freely — it stays in memory until you wire up a vault on disk.
99-- Later you can plug in CRDT sync without throwing away the editor surface.
1010-`;
1111-1212-export function HomePage() {
1313- const [note] = useState(INITIAL_NOTE);
1414-1515- return <WorkspaceTiling initialNote={note} />;
1616-}
···11-import { ReactNode, useMemo, useRef, useState } from 'react';
22-import { EditPad } from '../components/EditPad';
11+import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
22+import { EditPad } from '../../components/EditPad';
33import {
44 clampRatioForAxis,
55 createInitialTree,
···99 SplitOrientation,
1010 TilingNode,
1111 updateSplitRatio,
1212-} from './model';
1313-import { useResizeObserver } from './useResizeObserver';
1212+} from '../../tiling/model';
1313+import { useResizeObserver } from '../../tiling/useResizeObserver';
1414+import { WorkspaceBarSlot } from '../barSlot';
1515+import type { WorkspaceProps } from '../workspace';
14161515-type WorkspaceTilingProps = {
1616- initialNote: string;
1717-};
1717+const INITIAL_NOTE = `# Welcome to Textile
1818+1919+This is a **local-first** note. The editor uses Markdown and is built for a smooth typing experience.
2020+2121+- Edit this buffer freely — it stays in memory until you wire up a vault on disk.
2222+- Later you can plug in CRDT sync without throwing away the editor surface.
2323+`;
18241925type SplitterProps = {
2026 node: SplitNode;
···102108 );
103109}
104110105105-export function WorkspaceTiling({ initialNote }: WorkspaceTilingProps) {
111111+export function TilingWorkspace({ openRequestId, openRequest }: WorkspaceProps) {
106112 const nextIdRef = useRef(2);
107113 const makeId = (prefix: string) => `${prefix}-${nextIdRef.current++}`;
108114···111117 );
112118 const [activeTileId, setActiveTileId] = useState('tile-1');
113119 const [tileNotes, setTileNotes] = useState<Record<string, string>>({
114114- 'tile-1': initialNote,
120120+ 'tile-1': INITIAL_NOTE,
115121 });
116122123123+ const lastSeenIdRef = useRef(0);
124124+ useEffect(() => {
125125+ if (openRequestId === lastSeenIdRef.current) return;
126126+ lastSeenIdRef.current = openRequestId;
127127+ if (!openRequest) return;
128128+ // TODO(tiling): Open into a new tab on the active tile once tabs land; for
129129+ // now we replace the active tile's note in parity with zen's silent open.
130130+ setTileNotes((prev) => ({
131131+ ...prev,
132132+ [activeTileId]: `# ${openRequest.title}\n\n_(content not yet loaded)_\n`,
133133+ }));
134134+ }, [openRequestId, openRequest, activeTileId]);
135135+117136 const commandIds = useMemo(
118137 () => ({
119138 splitRight: 'workspace.splitActiveRight',
···134153 setActiveTileId(result.newTileId);
135154 setTileNotes((prev) => ({
136155 ...prev,
137137- [result.newTileId]: prev[activeTileId] ?? initialNote,
156156+ [result.newTileId]: prev[activeTileId] ?? INITIAL_NOTE,
138157 }));
139158 };
140159···189208 <div className="h-full min-h-0 w-full overflow-hidden p-2">
190209 {renderNode(tree)}
191210 </div>
192192- <div className="border-border bg-background/90 fixed right-0 bottom-0 left-0 z-30 flex h-12 items-center border-t">
193193- <div className="flex min-w-0 flex-1 items-center gap-2 px-2">
211211+ <WorkspaceBarSlot>
212212+ <div className="flex min-w-0 flex-1 items-center gap-2">
194213 <button
195214 type="button"
196215 onClick={() => applySplit('row')}
197216 data-command-id={commandIds.splitRight}
198198- className="border-border bg-background text-foreground hover:bg-border/30 rounded border px-2 py-1 text-xs"
217217+ className="border-border bg-background text-foreground hover:bg-border/30 focus-visible:ring-accent/50 shrink-0 rounded border px-2 py-1 text-xs outline-none focus-visible:ring-2"
199218 >
200219 Split Right
201220 </button>
···203222 type="button"
204223 onClick={() => applySplit('column')}
205224 data-command-id={commandIds.splitDown}
206206- className="border-border bg-background text-foreground hover:bg-border/30 rounded border px-2 py-1 text-xs"
225225+ className="border-border bg-background text-foreground hover:bg-border/30 focus-visible:ring-accent/50 shrink-0 rounded border px-2 py-1 text-xs outline-none focus-visible:ring-2"
207226 >
208227 Split Down
209228 </button>
210210- <div className="text-foreground/60 ml-2 text-xs">
229229+ <div className="text-foreground/60 ml-2 truncate text-xs">
211230 Active: {activeTileId}
212231 </div>
213232 </div>
214214- </div>
233233+ </WorkspaceBarSlot>
215234 </>
216235 );
217236}
+17
src/workspaces/barSlot.tsx
···11+import { createContext, useContext, type ReactNode } from 'react';
22+import { createPortal } from 'react-dom';
33+44+/**
55+ * Shell paints the bottom bar strip; workspaces portal their contextual controls
66+ * into it via {@link WorkspaceBarSlot}. Provider value is the DOM node owned by
77+ * the shell `AppBottomBar`.
88+ */
99+const BarTargetContext = createContext<HTMLElement | null>(null);
1010+1111+export const WorkspaceBarTargetProvider = BarTargetContext.Provider;
1212+1313+export function WorkspaceBarSlot({ children }: { children: ReactNode }) {
1414+ const target = useContext(BarTargetContext);
1515+ if (!target) return null;
1616+ return createPortal(children, target);
1717+}
+108
src/workspaces/workspace.tsx
···11+import type { ComponentType } from 'react';
22+import { TilingWorkspace } from './tiling/tiling';
33+import { ZenWorkspace } from './zen/zen';
44+55+export type WorkspaceKind = 'tiling' | 'zen';
66+77+/**
88+ * Intent from the shell (e.g. sidebar) asking the active workspace to open a
99+ * document.
1010+ *
1111+ * TODO(naming): `providerId` / `entryId` come from the FileSystemProvider
1212+ * abstraction in `src/filesystem/types.ts`. As we add non-filesystem sources
1313+ * (search results, recent items, deep links) these names start to lie. Options
1414+ * we may want to workshop: `sourceId` + `documentId`, a single opaque `uri`
1515+ * (e.g. `habitat-docs://<entry>`), or a tagged union per source kind. Leaving
1616+ * the FS-flavoured names for now since the only source today *is* the
1717+ * filesystem sidebar.
1818+ */
1919+export type OpenDocRequest = {
2020+ /**
2121+ * Identifies the source that owns the document. Today this is a
2222+ * `FileSystemProvider.id` such as `"habitat-docs"`; the shell uses it to
2323+ * route reads/writes back to the right provider.
2424+ */
2525+ providerId: string;
2626+ /**
2727+ * Identifies the specific document within that source. For
2828+ * `habitat-docs` this is an AT-protocol URI; for future providers it could
2929+ * be a file path, blob CID, etc. Opaque to the workspace.
3030+ */
3131+ entryId: string;
3232+ /** Human-readable label used for tab/title chrome. */
3333+ title: string;
3434+};
3535+3636+export type WorkspaceProps = {
3737+ /**
3838+ * Monotonically bumped each time the shell issues a new open intent.
3939+ * Workspaces detect "a new open arrived" by comparing against their
4040+ * last-seen id, which avoids re-opening on unrelated re-renders.
4141+ */
4242+ openRequestId: number;
4343+ openRequest: OpenDocRequest | null;
4444+};
4545+4646+export type WorkspaceComponent = ComponentType<WorkspaceProps>;
4747+4848+export type WorkspaceDescriptor = {
4949+ kind: WorkspaceKind;
5050+ label: string;
5151+ description: string;
5252+ Component: WorkspaceComponent;
5353+};
5454+5555+export const WORKSPACE_DESCRIPTORS: readonly WorkspaceDescriptor[] = [
5656+ {
5757+ kind: 'tiling',
5858+ label: 'Tiling',
5959+ description: 'Split-pane editor with resizable tiles and split controls.',
6060+ Component: TilingWorkspace,
6161+ },
6262+ {
6363+ kind: 'zen',
6464+ label: 'Zen',
6565+ description: 'Single editor focused on one document at a time.',
6666+ Component: ZenWorkspace,
6767+ },
6868+];
6969+7070+const BY_KIND = new Map(WORKSPACE_DESCRIPTORS.map((d) => [d.kind, d]));
7171+7272+export const DEFAULT_WORKSPACE_KIND: WorkspaceKind = 'tiling';
7373+7474+export function getWorkspaceDescriptor(kind: WorkspaceKind): WorkspaceDescriptor {
7575+ const d = BY_KIND.get(kind);
7676+ if (!d) throw new Error(`Unknown workspace kind: ${kind}`);
7777+ return d;
7878+}
7979+8080+export function isWorkspaceKind(value: unknown): value is WorkspaceKind {
8181+ return typeof value === 'string' && BY_KIND.has(value as WorkspaceKind);
8282+}
8383+8484+type WorkspaceHostProps = {
8585+ kind: WorkspaceKind;
8686+ openRequestId: number;
8787+ openRequest: OpenDocRequest | null;
8888+};
8989+9090+/**
9191+ * Mounts the active workspace. The `key={kind}` remount on switch is
9292+ * intentional: each workspace owns its own state, and resetting on switch
9393+ * keeps the abstraction boundary clean (no cross-kind state to reason about).
9494+ */
9595+export function WorkspaceHost({
9696+ kind,
9797+ openRequestId,
9898+ openRequest,
9999+}: WorkspaceHostProps) {
100100+ const { Component } = getWorkspaceDescriptor(kind);
101101+ return (
102102+ <Component
103103+ key={kind}
104104+ openRequestId={openRequestId}
105105+ openRequest={openRequest}
106106+ />
107107+ );
108108+}
+40
src/workspaces/zen/doc.test.ts
···11+import { describe, expect, it } from 'vitest';
22+33+import { applyOpenRequest, makeZenDocFromRequest } from './doc';
44+55+const req = (overrides: Partial<{ providerId: string; entryId: string; title: string }> = {}) => ({
66+ providerId: 'habitat-docs',
77+ entryId: 'at://did:plc:abc/network.habitat.docs/123',
88+ title: 'Notes',
99+ ...overrides,
1010+});
1111+1212+describe('makeZenDocFromRequest', () => {
1313+ it('namespaces id by provider and entry', () => {
1414+ const doc = makeZenDocFromRequest(req({ providerId: 'p', entryId: 'e' }));
1515+ expect(doc.id).toBe('p:e');
1616+ });
1717+1818+ it('uses the request title as the doc title', () => {
1919+ expect(makeZenDocFromRequest(req({ title: 'Hello' })).title).toBe('Hello');
2020+ });
2121+});
2222+2323+describe('applyOpenRequest', () => {
2424+ it('returns current doc when request is null', () => {
2525+ const current = makeZenDocFromRequest(req({ title: 'Keep me' }));
2626+ expect(applyOpenRequest(current, null)).toBe(current);
2727+ });
2828+2929+ it('opens a doc when there was none', () => {
3030+ const next = applyOpenRequest(null, req({ title: 'First' }));
3131+ expect(next?.title).toBe('First');
3232+ });
3333+3434+ it('silently replaces an existing doc with the new one', () => {
3535+ const current = makeZenDocFromRequest(req({ entryId: 'one', title: 'One' }));
3636+ const next = applyOpenRequest(current, req({ entryId: 'two', title: 'Two' }));
3737+ expect(next?.id).toBe('habitat-docs:two');
3838+ expect(next?.title).toBe('Two');
3939+ });
4040+});
+30
src/workspaces/zen/doc.ts
···11+import type { OpenDocRequest } from '../workspace';
22+33+export type ZenDoc = {
44+ /** Stable handle derived from the source; not shown in the UI. */
55+ id: string;
66+ title: string;
77+ content: string;
88+};
99+1010+export function makeZenDocFromRequest(req: OpenDocRequest): ZenDoc {
1111+ return {
1212+ id: `${req.providerId}:${req.entryId}`,
1313+ title: req.title,
1414+ // TODO: Replace placeholder body with real provider content once a read API exists.
1515+ content: `# ${req.title}\n\n_(content not yet loaded)_\n`,
1616+ };
1717+}
1818+1919+/**
2020+ * Silent replace: any non-null open request supersedes the current doc with no
2121+ * confirmation. Acceptable while Zen content is in-memory only; revisit before
2222+ * Zen edits hit real persistence.
2323+ */
2424+export function applyOpenRequest(
2525+ current: ZenDoc | null,
2626+ req: OpenDocRequest | null,
2727+): ZenDoc | null {
2828+ if (!req) return current;
2929+ return makeZenDocFromRequest(req);
3030+}