diff --git a/chat/CLAUDE.md b/chat/CLAUDE.md new file mode 100644 index 0000000..26f3166 --- /dev/null +++ b/chat/CLAUDE.md @@ -0,0 +1,42 @@ +# Email + +This is a chat beside someone's mailbox. **You are running inside the account's own directory**, so the +mail is not somewhere you have to go and find — `emails.db` is right here, and so are its attachments. + +## Answer from the local database, not from Gmail + +Every question about mail that has already arrived — search, counts, who sends the most, what is unread, +how much space something takes — is answered from `emails.db` in this directory. It is SQLite with an FTS5 +index over subject, sender, recipients, snippet and body. + +The `email_db` tool queries it. Use that rather than shelling out, and read the `email-db` skill for the +schema and the queries worth knowing before writing your own. + +**Do not reach for the Gmail integration to answer a question about existing mail.** It is slower, it is +rate-limited, and it will disagree with what the screen beside you is showing. Gmail is for things the +local copy cannot do — sending, or fetching something that has not synced yet. + +## What the person can see while you talk + +The list to the left and the message above you are reading the same database you are. So: + +- **Do not paste back what is already on screen.** They can see the subject and the sender. Tell them the + thing they asked for. +- **Counts and aggregates are the point.** "How many did GitHub send me last month", "which domains send + me the most", "what is unread from a human" — those are what a database beside a mailbox is for, and + they are the questions the UI cannot answer. +- **A number produced by searching is confidently wrong.** If you counted by fetching pages until you got + bored, say so. `SELECT count(*)` is a different kind of answer from "I found about thirty". + +## Deleting and modifying + +Deleting mail from the local database does not delete it at Gmail — it removes the local copy, which the +next sync may bring back. Say which one you did. **Confirm before any bulk delete**, and say how many rows +it will touch, because "delete everything from that sender" over 19,000 messages is not reversible from +here. + +## The mailbox you are in + +The directory you are running in IS the account. With more than one account configured, the chat panel +follows the mailbox picker, so a question is about the mailbox on screen unless they say otherwise. If you +need to be sure which one you are in, look at the directory name — it is the address. diff --git a/web/EmailChatPanel.tsx b/web/EmailChatPanel.tsx index 2ab6caf..e24e719 100644 --- a/web/EmailChatPanel.tsx +++ b/web/EmailChatPanel.tsx @@ -1,11 +1,75 @@ -import { ChatPanelWrapper } from 'officerdev'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; +import { ChatPanelWrapper, ChatHeader } from 'officerdev'; -// The prompt belongs to this panel rather than to the workspace, so the panel that gets it is the one that -// mounts it. -// -// `email_db` is an MCP tool resolved at runtime from PI_TOOLS_DIRS. It is NOT in this repository and does -// not travel with this plugin — the platform points it at the account's SQLite store. If that stops -// happening, this prompt is describing a tool the agent does not have, which is worse than no prompt. -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.`; +/** + * A chat, sitting under the message you are reading. + * + * ── Why the panel is ours ── + * + * `PluginScreen` renders the workspace with `appTypes={{ allowed, fallback: allowed[0] }}` where `allowed` + * is this plugin's own panel keys, so a layout naming `officerdev/chat` silently renders the fallback + * instead. That is deliberate — a persisted layout must not be able to mount another plugin's panel here. + * So the panel is ours and the chat inside it is the platform's; `ChatPanelWrapper` is exported from + * `officerdev` for exactly this, and carries the composer, transcript, queue, model picker, session + * persistence and the agent identity bar with no second implementation to fall behind the first. + * + * ── What these chats are told, and where it lives ── + * + * `chat/CLAUDE.md`, in this plugin. The platform writes it into the directory these chats run in, and + * Claude Code reads it from its own cwd at session start. + * + * There WAS a `promptPrefix` here and it is gone. A prefix rides every message: paid per turn forever, + * invisible as a design artefact because it lives in a TypeScript constant, and visible in the one place + * it should not be — three words sent, and a paragraph of preamble going out attached to them. The file + * costs nothing per turn and can be opened by the person it is about. + */ -export const EmailChatPanel = () => ; +/** + * Which mailbox this chat is about. + * + * `context: 'email'` resolves server-side to the ACCOUNT's own directory — the one holding `emails.db` — + * rather than to a chats folder. That is the whole point: the agent starts beside the database it is meant + * to answer from, and its brief can say so. + * + * This panel declared no context at all until now, and it did not need to while email was part of the + * platform: `ChatPanelWrapper` recognised the screen by `workspace.id === 'email'` and supplied it. A + * plugin's workspace key is `screens/plugin/email`, so that id became `plugin/email` and the branch + * stopped matching the day email was extracted — every email chat since has run somewhere with no mailbox + * near it. Declaring it here is what that branch's own comment asked for, and it carries the mailbox too, + * which a hardcoded screen id never could. + * + * `all` is the list's sentinel for the unified view and is NOT an address. Sending it would name a mailbox + * that does not exist, so it is dropped here and the server picks the most recently synced account. + * + * Memoised because it is passed to hooks that key on it. An object rebuilt every render is the trap that + * silently broke playback reporting in the Jellyfin player for days; it costs one line to not repeat. + */ +const useEmailChatContext = () => { + const [searchParams] = useSearchParams(); + const account = searchParams.get('account'); + const mailbox = account && account !== 'all' ? account : undefined; + return useMemo(() => ({ context: 'email', ...(mailbox ? { contextId: mailbox } : {}) }), [mailbox]); +}; + +export const EmailChatPanel = ({ panelId }: { panelId: string }) => { + const chatContext = useEmailChatContext(); + + return ( +
+ +
+ ); +}; + +/** + * The panel's header: the conversation picker, scoped to this mailbox's directory. + * + * A registry `header` is mounted with `{ panelId }` only, so the context is bound here rather than passed — + * which is the whole reason this wrapper exists. + */ +export const EmailChatPanelHeader = ({ panelId }: { panelId: string }) => { + const chatContext = useEmailChatContext(); + + return ; +}; diff --git a/web/panels.ts b/web/panels.ts index dce5e76..5482bec 100644 --- a/web/panels.ts +++ b/web/panels.ts @@ -2,7 +2,7 @@ import type { AppRegistryMeta } from 'officerdev'; import { Inbox, Mail, MessageSquare } from 'lucide-react'; import { EmailList } from './EmailList'; import { EmailReader } from './EmailReader'; -import { EmailChatPanel } from './EmailChatPanel'; +import { EmailChatPanel, EmailChatPanelHeader } from './EmailChatPanel'; // `panels.ts`, not `.tsx` — discovery checks for that exact name and refuses the plugin by name if it is // missing, so the JSX lives in EmailChatPanel.tsx and this file stays a declaration. @@ -13,5 +13,13 @@ import { EmailChatPanel } from './EmailChatPanel'; export const appRegistryMetas: AppRegistryMeta[] = [ { key: 'email-list', name: 'Mail', icon: Inbox, component: EmailList, availableOnPanel: false }, { key: 'email-reader', name: 'Message', icon: Mail, component: EmailReader, availableOnPanel: false }, - { key: 'email-chat', name: 'Email chat', icon: MessageSquare, component: EmailChatPanel, availableOnPanel: false }, + { + key: 'email-chat', + name: 'Email chat', + icon: MessageSquare, + component: EmailChatPanel, + // The conversation picker, scoped to this mailbox's own directory. + header: EmailChatPanelHeader, + availableOnPanel: false, + }, ];