mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix: resolve structured chat overnight anomalies
This commit is contained in:
@@ -52,4 +52,105 @@ describe('structured session cold restoration', () => {
|
||||
hydrate.mock.invocationCallOrder[0] ?? Infinity
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes a restored tab id and removes it when closed', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const internal = runtime as unknown as {
|
||||
getKnownWorkspaceSessionWorktreeIds(): Set<string>
|
||||
hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set<string>
|
||||
refreshMobileSessionPtyRecords(): Promise<Set<string> | null>
|
||||
ensureStructuredAgentSessionHost(): Promise<void>
|
||||
}
|
||||
internal.getKnownWorkspaceSessionWorktreeIds = () => new Set()
|
||||
internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set()
|
||||
internal.refreshMobileSessionPtyRecords = async () => new Set()
|
||||
internal.ensureStructuredAgentSessionHost = async () => undefined
|
||||
setStructuredAgentSessionHost({
|
||||
restoreReadableSessions: async () => undefined,
|
||||
listSessionTabs: () => [
|
||||
{
|
||||
sessionId: 'agent-session:agent-session:restored-session',
|
||||
workspaceId: 'workspace-1',
|
||||
agent: 'codex'
|
||||
}
|
||||
]
|
||||
} as never)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: 'workspace-1',
|
||||
publicationEpoch: 'renderer-restored',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'terminal-tab::leaf-1',
|
||||
activeTabType: 'terminal',
|
||||
tabGroups: [{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }],
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'terminal-tab::leaf-1',
|
||||
parentTabId: 'terminal-tab',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Terminal',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'terminal-tab::leaf-2',
|
||||
parentTabId: 'terminal-tab',
|
||||
leafId: 'leaf-2',
|
||||
title: 'Terminal',
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
await runtime.restoreStructuredAgentSessionTabs()
|
||||
|
||||
const restored = await runtime.listMobileSessionTabs('id:workspace-1')
|
||||
expect(restored.tabs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
id: 'terminal-tab::leaf-1'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
id: 'terminal-tab::leaf-2'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:restored-session',
|
||||
sessionId: 'restored-session'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(restored.tabs).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'agent-session',
|
||||
id: 'agent-session:agent-session:restored-session'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(restored.tabGroups?.[0]?.tabOrder).toEqual([
|
||||
'terminal-tab',
|
||||
'agent-session:restored-session'
|
||||
])
|
||||
|
||||
await runtime.closeMobileSessionTab('id:workspace-1', 'agent-session:restored-session', {
|
||||
reason: 'user'
|
||||
})
|
||||
|
||||
const closed = await runtime.listMobileSessionTabs('id:workspace-1')
|
||||
expect(closed.tabs.map((tab) => tab.id)).toEqual([
|
||||
'terminal-tab::leaf-1',
|
||||
'terminal-tab::leaf-2'
|
||||
])
|
||||
expect(closed.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8163,6 +8163,9 @@ export class OrcaRuntimeService {
|
||||
// route closeSessionTab to. Close the page directly and drop it from the
|
||||
// snapshot so paired clients stop showing it.
|
||||
await this.closeHeadlessMobileBrowserTab(worktreeId, snapshot!, tab)
|
||||
} else if (tab.type === 'agent-session') {
|
||||
this.notifier?.closeSessionTab?.(tab.id, worktreeId)
|
||||
this.closeStructuredAgentSessionTab(worktreeId, snapshot!, tab)
|
||||
} else {
|
||||
this.notifier?.closeSessionTab?.(tab.id, worktreeId)
|
||||
}
|
||||
@@ -8234,6 +8237,30 @@ export class OrcaRuntimeService {
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
}
|
||||
|
||||
private closeStructuredAgentSessionTab(
|
||||
worktreeId: string,
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot,
|
||||
tab: RuntimeMobileSessionAgentTab
|
||||
): void {
|
||||
const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id)
|
||||
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
|
||||
const nextSnapshot: RuntimeMobileSessionTabsSnapshot = {
|
||||
...snapshot,
|
||||
snapshotVersion: snapshot.snapshotVersion + 1,
|
||||
activeTabId: active?.id ?? null,
|
||||
activeTabType: active?.type ?? null,
|
||||
tabGroups: (snapshot.tabGroups ?? []).map((group) => ({
|
||||
...group,
|
||||
tabOrder: group.tabOrder.filter((id) => id !== tab.id),
|
||||
activeTabId: group.activeTabId === tab.id ? null : group.activeTabId,
|
||||
recentTabIds: group.recentTabIds?.filter((id) => id !== tab.id)
|
||||
})),
|
||||
tabs: nextTabs
|
||||
}
|
||||
this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot)
|
||||
this.emitMobileSessionTabsSnapshot(nextSnapshot)
|
||||
}
|
||||
|
||||
private markHeadlessBrowserSessionTabActive(
|
||||
worktreeId: string | undefined,
|
||||
browserPageId: string,
|
||||
@@ -9999,7 +10026,16 @@ export class OrcaRuntimeService {
|
||||
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession()
|
||||
const host = getStructuredAgentSessionHost()
|
||||
for (const session of host?.listSessionTabs() ?? []) {
|
||||
this.publishStructuredAgentSessionTab({ ...session, activate: false, notify: false })
|
||||
let sessionId = session.sessionId
|
||||
while (sessionId.startsWith('agent-session:')) {
|
||||
sessionId = sessionId.slice('agent-session:'.length)
|
||||
}
|
||||
this.publishStructuredAgentSessionTab({
|
||||
...session,
|
||||
sessionId,
|
||||
activate: false,
|
||||
notify: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => ({
|
||||
createClaudeModelSwitchConfirmationObserver: vi.fn(),
|
||||
discoverCommitMessageModels: vi.fn(),
|
||||
draft: 'hello',
|
||||
imageAttachments: [] as { id: string; path: string }[],
|
||||
getMainBufferSnapshot: vi.fn(),
|
||||
sendHandle: { cancel: vi.fn(), settleAfterMs: 500 },
|
||||
sendNativeChatMessage: vi.fn(),
|
||||
@@ -108,7 +109,7 @@ vi.mock('./use-native-chat-skills', () => ({
|
||||
}))
|
||||
vi.mock('./use-native-chat-composer-attachments', () => ({
|
||||
useNativeChatComposerAttachments: () => ({
|
||||
imageAttachments: [],
|
||||
imageAttachments: mocks.imageAttachments,
|
||||
attachResolvedPaths: vi.fn(),
|
||||
clearImageAttachments: vi.fn(),
|
||||
removeImageAttachment: vi.fn()
|
||||
@@ -149,6 +150,7 @@ describe('NativeChatComposer', () => {
|
||||
mocks.fieldProps = null
|
||||
mocks.modelSwitchOutcome = 'applied'
|
||||
mocks.draft = 'hello'
|
||||
mocks.imageAttachments = []
|
||||
mocks.draftScopeKeys.length = 0
|
||||
mocks.confirmationObserver = null
|
||||
mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => {
|
||||
@@ -276,15 +278,52 @@ describe('NativeChatComposer', () => {
|
||||
|
||||
expect(mocks.fieldProps?.sessionOptionsSurface).toBe(optionsSurface)
|
||||
expect(mocks.fieldProps?.sessionOptionsSnapshot).toBe(optionSnapshot)
|
||||
expect(mocks.fieldProps?.attachDisabled).toBe(true)
|
||||
expect(mocks.fieldProps?.attachDisabled).toBe(false)
|
||||
await act(async () => mocks.fieldProps?.onSend?.())
|
||||
|
||||
expect(dispatchCommand).toHaveBeenCalledWith('hello')
|
||||
expect(send).toHaveBeenCalledWith('hello')
|
||||
expect(send).toHaveBeenCalledWith('hello', [])
|
||||
expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled()
|
||||
expect(mocks.setDraft).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('sends structured image attachments through the durable transport', async () => {
|
||||
mocks.draft = ''
|
||||
mocks.imageAttachments = [{ id: 'image-1', path: '/tmp/image.png' }]
|
||||
const send = vi.fn(() => true)
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:structured"
|
||||
targetPtyId={null}
|
||||
agent="codex"
|
||||
structuredTransport={{
|
||||
send,
|
||||
dispatchCommand: vi.fn(async () => ({
|
||||
handled: false,
|
||||
accepted: false,
|
||||
error: null
|
||||
})),
|
||||
optionsSurface: {
|
||||
getSnapshot: () => [],
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
subscribe: () => () => {}
|
||||
},
|
||||
optionSnapshot: [],
|
||||
worktreeId: 'wt-1',
|
||||
onError: vi.fn(),
|
||||
runtime: 'local'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
await act(async () => mocks.fieldProps?.onSend?.())
|
||||
|
||||
expect(send).toHaveBeenCalledWith('', mocks.imageAttachments)
|
||||
expect(mocks.setDraft).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('types Codex slash composer sends instead of pasting them', () => {
|
||||
mocks.draft = '/status'
|
||||
render(
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useAppStore } from '../../store'
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
|
||||
import { STRUCTURED_AGENT_SESSION_SLASH_COMMANDS } from '../../../../shared/structured-agent-session-composer'
|
||||
import {
|
||||
isStructuredAgentSessionComposerCommand,
|
||||
STRUCTURED_AGENT_SESSION_SLASH_COMMANDS
|
||||
} from '../../../../shared/structured-agent-session-composer'
|
||||
import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry'
|
||||
import {
|
||||
applyMentionSuggestion,
|
||||
@@ -169,6 +172,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } =
|
||||
useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: paneKey,
|
||||
allowWithoutTarget: Boolean(structuredTransport),
|
||||
caret,
|
||||
resolveTarget,
|
||||
textareaRef,
|
||||
@@ -192,6 +196,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
|
||||
const { attachExternalPaths, resolveAttachmentOwner } = useNativeChatExternalAttachments({
|
||||
terminalTabId,
|
||||
structuredWorktreeId: structuredTransport?.worktreeId,
|
||||
disabled,
|
||||
attachResolvedPaths,
|
||||
setNotice
|
||||
@@ -239,11 +244,15 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
const sessionOptionsSnapshot = structuredTransport?.optionSnapshot ?? ptySessionOptionsSnapshot
|
||||
|
||||
const sendStructured = useCallback(
|
||||
(text: string): void => {
|
||||
(text: string, attachments = imageAttachments): void => {
|
||||
if (!structuredTransport) {
|
||||
return
|
||||
}
|
||||
void dispatchNativeChatStructuredComposerText(structuredTransport, text)
|
||||
if (attachments.length > 0 && isStructuredAgentSessionComposerCommand(text, agent)) {
|
||||
structuredTransport.onError('Remove attachments before using a chat-session command.')
|
||||
return
|
||||
}
|
||||
void dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments)
|
||||
.then(({ accepted, error }) => {
|
||||
structuredTransport.onError(error)
|
||||
if (!accepted) {
|
||||
@@ -260,7 +269,14 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
structuredTransport.onError(error instanceof Error ? error.message : String(error))
|
||||
)
|
||||
},
|
||||
[agent, clearImageAttachments, clearSkillOrigin, setDraft, structuredTransport]
|
||||
[
|
||||
agent,
|
||||
clearImageAttachments,
|
||||
clearSkillOrigin,
|
||||
imageAttachments,
|
||||
setDraft,
|
||||
structuredTransport
|
||||
]
|
||||
)
|
||||
|
||||
const sendPty = useNativeChatPtyComposerSend({
|
||||
@@ -289,10 +305,10 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
const send = useCallback(() => {
|
||||
if (!structuredTransport) {
|
||||
sendPty()
|
||||
} else if (draft.trim() !== '' && !disabled) {
|
||||
sendStructured(draft)
|
||||
} else if ((draft.trim() !== '' || imageAttachments.length > 0) && !disabled) {
|
||||
sendStructured(draft, imageAttachments)
|
||||
}
|
||||
}, [disabled, draft, sendPty, sendStructured, structuredTransport])
|
||||
}, [disabled, draft, imageAttachments, sendPty, sendStructured, structuredTransport])
|
||||
|
||||
const interrupt = useCallback(() => {
|
||||
cancelPendingSends()
|
||||
@@ -375,7 +391,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
imageAttachments={imageAttachments}
|
||||
sendButtonDisabled={sendButtonDisabled}
|
||||
isWorking={isWorking}
|
||||
attachDisabled={disabled || Boolean(structuredTransport)}
|
||||
attachDisabled={disabled}
|
||||
dictationDisabled={dictationDisabled}
|
||||
isDictating={isDictating}
|
||||
isDictationHoldMode={isDictationHoldMode}
|
||||
@@ -419,6 +435,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
onStop={interrupt}
|
||||
sessionOptionsSurface={sessionOptionsSurface}
|
||||
sessionOptionsSnapshot={sessionOptionsSnapshot}
|
||||
sessionOptionsPickerRequest={structuredTransport?.optionPickerRequest ?? null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
SessionOptionsSurface
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers'
|
||||
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
|
||||
|
||||
export type NativeChatComposerActionsProps = {
|
||||
attachDisabled: boolean
|
||||
@@ -23,6 +24,7 @@ export type NativeChatComposerActionsProps = {
|
||||
onStop?: () => void
|
||||
sessionOptionsSurface: SessionOptionsSurface | null
|
||||
sessionOptionsSnapshot: SessionOptionDescriptor[]
|
||||
sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null
|
||||
}
|
||||
|
||||
export function NativeChatComposerActions({
|
||||
@@ -39,7 +41,8 @@ export function NativeChatComposerActions({
|
||||
onSend,
|
||||
onStop,
|
||||
sessionOptionsSurface,
|
||||
sessionOptionsSnapshot
|
||||
sessionOptionsSnapshot,
|
||||
sessionOptionsPickerRequest
|
||||
}: NativeChatComposerActionsProps): React.JSX.Element {
|
||||
const dictationLabel = isDictating
|
||||
? translate('components.native-chat.composer.stopDictation', 'Stop dictation')
|
||||
@@ -73,6 +76,7 @@ export function NativeChatComposerActions({
|
||||
surface={sessionOptionsSurface}
|
||||
snapshot={sessionOptionsSnapshot}
|
||||
isWorking={isWorking}
|
||||
pickerRequest={sessionOptionsPickerRequest}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionsSurface
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
|
||||
|
||||
export type NativeChatComposerFieldProps = {
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
@@ -54,6 +55,7 @@ export type NativeChatComposerFieldProps = {
|
||||
onStop?: () => void
|
||||
sessionOptionsSurface: SessionOptionsSurface | null
|
||||
sessionOptionsSnapshot: SessionOptionDescriptor[]
|
||||
sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null
|
||||
}
|
||||
|
||||
export type NativeChatComposerImageAttachment = {
|
||||
@@ -95,7 +97,8 @@ export function NativeChatComposerField({
|
||||
onSend,
|
||||
onStop,
|
||||
sessionOptionsSurface,
|
||||
sessionOptionsSnapshot
|
||||
sessionOptionsSnapshot,
|
||||
sessionOptionsPickerRequest
|
||||
}: NativeChatComposerFieldProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="shrink-0 bg-background">
|
||||
@@ -213,6 +216,7 @@ export function NativeChatComposerField({
|
||||
onStop={onStop}
|
||||
sessionOptionsSurface={sessionOptionsSurface}
|
||||
sessionOptionsSnapshot={sessionOptionsSnapshot}
|
||||
sessionOptionsPickerRequest={sessionOptionsPickerRequest}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,17 @@ vi.mock('@/components/ui/tooltip', () => ({
|
||||
vi.mock('@/components/ui/dropdown-menu', () => {
|
||||
const React = require('react') as typeof ReactModule
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenu: ({
|
||||
children,
|
||||
defaultOpen
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
}) => (
|
||||
<div data-testid="dropdown-root" data-open={defaultOpen ? 'true' : 'false'}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
disabled
|
||||
@@ -188,6 +198,41 @@ const fast: SessionOptionDescriptor = {
|
||||
afterEach(() => cleanup())
|
||||
|
||||
describe('NativeChatSessionOptionPickers', () => {
|
||||
it('opens the native picker requested by a structured slash command', async () => {
|
||||
const { rerender } = render(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[model(), effort]}
|
||||
isWorking={false}
|
||||
pickerRequest={null}
|
||||
/>
|
||||
)
|
||||
|
||||
rerender(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[model(), effort]}
|
||||
isWorking={false}
|
||||
pickerRequest={{ id: 'model', sequence: 1 }}
|
||||
/>
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId('dropdown-root')[1]?.getAttribute('data-open')).toBe('true')
|
||||
)
|
||||
|
||||
rerender(
|
||||
<NativeChatSessionOptionPickers
|
||||
surface={surface}
|
||||
snapshot={[model(), effort]}
|
||||
isWorking={false}
|
||||
pickerRequest={{ id: 'effort', sequence: 2 }}
|
||||
/>
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByTestId('dropdown-root')[0]?.getAttribute('data-open')).toBe('true')
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers collision-aware upward placement for model and option menus', () => {
|
||||
render(
|
||||
<NativeChatSessionOptionPickers
|
||||
|
||||
@@ -28,11 +28,13 @@ import {
|
||||
nativeChatSessionOptionDisabledReason,
|
||||
nativeChatSessionOptionLabel
|
||||
} from './native-chat-session-option-labels'
|
||||
import type { NativeChatOptionPickerRequest } from './native-chat-composer-types'
|
||||
|
||||
export type NativeChatSessionOptionPickersProps = {
|
||||
surface: SessionOptionsSurface | null
|
||||
snapshot: SessionOptionDescriptor[]
|
||||
isWorking: boolean
|
||||
pickerRequest?: NativeChatOptionPickerRequest | null
|
||||
}
|
||||
|
||||
function PickerTooltipContent(props: {
|
||||
@@ -207,7 +209,8 @@ function runSurfaceCall(
|
||||
function NativeChatSessionOptionPickersInner({
|
||||
surface,
|
||||
snapshot,
|
||||
isWorking
|
||||
isWorking,
|
||||
pickerRequest
|
||||
}: NativeChatSessionOptionPickersProps): React.JSX.Element | null {
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const model = snapshot.find((descriptor) => descriptor.category === 'model')
|
||||
@@ -215,6 +218,10 @@ function NativeChatSessionOptionPickersInner({
|
||||
if (!surface || !model) {
|
||||
return null
|
||||
}
|
||||
const requestedModelSequence = pickerRequest?.id === model.id ? pickerRequest.sequence : null
|
||||
const requestedOptionsSequence = options.some((descriptor) => descriptor.id === pickerRequest?.id)
|
||||
? (pickerRequest?.sequence ?? null)
|
||||
: null
|
||||
|
||||
const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => {
|
||||
runSurfaceCall(descriptor.id, setPendingId, () => surface.setOption(descriptor.id, value))
|
||||
@@ -234,7 +241,10 @@ function NativeChatSessionOptionPickersInner({
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
{options.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
key={`options:${requestedOptionsSequence ?? 'idle'}`}
|
||||
defaultOpen={requestedOptionsSequence !== null}
|
||||
>
|
||||
<PickerTrigger
|
||||
label={nativeChatOptionsPillLabel(options)}
|
||||
tooltipLabel={optionsTooltip}
|
||||
@@ -264,7 +274,10 @@ function NativeChatSessionOptionPickersInner({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
key={`model:${requestedModelSequence ?? 'idle'}`}
|
||||
defaultOpen={requestedModelSequence !== null}
|
||||
>
|
||||
<PickerTrigger
|
||||
label={nativeChatModelPillLabel(model)}
|
||||
tooltipLabel={modelTooltip}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -8,7 +8,8 @@ const mocks = vi.hoisted(() => ({
|
||||
messageListProps: null as null | {
|
||||
allowFileUriLinks?: boolean
|
||||
onLinkClick?: (...args: unknown[]) => void
|
||||
}
|
||||
},
|
||||
composerProps: null as null | { structuredTransport?: Record<string, unknown> }
|
||||
}))
|
||||
|
||||
vi.mock('./use-structured-agent-session', () => ({
|
||||
@@ -36,8 +37,26 @@ vi.mock('./use-structured-agent-session', () => ({
|
||||
turnId: null,
|
||||
cancel: vi.fn(),
|
||||
respond: vi.fn(),
|
||||
optionSnapshot: [],
|
||||
optionSurface: null,
|
||||
optionSnapshot: [
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
category: 'model',
|
||||
kind: {
|
||||
type: 'select',
|
||||
currentValue: 'gpt-live',
|
||||
choices: [{ value: 'gpt-live', label: 'GPT Live' }]
|
||||
},
|
||||
valueSource: 'reported',
|
||||
settable: true
|
||||
}
|
||||
],
|
||||
optionSurface: {
|
||||
getSnapshot: () => [],
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
subscribe: () => () => {}
|
||||
},
|
||||
setStructuredOption: vi.fn()
|
||||
})
|
||||
}))
|
||||
@@ -65,7 +84,12 @@ vi.mock('./NativeChatMessageList', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./NativeChatComposer', () => ({ NativeChatComposer: () => null }))
|
||||
vi.mock('./NativeChatComposer', () => ({
|
||||
NativeChatComposer: (props: typeof mocks.composerProps) => {
|
||||
mocks.composerProps = props
|
||||
return null
|
||||
}
|
||||
}))
|
||||
vi.mock('./NativeChatEmptyState', () => ({ NativeChatEmptyState: () => null }))
|
||||
vi.mock('./NativeChatApprovalCard', () => ({ NativeChatApprovalCard: () => null }))
|
||||
vi.mock('./NativeChatQuestionCard', () => ({ NativeChatQuestionCard: () => null }))
|
||||
@@ -76,6 +100,7 @@ describe('NativeChatStructuredSession', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
mocks.messageListProps = null
|
||||
mocks.composerProps = null
|
||||
})
|
||||
|
||||
it('wires local structured file links through the native chat opener', () => {
|
||||
@@ -92,4 +117,28 @@ describe('NativeChatStructuredSession', () => {
|
||||
expect(mocks.messageListProps?.allowFileUriLinks).toBe(true)
|
||||
expect(mocks.messageListProps?.onLinkClick).toBe(mocks.fileLinkClick)
|
||||
})
|
||||
|
||||
it('routes a bare model command to the native option picker', async () => {
|
||||
render(
|
||||
<NativeChatStructuredSession
|
||||
tabId="structured-tab-1"
|
||||
sessionId="session-1"
|
||||
target={{ kind: 'local' }}
|
||||
agent="codex"
|
||||
allowFileUriLinks
|
||||
/>
|
||||
)
|
||||
const dispatchCommand = mocks.composerProps?.structuredTransport?.dispatchCommand as
|
||||
| ((text: string) => Promise<{ accepted: boolean }>)
|
||||
| undefined
|
||||
|
||||
await act(async () => {
|
||||
await expect(dispatchCommand?.('/model')).resolves.toMatchObject({ accepted: true })
|
||||
})
|
||||
|
||||
expect(mocks.composerProps?.structuredTransport?.optionPickerRequest).toEqual({
|
||||
id: 'model',
|
||||
sequence: 1
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,10 @@ export function NativeChatStructuredSession(props: {
|
||||
}): React.JSX.Element {
|
||||
const controller = useStructuredAgentSession(props)
|
||||
const [composerError, setComposerError] = useState<string | null>(null)
|
||||
const [optionPickerRequest, setOptionPickerRequest] = useState<{
|
||||
id: string
|
||||
sequence: number
|
||||
} | null>(null)
|
||||
const paneKey = useMemo(
|
||||
() => structuredAgentSessionPaneKey(props.tabId, props.sessionId),
|
||||
[props.sessionId, props.tabId]
|
||||
@@ -77,20 +81,32 @@ export function NativeChatStructuredSession(props: {
|
||||
null
|
||||
const structuredTransport = useMemo(
|
||||
() => ({
|
||||
send: controller.send,
|
||||
send: (text: string, attachments: readonly { id: string; path: string }[]): boolean =>
|
||||
controller.send(
|
||||
text,
|
||||
attachments.map((attachment) => ({
|
||||
path: attachment.path,
|
||||
previewUri: attachment.path
|
||||
}))
|
||||
),
|
||||
dispatchCommand: (text: string) =>
|
||||
dispatchStructuredAgentSessionComposerCommand(text, {
|
||||
agent: props.agent,
|
||||
snapshot: controller.optionSnapshot,
|
||||
invokeAction: async () => false,
|
||||
invokeAction: async (id) => {
|
||||
setOptionPickerRequest((current) => ({ id, sequence: (current?.sequence ?? 0) + 1 }))
|
||||
return true
|
||||
},
|
||||
setOption: controller.setStructuredOption
|
||||
}),
|
||||
optionsSurface: controller.optionSurface,
|
||||
optionSnapshot: controller.optionSnapshot,
|
||||
optionPickerRequest,
|
||||
worktreeId: fileLinkContext?.worktreeId,
|
||||
onError: setComposerError,
|
||||
runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote'
|
||||
}),
|
||||
[controller, props.agent, props.target.kind]
|
||||
[controller, fileLinkContext?.worktreeId, optionPickerRequest, props.agent, props.target.kind]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock('@/i18n/i18n', () => ({
|
||||
|
||||
import {
|
||||
resolveNativeChatAttachmentOwner,
|
||||
resolveNativeChatAttachmentOwnerForWorktree,
|
||||
uploadNativeChatAttachmentPaths
|
||||
} from './native-chat-attachment-upload'
|
||||
|
||||
@@ -66,6 +67,28 @@ describe('resolveNativeChatAttachmentOwner', () => {
|
||||
expect(resolveNativeChatAttachmentOwner(state(), 'tab-1')).toEqual({ kind: 'local' })
|
||||
})
|
||||
|
||||
it('resolves a structured tab owner directly from its worktree', () => {
|
||||
expect(resolveNativeChatAttachmentOwnerForWorktree(state(), 'wt-1')).toEqual({
|
||||
kind: 'local'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a structured SSH owner directly from its worktree', () => {
|
||||
expect(
|
||||
resolveNativeChatAttachmentOwnerForWorktree(
|
||||
state({
|
||||
repos: [{ id: 'repo', connectionId: 'conn-1' }] as never,
|
||||
sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]])
|
||||
}),
|
||||
'wt-1'
|
||||
)
|
||||
).toMatchObject({
|
||||
kind: 'ssh',
|
||||
connectionId: 'conn-1',
|
||||
worktreePath: '/repo/worktree'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves an SSH repo worktree to ssh with the worktree path', () => {
|
||||
expect(
|
||||
resolveNativeChatAttachmentOwner(
|
||||
|
||||
@@ -58,6 +58,14 @@ export function resolveNativeChatAttachmentOwner(
|
||||
if (!worktreeId) {
|
||||
return { kind: 'not-ready' }
|
||||
}
|
||||
return resolveNativeChatAttachmentOwnerForWorktree(state, worktreeId, terminalTabId)
|
||||
}
|
||||
|
||||
export function resolveNativeChatAttachmentOwnerForWorktree(
|
||||
state: NativeChatAttachmentOwnerState,
|
||||
worktreeId: string,
|
||||
terminalTabId?: string
|
||||
): NativeChatAttachmentOwner {
|
||||
if (getRuntimeEnvironmentIdForWorktree(state, worktreeId)) {
|
||||
return { kind: 'runtime' }
|
||||
}
|
||||
@@ -68,7 +76,9 @@ export function resolveNativeChatAttachmentOwner(
|
||||
if (connectionId === null) {
|
||||
return { kind: 'local' }
|
||||
}
|
||||
const worktreePath = resolveNativeChatFileLinkContext(state, terminalTabId)?.worktreePath
|
||||
const worktreePath = terminalTabId
|
||||
? resolveNativeChatFileLinkContext(state, terminalTabId)?.worktreePath
|
||||
: state.getKnownWorktreeById(worktreeId)?.path
|
||||
if (!worktreePath) {
|
||||
return { kind: 'not-ready' }
|
||||
}
|
||||
@@ -87,6 +97,13 @@ export function nativeChatWorktreeNotReadyNotice(): string {
|
||||
)
|
||||
}
|
||||
|
||||
export function nativeChatLocalAttachmentUnsupportedNotice(): string {
|
||||
return translate(
|
||||
'components.native-chat.composer.localAttachmentUnsupported',
|
||||
'Local attachments are not available for remote sessions.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload client-local paths into `${worktreePath}/.orca/drops` on the SSH
|
||||
* remote and return the remote paths the agent can read (input order
|
||||
|
||||
@@ -5,12 +5,20 @@ import type {
|
||||
SessionOptionsSurface
|
||||
} from '../../../../shared/native-chat-session-options'
|
||||
import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt'
|
||||
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
|
||||
|
||||
export type NativeChatOptionPickerRequest = {
|
||||
id: string
|
||||
sequence: number
|
||||
}
|
||||
|
||||
export type NativeChatStructuredComposerTransport = {
|
||||
send: (text: string) => boolean
|
||||
send: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => boolean
|
||||
dispatchCommand: (text: string) => Promise<StructuredAgentSessionCommandOutcome>
|
||||
optionsSurface: SessionOptionsSurface
|
||||
optionSnapshot: SessionOptionDescriptor[]
|
||||
optionPickerRequest?: NativeChatOptionPickerRequest | null
|
||||
worktreeId?: string
|
||||
onError: (message: string | null) => void
|
||||
runtime: 'local' | 'remote'
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { NativeChatStructuredComposerTransport } from './native-chat-composer-types'
|
||||
import type { NativeChatComposerImageAttachment } from './NativeChatComposerField'
|
||||
|
||||
export async function dispatchNativeChatStructuredComposerText(
|
||||
transport: NativeChatStructuredComposerTransport,
|
||||
text: string
|
||||
text: string,
|
||||
attachments: readonly NativeChatComposerImageAttachment[] = []
|
||||
): Promise<{ accepted: boolean; error: string | null }> {
|
||||
const command = await transport.dispatchCommand(text)
|
||||
if (command.handled) {
|
||||
return { accepted: command.accepted, error: command.error }
|
||||
}
|
||||
return { accepted: transport.send(text), error: null }
|
||||
return { accepted: transport.send(text, attachments), error: null }
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox'
|
||||
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
|
||||
|
||||
const SEND_COUNT = 8
|
||||
|
||||
function submission(index: number): AgentJournalSubmission {
|
||||
return {
|
||||
clientMessageId: `client-${index}`,
|
||||
fence: 1,
|
||||
payloadFingerprint: `fingerprint-${index}`,
|
||||
dispatchState: 'accepted',
|
||||
providerItemId: `provider-${index}`,
|
||||
reason: null,
|
||||
submittedAt: index,
|
||||
resolvedAt: index
|
||||
}
|
||||
}
|
||||
|
||||
function item(index: number): AgentJournalRenderItem {
|
||||
return {
|
||||
itemId: `journal-${index}`,
|
||||
revision: 1,
|
||||
sequence: index,
|
||||
observedAt: index,
|
||||
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `send ${index}` }] }
|
||||
}
|
||||
}
|
||||
|
||||
describe('structured agent session message projection', () => {
|
||||
it('renders N rapid accepted sends once when journal and optimistic updates interleave', () => {
|
||||
const outbox = Array.from({ length: SEND_COUNT }, (_, index) =>
|
||||
createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: `client-${index}`,
|
||||
sessionId: 'session-1',
|
||||
text: `send ${index}`,
|
||||
attachments: [],
|
||||
queuedAt: index
|
||||
})
|
||||
)
|
||||
const messages = projectStructuredAgentSessionMessages(
|
||||
Array.from({ length: SEND_COUNT }, (_, index) => item(index)),
|
||||
outbox,
|
||||
Array.from({ length: SEND_COUNT }, (_, index) => submission(SEND_COUNT - index - 1))
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(SEND_COUNT)
|
||||
expect(messages.map((message) => message.id)).toEqual(
|
||||
Array.from({ length: SEND_COUNT }, (_, index) => `journal-${index}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an optimistic send until its acceptance arrives', () => {
|
||||
const outbox = [
|
||||
createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: 'client-pending',
|
||||
sessionId: 'session-1',
|
||||
text: 'pending',
|
||||
attachments: [],
|
||||
queuedAt: 1
|
||||
})
|
||||
]
|
||||
|
||||
expect(projectStructuredAgentSessionMessages([], outbox, [])).toMatchObject([
|
||||
{ id: 'client-pending', role: 'user' }
|
||||
])
|
||||
})
|
||||
})
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
AgentJournalRenderItem,
|
||||
AgentJournalSubmission
|
||||
} from '../../../../shared/agent-session-journal-types'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
reconcileStructuredAgentSessionOutbox,
|
||||
type StructuredAgentSessionOutboxEntry
|
||||
} from '../../../../shared/structured-agent-session-outbox'
|
||||
import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection'
|
||||
|
||||
export function projectStructuredAgentSessionMessages(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
outbox: readonly StructuredAgentSessionOutboxEntry[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
): NativeChatMessage[] {
|
||||
const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions)
|
||||
return [
|
||||
...projectStructuredItemsToNativeChat(items),
|
||||
...optimistic.map(
|
||||
(entry): NativeChatMessage => ({
|
||||
id: entry.clientMessageId,
|
||||
role: 'user',
|
||||
source: 'transcript',
|
||||
timestamp: entry.queuedAt,
|
||||
blocks: entry.body.blocks
|
||||
})
|
||||
)
|
||||
]
|
||||
}
|
||||
+19
-4
@@ -25,9 +25,11 @@ const target: NativeChatResolvedTarget = {
|
||||
|
||||
function Probe({
|
||||
scopeKey,
|
||||
structured = false,
|
||||
onReady
|
||||
}: {
|
||||
scopeKey: string
|
||||
structured?: boolean
|
||||
onReady: (api: ProbeApi) => void
|
||||
}): React.JSX.Element {
|
||||
const [caret, setCaret] = useState(0)
|
||||
@@ -36,8 +38,9 @@ function Probe({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const api = useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: scopeKey,
|
||||
allowWithoutTarget: structured,
|
||||
caret,
|
||||
resolveTarget: () => target,
|
||||
resolveTarget: () => (structured ? null : target),
|
||||
textareaRef,
|
||||
setCaret,
|
||||
setDraft: (updater) => setDraftValue((previous) => updater(previous)),
|
||||
@@ -48,7 +51,8 @@ function Probe({
|
||||
}
|
||||
|
||||
async function renderProbe(
|
||||
scopeKey: string
|
||||
scopeKey: string,
|
||||
structured = false
|
||||
): Promise<{ root: Root; latest: () => ProbeApi; rerender: (scopeKey: string) => Promise<void> }> {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
@@ -60,7 +64,7 @@ async function renderProbe(
|
||||
api = next
|
||||
}
|
||||
await act(async () => {
|
||||
root.render(createElement(Probe, { scopeKey, onReady }))
|
||||
root.render(createElement(Probe, { scopeKey, structured, onReady }))
|
||||
})
|
||||
if (!api) {
|
||||
throw new Error('Probe did not render')
|
||||
@@ -75,7 +79,7 @@ async function renderProbe(
|
||||
},
|
||||
rerender: async (nextScopeKey: string) => {
|
||||
await act(async () => {
|
||||
root.render(createElement(Probe, { scopeKey: nextScopeKey, onReady }))
|
||||
root.render(createElement(Probe, { scopeKey: nextScopeKey, structured, onReady }))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -112,6 +116,17 @@ describe('useNativeChatComposerAttachments', () => {
|
||||
act(() => second.root.unmount())
|
||||
})
|
||||
|
||||
it('accepts host-readable image paths without a PTY for structured transport', async () => {
|
||||
const probe = await renderProbe('structured-session-1', true)
|
||||
|
||||
await act(async () => {
|
||||
probe.latest().attachResolvedPaths(['/tmp/structured-image.png'])
|
||||
})
|
||||
|
||||
expect(probe.latest().imageAttachments).toMatchObject([{ path: '/tmp/structured-image.png' }])
|
||||
act(() => probe.root.unmount())
|
||||
})
|
||||
|
||||
it('removes an attached image chip cleanly', async () => {
|
||||
const probe = await renderProbe('pty-1')
|
||||
await act(async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
|
||||
|
||||
export type UseNativeChatComposerAttachmentsArgs = {
|
||||
attachmentScopeKey: string
|
||||
allowWithoutTarget?: boolean
|
||||
caret: number
|
||||
resolveTarget: () => NativeChatResolvedTarget | null
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>
|
||||
@@ -21,6 +22,7 @@ export type UseNativeChatComposerAttachmentsArgs = {
|
||||
|
||||
export function useNativeChatComposerAttachments({
|
||||
attachmentScopeKey,
|
||||
allowWithoutTarget = false,
|
||||
caret,
|
||||
resolveTarget,
|
||||
textareaRef,
|
||||
@@ -107,7 +109,10 @@ export function useNativeChatComposerAttachments({
|
||||
const attachResolvedPaths = useCallback(
|
||||
(paths: string[]) => {
|
||||
const target = resolveTarget()
|
||||
if (!target || nativeChatComposerTargetIsRemote(target.ptyId)) {
|
||||
if (
|
||||
(!target && !allowWithoutTarget) ||
|
||||
(target && nativeChatComposerTargetIsRemote(target.ptyId))
|
||||
) {
|
||||
setNotice(
|
||||
translate(
|
||||
'components.native-chat.composer.localAttachmentUnsupported',
|
||||
@@ -128,7 +133,14 @@ export function useNativeChatComposerAttachments({
|
||||
requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
}
|
||||
},
|
||||
[appendImageAttachments, insertFileReferences, resolveTarget, setNotice, textareaRef]
|
||||
[
|
||||
allowWithoutTarget,
|
||||
appendImageAttachments,
|
||||
insertFileReferences,
|
||||
resolveTarget,
|
||||
setNotice,
|
||||
textareaRef
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,8 @@ vi.mock('./native-chat-composer-target', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./native-chat-attachment-upload', () => ({
|
||||
nativeChatLocalAttachmentUnsupportedNotice: () =>
|
||||
'Local attachments are not available for remote sessions.',
|
||||
nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.'
|
||||
}))
|
||||
|
||||
@@ -133,6 +135,24 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('useNativeChatComposerPaste', () => {
|
||||
it('does not save a clipboard image locally for a remote runtime', async () => {
|
||||
const setNotice = vi.fn()
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const probe = await renderProbe({
|
||||
resolveAttachmentOwner: () => ({ kind: 'runtime' }),
|
||||
attachResolvedPaths,
|
||||
setNotice
|
||||
})
|
||||
|
||||
await act(async () => probe.latest().pasteFromClipboard())
|
||||
|
||||
expect(setNotice).toHaveBeenCalledWith(
|
||||
'Local attachments are not available for remote sessions.'
|
||||
)
|
||||
expect(mocks.saveClipboardImageAsTempFile).not.toHaveBeenCalled()
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a failed SSH image save through the composer notice', async () => {
|
||||
mocks.saveClipboardImageAsTempFile.mockRejectedValue(
|
||||
new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.')
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { resolveImagePaste } from './native-chat-image-paste'
|
||||
import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target'
|
||||
import {
|
||||
nativeChatLocalAttachmentUnsupportedNotice,
|
||||
nativeChatWorktreeNotReadyNotice,
|
||||
type NativeChatAttachmentOwner
|
||||
} from './native-chat-attachment-upload'
|
||||
@@ -73,6 +74,10 @@ export function useNativeChatComposerPaste({
|
||||
async (
|
||||
owner: NativeChatAttachmentOwner
|
||||
): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> => {
|
||||
if (owner.kind === 'runtime') {
|
||||
setNotice(nativeChatLocalAttachmentUnsupportedNotice())
|
||||
return { status: 'failed' }
|
||||
}
|
||||
try {
|
||||
// SSH panes save the image on the remote host (SFTP) so the attached
|
||||
// path is readable by the remote agent, matching terminal image paste.
|
||||
|
||||
@@ -13,6 +13,8 @@ vi.mock('@/store', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./native-chat-attachment-upload', () => ({
|
||||
nativeChatLocalAttachmentUnsupportedNotice: () =>
|
||||
'Local attachments are not available for remote sessions.',
|
||||
resolveNativeChatAttachmentOwner: mocks.resolveNativeChatAttachmentOwner,
|
||||
uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths,
|
||||
nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.'
|
||||
@@ -137,6 +139,20 @@ describe('useNativeChatExternalAttachments', () => {
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not attach client-local paths to a remote runtime', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'runtime' })
|
||||
const attachResolvedPaths = vi.fn()
|
||||
const setNotice = vi.fn()
|
||||
const probe = await renderProbe({ attachResolvedPaths, setNotice })
|
||||
await act(async () => {
|
||||
probe.latest().attachExternalPaths(['/local/a.txt'])
|
||||
})
|
||||
expect(setNotice).toHaveBeenCalledWith(
|
||||
'Local attachments are not available for remote sessions.'
|
||||
)
|
||||
expect(attachResolvedPaths).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops an upload that resolves after the composer became disabled', async () => {
|
||||
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({
|
||||
kind: 'ssh',
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
nativeChatLocalAttachmentUnsupportedNotice,
|
||||
nativeChatWorktreeNotReadyNotice,
|
||||
resolveNativeChatAttachmentOwner,
|
||||
resolveNativeChatAttachmentOwnerForWorktree,
|
||||
uploadNativeChatAttachmentPaths,
|
||||
type NativeChatAttachmentOwner
|
||||
} from './native-chat-attachment-upload'
|
||||
|
||||
export type UseNativeChatExternalAttachmentsArgs = {
|
||||
terminalTabId: string
|
||||
structuredWorktreeId?: string
|
||||
/** Live composer-disabled state; read at await-resume via a ref so a flip
|
||||
* mid-upload doesn't attach into a guarded composer. */
|
||||
disabled: boolean
|
||||
@@ -23,6 +26,7 @@ export type UseNativeChatExternalAttachmentsArgs = {
|
||||
*/
|
||||
export function useNativeChatExternalAttachments({
|
||||
terminalTabId,
|
||||
structuredWorktreeId,
|
||||
disabled,
|
||||
attachResolvedPaths,
|
||||
setNotice
|
||||
@@ -34,8 +38,11 @@ export function useNativeChatExternalAttachments({
|
||||
disabledRef.current = disabled
|
||||
|
||||
const resolveAttachmentOwner = useCallback(
|
||||
() => resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId),
|
||||
[terminalTabId]
|
||||
() =>
|
||||
structuredWorktreeId
|
||||
? resolveNativeChatAttachmentOwnerForWorktree(useAppStore.getState(), structuredWorktreeId)
|
||||
: resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId),
|
||||
[structuredWorktreeId, terminalTabId]
|
||||
)
|
||||
|
||||
const attachExternalPaths = useCallback(
|
||||
@@ -48,9 +55,11 @@ export function useNativeChatExternalAttachments({
|
||||
setNotice(nativeChatWorktreeNotReadyNotice())
|
||||
return
|
||||
}
|
||||
if (owner.kind === 'runtime') {
|
||||
setNotice(nativeChatLocalAttachmentUnsupportedNotice())
|
||||
return
|
||||
}
|
||||
if (owner.kind !== 'ssh') {
|
||||
// 'runtime' proceeds so attachResolvedPaths' existing remote-session
|
||||
// gate reports the unsupported state.
|
||||
attachResolvedPaths(paths)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,4 +148,31 @@ describe('useStructuredAgentSessionOutbox', () => {
|
||||
.clientOperationId
|
||||
).toBe(firstId)
|
||||
})
|
||||
|
||||
it('persists and dispatches an attachment-only structured send', async () => {
|
||||
mocks.call.mockResolvedValue(acceptedResult(1))
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSessionOutbox({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
fence: 1,
|
||||
submissions: []
|
||||
})
|
||||
)
|
||||
|
||||
act(() =>
|
||||
expect(
|
||||
result.current.send('', [{ path: '/tmp/image.png', previewUri: 'file:///tmp/image.png' }])
|
||||
).toBe(true)
|
||||
)
|
||||
await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce())
|
||||
|
||||
expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
blocks: [{ type: 'image-ref', path: '/tmp/image.png' }]
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -220,15 +220,15 @@ export function useStructuredAgentSessionOutbox(args: {
|
||||
}, [fence, outbox, sessionId, target])
|
||||
|
||||
const send = useCallback(
|
||||
(text: string): boolean => {
|
||||
if (!text.trim()) {
|
||||
(text: string, attachments: readonly { path: string; previewUri: string }[] = []): boolean => {
|
||||
if (!text.trim() && attachments.length === 0) {
|
||||
return false
|
||||
}
|
||||
const entry = createStructuredAgentSessionOutboxEntry({
|
||||
clientMessageId: structuredSessionOperationId(),
|
||||
sessionId,
|
||||
text,
|
||||
attachments: [],
|
||||
attachments,
|
||||
queuedAt: Date.now()
|
||||
})
|
||||
const next = [...outboxRef.current, entry]
|
||||
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
createStructuredAgentSessionOptionState,
|
||||
structuredAgentSessionOptionSnapshot
|
||||
} from '../../../../shared/structured-agent-session-options'
|
||||
import {
|
||||
activeStructuredAgentSessionTurnId,
|
||||
projectStructuredItemsToNativeChat
|
||||
} from '../../../../shared/structured-agent-session-projection'
|
||||
import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import {
|
||||
@@ -29,6 +26,7 @@ import {
|
||||
useStructuredAgentSessionOutbox
|
||||
} from './use-structured-agent-session-outbox'
|
||||
import { useStructuredAgentSessionRead } from './use-structured-agent-session-read'
|
||||
import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection'
|
||||
|
||||
export type StructuredPromptItem = AgentJournalRenderItem & {
|
||||
body: Extract<AgentJournalRenderItem['body'], { kind: 'approval' | 'question' }>
|
||||
@@ -209,16 +207,11 @@ export function useStructuredAgentSession(args: {
|
||||
)
|
||||
const turnId = activeStructuredAgentSessionTurnId(state.items)
|
||||
return {
|
||||
messages: [
|
||||
...projectStructuredItemsToNativeChat(state.items),
|
||||
...outboxController.outbox.map((entry) => ({
|
||||
id: entry.clientMessageId,
|
||||
role: 'user' as const,
|
||||
source: 'transcript' as const,
|
||||
timestamp: entry.queuedAt,
|
||||
blocks: entry.body.blocks
|
||||
}))
|
||||
],
|
||||
messages: projectStructuredAgentSessionMessages(
|
||||
state.items,
|
||||
outboxController.outbox,
|
||||
state.submissions
|
||||
),
|
||||
status: state.status,
|
||||
error: state.error ?? writeError ?? outboxController.error,
|
||||
hasOlder: state.hasOlder,
|
||||
|
||||
@@ -15,6 +15,19 @@ function item(id: string, sequence: number): AgentJournalRenderItem {
|
||||
}
|
||||
}
|
||||
|
||||
function submission(index: number) {
|
||||
return {
|
||||
clientMessageId: `client-${index}`,
|
||||
fence: 1,
|
||||
payloadFingerprint: `fingerprint-${index}`,
|
||||
dispatchState: 'accepted' as const,
|
||||
providerItemId: `provider-${index}`,
|
||||
reason: null,
|
||||
submittedAt: index,
|
||||
resolvedAt: index
|
||||
}
|
||||
}
|
||||
|
||||
describe('structured agent session reducer', () => {
|
||||
it('does not let a stale focus refresh replace newer streamed state', () => {
|
||||
const streamed = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
||||
@@ -53,4 +66,87 @@ describe('structured agent session reducer', () => {
|
||||
|
||||
expect(afterRefresh).toBe(streamed)
|
||||
})
|
||||
|
||||
it('keeps rapid-send submissions when a newer tail refresh contains only the last one', () => {
|
||||
const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'snapshot',
|
||||
sessionId: 'session-a',
|
||||
fence: 1,
|
||||
snapshot: {
|
||||
sessionId: 'session-a',
|
||||
cursor: { epoch: 'epoch-a', sequence: 10 },
|
||||
items: [item('first', 10)],
|
||||
submissions: Array.from({ length: 8 }, (_, index) => submission(index))
|
||||
}
|
||||
}
|
||||
})
|
||||
const refreshed = reduceStructuredAgentSession(initial, {
|
||||
type: 'tail-page',
|
||||
page: {
|
||||
sessionId: 'session-a',
|
||||
epoch: 'epoch-a',
|
||||
direction: 'tail',
|
||||
items: [item('latest', 11)],
|
||||
removedItemIds: [],
|
||||
submissions: [submission(7)],
|
||||
window: {
|
||||
oldest: { epoch: 'epoch-a', sequence: 11 },
|
||||
newest: { epoch: 'epoch-a', sequence: 11 },
|
||||
nextCursor: { epoch: 'epoch-a', sequence: 11 }
|
||||
},
|
||||
liveCursor: { epoch: 'epoch-a', sequence: 11 },
|
||||
hasOlder: true,
|
||||
hasNewer: false
|
||||
}
|
||||
})
|
||||
|
||||
expect(refreshed.submissions.map((entry) => entry.clientMessageId)).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => `client-${index}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('bounds retained submission identities across repeated tail refreshes', () => {
|
||||
let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
||||
type: 'event',
|
||||
event: {
|
||||
type: 'snapshot',
|
||||
sessionId: 'session-a',
|
||||
fence: 1,
|
||||
snapshot: {
|
||||
sessionId: 'session-a',
|
||||
cursor: { epoch: 'epoch-a', sequence: 1 },
|
||||
items: [item('first', 1)],
|
||||
submissions: []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
state = reduceStructuredAgentSession(state, {
|
||||
type: 'tail-page',
|
||||
page: {
|
||||
sessionId: 'session-a',
|
||||
epoch: 'epoch-a',
|
||||
direction: 'tail',
|
||||
items: [item(`item-${index}`, index + 2)],
|
||||
removedItemIds: [],
|
||||
submissions: [submission(index)],
|
||||
window: {
|
||||
oldest: { epoch: 'epoch-a', sequence: index + 2 },
|
||||
newest: { epoch: 'epoch-a', sequence: index + 2 },
|
||||
nextCursor: { epoch: 'epoch-a', sequence: index + 2 }
|
||||
},
|
||||
liveCursor: { epoch: 'epoch-a', sequence: index + 2 },
|
||||
hasOlder: true,
|
||||
hasNewer: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
expect(state.submissions).toHaveLength(256)
|
||||
expect(state.submissions[0]?.clientMessageId).toBe('client-44')
|
||||
expect(state.submissions.at(-1)?.clientMessageId).toBe('client-299')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,6 +41,8 @@ export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = {
|
||||
handoff: null
|
||||
}
|
||||
|
||||
const MAX_RETAINED_SUBMISSIONS = 256
|
||||
|
||||
function replaceSnapshot(
|
||||
snapshot: AgentJournalSnapshot,
|
||||
fence: number,
|
||||
@@ -84,7 +86,9 @@ function mergeSubmissions(
|
||||
for (const submission of incoming) {
|
||||
byId.set(submission.clientMessageId, submission)
|
||||
}
|
||||
return [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt)
|
||||
return [...byId.values()]
|
||||
.sort((left, right) => left.submittedAt - right.submittedAt)
|
||||
.slice(-MAX_RETAINED_SUBMISSIONS)
|
||||
}
|
||||
|
||||
export function reduceStructuredAgentSession(
|
||||
@@ -109,12 +113,15 @@ export function reduceStructuredAgentSession(
|
||||
) {
|
||||
return state
|
||||
}
|
||||
const sameEpoch = state.epoch === action.page.epoch
|
||||
return {
|
||||
epoch: action.page.epoch,
|
||||
cursor: action.page.liveCursor ?? null,
|
||||
fence: action.page.fence ?? null,
|
||||
items: action.page.items,
|
||||
submissions: action.page.submissions,
|
||||
submissions: sameEpoch
|
||||
? mergeSubmissions(state.submissions, action.page.submissions)
|
||||
: action.page.submissions,
|
||||
hasOlder: action.page.hasOlder,
|
||||
status: 'ready',
|
||||
handoff: state.handoff
|
||||
|
||||
Reference in New Issue
Block a user