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 = () => {
{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 = () => {