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:
@@ -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;
|
||||
Reference in New Issue
Block a user