A local-first note taking app
0

Configure Feed

Select the types of activity you want to include in your feed.

Tabs per tile (phase 1): tab-state model + split collapse

Foundation for multiple tabs per tile in the tiling workspace. No UI
change yet; behavior is preserved.

- model.ts: add removeLeaf (collapse split into sibling; root removal is
a no-op) and listTileIds, for last-tab close.
- tabsModel.ts: pure tab-state model (TileTabsMap), React-free and
side-effect-free. closeTab reports removedTabIds + tileEmptied so the
hook owns handle release and the caller owns collapse-vs-reseed.
- useTilingDocumentSlots: re-back onto TileTabsMap, key DocumentHandles
by tab id, preserve getSlot/ensureTile, add getTabs/getActiveTabId/
setActiveTab/newTab/closeTab for the upcoming tab UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Ethan Graf (Jun 24, 2026, 8:40 PM EDT) 17d57f8a d91e979c

+598 -45
+64
src/tiling/model.test.ts
··· 4 4 clampRatioForAxis, 5 5 createInitialTree, 6 6 DEFAULT_SPLIT_RATIO, 7 + listTileIds, 7 8 MIN_TILE_HEIGHT_PX, 8 9 MIN_TILE_WIDTH_PX, 10 + removeLeaf, 9 11 splitLeaf, 10 12 updateSplitRatio, 11 13 type SplitNode, ··· 124 126 125 127 it('is a no-op for tile nodes', () => { 126 128 expect(updateSplitRatio(tile('x'), 'any', 0.99)).toEqual(tile('x')); 129 + }); 130 + }); 131 + 132 + describe('removeLeaf', () => { 133 + it('collapses a split into its sibling when removing the first child', () => { 134 + const tree = splitRow('root', 0.4, tile('a'), tile('b')); 135 + expect(removeLeaf(tree, 'a')).toEqual(tile('b')); 136 + }); 137 + 138 + it('collapses a split into its sibling when removing the second child', () => { 139 + const tree = splitRow('root', 0.4, tile('a'), tile('b')); 140 + expect(removeLeaf(tree, 'b')).toEqual(tile('a')); 141 + }); 142 + 143 + it('collapses a nested split, preserving the rest of the tree', () => { 144 + const tree: TilingNode = splitRow( 145 + 'root', 146 + 0.5, 147 + tile('keep'), 148 + splitRow('inner', 0.5, tile('gone'), tile('survivor')), 149 + ); 150 + expect(removeLeaf(tree, 'gone')).toEqual( 151 + splitRow('root', 0.5, tile('keep'), tile('survivor')), 152 + ); 153 + }); 154 + 155 + it('is a no-op when removing the only (root) tile', () => { 156 + const tree = createInitialTree('only'); 157 + expect(removeLeaf(tree, 'only')).toBe(tree); 158 + }); 159 + 160 + it('returns the same reference when the tile is not found', () => { 161 + const tree = splitRow('root', 0.5, tile('a'), tile('b')); 162 + expect(removeLeaf(tree, 'missing')).toBe(tree); 163 + }); 164 + 165 + it('does not mutate the original tree', () => { 166 + const tree: TilingNode = splitRow( 167 + 'root', 168 + 0.5, 169 + tile('keep'), 170 + splitRow('inner', 0.5, tile('gone'), tile('survivor')), 171 + ); 172 + const before = structuredClone(tree); 173 + removeLeaf(tree, 'gone'); 174 + expect(tree).toEqual(before); 175 + }); 176 + }); 177 + 178 + describe('listTileIds', () => { 179 + it('returns the single id for a lone tile', () => { 180 + expect(listTileIds(tile('solo'))).toEqual(['solo']); 181 + }); 182 + 183 + it('lists tile ids left-to-right, depth-first', () => { 184 + const tree: TilingNode = splitRow( 185 + 'root', 186 + 0.5, 187 + tile('a'), 188 + splitRow('inner', 0.5, tile('b'), tile('c')), 189 + ); 190 + expect(listTileIds(tree)).toEqual(['a', 'b', 'c']); 127 191 }); 128 192 }); 129 193
+38
src/tiling/model.ts
··· 139 139 second: updateSplitRatio(node.second, splitId, ratio), 140 140 }; 141 141 } 142 + 143 + /** 144 + * Remove a tile from the tree, collapsing its parent split into the sibling 145 + * subtree (the sibling expands to fill the freed space — VS Code / Obsidian 146 + * behavior on closing the last tab in a pane). 147 + * 148 + * Removing the root (only) tile is a **no-op**: there is always at least one 149 + * tile, so callers should keep it as a single empty tile rather than emptying 150 + * the workspace. Returns the same reference when `tileId` is not found. 151 + */ 152 + export function removeLeaf(node: TilingNode, tileId: string): TilingNode { 153 + if (node.kind === 'tile') { 154 + // The only tile cannot be removed; the workspace always has >= 1 tile. 155 + return node; 156 + } 157 + if (node.first.kind === 'tile' && node.first.id === tileId) { 158 + return node.second; 159 + } 160 + if (node.second.kind === 'tile' && node.second.id === tileId) { 161 + return node.first; 162 + } 163 + const first = removeLeaf(node.first, tileId); 164 + const second = removeLeaf(node.second, tileId); 165 + if (first === node.first && second === node.second) { 166 + return node; 167 + } 168 + return { ...node, first, second }; 169 + } 170 + 171 + /** 172 + * In-order (left-to-right, depth-first) list of every tile id in the tree. 173 + * Used to detect the single-tile case and to pick a focus target after a 174 + * collapse. 175 + */ 176 + export function listTileIds(node: TilingNode): string[] { 177 + if (node.kind === 'tile') return [node.id]; 178 + return [...listTileIds(node.first), ...listTileIds(node.second)]; 179 + }
+194
src/workspaces/tiling/tabsModel.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + 3 + import type { OpenDocRequest } from '../workspace'; 4 + import { 5 + addTab, 6 + closeTab, 7 + createTile, 8 + ensureTile, 9 + findTabForRequest, 10 + getActiveSlot, 11 + getActiveTabId, 12 + getTabs, 13 + setActiveTab, 14 + setTabSlot, 15 + type TileTabsMap, 16 + } from './tabsModel'; 17 + 18 + function request(entryId: string, providerId = 'fs'): OpenDocRequest { 19 + return { providerId, entryId, title: entryId }; 20 + } 21 + 22 + /** A tile with the given tab ids, each holding a `loading` slot for its entry. */ 23 + function tileWithDocs( 24 + tileId: string, 25 + entries: string[], 26 + activeEntry = entries[0], 27 + ): TileTabsMap { 28 + return { 29 + [tileId]: { 30 + tabs: entries.map((entryId) => ({ 31 + tabId: `tab-${entryId}`, 32 + slot: { kind: 'loading', request: request(entryId) }, 33 + })), 34 + activeTabId: `tab-${activeEntry}`, 35 + }, 36 + }; 37 + } 38 + 39 + describe('createTile / ensureTile', () => { 40 + it('creates a tile with one empty active tab', () => { 41 + expect(createTile('tab-1')).toEqual({ 42 + tabs: [{ tabId: 'tab-1', slot: { kind: 'empty' } }], 43 + activeTabId: 'tab-1', 44 + }); 45 + }); 46 + 47 + it('ensureTile adds a missing tile', () => { 48 + const next = ensureTile({}, 'tile-1', 'tab-1'); 49 + expect(getActiveTabId(next, 'tile-1')).toBe('tab-1'); 50 + expect(getActiveSlot(next, 'tile-1')).toEqual({ kind: 'empty' }); 51 + }); 52 + 53 + it('ensureTile is a no-op (same reference) when the tile exists', () => { 54 + const map = ensureTile({}, 'tile-1', 'tab-1'); 55 + expect(ensureTile(map, 'tile-1', 'tab-999')).toBe(map); 56 + }); 57 + }); 58 + 59 + describe('getActiveSlot', () => { 60 + it('returns the active tab slot', () => { 61 + const map = tileWithDocs('tile-1', ['a', 'b'], 'b'); 62 + expect(getActiveSlot(map, 'tile-1')).toEqual({ 63 + kind: 'loading', 64 + request: request('b'), 65 + }); 66 + }); 67 + 68 + it('returns empty for an unknown tile', () => { 69 + expect(getActiveSlot({}, 'nope')).toEqual({ kind: 'empty' }); 70 + }); 71 + }); 72 + 73 + describe('setActiveTab', () => { 74 + it('activates an existing tab', () => { 75 + const map = tileWithDocs('tile-1', ['a', 'b'], 'a'); 76 + const next = setActiveTab(map, 'tile-1', 'tab-b'); 77 + expect(getActiveTabId(next, 'tile-1')).toBe('tab-b'); 78 + }); 79 + 80 + it('is a no-op (same reference) for a missing tab or already-active tab', () => { 81 + const map = tileWithDocs('tile-1', ['a', 'b'], 'a'); 82 + expect(setActiveTab(map, 'tile-1', 'missing')).toBe(map); 83 + expect(setActiveTab(map, 'tile-1', 'tab-a')).toBe(map); 84 + }); 85 + }); 86 + 87 + describe('addTab', () => { 88 + it('appends an empty tab and activates it', () => { 89 + const map = tileWithDocs('tile-1', ['a'], 'a'); 90 + const next = addTab(map, 'tile-1', 'tab-new'); 91 + expect(getTabs(next, 'tile-1').map((t) => t.tabId)).toEqual([ 92 + 'tab-a', 93 + 'tab-new', 94 + ]); 95 + expect(getActiveTabId(next, 'tile-1')).toBe('tab-new'); 96 + expect(getActiveSlot(next, 'tile-1')).toEqual({ kind: 'empty' }); 97 + }); 98 + 99 + it('creates the tile when it is missing', () => { 100 + const next = addTab({}, 'tile-1', 'tab-1'); 101 + expect(getActiveTabId(next, 'tile-1')).toBe('tab-1'); 102 + }); 103 + }); 104 + 105 + describe('setTabSlot', () => { 106 + it('updates only the targeted tab', () => { 107 + const map = tileWithDocs('tile-1', ['a', 'b'], 'a'); 108 + const next = setTabSlot(map, 'tile-1', 'tab-b', { kind: 'empty' }); 109 + expect(getTabs(next, 'tile-1')[0].slot).toEqual({ 110 + kind: 'loading', 111 + request: request('a'), 112 + }); 113 + expect(getTabs(next, 'tile-1')[1].slot).toEqual({ kind: 'empty' }); 114 + }); 115 + 116 + it('is a no-op (same reference) for a missing tab', () => { 117 + const map = tileWithDocs('tile-1', ['a'], 'a'); 118 + expect(setTabSlot(map, 'tile-1', 'missing', { kind: 'empty' })).toBe(map); 119 + }); 120 + }); 121 + 122 + describe('findTabForRequest', () => { 123 + it('finds a tab already showing the requested document', () => { 124 + const map = tileWithDocs('tile-1', ['a', 'b'], 'a'); 125 + expect(findTabForRequest(map, 'tile-1', request('b'))).toBe('tab-b'); 126 + }); 127 + 128 + it('returns null when no tab matches', () => { 129 + const map = tileWithDocs('tile-1', ['a'], 'a'); 130 + expect(findTabForRequest(map, 'tile-1', request('z'))).toBeNull(); 131 + }); 132 + 133 + it('distinguishes by provider', () => { 134 + const map = tileWithDocs('tile-1', ['a'], 'a'); 135 + expect(findTabForRequest(map, 'tile-1', request('a', 'other'))).toBeNull(); 136 + }); 137 + 138 + it('ignores empty tabs', () => { 139 + const map = addTab( 140 + tileWithDocs('tile-1', ['a'], 'a'), 141 + 'tile-1', 142 + 'tab-blank', 143 + ); 144 + expect(findTabForRequest(map, 'tile-1', request('a'))).toBe('tab-a'); 145 + }); 146 + }); 147 + 148 + describe('closeTab', () => { 149 + it('removes a non-active tab without changing the active tab', () => { 150 + const map = tileWithDocs('tile-1', ['a', 'b', 'c'], 'a'); 151 + const res = closeTab(map, 'tile-1', 'tab-b'); 152 + expect(res.removedTabIds).toEqual(['tab-b']); 153 + expect(res.tileEmptied).toBe(false); 154 + expect(getTabs(res.map, 'tile-1').map((t) => t.tabId)).toEqual([ 155 + 'tab-a', 156 + 'tab-c', 157 + ]); 158 + expect(getActiveTabId(res.map, 'tile-1')).toBe('tab-a'); 159 + }); 160 + 161 + it('activates the right neighbor when closing the active tab', () => { 162 + const map = tileWithDocs('tile-1', ['a', 'b', 'c'], 'b'); 163 + const res = closeTab(map, 'tile-1', 'tab-b'); 164 + expect(getActiveTabId(res.map, 'tile-1')).toBe('tab-c'); 165 + }); 166 + 167 + it('falls back to the left neighbor when closing the last (active) tab', () => { 168 + const map = tileWithDocs('tile-1', ['a', 'b'], 'b'); 169 + const res = closeTab(map, 'tile-1', 'tab-b'); 170 + expect(getActiveTabId(res.map, 'tile-1')).toBe('tab-a'); 171 + }); 172 + 173 + it('empties the tile when closing its last tab', () => { 174 + const map = tileWithDocs('tile-1', ['a'], 'a'); 175 + const res = closeTab(map, 'tile-1', 'tab-a'); 176 + expect(res.tileEmptied).toBe(true); 177 + expect(res.removedTabIds).toEqual(['tab-a']); 178 + expect(res.map['tile-1']).toBeUndefined(); 179 + }); 180 + 181 + it('is a no-op for a missing tile or tab', () => { 182 + const map = tileWithDocs('tile-1', ['a'], 'a'); 183 + expect(closeTab(map, 'other', 'tab-a')).toEqual({ 184 + map, 185 + removedTabIds: [], 186 + tileEmptied: false, 187 + }); 188 + expect(closeTab(map, 'tile-1', 'missing')).toEqual({ 189 + map, 190 + removedTabIds: [], 191 + tileEmptied: false, 192 + }); 193 + }); 194 + });
+196
src/workspaces/tiling/tabsModel.ts
··· 1 + /** 2 + * Pure tab-state model for the tiling workspace. 3 + * 4 + * Each tile owns an ordered list of tabs and one active tab. State is keyed by 5 + * tile id (`TileTabsMap`) so it survives tile component remounts (e.g. after a 6 + * split), mirroring how `src/tiling/model.ts` owns the split tree. 7 + * 8 + * This module is intentionally free of React and of document-handle side 9 + * effects: it describes *what* the tab structure becomes. The owning hook 10 + * (`useTilingDocumentSlots`) performs the impure work — running opens and 11 + * releasing `DocumentHandle`s — driven by the results returned here. `closeTab` 12 + * reports which tab ids it removed so the hook knows exactly which handles to 13 + * release. 14 + */ 15 + import type { DocumentSlotState } from '../../documents/useDocumentSlot'; 16 + import type { OpenDocRequest } from '../workspace'; 17 + 18 + export type TileTab = { 19 + tabId: string; 20 + slot: DocumentSlotState; 21 + }; 22 + 23 + export type TileTabs = { 24 + tabs: TileTab[]; 25 + activeTabId: string; 26 + }; 27 + 28 + /** Tab state for the whole workspace, keyed by tile id. */ 29 + export type TileTabsMap = Record<string, TileTabs>; 30 + 31 + const emptySlot = (): DocumentSlotState => ({ kind: 'empty' }); 32 + 33 + /** A fresh tile: one empty tab, active. */ 34 + export function createTile(tabId: string): TileTabs { 35 + return { tabs: [{ tabId, slot: emptySlot() }], activeTabId: tabId }; 36 + } 37 + 38 + /** Add a tile (with one empty tab) if it does not already exist. */ 39 + export function ensureTile( 40 + map: TileTabsMap, 41 + tileId: string, 42 + tabId: string, 43 + ): TileTabsMap { 44 + if (map[tileId]) return map; 45 + return { ...map, [tileId]: createTile(tabId) }; 46 + } 47 + 48 + export function getTabs(map: TileTabsMap, tileId: string): readonly TileTab[] { 49 + return map[tileId]?.tabs ?? []; 50 + } 51 + 52 + export function getActiveTabId( 53 + map: TileTabsMap, 54 + tileId: string, 55 + ): string | null { 56 + return map[tileId]?.activeTabId ?? null; 57 + } 58 + 59 + /** The slot shown by a tile = its active tab's slot. */ 60 + export function getActiveSlot( 61 + map: TileTabsMap, 62 + tileId: string, 63 + ): DocumentSlotState { 64 + const tile = map[tileId]; 65 + if (!tile) return emptySlot(); 66 + return ( 67 + tile.tabs.find((t) => t.tabId === tile.activeTabId)?.slot ?? emptySlot() 68 + ); 69 + } 70 + 71 + /** Activate an existing tab. No-op if the tile/tab is missing or already active. */ 72 + export function setActiveTab( 73 + map: TileTabsMap, 74 + tileId: string, 75 + tabId: string, 76 + ): TileTabsMap { 77 + const tile = map[tileId]; 78 + if (!tile) return map; 79 + if (tile.activeTabId === tabId) return map; 80 + if (!tile.tabs.some((t) => t.tabId === tabId)) return map; 81 + return { ...map, [tileId]: { ...tile, activeTabId: tabId } }; 82 + } 83 + 84 + /** 85 + * Append a new empty tab and activate it. If the tab id already exists, just 86 + * activate it (defensive against duplicate ids). 87 + */ 88 + export function addTab( 89 + map: TileTabsMap, 90 + tileId: string, 91 + tabId: string, 92 + ): TileTabsMap { 93 + const tile = map[tileId]; 94 + if (!tile) return { ...map, [tileId]: createTile(tabId) }; 95 + if (tile.tabs.some((t) => t.tabId === tabId)) { 96 + return setActiveTab(map, tileId, tabId); 97 + } 98 + return { 99 + ...map, 100 + [tileId]: { 101 + tabs: [...tile.tabs, { tabId, slot: emptySlot() }], 102 + activeTabId: tabId, 103 + }, 104 + }; 105 + } 106 + 107 + /** Replace one tab's slot state (loading/open/error/empty). */ 108 + export function setTabSlot( 109 + map: TileTabsMap, 110 + tileId: string, 111 + tabId: string, 112 + slot: DocumentSlotState, 113 + ): TileTabsMap { 114 + const tile = map[tileId]; 115 + if (!tile) return map; 116 + let changed = false; 117 + const tabs = tile.tabs.map((t) => { 118 + if (t.tabId !== tabId) return t; 119 + changed = true; 120 + return { ...t, slot }; 121 + }); 122 + if (!changed) return map; 123 + return { ...map, [tileId]: { ...tile, tabs } }; 124 + } 125 + 126 + /** 127 + * Find a tab in a tile already showing the requested document (matched by 128 + * provider + entry), or null. Used to focus an existing tab instead of opening 129 + * a duplicate. 130 + */ 131 + export function findTabForRequest( 132 + map: TileTabsMap, 133 + tileId: string, 134 + request: OpenDocRequest, 135 + ): string | null { 136 + const tile = map[tileId]; 137 + if (!tile) return null; 138 + for (const t of tile.tabs) { 139 + const slot = t.slot; 140 + if ( 141 + slot.kind !== 'empty' && 142 + slot.request.providerId === request.providerId && 143 + slot.request.entryId === request.entryId 144 + ) { 145 + return t.tabId; 146 + } 147 + } 148 + return null; 149 + } 150 + 151 + export type CloseTabResult = { 152 + map: TileTabsMap; 153 + /** Tab ids whose document handles the caller must release. */ 154 + removedTabIds: string[]; 155 + /** 156 + * True when the closed tab was the tile's last: the tile entry is removed 157 + * from the map. The caller decides whether to collapse the split 158 + * (`removeLeaf`) or, for the sole remaining tile, reseed an empty tile. 159 + */ 160 + tileEmptied: boolean; 161 + }; 162 + 163 + /** 164 + * Close a tab. If it was the active tab, the right neighbor (else the left) 165 + * becomes active. If it was the tile's last tab, the tile entry is dropped and 166 + * `tileEmptied` is set. 167 + */ 168 + export function closeTab( 169 + map: TileTabsMap, 170 + tileId: string, 171 + tabId: string, 172 + ): CloseTabResult { 173 + const tile = map[tileId]; 174 + if (!tile) return { map, removedTabIds: [], tileEmptied: false }; 175 + 176 + const index = tile.tabs.findIndex((t) => t.tabId === tabId); 177 + if (index === -1) return { map, removedTabIds: [], tileEmptied: false }; 178 + 179 + if (tile.tabs.length === 1) { 180 + const next = { ...map }; 181 + delete next[tileId]; 182 + return { map: next, removedTabIds: [tabId], tileEmptied: true }; 183 + } 184 + 185 + const tabs = tile.tabs.filter((t) => t.tabId !== tabId); 186 + let activeTabId = tile.activeTabId; 187 + if (activeTabId === tabId) { 188 + const neighbor = tile.tabs[index + 1] ?? tile.tabs[index - 1]; 189 + activeTabId = neighbor.tabId; 190 + } 191 + return { 192 + map: { ...map, [tileId]: { tabs, activeTabId } }, 193 + removedTabIds: [tabId], 194 + tileEmptied: false, 195 + }; 196 + }
+106 -45
src/workspaces/tiling/useTilingDocumentSlots.ts
··· 1 1 import { useCallback, useEffect, useRef, useState } from 'react'; 2 2 3 - import { 4 - emptyDocumentSlot, 5 - type DocumentSlotState, 6 - } from '../../documents/useDocumentSlot'; 3 + import type { DocumentSlotState } from '../../documents/useDocumentSlot'; 7 4 import { startDocumentOpen } from '../../documents/documentSlotOpen'; 8 5 import type { DocumentHandle } from '../../documents/types'; 9 6 import type { OpenDocRequest, WorkspaceProps } from '../workspace'; 7 + import * as tabsModel from './tabsModel'; 8 + import type { TileTab, TileTabsMap } from './tabsModel'; 10 9 11 10 const INITIAL_TILE_ID = 'tile-1'; 11 + const INITIAL_TAB_ID = 'tab-1'; 12 12 13 13 export type UseTilingDocumentSlotsInput = { 14 14 openRequestId: number; ··· 18 18 }; 19 19 20 20 /** 21 - * Per-tile document slots owned by the tiling workspace. State lives here so 21 + * Per-tile tab state owned by the tiling workspace. Each tile holds an ordered 22 + * list of tabs (one document slot each) and an active tab; state lives here so 22 23 * tile components can remount (e.g. after a split) without losing documents. 24 + * 25 + * Pure tab transitions live in `./tabsModel`; this hook layers on the impure 26 + * concerns: running opens via `startDocumentOpen` and releasing 27 + * `DocumentHandle`s (tracked per tab id) on close and unmount. 23 28 */ 24 29 export function useTilingDocumentSlots({ 25 30 openRequestId, ··· 27 32 resolveProvider, 28 33 activeTileId, 29 34 }: UseTilingDocumentSlotsInput) { 30 - const [tileSlots, setTileSlots] = useState<Record<string, DocumentSlotState>>( 31 - () => ({ [INITIAL_TILE_ID]: emptyDocumentSlot() }), 32 - ); 35 + const [tileTabs, setTileTabs] = useState<TileTabsMap>(() => ({ 36 + [INITIAL_TILE_ID]: tabsModel.createTile(INITIAL_TAB_ID), 37 + })); 38 + // Mirror for synchronous reads inside callbacks/effects without adding 39 + // `tileTabs` to dependency arrays. 40 + const tileTabsRef = useRef(tileTabs); 41 + tileTabsRef.current = tileTabs; 42 + 33 43 const handleRefs = useRef(new Map<string, DocumentHandle>()); 34 44 const lastSeenIdRef = useRef(openRequestId); 35 45 36 - const releaseHandleFor = useCallback((tileId: string) => { 37 - const handle = handleRefs.current.get(tileId); 46 + // Mints tab ids; starts past the seeded INITIAL_TAB_ID. 47 + const nextTabId = useRef(2); 48 + const makeTabId = useCallback(() => `tab-${nextTabId.current++}`, []); 49 + 50 + const releaseTab = useCallback((tabId: string) => { 51 + const handle = handleRefs.current.get(tabId); 38 52 if (handle) { 39 53 handle.release(); 40 - handleRefs.current.delete(tileId); 54 + handleRefs.current.delete(tabId); 41 55 } 42 56 }, []); 43 57 ··· 58 72 const request = openRequest; 59 73 const tileId = activeTileId; 60 74 75 + // Open into the active tile's active tab. (Focus-or-append routing arrives 76 + // in a later phase, once the tab strip exists.) 77 + let map = tileTabsRef.current; 78 + if (!map[tileId]) { 79 + map = tabsModel.ensureTile(map, tileId, makeTabId()); 80 + setTileTabs(map); 81 + } 82 + const tabId = tabsModel.getActiveTabId(map, tileId); 83 + if (!tabId) return; 84 + 85 + const setSlot = (slot: DocumentSlotState) => 86 + setTileTabs((prev) => tabsModel.setTabSlot(prev, tileId, tabId, slot)); 87 + 61 88 return startDocumentOpen(request, resolveProvider, { 62 - onLoading: () => { 63 - setTileSlots((prev) => ({ 64 - ...prev, 65 - [tileId]: { kind: 'loading', request }, 66 - })); 67 - }, 89 + onLoading: () => setSlot({ kind: 'loading', request }), 68 90 onOpen: (handle) => { 69 - handleRefs.current.set(tileId, handle); 70 - setTileSlots((prev) => ({ 71 - ...prev, 72 - [tileId]: { kind: 'open', request, handle }, 73 - })); 91 + handleRefs.current.set(tabId, handle); 92 + setSlot({ kind: 'open', request, handle }); 74 93 }, 75 94 onError: (message) => { 76 - handleRefs.current.delete(tileId); 77 - setTileSlots((prev) => ({ 78 - ...prev, 79 - [tileId]: { kind: 'error', request, message }, 80 - })); 95 + handleRefs.current.delete(tabId); 96 + setSlot({ kind: 'error', request, message }); 81 97 }, 82 98 takePreviousHandle: () => { 83 - const prev = handleRefs.current.get(tileId) ?? null; 84 - handleRefs.current.delete(tileId); 99 + const prev = handleRefs.current.get(tabId) ?? null; 100 + handleRefs.current.delete(tabId); 85 101 return prev; 86 102 }, 87 - isStaleHandle: (handle) => handleRefs.current.get(tileId) !== handle, 103 + isStaleHandle: (handle) => handleRefs.current.get(tabId) !== handle, 88 104 }); 89 - }, [openRequestId, openRequest, activeTileId, resolveProvider]); 105 + }, [openRequestId, openRequest, activeTileId, resolveProvider, makeTabId]); 90 106 91 - const ensureTile = useCallback((tileId: string) => { 92 - setTileSlots((prev) => { 93 - if (prev[tileId]) return prev; 94 - return { ...prev, [tileId]: emptyDocumentSlot() }; 95 - }); 96 - }, []); 107 + const ensureTile = useCallback( 108 + (tileId: string) => { 109 + setTileTabs((prev) => 110 + prev[tileId] ? prev : tabsModel.ensureTile(prev, tileId, makeTabId()), 111 + ); 112 + }, 113 + [makeTabId], 114 + ); 97 115 116 + /** Active tab's slot for a tile (the content the tile renders). */ 98 117 const getSlot = useCallback( 99 118 (tileId: string): DocumentSlotState => 100 - tileSlots[tileId] ?? emptyDocumentSlot(), 101 - [tileSlots], 119 + tabsModel.getActiveSlot(tileTabs, tileId), 120 + [tileTabs], 102 121 ); 103 122 104 - const closeSlot = useCallback( 105 - (tileId: string) => { 106 - releaseHandleFor(tileId); 107 - setTileSlots((prev) => ({ ...prev, [tileId]: emptyDocumentSlot() })); 123 + const getTabs = useCallback( 124 + (tileId: string): readonly TileTab[] => tabsModel.getTabs(tileTabs, tileId), 125 + [tileTabs], 126 + ); 127 + 128 + const getActiveTabId = useCallback( 129 + (tileId: string): string | null => 130 + tabsModel.getActiveTabId(tileTabs, tileId), 131 + [tileTabs], 132 + ); 133 + 134 + const setActiveTab = useCallback((tileId: string, tabId: string) => { 135 + setTileTabs((prev) => tabsModel.setActiveTab(prev, tileId, tabId)); 136 + }, []); 137 + 138 + /** Open a new empty tab in a tile and activate it. Returns the new tab id. */ 139 + const newTab = useCallback( 140 + (tileId: string): string => { 141 + const tabId = makeTabId(); 142 + setTileTabs((prev) => tabsModel.addTab(prev, tileId, tabId)); 143 + return tabId; 108 144 }, 109 - [releaseHandleFor], 145 + [makeTabId], 110 146 ); 111 147 112 - return { getSlot, ensureTile, closeSlot }; 148 + /** 149 + * Close a tab, releasing its document handle. Returns `tileEmptied: true` 150 + * when the tile lost its last tab, so the caller can collapse the split (or 151 + * reseed the sole remaining tile). 152 + */ 153 + const closeTab = useCallback( 154 + (tileId: string, tabId: string): { tileEmptied: boolean } => { 155 + const result = tabsModel.closeTab(tileTabsRef.current, tileId, tabId); 156 + for (const removed of result.removedTabIds) { 157 + releaseTab(removed); 158 + } 159 + setTileTabs(result.map); 160 + return { tileEmptied: result.tileEmptied }; 161 + }, 162 + [releaseTab], 163 + ); 164 + 165 + return { 166 + getSlot, 167 + getTabs, 168 + getActiveTabId, 169 + ensureTile, 170 + setActiveTab, 171 + newTab, 172 + closeTab, 173 + }; 113 174 }