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,