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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user