From 2ed47fa65eb36c2d404a860e193f29ca5b3112e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 18:30:54 +0000 Subject: [PATCH] a screen can declare its own chat context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatPanelWrapper's own comment asked for this: "the general fix is a way for a plugin to declare a chat context, which does not exist. Until it does, adding a SECOND id here is the signal that it needs to." this is the general fix rather than the second id. a caller passes chatContext; the workspace-derived branch stays for the screens the platform mounts, including the hardcoded 'email' one, which is not a caller that could pass anything. the listing needed the same. ChatHeader derives an absolute cwd by joining the workspace's home-relative path onto the account home — right for a dashboard, impossible for a plugin, whose directory the server builds and never sends. so useClaudeSessions takes a context instead and lets the server resolve the same path it resolved for the turn. it goes in the query key too, or two plugins share one cached list. ChatHeader is exported now, because a registry header is mounted with only { panelId } — binding a context means wrapping it. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .../officerdev/src/apps/Chat/ChatHeader.tsx | 21 +++++++++++++-- .../src/apps/Chat/ChatPanelWrapper.tsx | 22 ++++++++++++--- .../officerdev/src/apps/Chat/index.ts | 3 +++ src/workspaces/officerdev/src/index.ts | 1 + src/workspaces/state/src/useClaudeSessions.ts | 27 ++++++++++++++++--- 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx index e8d9ac87..9fbad8e7 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx @@ -33,7 +33,22 @@ export type ChatSessionSelection = { const title = (s: { title: string }) => s.title || '(untitled)'; -export const ChatHeader = ({ panelId }: { panelId?: string }) => { +/** + * `chatContext` is for a panel whose directory the CLIENT cannot name. + * + * A dashboard is rooted in a home-relative path the browser already knows, so the cwd derivation below + * works. A plugin's chats live in a sibling of the home that the server builds and never sends — so + * without this the header would list nothing at all, which is the failure the cwd comment right below + * describes for a different reason. Given a context, the whole derivation is skipped and the server + * resolves the same directory it resolved for the turn. + */ +export const ChatHeader = ({ + panelId, + chatContext, +}: { + panelId?: string; + chatContext?: { context: string; contextId?: string } | null; +}) => { const { t, tUi } = useTranslation(); const { workspace, cwd } = useWorkspace(); @@ -52,7 +67,9 @@ export const ChatHeader = ({ panelId }: { panelId?: string }) => { const relative = cwdToPath(cwd); const absoluteCwd = relative === '/' ? null : defaultCwd ? `${defaultCwd.replace(/\/$/, '')}${relative}` : null; - const { sessions, renameSession, deleteSession } = useClaudeSessions(absoluteCwd); + const { sessions, renameSession, deleteSession } = useClaudeSessions(absoluteCwd, { + chatContext: chatContext ?? null, + }); const [sessionId, setSessionId] = usePanelSession(panelId, workspace); const [renaming, setRenaming] = useState(null); diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index fd88afac..94f51c60 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -151,9 +151,22 @@ const ChatPanelInner = ({ // `promptPrefix` is a system prompt only this app understands, so the screen that wants one passes it // here rather than through the workspace context — a screen supplies its own chat panel through // `components`, which is what that prop exists for. The registry mounts this with no prefix. -type ChatPanelWrapperProps = { panelId: string; promptPrefix?: string }; +type ChatPanelWrapperProps = { + panelId: string; + promptPrefix?: string; + /** + * Declared by a screen that owns its own chat directory — currently a plugin's panel. + * + * This is the "general fix" the `chatContext` comment below asks for. It said a plugin declaring a chat + * context did not exist, and that a SECOND hardcoded screen id here would be the signal it was needed. + * Rather than add that second id, a caller can now say what it is, and the server resolves the directory + * from the same pair it uses for the turn. The `'email'` branch stays where it is: it is a screen the + * platform mounts, not a caller that could pass this. + */ + chatContext?: { context: string; contextId?: string }; +}; -export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProps) => { +export const ChatPanelWrapper = ({ panelId, promptPrefix, chatContext: declared }: ChatPanelWrapperProps) => { const { workspace, cwd, root } = useWorkspace(); const scoped = cwd !== '~'; @@ -171,8 +184,9 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp // // The general fix is a way for a plugin to declare a chat context, which does not exist. Until it does, // adding a SECOND id here is the signal that it needs to. - const chatContext = - workspace?.kind === 'screen' && workspace.id === 'email' + const chatContext = declared + ? declared + : workspace?.kind === 'screen' && workspace.id === 'email' ? { context: 'email' as const } : workspace?.kind === 'dashboard' ? { context: 'dashboard' as const, contextId: workspace.key } diff --git a/src/workspaces/officerdev/src/apps/Chat/index.ts b/src/workspaces/officerdev/src/apps/Chat/index.ts index 8be87046..11befc68 100644 --- a/src/workspaces/officerdev/src/apps/Chat/index.ts +++ b/src/workspaces/officerdev/src/apps/Chat/index.ts @@ -17,6 +17,9 @@ export { AttachmentList } from './components/AttachmentList'; export { AttachButton } from './components/AttachButton'; export { WebpageDialog } from './components/WebpageDialog'; export { EmbeddableChat, type UseEmbeddableChatType } from './EmbeddableChat'; +// Exported for a plugin that mounts its own chat panel: the registry hands a header only `{ panelId }`, +// so binding a chat context means wrapping this rather than passing a prop through the registry. +export { ChatHeader } from './ChatHeader'; export { useChat, type UseChatType } from '../../hooks/useChat'; export { useSlashCommands } from './useSlashCommands'; export { useAttachments, type UseAttachmentsType } from './useAttachments'; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index 6cf530ef..45acaef9 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -19,6 +19,7 @@ export { InputArea, ChatLauncher, ChatPanelWrapper, + ChatHeader, AttachmentList, AttachButton, WebpageDialog, diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index 9ed74eb4..ab72b11a 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -119,6 +119,18 @@ export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; // Query string for the selected working directory (null/undefined = the caller's own home). const cwdQuery = (cwd?: string | null) => (cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''); +/** + * A chat context, for a caller that cannot name its own directory. + * + * A plugin's chats run in `$DATA_PATH//plugins//chats`, which the server builds and never + * sends to the browser — so the `?cwd=` form has nothing to put in it. Naming the context instead lets the + * server resolve the same path it resolves for the turn. See `cwdOf` in `api/chat/chat.ts`. + */ +export type ChatContext = { context: string; contextId?: string }; + +const contextQuery = (ctx: ChatContext) => + `?context=${encodeURIComponent(ctx.context)}${ctx.contextId ? `&contextId=${encodeURIComponent(ctx.contextId)}` : ''}`; + /** The default /chat dir plus every directory that already has Claude sessions. */ export function useChatPwds() { const client = useClient(); @@ -143,16 +155,25 @@ export function useChatPwds() { * ZERO requests while a node is collapsed — twenty directories on screen is otherwise twenty transcript * scans for rows nobody has asked to see. */ -export function useClaudeSessions(cwd?: string | null, options?: { all?: boolean; enabled?: boolean }) { +export function useClaudeSessions( + cwd?: string | null, + options?: { all?: boolean; enabled?: boolean; chatContext?: ChatContext | null }, +) { const client = useClient(); const queryClient = useQueryClient(); const { isAuthenticated } = useAuth(); const all = options?.all === true; + const chatContext = options?.chatContext ?? null; // `all` ignores cwd entirely — sending both would imply the server intersects them, and it does not. - const q = all ? '?all=1' : cwdQuery(cwd); + // A context likewise replaces cwd rather than joining it: the server resolves one directory from it. + const q = all ? '?all=1' : chatContext ? contextQuery(chatContext) : cwdQuery(cwd); const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({ - queryKey: [SESSIONS_KEY, all ? '__all__' : (cwd ?? 'default')], + // The context belongs in the key, or two panels on different plugins share one cached list. + queryKey: [ + SESSIONS_KEY, + all ? '__all__' : chatContext ? `ctx:${chatContext.context}:${chatContext.contextId ?? ''}` : (cwd ?? 'default'), + ], enabled: isAuthenticated && options?.enabled !== false, queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`), staleTime: 30 * 1000,