diff --git a/docs/navigation-audit.md b/docs/navigation-audit.md index e933843a..c79df7db 100644 --- a/docs/navigation-audit.md +++ b/docs/navigation-audit.md @@ -35,12 +35,16 @@ That fix is the template for the HIGH items below. **Caveat:** the fix only did ## Route map (from `App.tsx`) -**Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/email/:emailId`. (`/projects/:id` +**Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/email/:emailId`, +`/browser/:tabId`, `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName`, `/task-logs/:id`, +`/activity/:id`. (`/projects/:id` was in this list; the Projects feature was removed end to end on 2026-07-30, so the route is gone with it. H3 and navigate-site 22 below still name it — they are the record of work that happened, not of code that exists.) -**Flat screens:** `/` `/files` `/music` `/tasks` `/skills` `/processes` `/activity` -`/system-monitor` `/task-logs` `/plans` `/terminal` `/desktop` `/code-editor`. (`/browser` is a pair now too — -`/browser/:tabId`.) +**Section routes** (a param that names a view rather than an entity): `/settings/:page/:section`, +`/soulseek/:section`, `/headscale/:section`, `/wallet/:section`, `/system-monitor/:scope`. +**Query-param screens:** `/music?path=`. +**Genuinely flat, still:** `/` `/files` `/plans` `/terminal` `/desktop` `/code-editor` `/qr-transfer`. +`/files` and `/code-editor` are M4 and M5 — the last two that ought not to be. The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}` plus a `:section` each (M6). There has never been a `/settings/apps`; that entry was wrong when this list was written. @@ -74,8 +78,8 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro | ID | file:line | Entity | Proposed route | Note | |----|-----------|--------|----------------|------| | ~~M1~~ | ~~`Screens/Dashboard/CapabilityPage.tsx:431`~~ | task / skill / process | `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName` | **Done.** One component backed three screens, so one change covered all of them. The auto-select-`items[0]` effect is gone — the bare route is now the list with an empty detail pane. `editing`/`isNew` moved to `?edit=1` / `?new=1` because a `` row cannot imperatively reset them. | -| M2 | `Screens/Dashboard/TaskLogs/index.tsx:104` | a task-log run | `/task-logs/:id` | detail fetch already keys off the id — clean move. | -| M3 | `Screens/Dashboard/Activity/ActivityScreen.tsx:63,73` | background task / detached job | `/activity/:id` | two row types; unify under one param, screen re-derives `task=`/`path=`. | +| ~~M2~~ | ~~`Screens/Dashboard/TaskLogs/index.tsx:104`~~ | a task-log run | `/task-logs/:id` | **Done.** As predicted — the detail fetch already keyed off the id, so only its source changed. `showDetail` is gone; the mobile swap and both back arrows derive from the param. | +| ~~M3~~ | ~~`Screens/Dashboard/Activity/ActivityScreen.tsx:63,73`~~ | background task / detached job | `/activity/:id` | **Done.** One param for both row kinds; the screen looks the id up in the polled registry and derives `task=`/`path=` from the row. The SSE effect now depends on that derived *string*, so the 3s poll no longer risks re-opening the stream. An id that has left the registry says so instead of hanging on "waiting for output". | | M4 | `FileBrowser/.../useFileBrowserApp.ts:269`, `FileItem.tsx:516`, `Breadcrumb.tsx:16`, search-hit `:249` | a folder | `/files?path=` | **files** already open via `?view=`; **folders** are pure `currentPath` state — no URL, no back/forward. Folder rows + crumbs → `` on a `?path=` param. | | M5 | `CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33` | open source file / active tab | `/code-editor?file=` (+ `open=` for tabs) | tree file rows → ``; active file in a param. | | ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was *almost* right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. | @@ -211,8 +215,8 @@ publishers means changing the chat panel, which is another agent's, so it is wri ### Phase 2 — Add a route, then link (per-entity, medium effort) - [x] **M6** Settings sub-sections → `/settings/:page/:section`; `SectionButton` is now a `SectionLink` (``), the five `*_SELECTED` globals and `INTEGRATIONS_SETTINGS_TAB` are gone, and each page renders one `SettingsRoute` guard that canonicalises the bare route and a bogus section. **Needs runtime test.** - [x] **M1** Capabilities → `/tasks|skills|processes/:dirName`, rows → ``; `CapabilityPage` takes an explicit `basePath` (not reused from `endpoint`, which only happens to match). Selection is `useParams`, the mobile pane swap and back arrow are derived from it, delete navigates to the bare route, and the two per-item modes are `?edit=1` / `?new=1`. No `` guard: an unknown `dirName` gets the empty detail pane. **Needs runtime test.** -- [ ] **M2** TaskLogs → `/task-logs/:id` (`TaskLogs/index.tsx:104`). -- [ ] **M3** Activity → `/activity/:id` (`ActivityScreen.tsx:63,73`). +- [x] **M2** TaskLogs → `/task-logs/:id`; rows are ``s, `showDetail` deleted. **Needs runtime test.** +- [x] **M3** Activity → `/activity/:id`; the `{label, query}` selection object is gone — the id is the URL and the stream query is derived from the registry row. `/activity` also had no `usePageTitle` rule (it read "Officer"); added. **Needs runtime test.** - [ ] **M4** FileBrowser folders → `/files?path=`; folder rows + breadcrumbs → `` (`useFileBrowserApp.ts:269`, `FileItem.tsx`, `Breadcrumb.tsx`). - [ ] **M5** CodeEditor active file → `/code-editor?file=` (`FileTree.tsx:59`, `EditorTabs.tsx:33`). - [ ] **M7** Combobox: render `href` options as `` (`Combobox.tsx:53`). diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 4f83b3c5..22aca748 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -82,6 +82,7 @@ export function App() { {/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */} } /> } /> + } /> } /> } /> @@ -91,6 +92,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx index 4e2d5b37..ffa7e95b 100644 --- a/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Activity/ActivityScreen.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { Link, useParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react'; @@ -10,10 +11,14 @@ type ProgressLine = { phase?: string; status?: string; pct?: number; detail?: st const POLL_MS = 3000; const MAX_LINES = 600; +// Which run you are following is `/activity/:id`. The two row kinds stream through different query +// params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and +// the screen re-derives the param from the registry row — one address shape for both kinds. export const ActivityScreen = () => { const { token, get } = useClient(); const [reg, setReg] = useState({ tasks: [], detached: [] }); - const [selected, setSelected] = useState<{ label: string; query: string } | null>(null); + const [regLoaded, setRegLoaded] = useState(false); + const selectedId = useParams<{ id: string }>().id ?? null; const [lines, setLines] = useState([]); const [progress, setProgress] = useState(null); const esRef = useRef(null); @@ -22,19 +27,24 @@ export const ActivityScreen = () => { // Poll the registry (harness task files + announced detached jobs). useEffect(() => { let alive = true; - const tick = () => get('/activity/tasks').then((r) => alive && setReg(r)).catch(() => {}); + const tick = () => get('/activity/tasks').then((r) => { if (alive) { setReg(r); setRegLoaded(true); } }).catch(() => {}); tick(); const iv = setInterval(tick, POLL_MS); return () => { alive = false; clearInterval(iv); }; }, []); + // The row backing the open id, and the stream query it implies. A string rather than the row object, + // so the 3s registry poll — which replaces every row — does not tear down and re-open the stream. + const row = selectedId ? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId)) : undefined; + const query = !row ? null : row.source === 'harness' ? `task=${encodeURIComponent(row.id)}` : `path=${encodeURIComponent(row.path)}`; + // Live-tail the selected task via SSE (EventSource can't set headers → token in the query string). useEffect(() => { esRef.current?.close(); setLines([]); setProgress(null); - if (!selected) return; - const es = new EventSource(`/api/activity/stream?${selected.query}&token=${encodeURIComponent(token ?? '')}`); + if (!query) return; + const es = new EventSource(`/api/activity/stream?${query}&token=${encodeURIComponent(token ?? '')}`); esRef.current = es; es.onmessage = (ev) => { try { @@ -44,7 +54,7 @@ export const ActivityScreen = () => { } catch { /* ignore */ } }; return () => es.close(); - }, [selected, token]); + }, [query, token]); useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]); @@ -61,30 +71,30 @@ export const ActivityScreen = () => {
Background tasks
{reg.tasks.length === 0 &&

none running

} {reg.tasks.map((t) => ( - + ))} {reg.detached.length > 0 && (
Detached
)} {reg.detached.map((d) => ( - + ))}
- {selected ? ( + {selectedId ? ( <>
- {selected.label} + {selectedId}
{progress && (
@@ -105,7 +115,14 @@ export const ActivityScreen = () => {
{lines.length === 0 ? ( - waiting for output… + + {query ? 'waiting for output…' : regLoaded ? ( + <> + no run called {selectedId} is in the registry — it finished, or it never started.{' '} + Back to the list + + ) : 'loading…'} + ) : ( lines.map((l, i) =>
{l}
) )} diff --git a/src/apps/officer-web/Screens/Dashboard/TaskLogs/index.tsx b/src/apps/officer-web/Screens/Dashboard/TaskLogs/index.tsx index 5dd77ed7..30b5c101 100644 --- a/src/apps/officer-web/Screens/Dashboard/TaskLogs/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/TaskLogs/index.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { Link, useParams } from 'react-router'; import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Card } from '@/components/Card'; @@ -34,11 +35,12 @@ const ProviderBadge = ({ provider }: { provider: string }) => ( ); +// Which run is open is `/task-logs/:id`. No redirect guard — the bare route is the list with nothing +// open, and an id that no longer exists gets the empty pane rather than a rewritten address. export const TaskLogs = () => { const client = useClient(); const [logs, setLogs] = useState([]); - const [selectedId, setSelectedId] = useState(null); - const [showDetail, setShowDetail] = useState(false); + const selectedId = useParams<{ id: string }>().id ?? null; const [selectedLog, setSelectedLog] = useState(null); const [search, setSearch] = useState(''); const [isLoading, setIsLoading] = useState(true); @@ -79,7 +81,7 @@ export const TaskLogs = () => {
{/* Left panel: list */}
@@ -102,13 +104,10 @@ export const TaskLogs = () => {
No logs found
)} {filtered.map((log) => ( - + ))}
{/* Right panel: log viewer */} - + {!selectedLog && (
Select a log to view - +
)} {selectedLog && ( <>
- +
{selectedLog.taskName} diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index a80019f8..471d27ca 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -28,6 +28,7 @@ const RULES: TitleRule[] = [ { match: (p) => p.startsWith('/wallet'), title: 'Wallet' }, { match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' }, { match: (p) => p.startsWith('/qr-transfer'), title: 'QR Transfer' }, + { match: (p) => p.startsWith('/activity'), title: 'Activity' }, { match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' }, { match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' }, { match: (p) => p.startsWith('/tasks'), title: 'Tasks' },