diff --git a/docs/navigation-audit.md b/docs/navigation-audit.md
index 82d18ca0..8f777b79 100644
--- a/docs/navigation-audit.md
+++ b/docs/navigation-audit.md
@@ -35,7 +35,9 @@ 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`, `/projects/:id`, `/email/:emailId`.
+**Existing entity routes:** `/chat/:sessionId`, `/jobs/:id`, `/dashboards/:id`, `/email/:emailId`. (`/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` `/browser` `/code-editor`.
The five settings pages are route **pairs** now, not flat screens — `/settings/{profile,ai,system,integrations,user-management}`
@@ -59,7 +61,7 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
| H2 | `workspaces/…/apps/Dashboards/DashboardListApp.tsx:133` | a dashboard | `
` → `useGlobal(SELECTED_DASHBOARD_KEY)` on-page (**no URL change**), `navigate()` off-page | rows → ``; drop the global as selection source (derive from `useParams`). Header is already a `` — app is internally inconsistent. |
| H3 | `workspaces/…/apps/Projects/ProjectListApp.tsx:161` | a project | `
` → `useGlobal(SELECTED_PROJECT)` on-page (**no URL change**), `navigate()` off-page | identical to H2 → ``; retire `SELECTED_PROJECT` as source of truth. |
| H4 | `workspaces/…/apps/ChatHistory/ChatDetailPanel.tsx:136` | which chat to render | reads `usePanelChannel('chat:selected-session')`, **not** `useParams` | make the route the source of truth: read `sessionId` from `useParams`, fetch by id, retire `chat:selected-session` (or make it a derived cache). **Finishes the /chat fix.** |
-| H5 | `Screens/Dashboard/Email/EmailList.tsx:336` | an email | `
-
+
);
})}
diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx
index d8708c01..f2b678e4 100644
--- a/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx
@@ -4,9 +4,9 @@ import { Mail, Reply, Paperclip } from 'lucide-react';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
-import { useGlobal } from 'hooks/useGlobal';
import type { EmailMessage, EmailThread } from 'types';
import { useComposer, replyDraft } from './Compose';
+import { useSelectedEmailId } from './shared';
type OpenAttachment = {
filePath: string;
@@ -123,7 +123,8 @@ export const EmailReader = () => {
const client = useClient();
const queryClient = useQueryClient();
const [, openCompose] = useComposer();
- const [selectedId] = useGlobal('EMAIL_SELECTED', null);
+ // Reads the URL itself rather than being told by the list — the two panels never talk.
+ const selectedId = useSelectedEmailId();
const [openAttachment, setOpenAttachment] = useState(null);
const [expanded, setExpanded] = useState>(new Set());
diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx
index 364f8fb1..274992a7 100644
--- a/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx
@@ -1,14 +1,14 @@
-import { useMemo, useCallback, useEffect } from 'react';
-import { useParams, useNavigate } from 'react-router';
+import { useMemo, useCallback } from 'react';
+import { useNavigate } from 'react-router';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { useIsMobile } from 'hooks/useIsMobile';
-import { useGlobal } from 'hooks/useGlobal';
import { defaultLayout } from './defaultLayout';
import { EmailList } from './EmailList';
import { EmailReader } from './EmailReader';
import { ComposeModal } from './Compose';
+import { useSelectedEmailId } from './shared';
const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite email database available via the "email_db" tool — use it for all email queries (search, count, stats, aggregations, deletions) unless the user explicitly asks you to use Gmail. Do not use the Gmail integration for questions about existing emails.`;
@@ -19,23 +19,11 @@ const EmailChatPanel = () => {
const isMobile = useIsMobile();
- const { emailId } = useParams<{ emailId?: string }>();
const navigate = useNavigate();
- const [selectedId, setSelectedId] = useGlobal('EMAIL_SELECTED', null);
-
- // Sync URL param → global state
- useEffect(() => {
- if (emailId && emailId !== selectedId) setSelectedId(emailId);
- }, [emailId]);
-
- // Sync global state → URL
- useEffect(() => {
- if (selectedId && selectedId !== emailId) {
- navigate(`/email/${selectedId}`, { replace: true });
- } else if (!selectedId && emailId) {
- navigate('/email', { replace: true });
- }
- }, [selectedId]);
+ // No `` guard: unlike a section route, the bare /email is a real state — the list with
+ // nothing open — exactly as /chat and /jobs are. An id that doesn't resolve is the reader's problem,
+ // not a reason to rewrite the address the user is looking at.
+ const selectedId = useSelectedEmailId();
const workspace = useDashboardState('screens/email', defaultLayout);
const components: PanelComponents = useMemo(
@@ -48,7 +36,8 @@ export const EmailScreen = () => {
);
const mobilePanelId = isMobile && selectedId ? 'email-reader' : undefined;
- const onMobileBack = useCallback(() => setSelectedId(null), [setSelectedId]);
+ // Back on mobile closes the email, which is now just navigating to the bare route.
+ const onMobileBack = useCallback(() => navigate('/email'), [navigate]);
return (
diff --git a/src/apps/officer-web/Screens/Dashboard/Email/shared.ts b/src/apps/officer-web/Screens/Dashboard/Email/shared.ts
new file mode 100644
index 00000000..8012e51c
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Email/shared.ts
@@ -0,0 +1,12 @@
+import { useParams } from 'react-router';
+
+// Which email is open is `/email/:emailId`. The route pair already existed, but the URL was a mirror
+// rather than the state: the selection lived in an `EMAIL_SELECTED` global and two effects copied it
+// back and forth with the route. That round-trip is what kept a row a `` — nothing to link to,
+// no cmd-click, no middle-click — and it meant Back raced the effect that had just written the URL
+// with `replace`. The param is the state now; there is nothing to keep in sync.
+
+export const emailPath = (id: string) => `/email/${encodeURIComponent(id)}`;
+
+/** The open email, or null on the bare `/email` route — which is a real state, not one to redirect away. */
+export const useSelectedEmailId = (): string | null => useParams<{ emailId?: string }>().emailId ?? null;