make the email url the selection instead of a mirror of it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 11:51:45 +00:00
co-authored by Claude Opus 5
parent fd923bb9be
commit 6c47cbeb74
6 changed files with 57 additions and 36 deletions
+7 -6
View File
@@ -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 | `<div onClick={handleClick}>``useGlobal(SELECTED_DASHBOARD_KEY)` on-page (**no URL change**), `navigate()` off-page | rows → `<Link to={`/dashboards/${ws.id}`}>`; drop the global as selection source (derive from `useParams`). Header is already a `<Link>` — app is internally inconsistent. |
| H3 | `workspaces/…/apps/Projects/ProjectListApp.tsx:161` | a project | `<div onClick={handleClick}>``useGlobal(SELECTED_PROJECT)` on-page (**no URL change**), `navigate()` off-page | identical to H2 → `<Link to={`/projects/${p.id}`}>`; 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 | `<button data-email-id onClick={setSelectedId}>` + `EMAIL_SELECTED` channel + a state↔URL sync effect (`EmailScreen.tsx:27-33`) | rows `<Link to={`/email/${msg.id}`}>`, drive selection **from** `useParams().emailId`, delete the two sync effects. (Already round-trips the URL + has `data-email-id`, but still isn't an anchor.) |
| ~~H5~~ | ~~`Screens/Dashboard/Email/EmailList.tsx:336`~~ | ~~an email~~ | ~~`<button data-email-id onClick={setSelectedId}>` + `EMAIL_SELECTED` channel + a state↔URL sync effect (`EmailScreen.tsx:27-33`)~~ | **Done.** Rows are `<Link to={emailPath(msg.id)}>`, list + reader + mobile-panel all read `useSelectedEmailId()` (`Email/shared.ts`), and `EMAIL_SELECTED` plus both sync effects are deleted. Arrow-key browsing navigates with `replace` so a sweep doesn't stack history. |
> **Verify:** `Dashboards/DashboardPreview.tsx:355,377` and `Projects/ProjectPreview.tsx:430,457` also
> `navigate('/…/:id')`. The nav auditor read these as HIGH "open entity"; the apps auditor read the Project
@@ -119,7 +121,6 @@ This is the primary surface to convert to URL-driven selection.
| `chat:active-cwd` | chat working dir | query param on `/chat` | `ChatDetailPanel.tsx:102`, `SessionList.tsx:14` |
| `SELECTED_DASHBOARD_KEY` (`useGlobal`) | selected dashboard | `/dashboards/:id` | `DashboardListApp.tsx:36`, `DashboardPreview.tsx:287` (H2) |
| `SELECTED_PROJECT` (`useGlobal`) | selected project | `/projects/:id` | `ProjectListApp.tsx:45`, `ProjectPreview.tsx:386` (H3) |
| `EMAIL_SELECTED` | open email | `/email/:emailId` | `EmailList.tsx:336`, `EmailScreen.tsx` sync (H5) |
| `music:cwd` | library location (album/artist/folder) | `/music?path=` | `MusicBrowser/MusicDetail/FavoritesView` |
| local `useState` selections | search / room / conversation / peer / open file / preview slug | respective nested routes | `SearchView`, `SoulseekRooms`, `SoulseekChat`, `SoulseekUsers`, `CodeEditor/useEditorState`, `PreviewProvider` |
@@ -159,8 +160,8 @@ publishers means changing the chat panel, which is another agent's, so it is wri
| 16 | `FileBrowserApp/useFileBrowserApp.ts:427` | create dashboard from files | `/dashboards/new?…` | ~ | action-nav |
| 17 | `FileBrowserApp/.../TaskRunnerModal.tsx:1168` | after starting job | `/jobs` `/jobs/:id` | NO | post-submit |
| 18 | `FileBrowserApp/.../TaskRunnerModal.tsx:1649` | after starting pipeline | `/jobs/:id` | NO | post-submit |
| 19 | `Email/EmailScreen.tsx:29` | sync selection → URL (effect) | `/email/:emailId` | NO | delete in H5 |
| 20 | `Email/EmailScreen.tsx:31` | clear selection → URL (effect) | `/email` | NO | delete in H5 |
| ~~19~~ | ~~`Email/EmailScreen.tsx:29`~~ | ~~sync selection → URL (effect)~~ | — | | deleted (H5) |
| ~~20~~ | ~~`Email/EmailScreen.tsx:31`~~ | ~~clear selection → URL (effect)~~ | — | | deleted (H5); the mobile Back button navigates to `/email` instead |
| 21 | `ChatHistory/index.tsx:107` | redirect when no id | `/chat` | NO | guard |
| 22 | `Projects/ProjectListScreen.tsx:66` | clear/redirect | `/projects` | NO | guard |
| 23 | `Authentication/ForgotPassword/useResetPassword.ts:48` | post-reset redirect | `/` | NO | action result |
@@ -185,7 +186,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
- [x] **H2** Dashboards rows → `<Link>`; `SELECTED_DASHBOARD_KEY` global replaced by `?selected=` URL param across `DashboardListApp`/`DashboardsScreen`/`DashboardPreview`. Done — `01365cb`. **Needs runtime test.** (The constant itself outlived its last reader by four months and has now been deleted; its siblings in `Dashboards/constants.ts` are dialog form state, not selection, and stay.)
- [x] **H3** Projects rows → `<Link>`; `SELECTED_PROJECT` global replaced by `?selected=` URL param across `ProjectListApp`/`ProjectListScreen`/`ProjectPreview`. Done — `2aaacc8`. **Needs runtime test.**
- [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**. *Deferred: overlaps the in-flight `sidecars-*` chat-comms work; do after that lands.*
- [ ] **H5** Email rows → `<Link>` driven by `useParams().emailId`; delete the state↔URL sync effects (`EmailList.tsx:336`, `EmailScreen.tsx:27-33`).
- [x] **H5** Email rows → `<Link>` driven by `useParams().emailId`; the `EMAIL_SELECTED` global and both state↔URL sync effects are gone. **Needs runtime test.** (`EMAIL_FOLDER` stays a `useGlobal` for now — it is read in one component and is view state, not selection; putting the folder in `?folder=` is a separate, smaller item.)
- [x] **M8** Dead `/settings/resources` menu item removed from `UserMenu.tsx`, along with its now-orphaned `en`/`pt` locale keys. **Needs runtime test.**
- [ ] Verify + convert the 4 preview "open" navigates (`DashboardPreview:355,377`, `ProjectPreview:430,457`); leave genuine post-mutation redirects.
+14 -1
View File
@@ -654,7 +654,20 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
re-run the lookup, and nonce-stamped so that asking for the *same* peer twice still counted twice.
A link needs none of that: it is idempotent, so there is nothing to consume and nothing to
disambiguate. That is the shape of most of this section's remaining work.
Remaining here: Music, Email, Browser. **Chat detail (`chat:selected-session`) is chat-owned —
**Email is done** (audit H5) — and it is the one of the seven where the route already existed and
the work was pure deletion. `/email/:emailId` has been a route pair all along; what sat on top of
it was an `EMAIL_SELECTED` global plus two effects copying state into the URL and the URL back into
state. That made the address a *mirror* rather than the source: rows stayed `<button>`s with the id
in a closure, and Back raced the effect that had just rewritten the URL with `replace`. List,
reader and the mobile-panel switch now each read `useSelectedEmailId()` (`Email/shared.ts`) and the
rows are `<Link>`s, so cmd-click opens a mail in a tab for the first time. Two decisions worth
recording: no `<Navigate>` guard, because unlike a section route the bare `/email` is a real state —
the list with nothing open, exactly as `/chat` and `/jobs` — and arrow-key browsing navigates with
`replace`, since sweeping a folder would otherwise stack one history entry per row and Back would
walk the sweep instead of leaving the mailbox. `EMAIL_FOLDER` stays a global for now: it is read in
one component and is view state rather than selection. Putting it in `?folder=` is a real but
separate item.
Remaining here: Music, Browser. **Chat detail (`chat:selected-session`) is chat-owned —
leave it; it is written up in `COMMS/`.**
### 5.9 The context has grown an app-config section — _(found 2026-08-07)_
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import {
ChevronLeft,
@@ -22,6 +22,7 @@ import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
import type { EmailSummary } from 'types';
import { useComposer } from './Compose';
import { emailPath, useSelectedEmailId } from './shared';
type EmailAccountRow = {
id: number;
@@ -54,7 +55,8 @@ export const EmailList = () => {
const client = useClient();
const queryClient = useQueryClient();
const [, openCompose] = useComposer();
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const navigate = useNavigate();
const selectedId = useSelectedEmailId();
const [folder, setFolder] = useGlobal<string>('EMAIL_FOLDER', 'inbox');
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
@@ -186,13 +188,16 @@ export const EmailList = () => {
if (prev >= 0) nextId = messages[prev]!.id;
}
if (nextId) {
setSelectedId(nextId);
// `replace` on purpose: sweeping down a folder with the arrow keys would otherwise stack one
// history entry per row, and Back would then walk the sweep instead of leaving the mailbox.
// A click is a real link and does push.
navigate(emailPath(nextId), { replace: true });
document.querySelector(`[data-email-id="${nextId}"]`)?.scrollIntoView({ block: 'nearest' });
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [messages, selectedId]);
}, [messages, selectedId, navigate]);
if (isLoading) {
return <div className="flex h-full items-center justify-center text-sm opacity-50">Loading emails...</div>;
@@ -333,10 +338,10 @@ export const EmailList = () => {
{messages.map((msg: EmailSummary) => {
const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read;
return (
<button
<Link
key={msg.id}
to={emailPath(msg.id)}
data-email-id={msg.id}
onClick={() => setSelectedId(msg.id)}
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
}`}
@@ -367,7 +372,7 @@ export const EmailList = () => {
)}
<span className="truncate opacity-50">{msg.snippet}</span>
</div>
</button>
</Link>
);
})}
</div>
@@ -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<string | null>('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<OpenAttachment | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
@@ -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 = () => <ChatPanelWrapper panelId="email-chat" promptPrefix
export const EmailScreen = () => {
const isMobile = useIsMobile();
const { emailId } = useParams<{ emailId?: string }>();
const navigate = useNavigate();
const [selectedId, setSelectedId] = useGlobal<string | null>('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 `<Navigate>` 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<LayoutNode>('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 (
<div className="h-full w-full pt-2">
@@ -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 `<button>` — 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;