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
101 changes: 101 additions & 0 deletions src/providers/claude/renderer/composerSubmit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Claude composer submit protocol (#394 phase 2c-4). Extracted from
// useComposerKeybinds' inline branches so the protocol lives with the
// provider that owns it. Every timing/race decision here is
// battle-scarred — the WHY blocks moved verbatim with the code; do
// not "simplify" the delays or the event-driven wait without reading
// docs/superpowers/plans/2026-05-11-paste-submit-*.md and #279/#90.
//
// Three routes:
// images — save drafts to disk, send text (with separator),
// paste the file paths with the LONG 750 ms fallback
// timer (image expansion is its own TUI animation and
// the text-paste placeholder never shows up for it —
// event-driven detection is disabled on purpose).
// paste-like — multiline or >threshold text goes bracketed-paste
// first, then Enter only after the TUI visibly
// committed the paste (placeholder OR inlined text),
// via the live screen snapshot. Sending \r in the same
// PTY chunk races Claude's paste accumulator and
// leaves the prompt sitting in the composer (#90).
// plain — raw text + \r in one write; the overwhelmingly
// common fast path.

import type { ComposerSubmitIo } from '@providers/registry.renderer.capabilities'
import {
buildClaudeImagePastePayload,
CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS,
CLAUDE_PASTE_SUBMIT_DELAY_MS,
CLAUDE_PASTE_THRESHOLD,
sendBracketedPasteThenSubmit,
sendClaudeDraftText,
} from '@renderer/workspace/tile-tree/TileLeaf/claudePaste'

export async function claudeComposerSubmit(io: ComposerSubmitIo): Promise<void> {
const { input, draftImages, send, pasteId } = io

if (draftImages.length > 0) {
const savedImages = await Promise.all(
draftImages.map(image =>
window.api.saveClaudeImage({
base64Data: image.base64Data,
mediaType: image.mediaType,
filename: image.filename,
}),
),
)
const imagePaths = savedImages.map(image => image.path)
if (input.length > 0) {
await sendClaudeDraftText(send, input)
// Claude collapses the following path paste into image pills.
// If the user's prompt ends in a non-whitespace character,
// inject one separator so the final prompt text does not run
// directly into the first `[Image #N]` placeholder.
if (!/\s$/.test(input)) await send(' ')
}
const payload = buildClaudeImagePastePayload('', imagePaths)
window.api.recordPasteDebugEvent(pasteId, {
layer: 'RENDER',
event: 'route:claude-images',
data: { imageCount: imagePaths.length, textLen: input.length },
})
await sendBracketedPasteThenSubmit(
send,
payload,
CLAUDE_IMAGE_PATH_SUBMIT_DELAY_MS,
{ pasteId },
)
return
}

const isPasteLike =
input.includes('\n') || input.length > CLAUDE_PASTE_THRESHOLD
if (isPasteLike) {
window.api.recordPasteDebugEvent(pasteId, {
layer: 'RENDER',
event: 'route:claude-paste-like',
data: {
inputLen: input.length,
hasNewline: input.includes('\n'),
eventDriven: true,
},
})
await sendBracketedPasteThenSubmit(send, input, CLAUDE_PASTE_SUBMIT_DELAY_MS, {
pasteId,
// Content-match submit: confirm Claude's composer actually
// shows the paste before sending Enter, via the live screen
// snapshot. No clock as the primary path. See #279 / #90.
eventDriven: {
enabled: true,
getScreen: io.getScreen,
},
})
return
}

window.api.recordPasteDebugEvent(pasteId, {
layer: 'RENDER',
event: 'route:claude-plain-text',
data: { inputLen: input.length },
})
await send(input + '\r', pasteId)
}
23 changes: 23 additions & 0 deletions src/providers/codex/renderer/composerSubmit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Codex composer submit protocol (#394 phase 2c-4).
//
// Codex is always bracketed-paste with the trailing Enter OUTSIDE the
// paste block, both in ONE PTY write — the path that fixed Codex
// swallowing `\r` as pasted text. Codex's own paste handling does NOT
// exhibit Claude's paste-commit race, so there is no event-driven
// wait here; the pasteId is still forwarded so a paste-debug dump can
// confirm "this Enter routed to the Codex protocol" when debugging.
//
// draftImages are ignored: Codex has no inline-image attachment story
// (supportsImageAttachments: false keeps the composer from
// accumulating drafts for codex panes in the first place).

import type { ComposerSubmitIo } from '@providers/registry.renderer.capabilities'
import { sendBracketedPasteThenSubmit } from '@renderer/workspace/tile-tree/TileLeaf/claudePaste'

export async function codexComposerSubmit(io: ComposerSubmitIo): Promise<void> {
window.api.recordPasteDebugEvent(io.pasteId, {
layer: 'RENDER',
event: 'route:codex-bracketed-paste',
})
await sendBracketedPasteThenSubmit(io.send, io.input, 0, { pasteId: io.pasteId })
}
46 changes: 46 additions & 0 deletions src/providers/registry.renderer.capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
} from '@providers/codex/renderer/rows/dispatch'
import type { TranscriptEntryMapper } from '@shared/types/providerConfig'
import { CLAUDE_IDENTITY } from '@providers/claude/renderer/identity'
import { claudeComposerSubmit } from '@providers/claude/renderer/composerSubmit'
import { codexComposerSubmit } from '@providers/codex/renderer/composerSubmit'
import { CODEX_IDENTITY } from '@providers/codex/renderer/identity'
import {
createClaudeTranscriptEntryMapper,
Expand Down Expand Up @@ -59,6 +61,44 @@ export type RendererProviderCapabilities = {
* Codex: session_meta `payload.id`.
*/
extractProviderSessionId: (raw: Record<string, unknown>) => string | null
/**
* Composer submit protocol (#394 phase 2c-4). Owns the provider's
* paste/submit discipline (Codex: one atomic bracketed-paste+Enter;
* Claude: three routes with paste-commit race guards — see
* providers/claude/renderer/composerSubmit.ts). The call site keeps
* the kind-agnostic machinery: pasteId minting, streaming-baseline
* capture, composer clearing, draft preservation on throw.
*/
composerSubmit: (io: ComposerSubmitIo) => Promise<void>
/**
* Whether this provider's composer accepts inline image
* attachments. Gates draft-image accumulation (paste handler), the
* pill strip, and post-submit draft clearing. Claude-only today.
*/
supportsImageAttachments: boolean
/**
* Whether the feed seeds an optimistic local user entry at submit
* time. Codex needs it (no reliable structured user message at
* submit; the rollout row arrives late and reconciles). Claude gets
* its user entry from the transcript synchronously enough not to.
* The STORE calls stay at the call site — this flag only decides
* whether they fire, keeping providers decoupled from the
* workspace store.
*/
usesOptimisticUserEcho: boolean
}

/** IO bag for composerSubmit. Draft images are structural (not the
* workspace-store type) so provider impls and this registry never
* import workspaceState — the same cycle-avoidance rule as the rest
* of this file. */
export type ComposerSubmitIo = {
sessionId: string
input: string
draftImages: Array<{ base64Data: string; mediaType: string; filename?: string }>
send: (data: string, pasteId?: string) => Promise<void>
pasteId: string
getScreen: () => string | undefined
}

const claudeCapabilities: RendererProviderCapabilities = {
Expand All @@ -70,6 +110,9 @@ const claudeCapabilities: RendererProviderCapabilities = {
renderToolResult: renderClaudeToolResult,
createTranscriptEntryMapper: () => createClaudeTranscriptEntryMapper(),
extractProviderSessionId: extractClaudeProviderSessionId,
composerSubmit: claudeComposerSubmit,
supportsImageAttachments: true,
usesOptimisticUserEcho: false,
}

const codexCapabilities: RendererProviderCapabilities = {
Expand All @@ -82,6 +125,9 @@ const codexCapabilities: RendererProviderCapabilities = {
createTranscriptEntryMapper: (initialTurnCursor) =>
createCodexTranscriptEntryMapper(initialTurnCursor ?? null),
extractProviderSessionId: extractCodexProviderSessionId,
composerSubmit: codexComposerSubmit,
supportsImageAttachments: false,
usesOptimisticUserEcho: true,
}

const rendererProviderCapabilities: Record<AgentProviderKind, RendererProviderCapabilities> = {
Expand Down
12 changes: 9 additions & 3 deletions src/renderer/src/workspace/tile-tree/TileLeaf.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AgentProviderKind } from '@shared/types/providerKind'
import { DEFAULT_PROVIDER, isAgentProviderKind, type AgentProviderKind } from '@shared/types/providerKind'
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'

import { useAppStore } from '@renderer/app-state/hooks'
Expand Down Expand Up @@ -134,8 +134,14 @@ export function TileLeaf({
acknowledgeWorkspaceSession(sessionId)
}, [acknowledgeWorkspaceSession, sessionId])
const setDraftImages = workspace.setDraftImages
const provider: AgentProviderKind =
workspace.state.sessions[sessionId]?.kind === 'codex' ? 'codex' : 'claude'
// Agent kinds route through the registry; undefined kind is the
// pre-kind-persistence back-compat case (#394 phase 2c-4 — the old
// `=== 'codex' ? codex : claude` ternary silently coerced any
// future provider to claude).
const sessionKindForProvider = workspace.state.sessions[sessionId]?.kind
const provider: AgentProviderKind = isAgentProviderKind(sessionKindForProvider)
? sessionKindForProvider
: DEFAULT_PROVIDER

// Auto-grow the composer textarea to fit its content — hook lives
// in ./TileLeaf/useComposerAutoGrow.ts, see there for the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities'
import type { AgentProviderKind } from '@shared/types/providerKind'
import type { MutableRefObject } from 'react'

Expand Down Expand Up @@ -108,7 +109,7 @@ export function ComposerInput({
of the box instead of vertically-centered because a
10-line prompt looks odd with a chevron floating in the
middle of nowhere. */}
{provider === 'claude' && draftImages.length > 0 && (
{getRendererProviderCapabilities(provider).supportsImageAttachments && draftImages.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{draftImages.map(image => (
<div
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities'
import type { AgentProviderKind } from '@shared/types/providerKind'
import { useCallback } from 'react'

Expand Down Expand Up @@ -96,7 +97,7 @@ export function useClaudeImagePaste({
async (e: ClipboardLike): Promise<ImagePasteResult> => {
// Codex has no inline-image content — fall through so the
// caller routes the clipboard's text instead.
if (provider !== 'claude') return { handledImages: false }
if (!getRendererProviderCapabilities(provider).supportsImageAttachments) return { handledImages: false }
const clipboardData = e.clipboardData
if (!clipboardData) return { handledImages: false }

Expand Down
Loading