diff options
| author | soryu <soryu@soryu.co> | 2026-05-01 18:06:38 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-01 18:06:38 +0100 |
| commit | 80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (patch) | |
| tree | 5802a9923eab572a98a6d8b21be2fdc56fdf7118 /makima/frontend/src/hooks | |
| parent | 6d922307223d12f436b229d4c4b29b8835b93b6c (diff) | |
| download | soryu-80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef.tar.gz soryu-80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef.zip | |
feat(doc-mode): unified surface — ephemeral tasks, tmp/, /exec redirect, palette, SWR (#117)
Phases 1-3 of the unified-surface plan, bundled per the user's request.
The directive document/folder UI is now the canonical place to interact
with makima; legacy /exec is subsumed by routing redirects, /contracts is
already hidden in nav, and orphan tasks surface under a top-level tmp/
pseudo-folder. Phase 4 (porting contract-only features) was confirmed
out of scope; Phase 5 (deleting the contracts code) is a follow-up.
## Backend
- New migration `20260501000000_archive_existing_contracts.sql` flips
every legacy contract to `archived` so /contracts is read-only history.
- New endpoint `POST /api/v1/directives/{id}/tasks` creates an ephemeral
task — `directive_id` set, `directive_step_id` NULL, repo/branch
inherited from the directive. Reuses `create_task_for_owner`.
- New endpoint `GET /api/v1/directives/{id}/tasks` lists ephemeral tasks
attached to a directive (drives the per-folder ephemeral group).
- `GET /api/v1/mesh/tasks?orphan=true` returns top-level tasks with no
`directive_id` AND no `parent_task_id` — backs the sidebar's tmp/.
- New repo helpers `list_ephemeral_directive_tasks_for_owner` and
`list_orphan_tasks_for_owner`.
- The existing `mesh_merge` endpoints are reused as-is for ephemeral
task merge (no new merge logic needed).
## Frontend
### Sticky composer + auto-scroll fix (`DocumentTaskStream.tsx`)
- Sticky comment composer pinned to viewport bottom; padding compensates
so the last entry isn't hidden behind it.
- `autoScroll` now resumes when the user scrolls back within 80px of
the bottom (previously stuck off forever after a single scroll-up).
- Floating "↓ Jump to latest" chip when the user has scrolled away.
- Action header strip: explicit Stop / Send / Open-in-task-page +
conditional "Merge to base ↗" button on ephemeral terminal tasks.
- Module-level cache of historical entries by taskId so re-selecting a
task you've viewed renders instantly while a fresh fetch runs.
### Sidebar (`document-directives.tsx`)
- Top-level `tmp/` folder: orphan tasks, polled every 5s.
- Per-directive `tasks/` subfolder now also surfaces ephemeral tasks
(lazily fetched on folder open) with a distinct asterisk-on-terminal
icon (`EphemeralTaskIcon`).
- Inline hover-action chips on each directive folder header: Start /
Pause / PR / +New task. Right-click menu still works as a power-user
fallback.
- "Now executing" amber strip in the editor pane: surfaces the live
orchestrator/completion/running-step task with a one-click jump.
- Inline `+ New task` modal (name + plan); on submit calls
`createDirectiveTask` and navigates into the freshly-spawned task.
- New `EphemeralAwareTaskStream` wrapper passes `ephemeral` and
`status` to `DocumentTaskStream` so the merge button only shows when
the selected task is genuinely an ephemeral spinoff in a terminal
state. Step-spawned tasks merge via the directive's PR completion.
### SWR cache (`useDirectives.ts`)
- Module-level `listCache` and per-id `detailCache` (mirrors the pattern
in `useUserSettings.ts`). Mounting the hook renders the cache value
immediately if present and kicks a background refresh; subscribers see
the new value when it lands. Cuts perceived navigation latency to
near-zero on warm cache hits.
### QuickSwitcher (`QuickSwitcher.tsx`, new)
- IntelliJ-style double-Shift command palette mounted at app root.
- Listens at the document level for two `Shift` keydowns within 300ms
with no other key in between; ignores while focus is in an
input/textarea so capitalising letters doesn't pop the palette.
- Searches across directives + their tasks (orchestrator/completion/
steps/ephemerals) + orphan tmp tasks. Fuzzy matches on title.
- Eagerly loads task details for the first 20 directives on open so
searches don't block on per-directive fetches.
### Routing (`main.tsx` + `exec-redirect.tsx` + `tmp.tsx`)
- New `ExecRedirect` wrapper at `/exec/:id`: when documentMode is on
AND the task has a `directiveId`, replaces the URL with
`/directives/<directiveId>?task=<taskId>`. Otherwise renders the
legacy `MeshPage` as before.
- New `/tmp/:taskId` route renders `DocumentTaskStream` standalone for
orphan tasks, with the masthead and a `tmp / <slug>` breadcrumb.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/frontend/src/hooks')
| -rw-r--r-- | makima/frontend/src/hooks/useDirectives.ts | 178 |
1 files changed, 152 insertions, 26 deletions
diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts index 898f671..8104de0 100644 --- a/makima/frontend/src/hooks/useDirectives.ts +++ b/makima/frontend/src/hooks/useDirectives.ts @@ -24,28 +24,111 @@ import { createDirectivePR, } from "../lib/api"; +// ============================================================================= +// Stale-while-revalidate cache +// +// Switching between directives in the document-mode sidebar used to feel +// noticeably laggy because every navigation re-fired the GET request and +// blocked the UI on it. We now keep a process-wide cache (mirrors the +// pattern in `useUserSettings.ts`) and use the cache as the immediate +// render value; a fresh fetch fires in the background and notifies all +// subscribed hook instances when it lands. +// +// Mutations (start/pause/etc.) push their result back into the cache so +// successive reads are also instant. Hard `refresh()` calls bypass the +// cache age check and refetch. +// ============================================================================= + +let listCache: DirectiveSummary[] | null = null; +let listInflight: Promise<DirectiveSummary[]> | null = null; +const listSubscribers = new Set<(d: DirectiveSummary[]) => void>(); + +const detailCache = new Map<string, DirectiveWithSteps>(); +const detailInflight = new Map<string, Promise<DirectiveWithSteps>>(); +const detailSubscribers = new Map<string, Set<(d: DirectiveWithSteps) => void>>(); + +function notifyList(value: DirectiveSummary[]) { + for (const sub of listSubscribers) sub(value); +} + +function notifyDetail(id: string, value: DirectiveWithSteps) { + const subs = detailSubscribers.get(id); + if (!subs) return; + for (const sub of subs) sub(value); +} + +function fetchList(): Promise<DirectiveSummary[]> { + if (listInflight) return listInflight; + listInflight = listDirectives() + .then((res) => { + listCache = res.directives; + notifyList(res.directives); + return res.directives; + }) + .finally(() => { + listInflight = null; + }); + return listInflight; +} + +function fetchDetail(id: string): Promise<DirectiveWithSteps> { + const existing = detailInflight.get(id); + if (existing) return existing; + const p = getDirective(id) + .then((d) => { + detailCache.set(id, d); + notifyDetail(id, d); + return d; + }) + .finally(() => { + detailInflight.delete(id); + }); + detailInflight.set(id, p); + return p; +} + export function useDirectives() { - const [directives, setDirectives] = useState<DirectiveSummary[]>([]); - const [loading, setLoading] = useState(true); + const [directives, setDirectives] = useState<DirectiveSummary[]>( + () => listCache ?? [], + ); + const [loading, setLoading] = useState<boolean>(listCache === null); const [error, setError] = useState<string | null>(null); + useEffect(() => { + let mounted = true; + const sub = (value: DirectiveSummary[]) => { + if (!mounted) return; + setDirectives(value); + setLoading(false); + }; + listSubscribers.add(sub); + + // Always kick a background fetch on mount so we don't ship stale data; + // subscribers see the new value when it lands. UI shows the cached + // value immediately if there is one. + fetchList().catch((e) => { + if (!mounted) return; + setError(e instanceof Error ? e.message : "Failed to load directives"); + setLoading(false); + }); + + return () => { + mounted = false; + listSubscribers.delete(sub); + }; + }, []); + const refresh = useCallback(async () => { try { - setLoading(true); - setError(null); const res = await listDirectives(); - setDirectives(res.directives); + listCache = res.directives; + notifyList(res.directives); + setError(null); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load directives"); - } finally { - setLoading(false); } }, []); - useEffect(() => { - refresh(); - }, [refresh]); - const create = useCallback(async (req: CreateDirectiveRequest) => { const d = await createDirective(req); await refresh(); @@ -54,6 +137,7 @@ export function useDirectives() { const remove = useCallback(async (id: string) => { await deleteDirective(id); + detailCache.delete(id); await refresh(); }, [refresh]); @@ -61,8 +145,12 @@ export function useDirectives() { } export function useDirective(id: string | undefined) { - const [directive, setDirective] = useState<DirectiveWithSteps | null>(null); - const [loading, setLoading] = useState(true); + const [directive, setDirective] = useState<DirectiveWithSteps | null>( + () => (id ? detailCache.get(id) ?? null : null), + ); + const [loading, setLoading] = useState<boolean>( + id !== undefined && !detailCache.has(id), + ); const [error, setError] = useState<string | null>(null); // Silently refresh without setting loading state (for polls) @@ -70,35 +158,73 @@ export function useDirective(id: string | undefined) { if (!id) return; try { const d = await getDirective(id); - setDirective(d); + detailCache.set(id, d); + notifyDetail(id, d); setError(null); - } catch (e) { + } catch { // Don't overwrite existing data on poll failure } }, [id]); - // Full refresh with loading state (for initial load / explicit refresh) + // Full refresh with loading state (for explicit refresh) const refresh = useCallback(async () => { if (!id) return; try { - setLoading(true); setError(null); const d = await getDirective(id); - setDirective(d); + detailCache.set(id, d); + notifyDetail(id, d); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load directive"); - } finally { - setLoading(false); } }, [id]); - // Reset state and fetch when ID changes + // Subscribe to detail updates for this id; render cached value + // immediately and kick a background fetch if the cache is missing. useEffect(() => { - setDirective(null); - setError(null); - setLoading(true); - refresh(); - }, [id]); // eslint-disable-line react-hooks/exhaustive-deps + if (!id) { + setDirective(null); + setLoading(false); + setError(null); + return; + } + + let mounted = true; + const sub = (value: DirectiveWithSteps) => { + if (!mounted) return; + setDirective(value); + setLoading(false); + }; + + let subs = detailSubscribers.get(id); + if (!subs) { + subs = new Set(); + detailSubscribers.set(id, subs); + } + subs.add(sub); + + const cached = detailCache.get(id); + if (cached) { + setDirective(cached); + setLoading(false); + } else { + setDirective(null); + setLoading(true); + } + + // Always kick a fresh fetch so polling-driven UIs see updates. + fetchDetail(id).catch((e) => { + if (!mounted) return; + setError(e instanceof Error ? e.message : "Failed to load directive"); + setLoading(false); + }); + + return () => { + mounted = false; + subs!.delete(sub); + if (subs!.size === 0) detailSubscribers.delete(id); + }; + }, [id]); // Auto-poll while directive is active, has an orchestrator task, or has a completion task useEffect(() => { |
