Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions program/plugins/hotkeys-composer.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
})

Expand All @@ -106,6 +106,6 @@ const hotkeysComposer: Plugin = (ctx) => {
})
}

hotkeysComposer.inject = ['clientHotkeys', 'clientComposer', 'clientConversation', 'clientUi']
hotkeysComposer.inject = ['clientHotkeys', 'clientComposer', 'clientConversation', 'clientUi', 'clientWorkspaceLayout']

export default hotkeysComposer
43 changes: 36 additions & 7 deletions program/plugins/sidebar.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -469,7 +493,6 @@ const sidebarClient: BrowserPlugin<SidebarResizeConfig> = (ctx, config) => {
}

function NewThreadSurface({ surface }: ClientSurfaceProps): ReactNode {
const turn = useStoreSelector(session, turnTag)
const { labeled, buttonClass } = appearance(surface)
const label = surface.label ?? 'New chat'
return (
Expand All @@ -480,11 +503,9 @@ const sidebarClient: BrowserPlugin<SidebarResizeConfig> = (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)
}}
>
<SquarePen size={16} strokeWidth={1.5} />
Expand Down Expand Up @@ -517,6 +538,7 @@ const sidebarClient: BrowserPlugin<SidebarResizeConfig> = (ctx, config) => {
}

function HistorySurface({ surface }: ClientSurfaceProps): ReactNode {
const [newThreadProblem, setNewThreadProblem] = useState('')
const historyState = useStoreSelector(
historySession,
sidebarHistoryDataSnapshot,
Expand All @@ -527,6 +549,7 @@ const sidebarClient: BrowserPlugin<SidebarResizeConfig> = (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<HistoryEntryDecorationProps>(
Expand All @@ -535,20 +558,26 @@ const sidebarClient: BrowserPlugin<SidebarResizeConfig> = (ctx, config) => {
return (
<HistoryPanel
label={surface.label ?? ''}
state={historyState.history}
state={historyWithOpenWorkspaceThreads(historyState.history, ctx.clientWorkspaceLayout.tabs())}
{...(selection.threadId ? { activeThreadId: selection.threadId } : {})}
{...(selection.activeProjectId ? { activeProjectId: selection.activeProjectId } : {})}
projects={historyState.projects}
disabled={selection.turn !== 'idle'}
newThreadDisabled={false}
newThreadProblem={newThreadProblem}
showAge={surface.showAge ?? true}
emptyText={surface.emptyText ?? 'No conversations yet'}
statusFor={(threadId) => threadWorkStatus(statusState, threadId)}
{...(EntryDecoration ? { EntryDecoration } : {})}
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) => {
Expand Down Expand Up @@ -686,7 +715,7 @@ const sidebarClient: BrowserPlugin<SidebarResizeConfig> = (ctx, config) => {
}
}

sidebarClient.inject = ['clientUi', 'clientSession', 'clientSessionRouter']
sidebarClient.inject = ['clientUi', 'clientSession', 'clientSessionRouter', 'clientWorkspaceLayout']
sidebarClient.provide = 'clientSidebar'
sidebarClient.resources = {
provides: {
Expand Down
6 changes: 6 additions & 0 deletions program/plugins/sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
7 changes: 6 additions & 1 deletion program/plugins/ui/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export function HistoryPanel({
activeProjectId,
projects,
disabled,
newThreadDisabled = disabled,
newThreadProblem,
showAge = false,
emptyText = 'No conversations yet',
statusFor,
Expand All @@ -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
Expand Down Expand Up @@ -273,6 +277,7 @@ export function HistoryPanel({
)}
</header>
)}
{newThreadProblem && <p className="history-new-thread-error" role="alert">{newThreadProblem}</p>}
<div className="history-list">
{groups.map((group) => {
const project = group.project
Expand Down Expand Up @@ -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)}
>
<Plus size={15} strokeWidth={1.7} />
Expand Down
16 changes: 14 additions & 2 deletions program/plugins/workspace-layout-api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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<WorkspaceOpenPaneRequest, 'id' | 'action'>,
Expand Down
27 changes: 22 additions & 5 deletions program/plugins/workspace-layout.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<WorkspaceOpenPaneRequest, 'id' | 'action'>,
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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<WorkspaceOpenPaneRequest, 'id' | 'action'>,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
17 changes: 17 additions & 0 deletions tests/chat-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof registry.bindController>[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 },
Expand Down
Loading
Loading