diff --git a/apps/desktop/src/app/chat/chat-drop-overlay.tsx b/apps/desktop/src/app/chat/chat-drop-overlay.tsx
index ff01687aaccb..9620af7a65b0 100644
--- a/apps/desktop/src/app/chat/chat-drop-overlay.tsx
+++ b/apps/desktop/src/app/chat/chat-drop-overlay.tsx
@@ -1,7 +1,7 @@
import { useRef } from 'react'
import type { DragKind } from '@/app/chat/hooks/use-file-drop-zone'
-import { Codicon } from '@/components/ui/codicon'
+import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS, DropPill } from '@/components/ui/drop-affordance'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
@@ -38,11 +38,16 @@ export function ChatDropOverlay({ kind }: { kind: DragKind }) {
)}
data-slot="chat-drop-overlay"
>
-
diff --git a/apps/desktop/src/app/chat/composer/contrib.test.ts b/apps/desktop/src/app/chat/composer/contrib.test.ts
new file mode 100644
index 000000000000..d90229a8c896
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/contrib.test.ts
@@ -0,0 +1,54 @@
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { registry } from '@/contrib/registry'
+
+import { COMPOSER_AREAS, type ComposerMiddleware, runComposerMiddleware } from './contrib'
+
+const disposers: Array<() => void> = []
+
+function addMiddleware(id: string, handler: ComposerMiddleware['handler'], order?: number) {
+ disposers.push(
+ registry.register({ id, area: COMPOSER_AREAS.middleware, order, data: { handler } satisfies ComposerMiddleware })
+ )
+}
+
+afterEach(() => {
+ disposers.splice(0).forEach(d => d())
+})
+
+describe('runComposerMiddleware', () => {
+ it('passes the draft through untouched when nothing is registered', async () => {
+ const draft = { text: 'hello' }
+
+ expect(await runComposerMiddleware(draft)).toBe(draft)
+ })
+
+ it('chains rewrites in registry order', async () => {
+ addMiddleware('b', d => ({ ...d, text: `${d.text}b` }), 20)
+ addMiddleware('a', d => ({ ...d, text: `${d.text}a` }), 10)
+
+ expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'xab' })
+ })
+
+ it('cancels the send when a handler returns null', async () => {
+ addMiddleware('gate', () => null)
+ addMiddleware('later', d => ({ ...d, text: 'never' }), 99)
+
+ expect(await runComposerMiddleware({ text: 'x' })).toBeNull()
+ })
+
+ it('treats a throwing handler as pass-through', async () => {
+ addMiddleware('boom', () => {
+ throw new Error('broken plugin')
+ })
+ addMiddleware('after', d => ({ ...d, text: `${d.text}!` }), 99)
+
+ expect(await runComposerMiddleware({ text: 'x' })).toEqual({ text: 'x!' })
+ })
+
+ it('supports async handlers', async () => {
+ addMiddleware('async', async d => ({ ...d, text: d.text.toUpperCase() }))
+
+ expect(await runComposerMiddleware({ text: 'quiet' })).toEqual({ text: 'QUIET' })
+ })
+})
diff --git a/apps/desktop/src/app/chat/composer/contrib.ts b/apps/desktop/src/app/chat/composer/contrib.ts
new file mode 100644
index 000000000000..893f5174c200
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/contrib.ts
@@ -0,0 +1,94 @@
+/**
+ * Composer contribution surface — every seam of the composer is hook-into-able
+ * through the SAME registry schema as every other surface (statusbar, titlebar,
+ * panes, layouts):
+ *
+ * render areas (`render`): composer.top — banner strip above the input
+ * composer.bottom — row below the input grid
+ * composer.leading — inline after the "+" menu
+ * composer.actions — inline before the model pill
+ *
+ * data kinds (`data`): composer.middleware (ComposerMiddleware)
+ * composer.attachments (ComposerAttachmentProvider)
+ *
+ * Core keeps ownership of the transcript, input, and submit engine — these
+ * seams AUGMENT the composer, they never replace it. Middleware runs as an
+ * ordered async chain around the app's onSubmit: each handler may rewrite the
+ * draft, pass it through, or cancel the send by returning null.
+ */
+
+import { useContributions } from '@/contrib/react/use-contributions'
+import { registry } from '@/contrib/registry'
+import type { ComposerAttachment } from '@/store/composer'
+
+export const COMPOSER_AREAS = {
+ top: 'composer.top',
+ bottom: 'composer.bottom',
+ leading: 'composer.leading',
+ actions: 'composer.actions',
+ middleware: 'composer.middleware',
+ attachments: 'composer.attachments'
+} as const
+
+export interface ComposerDraft {
+ text: string
+ attachments?: ComposerAttachment[]
+}
+
+/** Payload of a `composer.middleware` data contribution. */
+export interface ComposerMiddleware {
+ /** Rewrite (return a draft), pass through (same draft), or cancel (null). */
+ handler: (draft: ComposerDraft) => ComposerDraft | null | Promise
+}
+
+export interface ComposerAttachmentContext {
+ insertText: (text: string) => void
+}
+
+/** Payload of a `composer.attachments` data contribution — an entry in the
+ * composer's "+" attach menu. */
+export interface ComposerAttachmentProvider {
+ label: string
+ /** Codicon name for the menu row. Defaults to `plug`. */
+ icon?: string
+ run: (ctx: ComposerAttachmentContext) => void | Promise
+}
+
+/**
+ * Run the ordered middleware chain over a draft. Contributions execute in
+ * registry order (`order`, then registration order); the first `null` wins
+ * and cancels the send. A throwing handler is treated as pass-through so a
+ * broken plugin can't eat messages.
+ */
+export async function runComposerMiddleware(draft: ComposerDraft): Promise {
+ let current = draft
+
+ for (const contribution of registry.getArea(COMPOSER_AREAS.middleware)) {
+ const middleware = contribution.data as ComposerMiddleware | undefined
+
+ if (!middleware?.handler) {
+ continue
+ }
+
+ try {
+ const next = await middleware.handler(current)
+
+ if (next === null) {
+ return null
+ }
+
+ current = next
+ } catch {
+ // Pass-through: a faulty middleware must never swallow the message.
+ }
+ }
+
+ return current
+}
+
+/** Attach-menu entries contributed by plugins/core, with stable render keys. */
+export function useComposerAttachmentProviders(): Array {
+ return useContributions(COMPOSER_AREAS.attachments)
+ .map(c => ({ key: `${c.source ?? 'core'}:${c.id}`, ...(c.data as ComposerAttachmentProvider) }))
+ .filter(p => Boolean(p.label && p.run))
+}
diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts
index d3969b700200..238642f54e49 100644
--- a/apps/desktop/src/app/chat/composer/focus.ts
+++ b/apps/desktop/src/app/chat/composer/focus.ts
@@ -13,7 +13,9 @@
import type { InlineRefInput } from './inline-refs'
import { RICH_INPUT_SLOT } from './rich-editor'
-export type ComposerTarget = 'edit' | 'main'
+/** Composer routing key. The main chat is `'main'`, the edit composer
+ * `'edit'`; scoped composers (session tiles) use `'tile:'`. */
+export type ComposerTarget = 'edit' | 'main' | (string & {})
export type ComposerInsertMode = 'block' | 'inline'
interface FocusDetail {
@@ -76,6 +78,10 @@ export const markActiveComposer = (target: ComposerTarget) => {
activeTarget = target
}
+/** The composer that last held focus — the target `'active'` resolves to.
+ * Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
+export const getActiveComposer = (): ComposerTarget => activeTarget
+
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
dispatch(FOCUS_EVENT, { target: resolve(target) })
@@ -129,12 +135,14 @@ export const requestComposerSubmit = (
export const onComposerSubmitRequest = (handler: (detail: SubmitDetail) => void) =>
subscribe(SUBMIT_EVENT, handler)
-/** Toggle the active composer's voice conversation — the `composer.voice`
- * hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
-export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
+/** Toggle ONE composer's voice conversation — the `composer.voice` hotkey
+ * (Ctrl+B) reaches the composer that owns voice. Defaults to the active
+ * composer so N tiles don't all flip together. */
+export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') =>
+ dispatch<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, { target: resolve(target) })
-export const onComposerVoiceToggleRequest = (handler: () => void) =>
- subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler())
+export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
+ subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
/**
* Focus a composer input across React commit + browser focus restore.
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts
index a8e5c65888cd..60a5255eec67 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-branch.ts
@@ -1,8 +1,9 @@
import { type MutableRefObject, useCallback } from 'react'
-import { clearComposerAttachments } from '@/store/composer'
import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
+import { useComposerScope } from '../scope'
+
interface UseComposerBranchOptions {
clearDraft: () => void
cwd: null | string | undefined
@@ -17,6 +18,8 @@ interface UseComposerBranchOptions {
* projects store) is the only dependency; nothing about ChatBar's render.
*/
export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBranchOptions) {
+ const scope = useComposerScope()
+
// Hand a worktree off to the controller: open a fresh session anchored there,
// carrying the composer draft as its first turn. Clearing here means the draft
// travels to the new session instead of getting stashed under this one.
@@ -24,7 +27,7 @@ export function useComposerBranch({ clearDraft, cwd, draftRef }: UseComposerBran
(path: string) => {
const text = draftRef.current
clearDraft()
- clearComposerAttachments()
+ scope.attachments.clear()
requestStartWorkSession(path, text)
},
[clearDraft, draftRef]
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts
index 52cb605f5413..b9fa789ad51d 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts
@@ -2,7 +2,7 @@ import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
-import { $composerAttachments, type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
+import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { isBrowsingHistory } from '@/store/composer-input-history'
import {
@@ -21,6 +21,7 @@ import {
} from '../focus'
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
import { composerPlainText, placeCaretEnd, renderComposerContents } from '../rich-editor'
+import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerDraftArgs {
@@ -50,6 +51,8 @@ export function useComposerDraft({
}: UseComposerDraftArgs) {
const aui = useAui()
const composerRuntime = useComposerRuntime()
+ // Which composer this is on the focus bus + which attachment set it owns.
+ const { attachments: attachmentScope, target } = useComposerScope()
// Coarse edges only — these flip rarely (empty↔non-empty, the `?` help sigil,
// steerable-vs-slash), so typing within a line costs no render.
@@ -98,8 +101,8 @@ export function useComposerDraft({
const focusInput = useCallback(() => {
focusComposerInput(editorRef.current)
- markActiveComposer('main')
- }, [])
+ markActiveComposer(target)
+ }, [target])
const requestMainFocus = useCallback(() => {
setFocusRequestId(id => id + 1)
@@ -155,14 +158,14 @@ export function useComposerDraft({
return undefined
}
- const offFocus = onComposerFocusRequest(target => {
- if (target === 'main') {
+ const offFocus = onComposerFocusRequest(requested => {
+ if (requested === target) {
setFocusRequestId(id => id + 1)
}
})
- const offInsert = onComposerInsertRequest(({ mode, target, text }) => {
- if (target === 'main') {
+ const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => {
+ if (requested === target) {
appendExternalText(text, mode)
}
})
@@ -171,13 +174,13 @@ export function useComposerDraft({
offFocus()
offInsert()
}
- }, [appendExternalText, inputDisabled])
+ }, [appendExternalText, inputDisabled, target])
- const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
+ const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) =>
stashSessionDraft(scope, text, attachments)
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
- $composerAttachments.set(cloneAttachments(attachments))
+ attachmentScope.$attachments.set(cloneAttachments(attachments))
paintDraft(text, false)
}
@@ -296,12 +299,12 @@ export function useComposerDraft({
insertInlineRefsRef.current = insertInlineRefs
useEffect(() => {
- return onComposerInsertRefsRequest(({ refs, target }) => {
- if (target === 'main') {
+ return onComposerInsertRefsRequest(({ refs, target: requested }) => {
+ if (requested === target) {
insertInlineRefsRef.current(refs)
}
})
- }, [])
+ }, [target])
// Per-thread draft swap — the composer's only session coupling. Lifecycle
// never clears composer state; this effect alone stashes on leave, restores
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts
index 37b3625a4f16..ff017edcf96e 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts
@@ -2,10 +2,15 @@ import { useEffect, useRef } from 'react'
import { triggerHaptic } from '@/lib/haptics'
+import { type ComposerTarget, getActiveComposer } from '../focus'
+
interface UseComposerEscCancelOptions {
awaitingInput: boolean
busy: boolean
onCancel: () => unknown
+ /** This composer's focus-bus key. With N composers mounted (main + tiles),
+ * only the active one's Esc cancels — otherwise every busy tile stops. */
+ target: ComposerTarget
}
/**
@@ -15,7 +20,7 @@ interface UseComposerEscCancelOptions {
* the window listener registered exactly once while still reading fresh
* busy/awaitingInput/onCancel each press.
*/
-export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseComposerEscCancelOptions) {
+export function useComposerEscCancel({ awaitingInput, busy, onCancel, target }: UseComposerEscCancelOptions) {
// Intentional only: we bail if (a) the composer/another field already handled
// Esc (defaultPrevented), (b) focus is in any input/textarea/contenteditable
// (you're typing, not stopping), or (c) a dialog/popover is open — Esc must
@@ -30,6 +35,12 @@ export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseCompo
return
}
+ // Only the focused composer cancels — otherwise every mounted busy tile
+ // stops at once (and the winner would be mount-order arbitrary).
+ if (getActiveComposer() !== target) {
+ return
+ }
+
const active = document.activeElement as HTMLElement | null
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) {
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts
index 3fb0d0372f88..518aa3658a56 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-popout.ts
@@ -11,6 +11,8 @@ import {
} from '@/store/composer-popout'
import { isSecondaryWindow } from '@/store/windows'
+import { useComposerScope } from '../scope'
+
import { useComposerPopoutGestures } from './use-popout-drag'
interface UseComposerPopoutOptions {
@@ -25,7 +27,10 @@ interface UseComposerPopoutOptions {
* window's composer out via the shared atom.
*/
export function useComposerPopout({ composerRef }: UseComposerPopoutOptions) {
- const popoutAllowed = !isSecondaryWindow()
+ // The floating composer is a window-level singleton: only the main scope
+ // (not tiles) in a primary window may pop out.
+ const scope = useComposerScope()
+ const popoutAllowed = !isSecondaryWindow() && scope.popoutAllowed
const poppedOut = useStore($composerPoppedOut) && popoutAllowed
const popoutPosition = useStore($composerPopoutPosition)
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts
index c40d56a4826b..761d6830a1bd 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-queue.ts
@@ -3,7 +3,7 @@ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { useSessionSlice } from '@/lib/use-session-slice'
-import { clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
+import { type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import {
$queuedPromptsBySession,
@@ -19,6 +19,7 @@ import {
import { notify } from '@/store/notifications'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
+import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerQueueArgs {
@@ -60,6 +61,7 @@ export function useComposerQueue({
sessionId
}: UseComposerQueueArgs) {
const { t } = useI18n()
+ const scope = useComposerScope()
// Per-session slice (edge): re-renders only when THIS session's queue changes,
// not on cross-session queue churn (the plain atom's map ref changes on every
@@ -173,11 +175,11 @@ export function useComposerQueue({
}
clearDraft()
- clearComposerAttachments()
+ scope.attachments.clear()
triggerHaptic('selection')
return true
- }, [activeQueueSessionKey, attachments, clearDraft, draftRef])
+ }, [activeQueueSessionKey, attachments, clearDraft, draftRef, scope.attachments])
// All queue drain paths share one lock + send-then-remove sequence.
// `pickEntry` lets each caller choose head, by-id, or skip-edited.
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts
index 2dc0ef8047f1..adf44e34a8d7 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts
@@ -2,13 +2,14 @@ import { type RefObject, useEffect, useRef } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
-import { clearComposerAttachments, clearSessionDraft, type ComposerAttachment } from '@/store/composer'
+import { clearSessionDraft, type ComposerAttachment } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
import { enqueueQueuedPrompt, type QueuedPromptEntry } from '@/store/composer-queue'
import { cloneAttachments, type QueueEditState } from '../composer-utils'
import { onComposerSubmitRequest } from '../focus'
import { composerPlainText } from '../rich-editor'
+import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
interface UseComposerSubmitArgs {
@@ -71,6 +72,8 @@ export function useComposerSubmit({
setComposerText,
stashAt
}: UseComposerSubmitArgs) {
+ const scope = useComposerScope()
+
// Shared send primitive: fire onSubmit, and if the gateway rejects (accepted
// === false) or throws, re-load + re-stash the draft so the words survive.
const dispatchSubmit = (text: string, attachments?: ComposerAttachment[]) => {
@@ -163,7 +166,7 @@ export function useComposerSubmit({
triggerHaptic('submit')
resetBrowseState(sessionId)
clearDraft()
- clearComposerAttachments()
+ scope.attachments.clear()
dispatchSubmit(text, submittedAttachments)
}
diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts
index 2cff7a4084c7..5ce92171d3f1 100644
--- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts
+++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts
@@ -8,6 +8,7 @@ import { notifyError } from '@/store/notifications'
import { $messages } from '@/store/session'
import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
+import type { ComposerTarget } from '../focus'
import { onComposerVoiceToggleRequest } from '../focus'
import type { ChatBarProps } from '../types'
@@ -25,6 +26,9 @@ interface UseComposerVoiceArgs {
onSubmit: ChatBarProps['onSubmit']
onTranscribeAudio: ChatBarProps['onTranscribeAudio']
sessionId: string | null | undefined
+ /** This composer's focus-bus key — voice toggles targeting another
+ * composer (or the active one, when not us) are ignored. */
+ target: ComposerTarget
}
/**
@@ -42,7 +46,8 @@ export function useComposerVoice({
maxRecordingSeconds,
onSubmit,
onTranscribeAudio,
- sessionId
+ sessionId,
+ target
}: UseComposerVoiceArgs) {
const { t } = useI18n()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
@@ -122,7 +127,10 @@ export function useComposerVoice({
}
}, [conversation, disabled, voiceConversationActive])
- useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
+ useEffect(
+ () => onComposerVoiceToggleRequest(toggled => toggled === target && toggleVoiceConversation()),
+ [target, toggleVoiceConversation]
+ )
// Explicit start/end for the on-screen conversation controls (the hotkey uses
// the gated toggle above).
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index 1f5df46eb2a4..661c4dbbae4a 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -1,21 +1,20 @@
import { ComposerPrimitive } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
-import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useEffect, useRef } from 'react'
+import { type ClipboardEvent, type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef } from 'react'
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
import { Button } from '@/components/ui/button'
+import { Slot as ContribSlot } from '@/contrib/react/slot'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
-import { $composerAttachments } from '@/store/composer'
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
import { removeQueuedPrompt } from '@/store/composer-queue'
-import { $activeSessionAwaitingInput } from '@/store/prompts'
import { toggleReview } from '@/store/review'
-import { $gatewayState, $messages } from '@/store/session'
+import { $gatewayState } from '@/store/session'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { $autoSpeakReplies } from '@/store/voice-prefs'
import { useTheme } from '@/themes'
@@ -23,6 +22,7 @@ import { useTheme } from '@/themes'
import { AttachmentList } from './attachments'
import { COMPOSER_FADE_BACKGROUND, type QueueEditState, slashArgStage } from './composer-utils'
import { ContextMenu } from './context-menu'
+import { COMPOSER_AREAS, runComposerMiddleware } from './contrib'
import { ComposerControls } from './controls'
import { COMPOSER_DROP_ACTIVE_CLASS, COMPOSER_DROP_FADE_CLASS } from './drop-affordance'
import { markActiveComposer } from './focus'
@@ -51,6 +51,7 @@ import {
normalizeComposerEditorDom,
RICH_INPUT_SLOT
} from './rich-editor'
+import { useComposerScope } from './scope'
import { ComposerStatusStack } from './status-stack'
import { CodingStatusRow } from './status-stack/coding-row'
import { extractClipboardImageBlobs } from './text-utils'
@@ -79,17 +80,36 @@ export function ChatBar({
onPickImages,
onRemoveAttachment,
onSteer,
- onSubmit,
+ onSubmit: onSubmitProp,
onTranscribeAudio
}: ChatBarProps) {
- const attachments = useStore($composerAttachments)
+ // Every send (typed, queued, voice) passes through the contributed
+ // middleware chain first — rewrite / pass-through / cancel. Empty chain =
+ // exact pass-through, so surfaces without contributions are byte-identical.
+ const onSubmit = useCallback(
+ async (value, options) => {
+ const draft = await runComposerMiddleware({ text: value, attachments: options?.attachments })
+
+ if (!draft) {
+ return false
+ }
+
+ return onSubmitProp(draft.text, { ...options, attachments: draft.attachments })
+ },
+ [onSubmitProp]
+ )
+
+ // Which live composer this instance IS (main | tile) — its attachment set,
+ // focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
+ const scope = useComposerScope()
+ const attachments = useStore(scope.attachments.$attachments)
const scrolledUp = useStore($threadScrolledUp)
const autoSpeak = useStore($autoSpeakReplies)
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
// not interrupt it — there's nothing actively running to stop, and stopping
// would discard a question the user may want to come back to. The blocking
// prompt owns its own dismissal (Skip, Reject, dialog close).
- const awaitingInput = useStore($activeSessionAwaitingInput)
+ const awaitingInput = useStore(scope.$awaitingInput)
const activeQueueSessionKey = queueSessionKey || sessionId || null
// Status items (subagents, background processes) are keyed by the RUNTIME
@@ -197,8 +217,6 @@ export function ChatBar({
// into a tool result) and never for a slash command (those execute inline).
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
- const showHelpHint = isHelpHint
-
// The submit engine — the orchestration seam where draft + queue meet. Owns
// the submit decision tree, the send-with-restore primitive, and steer.
const { steerDraft, submitDraft } = useComposerSubmit({
@@ -506,11 +524,11 @@ export function ChatBar({
// $messages is read imperatively (not subscribed) so the composer
// doesn't re-render on every streaming delta flush.
- const history = deriveUserHistory($messages.get(), chatMessageText)
+ const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const entry = browseBackward(sessionId, currentDraft, history)
if (entry !== null) {
- loadIntoComposer(entry, $composerAttachments.get())
+ loadIntoComposer(entry, scope.attachments.$attachments.get())
}
return
@@ -531,11 +549,11 @@ export function ChatBar({
event.preventDefault()
triggerKeyConsumedRef.current = true
- const history = deriveUserHistory($messages.get(), chatMessageText)
+ const history = deriveUserHistory(scope.readMessages(), chatMessageText)
const result = browseForward(sessionId, history)
if (result !== null) {
- loadIntoComposer(result.text, $composerAttachments.get())
+ loadIntoComposer(result.text, scope.attachments.$attachments.get())
}
}
@@ -643,7 +661,7 @@ export function ChatBar({
useComposerBranch({ clearDraft, cwd, draftRef })
// Global Esc-to-cancel when the chat (not the composer input) has focus.
- useComposerEscCancel({ awaitingInput, busy, onCancel })
+ useComposerEscCancel({ awaitingInput, busy, onCancel, target: scope.target })
const {
conversation,
@@ -663,7 +681,8 @@ export function ChatBar({
maxRecordingSeconds,
onSubmit,
onTranscribeAudio,
- sessionId
+ sessionId,
+ target: scope.target
})
const contextMenu = (
@@ -741,7 +760,7 @@ export function ChatBar({
}}
onDragOver={handleInputDragOver}
onDrop={handleInputDrop}
- onFocus={() => markActiveComposer('main')}
+ onFocus={() => markActiveComposer(scope.target)}
onInput={handleEditorInput}
onKeyDown={handleEditorKeyDown}
onKeyUp={handleEditorKeyUp}
@@ -845,7 +864,7 @@ export function ChatBar({
: undefined
}
>
- {showHelpHint && }
+ {isHelpHint && }
{trigger && !argStageEmpty && (
+ {/* Contribution seams: banners above, a row below, inline
+ additions beside the "+" menu and before the controls.
+ All four render nothing until something contributes. */}
+
{queueEdit && editingQueuedPrompt && (
@@ -970,10 +993,17 @@ export function ChatBar({
: 'grid-cols-[auto_1fr_auto] items-center gap-(--composer-control-gap) [grid-template-areas:"menu_input_controls"]'
)}
>
- {contextMenu}
+
+ {contextMenu}
+
+
{input}
- {controls}
+
+
+ {controls}
+
+