diff --git a/docs/design/chat-creation/new-chat-history.png b/docs/design/chat-creation/new-chat-history.png new file mode 100644 index 0000000..37105d7 Binary files /dev/null and b/docs/design/chat-creation/new-chat-history.png differ diff --git a/program/plugins/hotkeys-composer.client.ts b/program/plugins/hotkeys-composer.client.ts index f87afd4..d56bfe2 100644 --- a/program/plugins/hotkeys-composer.client.ts +++ b/program/plugins/hotkeys-composer.client.ts @@ -83,13 +83,13 @@ const hotkeysComposer: Plugin = (ctx) => { ctx.clientHotkeys.registerAction(ctx, { id: 'chat.new', label: 'New chat', - detail: 'Start a blank task in the current workspace.', + detail: 'Open a new chat tab.', category: 'Navigation', binding: { kind: 'leader', key: 'n' }, - enabled: () => ctx.clientConversation.snapshot().turn.tag === 'idle', + enabled: () => ctx.clientWorkspaceLayout.available(), run: () => { ctx.clientUi.overlays.closeAll() - ctx.clientConversation.newThread() + ctx.clientWorkspaceLayout.newTab() }, }) @@ -106,6 +106,6 @@ const hotkeysComposer: Plugin = (ctx) => { }) } -hotkeysComposer.inject = ['clientHotkeys', 'clientComposer', 'clientConversation', 'clientUi'] +hotkeysComposer.inject = ['clientHotkeys', 'clientComposer', 'clientConversation', 'clientUi', 'clientWorkspaceLayout'] export default hotkeysComposer diff --git a/program/plugins/sidebar.client.tsx b/program/plugins/sidebar.client.tsx index 585357c..e0fcaf1 100644 --- a/program/plugins/sidebar.client.tsx +++ b/program/plugins/sidebar.client.tsx @@ -35,6 +35,8 @@ import { threadWorkStatus, } from './thread-status-api.js' import { HistoryPanel } from './ui/history.js' +import type { HistoryState } from './ui/history-model.js' +import { requestWorkspaceNewChat, type WorkspaceTabTarget } from './workspace-layout-api.js' import { NewWorkspaceDialog } from './ui/project-settings.js' import { useStoreSelector } from './ui/store-selector.js' import styles from './sidebar.css' @@ -282,6 +284,28 @@ interface SidebarHistoryDataSnapshot { projects: ClientSessionSnapshot['projects'] } +/** Keep chats visible while the global history list catches up with a new pane. */ +export function historyWithOpenWorkspaceThreads( + history: HistoryState, + tabs: readonly WorkspaceTabTarget[], +): HistoryState { + const entries = new Map(history.entries.map((thread) => [thread.id, thread])) + let changed = false + for (const tab of tabs) { + for (const thread of tab.threads ?? []) { + const listed = entries.get(thread.id) + if (!listed) { + entries.set(thread.id, thread) + changed = true + } else if (thread.projectId && !listed.projectId) { + entries.set(thread.id, { ...listed, projectId: thread.projectId }) + changed = true + } + } + } + return changed ? { ...history, entries: [...entries.values()] } : history +} + function sidebarHistoryDataSnapshot(state: ClientSessionSnapshot): SidebarHistoryDataSnapshot { return { history: state.history, projects: state.projects } } @@ -469,7 +493,6 @@ const sidebarClient: BrowserPlugin = (ctx, config) => { } function NewThreadSurface({ surface }: ClientSurfaceProps): ReactNode { - const turn = useStoreSelector(session, turnTag) const { labeled, buttonClass } = appearance(surface) const label = surface.label ?? 'New chat' return ( @@ -480,11 +503,9 @@ const sidebarClient: BrowserPlugin = (ctx, config) => { data-hotkey-action="chat.new" title={label} aria-label={label} - disabled={turn !== 'idle'} onClick={() => { overlays.closeAll() - session.newThread() - if (controller.snapshot().mode === 'floating') controller.setCollapsed(true) + if (requestWorkspaceNewChat() && controller.snapshot().mode === 'floating') controller.setCollapsed(true) }} > @@ -517,6 +538,7 @@ const sidebarClient: BrowserPlugin = (ctx, config) => { } function HistorySurface({ surface }: ClientSurfaceProps): ReactNode { + const [newThreadProblem, setNewThreadProblem] = useState('') const historyState = useStoreSelector( historySession, sidebarHistoryDataSnapshot, @@ -527,6 +549,7 @@ const sidebarClient: BrowserPlugin = (ctx, config) => { sidebarSelectionSnapshot, sidebarSelectionSnapshotEqual, ) + useSyncExternalStore(ctx.clientWorkspaceLayout.subscribe, ctx.clientWorkspaceLayout.snapshot) const statusState = useSyncExternalStore(threadStatus.subscribe, threadStatus.snapshot) useSyncExternalStore(ctx.clientUi.subscribe, ctx.clientUi.snapshot) const EntryDecoration = ctx.clientUi.component( @@ -535,11 +558,13 @@ const sidebarClient: BrowserPlugin = (ctx, config) => { return ( threadWorkStatus(statusState, threadId)} @@ -547,8 +572,12 @@ const sidebarClient: BrowserPlugin = (ctx, config) => { onNewWorkspace={() => overlays.open('new-workspace')} onClose={() => controller.setCollapsed(true)} onNewThread={(project) => { + if (!requestWorkspaceNewChat(project.id)) { + setNewThreadProblem('Workspace tabs are not ready. Try again in a moment.') + return + } + setNewThreadProblem('') overlays.closeAll() - session.newThread(project) if (controller.snapshot().mode === 'floating') controller.setCollapsed(true) }} onOpen={(thread) => { @@ -686,7 +715,7 @@ const sidebarClient: BrowserPlugin = (ctx, config) => { } } -sidebarClient.inject = ['clientUi', 'clientSession', 'clientSessionRouter'] +sidebarClient.inject = ['clientUi', 'clientSession', 'clientSessionRouter', 'clientWorkspaceLayout'] sidebarClient.provide = 'clientSidebar' sidebarClient.resources = { provides: { diff --git a/program/plugins/sidebar.css b/program/plugins/sidebar.css index ecb528b..7647f40 100644 --- a/program/plugins/sidebar.css +++ b/program/plugins/sidebar.css @@ -448,3 +448,9 @@ html[data-alto-theme='dark'] .history-entry.active { box-shadow: var(--shadow-popover); } } + +.history-new-thread-error { + margin: var(--space-2) var(--space-3); + color: var(--danger); + font-size: var(--type-caption); +} diff --git a/program/plugins/ui/history.tsx b/program/plugins/ui/history.tsx index c0f8140..4ffde4a 100644 --- a/program/plugins/ui/history.tsx +++ b/program/plugins/ui/history.tsx @@ -152,6 +152,8 @@ export function HistoryPanel({ activeProjectId, projects, disabled, + newThreadDisabled = disabled, + newThreadProblem, showAge = false, emptyText = 'No conversations yet', statusFor, @@ -169,6 +171,8 @@ export function HistoryPanel({ activeProjectId?: string projects: LocalProject[] disabled: boolean + newThreadDisabled?: boolean + newThreadProblem?: string showAge?: boolean emptyText?: string statusFor?: (threadId: string) => ThreadWorkStatus | undefined @@ -273,6 +277,7 @@ export function HistoryPanel({ )} )} + {newThreadProblem &&

{newThreadProblem}

}
{groups.map((group) => { const project = group.project @@ -309,7 +314,7 @@ export function HistoryPanel({ type="button" title={`New chat in ${group.label}`} aria-label={`New chat in ${group.label}`} - disabled={disabled} + disabled={newThreadDisabled} onClick={() => onNewThread(project)} > diff --git a/program/plugins/workspace-layout-api.ts b/program/plugins/workspace-layout-api.ts index 69e7ccf..06d8508 100644 --- a/program/plugins/workspace-layout-api.ts +++ b/program/plugins/workspace-layout-api.ts @@ -1,5 +1,5 @@ import type { ComponentType, Dispatch, KeyboardEvent, MouseEvent, ReactNode, RefObject, SetStateAction } from 'react' -import type { ThreadSummary } from '../../src/shared/protocol.js' +import type { LocalProject, ThreadSummary } from '../../src/shared/protocol.js' import type { ClientSessionService } from './session-api.js' import type { WorkspaceOpenPaneRequest, @@ -148,6 +148,18 @@ export interface WorkspaceTabAction { run(tab: WorkspaceView): void } +export const WORKSPACE_NEW_CHAT_EVENT = 'alto:workspace-new-chat' + +/** Request a tab from the mounted compositor and report whether it handled the click. */ +export function requestWorkspaceNewChat(projectId?: string): boolean { + const event = new CustomEvent<{ projectId?: string }>(WORKSPACE_NEW_CHAT_EVENT, { + cancelable: true, + detail: projectId ? { projectId } : {}, + }) + window.dispatchEvent(event) + return event.defaultPrevented +} + export interface ClientWorkspaceLayoutService { subscribe(listener: () => void): () => void snapshot(): number @@ -167,7 +179,7 @@ export interface ClientWorkspaceLayoutService { tabs(): readonly WorkspaceTabTarget[] moveActiveTab(direction: 1 | -1): void /** Open a full-size tab; omit kind for the normal new-chat behavior. */ - newTab(kind?: string, thread?: ThreadSummary): void + newTab(kind?: string, thread?: ThreadSummary, project?: LocalProject | null): void showThreadInPane(workspaceId: string, paneId: string, thread: ThreadSummary): void openPane( request: Omit, diff --git a/program/plugins/workspace-layout.client.tsx b/program/plugins/workspace-layout.client.tsx index 064c20e..6068b5c 100644 --- a/program/plugins/workspace-layout.client.tsx +++ b/program/plugins/workspace-layout.client.tsx @@ -79,6 +79,7 @@ import type { WorkspaceTabStrip, WorkspaceTabStripProps, } from './workspace-layout-api.js' +import { WORKSPACE_NEW_CHAT_EVENT } from './workspace-layout-api.js' import type { WorkspaceOpenPaneRequest, WorkspaceOpenPaneResult, @@ -228,7 +229,7 @@ interface WorkspaceLayoutController { selectTab(index: number): void tabs(): readonly WorkspaceTabTarget[] moveActiveTab(direction: 1 | -1): void - newTab(kind?: string, thread?: ThreadSummary): void + newTab(kind?: string, thread?: ThreadSummary, project?: LocalProject | null): void showThreadInPane(workspaceId: string, paneId: string, thread: ThreadSummary): void openPane( request: Omit, @@ -368,8 +369,9 @@ export class WorkspaceLayoutRegistry implements ClientWorkspaceLayoutService { this.controller?.moveActiveTab(direction) } - newTab(kind?: string, thread?: ThreadSummary): void { - if (thread) this.controller?.newTab(kind, thread) + newTab(kind?: string, thread?: ThreadSummary, project?: LocalProject | null): void { + if (project !== undefined) this.controller?.newTab(kind, thread, project) + else if (thread) this.controller?.newTab(kind, thread) else this.controller?.newTab(kind) } @@ -1592,7 +1594,7 @@ function WorkspaceLayoutControllerBridge({ selectTab: (index: number) => void tabs: () => readonly WorkspaceTabTarget[] moveActiveTab: (direction: 1 | -1) => void - newTab: (kind?: string, thread?: ThreadSummary) => void + newTab: (kind?: string, thread?: ThreadSummary, project?: LocalProject | null) => void showThreadInPane: (workspaceId: string, paneId: string, thread: ThreadSummary) => void openPane: ( request: Omit, @@ -2642,6 +2644,21 @@ function WorkspaceLayout({ })) } + useEffect(() => { + const newChat = (event: Event): void => { + const request = event as CustomEvent<{ projectId?: string }> + const projectId = request.detail?.projectId + const project = projectId + ? globalSession.snapshot().projects.find((candidate) => candidate.id === projectId) + : undefined + if (projectId && !project) return + addView(project) + request.preventDefault() + } + window.addEventListener(WORKSPACE_NEW_CHAT_EVENT, newChat) + return () => window.removeEventListener(WORKSPACE_NEW_CHAT_EVENT, newChat) + }, [addView, globalSession]) + const splitFocused = (direction: WorkspaceSplitDirection, kind?: string): void => { registry.showPage() const paneId = activeView.focusedPaneId @@ -2981,7 +2998,7 @@ function WorkspaceLayout({ paneKinds: workspaceViewPaneKinds(view), }))} moveActiveTab={moveActiveTab} - newTab={(kind, thread) => addView(undefined, kind, thread)} + newTab={(kind, thread, project) => addView(project, kind, thread)} showThreadInPane={showThreadInPane} openPane={openPane} splitFocused={splitFocused} diff --git a/tests/chat-history.test.ts b/tests/chat-history.test.ts index 277903e..ec020af 100644 --- a/tests/chat-history.test.ts +++ b/tests/chat-history.test.ts @@ -30,6 +30,23 @@ describe('chat history pages', () => { expect(newTab).toHaveBeenCalledTimes(2) }) + it('forwards an explicit project or projectless chat without changing the selected thread', () => { + const registry = new WorkspaceLayoutRegistry() + const newTab = vi.fn() + const unbind = registry.bindController({ tabs: () => [], paneTargets: () => [], newTab } as unknown as Parameters[0]) + const project = { id: 'atlas', name: 'Atlas', primaryRoot: '/repo/atlas', roots: ['/repo/atlas'] } + const thread = chat('existing', 10) + registry.newTab(undefined, undefined, project) + registry.newTab(undefined, undefined, null) + registry.newTab(undefined, thread) + expect(newTab.mock.calls).toEqual([ + [undefined, undefined, project], + [undefined, undefined, null], + [undefined, thread], + ]) + unbind() + }) + it('requests one cursor page of top-level chats and uses the shared summary parser and workspace classifier', async () => { const request = vi.fn().mockResolvedValue({ data: [ { id: 'a', name: 'Actual chat name', preview: 'First message', cwd: '/repo', createdAt: 10, updatedAt: 20 }, diff --git a/tests/fixtures/workspace-chat-creation.cjs b/tests/fixtures/workspace-chat-creation.cjs new file mode 100644 index 0000000..5fcfe97 --- /dev/null +++ b/tests/fixtures/workspace-chat-creation.cjs @@ -0,0 +1,129 @@ +const { app, BrowserWindow } = require('./background-electron.cjs') +const { build } = require('esbuild') +const assert = require('node:assert/strict') +const fs = require('node:fs/promises') +const http = require('node:http') +const path = require('node:path') + +async function check() { + await app.whenReady() + const bundle = await build({ + stdin: { resolveDir: process.cwd(), sourcefile: 'workspace-chat-creation.tsx', loader: 'tsx', contents: ` + import React,{useEffect,useRef} from 'react'; + import {createRoot} from 'react-dom/client'; + import {flushSync} from 'react-dom'; + import layoutPlugin from './program/plugins/workspace-layout.client'; + import sidebarPlugin from './program/plugins/sidebar.client'; + const project={id:'atlas',name:'Atlas',primaryRoot:'/repo/atlas',roots:['/repo/atlas']}; + const original={id:'working',title:'Agent is working',preview:'',cwd:project.primaryRoot,createdAt:1,updatedAt:1,projectId:project.id}; + let saved={version:2,activeViewId:'working-tab',views:[{id:'working-tab',name:original.title,workspace:original.cwd,projectId:project.id, + focusedPaneId:'working-pane',root:{type:'pane',id:'working-pane',workspace:original.cwd,projectId:project.id,thread:original}}]}; + localStorage.setItem('codex-cordis.workspace-layout',JSON.stringify(saved)); + const state={connected:true,session:{workspace:original.cwd,permissionMode:'ask'},projects:[project],threads:[original],threadId:original.id, + activeProjectId:project.id,projectScope:'workspace',skills:[],turn:{tag:'running'},activities:[],history:{tag:'loading',entries:[]}, + harness:{extensions:{'session.workspace':'/tmp/alto-scratch'},codex:{status:'ready',models:[]},ui:{surfaces:[],contributions:[],regions:[]}}}; + const globalSession={snapshot:()=>state,subscribe:()=>()=>{},newThread:()=>{throw Error('Replaced the working chat')},openThread:()=>{throw Error('Replaced the working chat')}}; + const host={snapshot:()=>({connectionEpoch:1}),subscribe:()=>()=>{},call:async(method,payload)=>{ + if(method.endsWith('.read'))return structuredClone(saved); + if(method.endsWith('.write'))saved=structuredClone(payload); + }}; + const surfaces=new Map(),sessions=[];let registry,layoutRoot; + const Composer=({autoFocus,session})=>{const input=useRef(null);useEffect(()=>{if(autoFocus)input.current.focus()},[autoFocus]); + return