From 968d502eaa7736143886913b8fe7c901e115ceaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 27 Feb 2026 00:23:21 +0000 Subject: [PATCH] persist panel sizes for email and automation screens, fix session title from prompt prefix - switch email and automation screens from WorkspaceLayout to WorkspaceView + useWorkspacesState - add promptPrefix prop to WorkspaceView, thread into WorkspaceContext - automation dynamically adds/removes chat panel while preserving persisted sizes - use displayText for session title so prompt prefix doesn't leak into titles - fix email context filter to match screens/email workspace key Co-Authored-By: Claude Opus 4.6 --- .../Screens/Dashboard/Automation/index.tsx | 118 +++++++++--------- .../Screens/Dashboard/Email/EmailScreen.tsx | 14 +-- src/servers/api/pi/types.ts | 1 + src/servers/api/pi/websocket.ts | 4 +- .../officerdev/src/apps/Chat/ChatHeader.tsx | 2 +- .../src/apps/Chat/ChatPanelWrapper.tsx | 2 +- .../components/Workspace/WorkspaceView.tsx | 4 +- .../officerdev/src/hooks/usePiChat.ts | 1 + 8 files changed, 73 insertions(+), 73 deletions(-) diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx b/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx index d4bdd224..e27f239e 100644 --- a/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Automation/index.tsx @@ -1,12 +1,9 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { useClient } from 'hooks/useClient'; -import { useAuth } from 'hooks/useAuth'; import { useIsMobile } from 'hooks/useIsMobile'; import { usePanelChannel } from 'hooks/usePanelChannel'; import type { LayoutNode, PanelComponents } from 'officerdev'; -import { WorkspaceLayout } from 'officerdev'; -import { useUserState } from 'state/useUserState'; +import { WorkspaceView } from 'officerdev'; +import { useWorkspacesState } from 'state/useWorkspacesState'; import { AutomationRightPanel } from './AutomationRightPanel'; import type { AutomationSelection } from './AutomationRightPanel'; @@ -15,7 +12,7 @@ import { AutomationSidebar } from './AutomationSidebar'; const CHAT_PANEL_ID = 'automation-chat'; -const baseLayout: LayoutNode = { +const defaultLayout: LayoutNode = { type: 'group', id: 'automation-root', direction: 'horizontal', @@ -25,66 +22,66 @@ const baseLayout: LayoutNode = { ], }; -const splitLayout: LayoutNode = { - type: 'group', - id: 'automation-root', - direction: 'horizontal', - children: [ - { node: { type: 'panel', id: 'automation-left', appType: null }, size: 20 }, - { - node: { - type: 'group', - id: 'automation-right-group', - direction: 'vertical', - children: [ - { node: { type: 'panel', id: 'automation-right', appType: null }, size: 50 }, - { node: { type: 'panel', id: CHAT_PANEL_ID, appType: null }, size: 50 }, - ], - }, - size: 80, +const hasChatPanel = (layout: LayoutNode): boolean => { + if (layout.type === 'panel') return layout.id === CHAT_PANEL_ID; + return layout.children.some((c) => hasChatPanel(c.node)); +}; + +const addChatPanel = (layout: LayoutNode): LayoutNode => { + if (layout.type !== 'group') return layout; + const clone = structuredClone(layout); + const rightChild = clone.children[1]; + if (!rightChild) return clone; + + // Wrap the right panel in a vertical group with the chat panel + const rightSize = rightChild.size; + rightChild.size = rightSize; + clone.children[1] = { + node: { + type: 'group', + id: 'automation-right-group', + direction: 'vertical', + children: [ + { node: rightChild.node, size: 50 }, + { node: { type: 'panel', id: CHAT_PANEL_ID, appType: null }, size: 50 }, + ], }, - ], + size: rightSize, + }; + return clone; +}; + +const removeChatPanel = (layout: LayoutNode): LayoutNode => { + if (layout.type !== 'group') return layout; + const clone = structuredClone(layout); + const rightChild = clone.children[1]; + if (!rightChild || rightChild.node.type !== 'group') return clone; + + // Unwrap: pull the right panel out of the vertical group + const rightGroup = rightChild.node; + const mainPanel = rightGroup.children.find((c) => c.node.type === 'panel' && (c.node as { id: string }).id !== CHAT_PANEL_ID); + if (mainPanel) { + clone.children[1] = { node: mainPanel.node, size: rightChild.size }; + } + return clone; }; export const Automation = () => { - const client = useClient(); - const { isAuthenticated } = useAuth(); - const { isFetched } = useQuery({ - queryKey: ['USER_STATE'], - enabled: isAuthenticated, - queryFn: () => client.get('/user/state'), - staleTime: Infinity, - }); - const [savedSizes, setSavedSizes] = useUserState('automation:chat-sizes', null); + const workspace = useWorkspacesState('screens/automation', defaultLayout); const [selection, setSelection] = usePanelChannel('automation:selected-capability', null); const isMobile = useIsMobile(); const editing = selection?.editing ?? false; const prevEditing = useRef(editing); - const layout = useMemo(() => { - if (!editing) return baseLayout; - const l = structuredClone(splitLayout); - if (savedSizes && l.type === 'group') { - const rightGroup = l.children[1]!.node; - if (rightGroup.type === 'group') { - rightGroup.children[0]!.size = savedSizes[0] ?? 50; - rightGroup.children[1]!.size = savedSizes[1] ?? 50; - } - } - return l; - }, [editing, savedSizes]); - - const handleLayoutChange = (newLayout: LayoutNode) => { - if (!editing || newLayout.type !== 'group') return; - const rightChild = newLayout.children[1]?.node; - if (rightChild?.type === 'group') { - const sizes = rightChild.children.map((c) => c.size); - setSavedSizes(sizes); - } - }; - useEffect(() => { + if (editing === prevEditing.current) return; prevEditing.current = editing; + + if (editing && !hasChatPanel(workspace.value)) { + workspace.setValue(addChatPanel(workspace.value)); + } else if (!editing && hasChatPanel(workspace.value)) { + workspace.setValue(removeChatPanel(workspace.value)); + } }, [editing]); const panelComponents: PanelComponents = useMemo( @@ -99,17 +96,16 @@ export const Automation = () => { const mobilePanelId = isMobile && selection ? 'automation-right' : undefined; const onMobileBack = useCallback(() => setSelection(null), [setSelection]); - if (!isFetched) return null; + if (!workspace.isLoaded) return null; return (
- onMobileBack() : undefined} />
); diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx index d67eb369..0937966f 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailScreen.tsx @@ -1,6 +1,7 @@ import { useMemo, useCallback } from 'react'; import type { LayoutNode, PanelComponents } from 'officerdev'; -import { WorkspaceLayout } from 'officerdev'; +import { WorkspaceView } from 'officerdev'; +import { useWorkspacesState } from 'state/useWorkspacesState'; import { useIsMobile } from 'hooks/useIsMobile'; import { useGlobal } from 'hooks/useGlobal'; import { defaultLayout } from './defaultLayout'; @@ -12,6 +13,7 @@ const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite e export const EmailScreen = () => { const isMobile = useIsMobile(); const [selectedId, setSelectedId] = useGlobal('EMAIL_SELECTED', null); + const workspace = useWorkspacesState('screens/email', defaultLayout); const components: PanelComponents = useMemo( () => ({ @@ -26,15 +28,13 @@ export const EmailScreen = () => { return (
- {}} + onMobileBack() : undefined} />
); diff --git a/src/servers/api/pi/types.ts b/src/servers/api/pi/types.ts index 939a92ad..c4763f94 100644 --- a/src/servers/api/pi/types.ts +++ b/src/servers/api/pi/types.ts @@ -47,6 +47,7 @@ export type ClientMessage = | { type: "chat"; prompt: string; + displayText?: string; sessionId?: string; model?: string; cwd?: string; diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index 011707c6..81daa3f6 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -242,7 +242,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora async function handleChat( ws: ServerWebSocket, - msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: string; context?: string; contextId?: string } + msg: { prompt: string; displayText?: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: string; context?: string; contextId?: string } ): Promise { const { email, username, userId } = ws.data; const sessionId = msg.sessionId || randomUUID(); @@ -336,7 +336,7 @@ async function handleChat( session.meta.updatedAt = Date.now(); if (!session.meta.title) { - session.meta.title = msg.prompt.slice(0, 100); + session.meta.title = (msg.displayText ?? msg.prompt).slice(0, 100); } // Set thinking level if provided diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx index 88b4f017..21eb40ef 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx @@ -22,7 +22,7 @@ function formatModel(model: string): string { export const ChatHeader = () => { const { workspaceId } = useWorkspace(); - const contextFilter = workspaceId === 'email' + const contextFilter = workspaceId === 'email' || workspaceId === 'screens/email' ? { context: 'email' as const } : workspaceId?.startsWith('proj-layout-') ? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') } diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 87f6ef77..d168d131 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -48,7 +48,7 @@ export const ChatPanelWrapper = () => { const hostRoot = root === '~' || root === 'officer.dev'; const sandboxed = !hostRoot; - const chatContext = workspaceId === 'email' + const chatContext = workspaceId === 'email' || workspaceId === 'screens/email' ? { context: 'email' as const } : workspaceId?.startsWith('proj-layout-') ? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') } diff --git a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx index ebcac4bb..66859abd 100644 --- a/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx +++ b/src/workspaces/officerdev/src/components/Workspace/WorkspaceView.tsx @@ -17,6 +17,7 @@ type WorkspaceViewProps = { root?: string; initialFilePath?: string; defaultFileSort?: DefaultFileSort; + promptPrefix?: string; components?: PanelComponents; ephemeral?: EphemeralPanels | null; mobilePanelId?: string; @@ -25,7 +26,7 @@ type WorkspaceViewProps = { const noop = () => {}; -export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => { +export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFilePath, defaultFileSort, promptPrefix, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => { const { registry } = useAppRegistry(); const isMobile = useIsMobile(); @@ -147,6 +148,7 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP root, initialFilePath, defaultFileSort, + promptPrefix, swapSourceId, setSwapSourceId, onSwap: handleSwap, diff --git a/src/workspaces/officerdev/src/hooks/usePiChat.ts b/src/workspaces/officerdev/src/hooks/usePiChat.ts index 18a8630a..312a7299 100644 --- a/src/workspaces/officerdev/src/hooks/usePiChat.ts +++ b/src/workspaces/officerdev/src/hooks/usePiChat.ts @@ -293,6 +293,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul send({ type: 'chat', prompt: text, + ...(displayText ? { displayText } : {}), sessionId: sessionIdRef.current, ...(selectedModel ? { model: selectedModel } : {}), ...(cwdParam?.path ? { cwd: cwdParam.path } : {}),