summaryrefslogtreecommitdiff
path: root/makima/frontend/src/hooks
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-01 18:06:38 +0100
committerGitHub <noreply@github.com>2026-05-01 18:06:38 +0100
commit80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (patch)
tree5802a9923eab572a98a6d8b21be2fdc56fdf7118 /makima/frontend/src/hooks
parent6d922307223d12f436b229d4c4b29b8835b93b6c (diff)
downloadsoryu-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.ts178
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(() => {