Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b0799b8
WIP: establish local chat draft and first submission ownership
brennanb2025 Oct 16, 2026
e57162b
WIP: retain first submission through preparation and settlement
brennanb2025 Oct 16, 2026
2e59d8e
Keep new chat drafts local through their first submission
brennanb2025 Oct 16, 2026
a9c331e
fix(native-chat): release staged drafts and preserve explicit choices
brennanb2025 Oct 16, 2026
a9fa876
test(native-chat): keep distinct draft actions independent
brennanb2025 Oct 16, 2026
3d6c983
fix(native-chat): defer cross-window draft announcements
brennanb2025 Oct 16, 2026
2dee84a
Fix local chat draft settlement and recovery ownership
brennanb2025 Oct 16, 2026
81df423
Adopt committed draft presence after a refused allocation
brennanb2025 Oct 16, 2026
fa7e136
Simplify first-send recovery and preserve hosted send policy
brennanb2025 Oct 16, 2026
ab3df80
Remove launch prompt import cycle and tighten refusal tests
brennanb2025 Oct 16, 2026
04183b3
Align draft storage contracts with typechecked callers
brennanb2025 Oct 16, 2026
a4838ea
Preserve definite create refusal feedback and issued recovery fixtures
brennanb2025 Oct 16, 2026
fcf6ecf
Revert the first-Send draft machinery, keeping the synchronous compos…
brennanb2025 Oct 16, 2026
9e138c4
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 16, 2026
6af401d
fix(native-chat): open a New Chat's composer at once, before its host…
brennanb2025 Oct 16, 2026
7cfabd2
Keep chat launch recovery and composer actions with their owners
brennanb2025 Oct 17, 2026
dd55317
Clarify paired launch admission fixtures and recovery checks
brennanb2025 Oct 17, 2026
267bb3c
Apply chat attachment guidance at both paste entry points
brennanb2025 Oct 17, 2026
b42c55b
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 19, 2026
1460c23
fix(native-chat): finish the immediate New Chat on top of the + menu'…
brennanb2025 Oct 19, 2026
49d85f8
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 19, 2026
9bbddba
fix: preserve chat ownership through launch admission and fallback
brennanb2025 Oct 19, 2026
bb295d9
test: type launcher projects without an assertion
brennanb2025 Oct 19, 2026
1ec3793
fix: preserve matching refusal details in terminal completion
brennanb2025 Oct 19, 2026
1838f9a
fix: preserve fallback terminal placement and appearance
brennanb2025 Oct 19, 2026
3937799
Merge main into native chat launch branch
brennanb2025 Oct 19, 2026
6496af6
test: preserve host launch failure notice during integration
brennanb2025 Oct 19, 2026
dcafde9
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 20, 2026
3b4777d
style: format native chat launch changes after integration
brennanb2025 Oct 20, 2026
2a18d27
fix: disable paired chat attachments until startup completes
brennanb2025 Oct 20, 2026
b4fac12
fix(menus): a closing menu no longer takes focus back from what its i…
brennanb2025 Oct 20, 2026
fb898ea
fix(native-chat): host notices wrap instead of cutting off their inst…
brennanb2025 Oct 20, 2026
91b5531
Merge remote-tracking branch 'origin/main' into brennankb5/native-cha…
brennanb2025 Oct 20, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ describe('NativeChatComposer', () => {
subscribe: () => () => {}
} satisfies SessionOptionsSurface
const optionSnapshot = [{ id: 'model' }] as SessionOptionDescriptor[]
render(
const composer = (sessionPending = false) => (
<NativeChatComposer
terminalTabId="tab-1"
paneKey="tab-1:structured"
Expand All @@ -307,16 +307,22 @@ describe('NativeChatComposer', () => {
optionsSurface,
optionSnapshot,
onError: vi.fn(),
runtime: 'local',
runtime: 'remote',
sessionId: 'session-test',
runtimeEnvironmentId: null
runtimeEnvironmentId: 'paired-host',
sessionPending
}}
/>
)
const { rerender } = render(composer())

expect(mocks.fieldProps?.sessionOptionsSurface).toBe(optionsSurface)
expect(mocks.fieldProps?.sessionOptionsSnapshot).toBe(optionSnapshot)
expect(mocks.fieldProps?.attachDisabled).toBe(false)
rerender(composer(true))
expect(mocks.fieldProps?.attachDisabled).toBe(true)
rerender(composer())
expect(mocks.fieldProps?.attachDisabled).toBe(false)
await act(async () => mocks.fieldProps?.onSend?.())

expect(dispatchCommand).toHaveBeenCalledWith('hello')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,10 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
isWorking={isWorking}
isStopping={isStopping}
afterStop={afterStop}
attachDisabled={disabled}
attachDisabled={
disabled ||
(structuredTransport?.runtime === 'remote' && structuredTransport.sessionPending === true)
}
dictationDisabled={dictation.dictationDisabled}
isDictating={dictation.isDictating}
isDictationHoldMode={dictation.isDictationHoldMode}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX.
/>
<p
className={cn(
'min-w-0 flex-1 select-text py-px leading-5',
notice.kind === 'host' ? 'truncate' : '[overflow-wrap:anywhere]',
'min-w-0 flex-1 select-text py-px leading-5 [overflow-wrap:anywhere]',
'text-muted-foreground',
isError && 'text-foreground',
isHostWarning && 'text-destructive'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export function NativeChatStructuredSession(
controller,
sendThroughLaunch: provisionalLaunch.sendThroughLaunch,
starting: provisionalLaunch.starting,
transportEnabled: provisionalLaunch.transportEnabled,
worktreeId: ownerWorktreeId ?? undefined,
optionPickerRequest,
setOptionPickerRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export type NativeChatAttachmentOwner =
/** Store not hydrated / worktree unknown. Callers must not attach local
* paths in this window — the worktree may turn out to be remote, and the
* agent would silently receive paths it cannot read (see #6648). */
| { kind: 'not-ready' }
| { kind: 'not-ready'; notice?: string }

type NativeChatAttachmentOwnerState = Pick<
AppState,
Expand Down Expand Up @@ -129,6 +129,9 @@ export type NativeChatStructuredAttachmentSession = {
sessionId: string
/** Null: the chat runs on this machine. */
runtimeEnvironmentId: string | null
/** The host has not confirmed this chat, so nothing can be uploaded into it. */
sessionPending?: boolean
sessionPendingNotice?: string
}

/** A structured chat on a paired server owns its attachments by where it runs, not by worktree. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe('useNativeChatComposerRevealFocus', () => {
tab.focus()

act(() => requestNativeChatComposerFocus('chat-tab'))
expect(container.querySelector('textarea')).toBe(document.activeElement)
drainFrames()

expect(container.querySelector('textarea')).toBe(document.activeElement)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ export type NativeChatStructuredComposerTransport = {
sessionId: string
/** Owning runtime for that report; null is the local runtime. */
runtimeEnvironmentId: string | null
/** The host has not confirmed this chat: session uploads wait for publication. */
sessionPending?: boolean
sessionPendingNotice?: string
}

export type NativeChatOptimisticSendOutcome = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ export function nativeChatPendingAttachmentSnapshot(
return pendingCache.get(scopeKey) ?? EMPTY
}

/** Pending chips are user content before their file paths settle into the draft. */
export function nativeChatComposerHasUserContent(scopeKey: string): boolean {
const draft = readNativeChatComposerDraft(scopeKey)
return (
draft.text.trim() !== '' ||
draft.images.length > 0 ||
nativeChatPendingAttachmentSnapshot(scopeKey).length > 0
)
}

function writePending(scopeKey: string, next: readonly NativeChatComposerImageAttachment[]): void {
if (next.length === 0) {
pendingCache.delete(scopeKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function nativeChatLaunchNotice({
)
const cause =
lifecycle === 'failed' && failure
? (failure.authStartupMessage ??
? (failure.causeText ??
agentSessionWriteNoticeText(
agentSessionRefusalCauseParts(failure, agentLabel ? { agentName: agentLabel } : {})
))
Expand Down Expand Up @@ -100,8 +100,9 @@ export function structuredSessionNotices({
/** Why this chat's host may not take a turn, from `useNativeChatHostPermissionGate`. */
hostPermission?: NativeChatComposerNotice | null
}): NativeChatComposerNotice[] {
// A host held for its permission mode already says why nothing starts; Retry returns with it.
const launchNotice = nativeChatLaunchNotice({
lifecycle: launch.lifecycle,
lifecycle: hostPermission ? null : launch.lifecycle,
failure: launch.failure,
agentLabel,
onRetry: launch.retry,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,8 @@ describe('a chat whose start was never confirmed', () => {
})
}),
expect.any(Function),
true
true,
expect.any(Function)
)
expect(lifecycle()).toBeNull()
expect(rows()).toEqual([])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function useNativeChatComposerPaste({
return
}
if (owner.kind === 'not-ready') {
setNotice(nativeChatWorktreeNotReadyNotice())
setNotice(owner.notice ?? nativeChatWorktreeNotReadyNotice())
return
}
// Why: snapshot the caret before the async temp-file round-trip — `caret`
Expand Down Expand Up @@ -285,7 +285,7 @@ export function useNativeChatComposerPaste({
setNotice(
owner.kind === 'remote'
? nativeChatLocalAttachmentUnsupportedNotice()
: nativeChatWorktreeNotReadyNotice()
: (owner.notice ?? nativeChatWorktreeNotReadyNotice())
)
}
return
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { RefObject } from 'react'
import { shouldPreserveEditableFocus } from '@/components/terminal-pane/pane-helpers'
import { scheduleNextFrame } from '@/components/terminal-pane/terminal-ime-input-context-refresh'
Expand Down Expand Up @@ -40,7 +40,7 @@ export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusI
}
}
const pending: { tabId: string | null; cancel: () => void } = { tabId: null, cancel }
let timeout: ReturnType<typeof setTimeout> | undefined
const timeout = setTimeout(cancel, PENDING_FOCUS_MS)
Comment thread
brennanb2025 marked this conversation as resolved.
pendingFocusRequest = pending
document.addEventListener('pointerdown', cancel, true)
document.addEventListener('keydown', cancelForNavigation, true)
Expand All @@ -50,8 +50,6 @@ export function captureNativeChatComposerFocusIntent(): NativeChatComposerFocusI
return
}
pending.tabId = tabId
clearTimeout(timeout)
timeout = setTimeout(cancel, PENDING_FOCUS_MS)
const reclaim = revealedComposerReclaims.get(tabId)
if (reclaim) {
cancel()
Expand Down Expand Up @@ -101,34 +99,43 @@ export function useNativeChatComposerRevealFocus({
const explicitRef = useRef(false)
const [reclaimCount, setReclaimCount] = useState(0)

useEffect(() => {
useLayoutEffect(() => {
mountedComposers.add(composerRef)
return () => {
mountedComposers.delete(composerRef)
}
}, [composerRef])

useEffect(() => {
useLayoutEffect(() => {
if (!revealed || !composerReady) {
return
}
const reclaim = (): void => {
claimedRef.current = false
explicitRef.current = true
const active = rootRef.current?.ownerDocument.activeElement ?? null
const siblingComposer = [...mountedComposers].some(
(ownerRef) => ownerRef !== composerRef && ownerRef.current?.contains(active)
)
if (
(!shouldPreserveEditableFocus(active) || siblingComposer) &&
composerRef.current?.focus()
) {
explicitRef.current = false
}
setReclaimCount((count) => count + 1)
}
revealedComposerReclaims.set(tabId, reclaim)
if (pendingFocusRequest?.tabId === tabId) {
pendingFocusRequest.cancel()
claimedRef.current = false
explicitRef.current = true
reclaim()
}
return () => {
if (revealedComposerReclaims.get(tabId) === reclaim) {
revealedComposerReclaims.delete(tabId)
}
}
}, [composerReady, revealed, tabId])
}, [composerReady, composerRef, revealed, rootRef, tabId])

useEffect(() => {
if (!revealed || !composerReady) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { useNativeChatExternalAttachments } from './use-native-chat-external-att
import type { NativeChatPendingAttachmentChips } from './native-chat-session-attachment-drop'
import type { AgentSessionAttachmentPathUploadResult } from '../../../../shared/agent-session-attachments'
import { replaceRuntimeEnvironmentRevisions } from '@/runtime/runtime-environment-revision'
import type { NativeChatStructuredAttachmentSession } from './native-chat-attachment-upload'

type HookApi = ReturnType<typeof useNativeChatExternalAttachments>

Expand Down Expand Up @@ -68,7 +69,7 @@ function Probe({
}: {
disabled: boolean
structuredWorktreeId?: string
structuredSession?: { sessionId: string; runtimeEnvironmentId: string | null }
structuredSession?: NativeChatStructuredAttachmentSession
attachResolvedPaths: (paths: string[]) => void
pendingChips: NativeChatPendingAttachmentChips
setNotice: (notice: string | null) => void
Expand Down Expand Up @@ -96,7 +97,7 @@ let root: Root | null = null
async function renderProbe(args: {
disabled?: boolean
structuredWorktreeId?: string
structuredSession?: { sessionId: string; runtimeEnvironmentId: string | null }
structuredSession?: NativeChatStructuredAttachmentSession
attachResolvedPaths: (paths: string[]) => void
pendingChips?: NativeChatPendingAttachmentChips
setNotice?: (notice: string | null) => void
Expand Down Expand Up @@ -160,6 +161,30 @@ afterEach(() => {
})

describe('useNativeChatExternalAttachments', () => {
it('keeps a failed paired chat from issuing an upload and says how to retry', async () => {
const attachResolvedPaths = vi.fn()
const setNotice = vi.fn()
const notice = 'Codex chat could not open. Use Retry in the chat, then attach the files again.'
const probe = await renderProbe({
structuredWorktreeId: 'folder-1',
structuredSession: {
sessionId: 'codex-1',
runtimeEnvironmentId: 'server-1',
sessionPending: true,
sessionPendingNotice: notice
},
attachResolvedPaths,
setNotice
})
await act(async () => {
await probe.latest().captureExternalDrop()(['/local/a.txt'])
})
expect(setNotice).toHaveBeenCalledWith(notice)
expect(mocks.prepareNativeChatSessionAttachmentUpload).not.toHaveBeenCalled()
expect(mocks.uploadNativeChatSessionAttachmentPaths).not.toHaveBeenCalled()
expect(attachResolvedPaths).not.toHaveBeenCalled()
})

it('attaches local worktree paths unchanged', async () => {
mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'local' })
const attachResolvedPaths = vi.fn()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,20 +97,14 @@ export function useNativeChatExternalAttachments({
// The post-await gate asks which workspace this composer serves now, so it
// reads the pane through a ref. Resolving through the render closure would
// re-ask the workspace the upload started in — a comparison with itself.
const sessionId = structuredSession?.sessionId
const runtimeEnvironmentId = structuredSession?.runtimeEnvironmentId ?? null
const workspaceRef = useRef<ComposerWorkspace>({
structuredWorktreeId,
terminalTabId,
structuredSession
})
useLayoutEffect(() => {
workspaceRef.current = {
structuredWorktreeId,
terminalTabId,
structuredSession: sessionId ? { sessionId, runtimeEnvironmentId } : undefined
}
}, [runtimeEnvironmentId, sessionId, structuredWorktreeId, terminalTabId])
workspaceRef.current = { structuredWorktreeId, terminalTabId, structuredSession }
}, [structuredSession, structuredWorktreeId, terminalTabId])
const pendingChipsRef = useRef(pendingChips)
useLayoutEffect(() => {
pendingChipsRef.current = pendingChips
Expand All @@ -119,6 +113,9 @@ export function useNativeChatExternalAttachments({
const resolveAttachmentOwner = useCallback((): NativeChatAttachmentOwner => {
const { structuredWorktreeId, structuredSession, terminalTabId } = workspaceRef.current
if (structuredWorktreeId && structuredSession?.runtimeEnvironmentId) {
if (structuredSession.sessionPending) {
return { kind: 'not-ready', notice: structuredSession.sessionPendingNotice }
}
return resolveNativeChatRuntimeSessionAttachmentOwner({
sessionId: structuredSession.sessionId,
runtimeEnvironmentId: structuredSession.runtimeEnvironmentId
Expand Down Expand Up @@ -159,7 +156,7 @@ export function useNativeChatExternalAttachments({
return nativeChatAttachmentOwnerChangedNotice()
}
if (owner.kind === 'not-ready') {
return nativeChatWorktreeNotReadyNotice()
return owner.notice ?? nativeChatWorktreeNotReadyNotice()
}
if (owner.kind === 'remote') {
return nativeChatLocalAttachmentUnsupportedNotice()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ export function useNativeChatHostPermissionGate(
const text = hostPermissionRefusedText(agentLabel, verdict.kind, requested)
return {
sendBlockedReason: text,
notice: { key: `host-permission:${verdict.kind}`, kind: 'host', tone: 'warning', text }
notice: {
key: `host-permission:${verdict.kind}`,
kind: 'host',
tone: 'warning',
text,
...(verdict.kind === 'unverified'
? {
action: {
label: translate('components.native-chat.hostPermission.checkAgain', 'Check again'),
onClick: verdict.checkAgain
}
}
: {})
}
}
}
Loading
Loading