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 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 00:23:21 +00:00
co-authored by Claude Opus 4.6
parent c443fe0fe2
commit 968d502eaa
8 changed files with 73 additions and 73 deletions
@@ -1,12 +1,9 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; 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 { useIsMobile } from 'hooks/useIsMobile';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import type { LayoutNode, PanelComponents } from 'officerdev'; import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev'; import { WorkspaceView } from 'officerdev';
import { useUserState } from 'state/useUserState'; import { useWorkspacesState } from 'state/useWorkspacesState';
import { AutomationRightPanel } from './AutomationRightPanel'; import { AutomationRightPanel } from './AutomationRightPanel';
import type { AutomationSelection } from './AutomationRightPanel'; import type { AutomationSelection } from './AutomationRightPanel';
@@ -15,7 +12,7 @@ import { AutomationSidebar } from './AutomationSidebar';
const CHAT_PANEL_ID = 'automation-chat'; const CHAT_PANEL_ID = 'automation-chat';
const baseLayout: LayoutNode = { const defaultLayout: LayoutNode = {
type: 'group', type: 'group',
id: 'automation-root', id: 'automation-root',
direction: 'horizontal', direction: 'horizontal',
@@ -25,66 +22,66 @@ const baseLayout: LayoutNode = {
], ],
}; };
const splitLayout: LayoutNode = { const hasChatPanel = (layout: LayoutNode): boolean => {
type: 'group', if (layout.type === 'panel') return layout.id === CHAT_PANEL_ID;
id: 'automation-root', return layout.children.some((c) => hasChatPanel(c.node));
direction: 'horizontal', };
children: [
{ node: { type: 'panel', id: 'automation-left', appType: null }, size: 20 }, const addChatPanel = (layout: LayoutNode): LayoutNode => {
{ if (layout.type !== 'group') return layout;
node: { const clone = structuredClone(layout);
type: 'group', const rightChild = clone.children[1];
id: 'automation-right-group', if (!rightChild) return clone;
direction: 'vertical',
children: [ // Wrap the right panel in a vertical group with the chat panel
{ node: { type: 'panel', id: 'automation-right', appType: null }, size: 50 }, const rightSize = rightChild.size;
{ node: { type: 'panel', id: CHAT_PANEL_ID, appType: null }, size: 50 }, rightChild.size = rightSize;
], clone.children[1] = {
}, node: {
size: 80, 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 = () => { export const Automation = () => {
const client = useClient(); const workspace = useWorkspacesState<LayoutNode>('screens/automation', defaultLayout);
const { isAuthenticated } = useAuth();
const { isFetched } = useQuery({
queryKey: ['USER_STATE'],
enabled: isAuthenticated,
queryFn: () => client.get('/user/state'),
staleTime: Infinity,
});
const [savedSizes, setSavedSizes] = useUserState<number[] | null>('automation:chat-sizes', null);
const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null); const [selection, setSelection] = usePanelChannel<AutomationSelection>('automation:selected-capability', null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const editing = selection?.editing ?? false; const editing = selection?.editing ?? false;
const prevEditing = useRef(editing); 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(() => { useEffect(() => {
if (editing === prevEditing.current) return;
prevEditing.current = editing; 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]); }, [editing]);
const panelComponents: PanelComponents = useMemo( const panelComponents: PanelComponents = useMemo(
@@ -99,17 +96,16 @@ export const Automation = () => {
const mobilePanelId = isMobile && selection ? 'automation-right' : undefined; const mobilePanelId = isMobile && selection ? 'automation-right' : undefined;
const onMobileBack = useCallback(() => setSelection(null), [setSelection]); const onMobileBack = useCallback(() => setSelection(null), [setSelection]);
if (!isFetched) return null; if (!workspace.isLoaded) return null;
return ( return (
<div className="h-full w-full pt-2"> <div className="h-full w-full pt-2">
<WorkspaceLayout <WorkspaceView
layout={layout} workspace={workspace}
onLayoutChange={handleLayoutChange} locked
components={panelComponents} components={panelComponents}
isMobile={isMobile}
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
onMobileBack={mobilePanelId ? onMobileBack : null} onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
/> />
</div> </div>
); );
@@ -1,6 +1,7 @@
import { useMemo, useCallback } from 'react'; import { useMemo, useCallback } from 'react';
import type { LayoutNode, PanelComponents } from 'officerdev'; 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 { useIsMobile } from 'hooks/useIsMobile';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
import { defaultLayout } from './defaultLayout'; 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 = () => { export const EmailScreen = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null); const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const workspace = useWorkspacesState<LayoutNode>('screens/email', defaultLayout);
const components: PanelComponents = useMemo( const components: PanelComponents = useMemo(
() => ({ () => ({
@@ -26,15 +28,13 @@ export const EmailScreen = () => {
return ( return (
<div className="h-full w-full pt-2"> <div className="h-full w-full pt-2">
<WorkspaceLayout <WorkspaceView
layout={defaultLayout} workspace={workspace}
onLayoutChange={() => {}} locked
components={components} components={components}
workspaceId="email"
promptPrefix={PROMPT_PREFIX} promptPrefix={PROMPT_PREFIX}
isMobile={isMobile}
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
onMobileBack={mobilePanelId ? onMobileBack : null} onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
/> />
</div> </div>
); );
+1
View File
@@ -47,6 +47,7 @@ export type ClientMessage =
| { | {
type: "chat"; type: "chat";
prompt: string; prompt: string;
displayText?: string;
sessionId?: string; sessionId?: string;
model?: string; model?: string;
cwd?: string; cwd?: string;
+2 -2
View File
@@ -242,7 +242,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
async function handleChat( async function handleChat(
ws: ServerWebSocket<WSData>, ws: ServerWebSocket<WSData>,
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<void> { ): Promise<void> {
const { email, username, userId } = ws.data; const { email, username, userId } = ws.data;
const sessionId = msg.sessionId || randomUUID(); const sessionId = msg.sessionId || randomUUID();
@@ -336,7 +336,7 @@ async function handleChat(
session.meta.updatedAt = Date.now(); session.meta.updatedAt = Date.now();
if (!session.meta.title) { 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 // Set thinking level if provided
@@ -22,7 +22,7 @@ function formatModel(model: string): string {
export const ChatHeader = () => { export const ChatHeader = () => {
const { workspaceId } = useWorkspace(); const { workspaceId } = useWorkspace();
const contextFilter = workspaceId === 'email' const contextFilter = workspaceId === 'email' || workspaceId === 'screens/email'
? { context: 'email' as const } ? { context: 'email' as const }
: workspaceId?.startsWith('proj-layout-') : workspaceId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') } ? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') }
@@ -48,7 +48,7 @@ export const ChatPanelWrapper = () => {
const hostRoot = root === '~' || root === 'officer.dev'; const hostRoot = root === '~' || root === 'officer.dev';
const sandboxed = !hostRoot; const sandboxed = !hostRoot;
const chatContext = workspaceId === 'email' const chatContext = workspaceId === 'email' || workspaceId === 'screens/email'
? { context: 'email' as const } ? { context: 'email' as const }
: workspaceId?.startsWith('proj-layout-') : workspaceId?.startsWith('proj-layout-')
? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') } ? { context: 'project' as const, contextId: workspaceId.replace('proj-layout-', '') }
@@ -17,6 +17,7 @@ type WorkspaceViewProps = {
root?: string; root?: string;
initialFilePath?: string; initialFilePath?: string;
defaultFileSort?: DefaultFileSort; defaultFileSort?: DefaultFileSort;
promptPrefix?: string;
components?: PanelComponents; components?: PanelComponents;
ephemeral?: EphemeralPanels | null; ephemeral?: EphemeralPanels | null;
mobilePanelId?: string; mobilePanelId?: string;
@@ -25,7 +26,7 @@ type WorkspaceViewProps = {
const noop = () => {}; 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 { registry } = useAppRegistry();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -147,6 +148,7 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFileP
root, root,
initialFilePath, initialFilePath,
defaultFileSort, defaultFileSort,
promptPrefix,
swapSourceId, swapSourceId,
setSwapSourceId, setSwapSourceId,
onSwap: handleSwap, onSwap: handleSwap,
@@ -293,6 +293,7 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul
send({ send({
type: 'chat', type: 'chat',
prompt: text, prompt: text,
...(displayText ? { displayText } : {}),
sessionId: sessionIdRef.current, sessionId: sessionIdRef.current,
...(selectedModel ? { model: selectedModel } : {}), ...(selectedModel ? { model: selectedModel } : {}),
...(cwdParam?.path ? { cwd: cwdParam.path } : {}), ...(cwdParam?.path ? { cwd: cwdParam.path } : {}),