mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
06e797d6dc
A side chat forks the parent transcript, so the server rejects it for a blank session. The new-session page still showed the side chat button in its top-right corner, and every click only produced a "Start a conversation before opening a side chat" toast. Gate every way in on the session having a conversation: the top-right button now only renders on mobile once the session has messages, and the workspace launcher, its + menu and the /btw slash menu entry leave side chat out of a blank session.
1794 lines
77 KiB
TypeScript
1794 lines
77 KiB
TypeScript
import { useSideChatStore } from '@/stores/sideChatStore'
|
|
import { openSideChat } from '@/lib/workspace/openSideChat'
|
|
import { parseSideQuestionCommand } from './composerUtils'
|
|
import { getSessionReferences } from '@/lib/composerMentions'
|
|
import { normalizeSessionReferences } from '@/lib/sessionReferences'
|
|
import { isComposerReferenceVisible, isComposerSlashCommandVisible } from '@/lib/composerCapabilityVisibility'
|
|
import { useState, useRef, useEffect, useCallback, useMemo, useId } from 'react'
|
|
import { ArrowUp, CornerDownRight, Pencil, Plus, Square, Trash2 } from 'lucide-react'
|
|
import { useDismissable } from '@/hooks/useDismissable'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { IconButton } from '@/components/ui/IconButton'
|
|
import { useTranslation } from '../../i18n'
|
|
import {
|
|
hasPendingAskUserQuestion,
|
|
useChatStore,
|
|
type RepositoryLaunchDraftState,
|
|
} from '../../stores/chatStore'
|
|
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
|
|
import { useUIStore } from '../../stores/uiStore'
|
|
import { useSessionStore } from '../../stores/sessionStore'
|
|
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
|
|
import { useTeamStore } from '../../stores/teamStore'
|
|
import { getMemberWorkState, resolveTeamMemberIdentity } from '../agentTeams/agentTeamsModel'
|
|
import { useTeamPlanStore } from '@/stores/teamPlanStore'
|
|
import { useSettingsStore } from '../../stores/settingsStore'
|
|
import {
|
|
formatWorkspaceReferencePrompt,
|
|
useWorkspaceChatContextStore,
|
|
type WorkspaceChatReference,
|
|
} from '../../stores/workspaceChatContextStore'
|
|
import { sessionsApi, type SessionGitInfo } from '../../api/sessions'
|
|
import { agentsApi } from '../../api/agents'
|
|
import { PermissionModeSelector } from '../controls/PermissionModeSelector'
|
|
import { ModelSelector, type ModelSelectorHandle } from '../controls/ModelSelector'
|
|
import type { AttachmentRef } from '../../types/chat'
|
|
import { AttachmentGallery } from './AttachmentGallery'
|
|
import { ComposerDropOverlay } from './ComposerDropOverlay'
|
|
import { ProjectContextChip } from '@/components/chat/ProjectContextChip'
|
|
import { RepositoryLaunchControls } from '@/components/chat/RepositoryLaunchControls'
|
|
import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from './ComposerReferenceMenu'
|
|
import { ComposerReferenceDetail } from './ComposerReferenceDetail'
|
|
import { ComposerCapabilityMenu } from './ComposerCapabilityMenu'
|
|
import { useCapabilityMenu } from './useCapabilityMenu'
|
|
import { composerReferencesApi, mentionProviderId } from '@/api/composerReferences'
|
|
import type { ComposerReferenceCandidate } from '@/types/composerReference'
|
|
import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel'
|
|
import { getSlashCommandOptionId, SlashCommandMenu } from './SlashCommandMenu'
|
|
import { ContextUsageIndicator } from './ContextUsageIndicator'
|
|
import {
|
|
appendAgentSlashCommands,
|
|
buildAgentSlashCommands,
|
|
getLocalizedFallbackCommands,
|
|
filterSlashCommands,
|
|
findSlashTrigger,
|
|
groupSlashCommands,
|
|
mergeSlashCommands,
|
|
replaceSlashToken,
|
|
resolveSlashUiAction,
|
|
} from './composerUtils'
|
|
import { useMobileViewport } from '../../hooks/useMobileViewport'
|
|
import { useElementWidth } from '../../hooks/useElementWidth'
|
|
import { isDesktopRuntime } from '../../lib/desktopRuntime'
|
|
import {
|
|
filesToComposerAttachments,
|
|
getDataTransferFiles,
|
|
selectNativeFileAttachments,
|
|
type ComposerAttachment,
|
|
} from '../../lib/composerAttachments'
|
|
import { useComposerFileDrop } from './useComposerFileDrop'
|
|
import { shouldSubmitOnEnter } from './sendShortcut'
|
|
import { MentionComposer, type MentionComposerHandle } from './MentionComposer'
|
|
import {
|
|
composerReferenceToMention,
|
|
findMentionRanges,
|
|
insertMentionIntoText,
|
|
type ComposerMention,
|
|
} from '../../lib/composerMentions'
|
|
import type { PermissionMode } from '../../types/settings'
|
|
import { getSessionWorkspaceState, getSessionSeedWorkDir } from '../../lib/sessionWorkspace'
|
|
import { hasRunningSubagentTasks } from '../../lib/backgroundTasks'
|
|
import { useComposerDictation } from '@/features/voiceInput/useComposerDictation'
|
|
import { VoiceInputButton } from '@/features/voiceInput/VoiceInputButton'
|
|
|
|
type GitInfo = SessionGitInfo
|
|
|
|
type Attachment = ComposerAttachment
|
|
|
|
type ChatInputProps = {
|
|
variant?: 'default' | 'hero'
|
|
compact?: boolean
|
|
sessionId?: string
|
|
visible?: boolean
|
|
}
|
|
|
|
const EMPTY_COMPOSER_REFERENCES: ComposerReferenceCandidate[] = []
|
|
|
|
const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = []
|
|
|
|
/**
|
|
* Both thresholds are the composer column's own width, never "is the workspace
|
|
* panel open". The panel is resizable and the window is not fixed, so its open
|
|
* state says nothing about the width the composer actually got — a maximised
|
|
* window with the panel open leaves the composer wider than a small window
|
|
* with no panel at all, yet the panel-keyed rule sent the wide one to the
|
|
* narrow layout and kept the narrow one wide.
|
|
*
|
|
* The number comes off the shipped toolbar with the longest mode label
|
|
* ("Ask permissions" / 询问权限): the leading group measures 193px and the
|
|
* trailing cluster 361px, which with the 32px round send button needs a 530px
|
|
* column.
|
|
*
|
|
* There used to be a second, wider threshold here that dropped the send
|
|
* button's label before the location went. The send button has no label to
|
|
* drop any more — it is the same 32px circle at every width — so the location
|
|
* is the only thing left that degrades.
|
|
*/
|
|
const TOOLBAR_LOCATION_MIN_WIDTH = 530
|
|
|
|
function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Attachment {
|
|
return {
|
|
id: reference.id,
|
|
name: reference.name,
|
|
referenceKind: reference.kind === 'chat-selection' ? 'chat-selection' : undefined,
|
|
type: 'file',
|
|
path: reference.kind === 'chat-selection' ? undefined : reference.path,
|
|
isDirectory: reference.isDirectory,
|
|
lineStart: reference.lineStart,
|
|
lineEnd: reference.lineEnd,
|
|
diffSide: reference.diffSide,
|
|
hunkId: reference.hunkId,
|
|
note: reference.note,
|
|
quote: reference.quote,
|
|
}
|
|
}
|
|
|
|
function insertComposerTokenAtRange(value: string, start: number, end: number, token: string) {
|
|
const boundedStart = Math.max(0, Math.min(start, value.length))
|
|
const boundedEnd = Math.max(boundedStart, Math.min(end, value.length))
|
|
const before = value.slice(0, boundedStart)
|
|
const after = value.slice(boundedEnd)
|
|
const leadingSpace = before.length > 0 && !/\s$/.test(before) ? ' ' : ''
|
|
const trailingSpace = after.length > 0 && !/^\s/.test(after) ? ' ' : ''
|
|
const insertion = `${leadingSpace}${token}${trailingSpace}`
|
|
|
|
return {
|
|
value: `${before}${insertion}${after}`,
|
|
cursorPos: before.length + insertion.length,
|
|
}
|
|
}
|
|
|
|
export function ChatInput({ variant = 'default', compact = false, sessionId, visible = true }: ChatInputProps) {
|
|
const t = useTranslation()
|
|
const isMobileComposer = useMobileViewport() && !isDesktopRuntime()
|
|
// The shell, not the panel inside it: the panel's own `max-w` changes with
|
|
// the layout this measurement picks, which would make the observer chase
|
|
// itself across the threshold. The shell just fills the chat column.
|
|
const [shellRef, shellWidth] = useElementWidth<HTMLDivElement>()
|
|
const [input, setInput] = useState('')
|
|
const [mentions, setMentions] = useState<ComposerMention[]>([])
|
|
const [referenceDetail, setReferenceDetail] = useState<ComposerMention | null>(null)
|
|
const [referenceOptionId, setReferenceOptionId] = useState<string | undefined>()
|
|
const [referenceState, setReferenceState] = useState<{ context: string, items: ComposerReferenceCandidate[], loading: boolean, error: boolean } | null>(null)
|
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
|
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
|
|
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
|
const [fileSearchOpen, setFileSearchOpen] = useState(false)
|
|
const [localSlashPanel, setLocalSlashPanel] = useState<LocalSlashCommandName | null>(null)
|
|
const [atFilter, setAtFilter] = useState('')
|
|
const [atCursorPos, setAtCursorPos] = useState(-1)
|
|
const [slashFilter, setSlashFilter] = useState('')
|
|
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0)
|
|
const [agentSlashCommands, setAgentSlashCommands] = useState<ReturnType<typeof buildAgentSlashCommands>>([])
|
|
const [launchReady, setLaunchReady] = useState(true)
|
|
const [launchTransitioning, setLaunchTransitioning] = useState(false)
|
|
const [editingQueuedMessageId, setEditingQueuedMessageId] = useState<string | null>(null)
|
|
const [editingQueuedMessageText, setEditingQueuedMessageText] = useState('')
|
|
const composingRef = useRef(false)
|
|
const composerRef = useRef<MentionComposerHandle>(null)
|
|
const composerContainerRef = useRef<HTMLDivElement>(null)
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const modelSelectorRef = useRef<ModelSelectorHandle>(null)
|
|
const plusMenuRef = useRef<HTMLDivElement>(null)
|
|
const slashMenuRef = useRef<HTMLDivElement>(null)
|
|
const fileSearchRef = useRef<ComposerReferenceMenuHandle>(null)
|
|
const slashItemRefs = useRef<(HTMLElement | null)[]>([])
|
|
const slashMenuId = useId()
|
|
const referenceMenuId = useId()
|
|
const capabilityMenuId = useId()
|
|
const previousActiveTabIdRef = useRef<string | null>(null)
|
|
const inputRef = useRef(input)
|
|
const mentionsRef = useRef(mentions)
|
|
const attachmentsRef = useRef(attachments)
|
|
const pasteGenerationRef = useRef(0)
|
|
const setComposerInput = useCallback((value: string, nextMentions?: ComposerMention[]) => {
|
|
inputRef.current = value
|
|
setInput(value)
|
|
if (nextMentions !== undefined) {
|
|
mentionsRef.current = nextMentions
|
|
setMentions(nextMentions)
|
|
}
|
|
}, [])
|
|
const setComposerAttachments = useCallback((value: Attachment[] | ((previous: Attachment[]) => Attachment[])) => {
|
|
setAttachments((previous) => {
|
|
const next = typeof value === 'function' ? value(previous) : value
|
|
attachmentsRef.current = next
|
|
return next
|
|
})
|
|
}, [])
|
|
const {
|
|
sendMessage,
|
|
stopGeneration,
|
|
clearComposerPrefill,
|
|
clearComposerInsertion,
|
|
queueUserMessage,
|
|
updateQueuedUserMessage,
|
|
removeQueuedUserMessage,
|
|
sendQueuedUserMessage,
|
|
setPreparingTurn,
|
|
} = useChatStore()
|
|
const selectedTabId = useTabStore((s) => s.activeTabId)
|
|
const activeTabId = sessionId ?? selectedTabId
|
|
const activeTabType = useTabStore((s) =>
|
|
s.tabs.find((tab) => tab.sessionId === (sessionId ?? s.activeTabId))?.type,
|
|
)
|
|
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
|
const repositoryLaunchDraft = sessionState?.repositoryLaunchDraft
|
|
const launchWorkDir = repositoryLaunchDraft?.workDir ?? ''
|
|
const launchBranch = repositoryLaunchDraft?.branch ?? null
|
|
const launchUseWorktree = repositoryLaunchDraft?.useWorktree ?? false
|
|
const chatState = sessionState?.chatState ?? 'idle'
|
|
// While a question is waiting, the card is the only way to reach the model: it
|
|
// is blocked inside the AskUserQuestion tool call, so a message typed here
|
|
// would sit in the queue until the question resolves — and reading that as
|
|
// "I already replied" is how questions get abandoned.
|
|
const questionPending = useChatStore((s) =>
|
|
activeTabId ? hasPendingAskUserQuestion(s.sessions[activeTabId]) : false)
|
|
const isPreparingTurn = Boolean(sessionState?.isPreparingTurn)
|
|
const slashCommands = sessionState?.slashCommands ?? []
|
|
const composerPrefill = sessionState?.composerPrefill ?? null
|
|
const composerInsertion = sessionState?.composerInsertion ?? null
|
|
const queuedUserMessages = sessionState?.queuedUserMessages ?? []
|
|
const runtimeSelection = useSessionRuntimeStore((state) =>
|
|
activeTabId ? state.selections[activeTabId] : undefined,
|
|
)
|
|
const currentModel = useSettingsStore((state) => state.currentModel)
|
|
const chatSendBehavior = useSettingsStore((state) => state.chatSendBehavior)
|
|
const runtimeSelectionKey = runtimeSelection
|
|
? `${runtimeSelection.providerId ?? 'official'}:${runtimeSelection.modelId}:${runtimeSelection.effortLevel ?? 'auto'}`
|
|
: undefined
|
|
const runtimeModelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? currentModel?.id
|
|
const sideChat = useSideChatStore(state => activeTabId ? state.entries[activeTabId] : undefined)
|
|
const listedSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null)
|
|
const activeSession = listedSession ?? (sideChat ? { ...sideChat, id: sideChat.sessionId, workDirExists: true, workspaceState: 'available' as const, messageCount: 1, projectRoot: sideChat.workDir } : null)
|
|
const loadedMessageCount = sessionState?.messages?.length ?? 0
|
|
const messageCount = Math.max(loadedMessageCount, activeSession?.messageCount ?? 0)
|
|
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
|
const [gitInfo, setGitInfo] = useState<GitInfo | null>(null)
|
|
const workspaceReferences = useWorkspaceChatContextStore(
|
|
(s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES,
|
|
)
|
|
const addWorkspaceReference = useWorkspaceChatContextStore((s) => s.addReference)
|
|
const removeWorkspaceReference = useWorkspaceChatContextStore((s) => s.removeReference)
|
|
const clearWorkspaceReferences = useWorkspaceChatContextStore((s) => s.clearReferences)
|
|
const updateRepositoryLaunchDraft = useCallback((
|
|
update: (current: RepositoryLaunchDraftState) => RepositoryLaunchDraftState,
|
|
) => {
|
|
if (!activeTabId) return
|
|
const chatStore = useChatStore.getState()
|
|
const current = chatStore.sessions[activeTabId]?.repositoryLaunchDraft ?? {
|
|
workDir: '',
|
|
branch: null,
|
|
useWorktree: false,
|
|
}
|
|
chatStore.setRepositoryLaunchDraft(activeTabId, update(current))
|
|
}, [activeTabId])
|
|
const setLaunchBranch = useCallback((branch: string | null) => {
|
|
updateRepositoryLaunchDraft((current) => ({ ...current, branch }))
|
|
}, [updateRepositoryLaunchDraft])
|
|
const setLaunchUseWorktree = useCallback((useWorktree: boolean) => {
|
|
updateRepositoryLaunchDraft((current) => ({ ...current, useWorktree }))
|
|
}, [updateRepositoryLaunchDraft])
|
|
const saveComposerDraft = useCallback((sessionId: string) => {
|
|
const draft = {
|
|
input: inputRef.current,
|
|
attachments: attachmentsRef.current,
|
|
mentions: mentionsRef.current,
|
|
}
|
|
const chatStore = useChatStore.getState()
|
|
if (draft.input.length === 0 && draft.attachments.length === 0) {
|
|
chatStore.clearComposerDraft(sessionId)
|
|
return
|
|
}
|
|
chatStore.setComposerDraft(sessionId, draft)
|
|
}, [])
|
|
const invalidatePendingPastes = useCallback(() => {
|
|
pasteGenerationRef.current += 1
|
|
}, [])
|
|
|
|
const isMemberSession = !!memberInfo || activeTabType === 'subagent'
|
|
const isActive = chatState !== 'idle'
|
|
const hasRunningSubagents = hasRunningSubagentTasks(sessionState?.backgroundAgentTasks)
|
|
// Approved team processes are tracked by their plan, not background-agent
|
|
// notifications. Keep Stop available after the review card is dismissed.
|
|
const teamPlanRunning = useTeamPlanStore(state => {
|
|
const plan = activeTabId ? state.bySession[activeTabId]?.plan : undefined
|
|
return plan?.state === 'launching' || plan?.state === 'running'
|
|
})
|
|
// Stop pauses a running team without ending its plan, so the plan alone
|
|
// would keep offering Stop after every member is already stopped.
|
|
const teamMembersAllStopped = useTeamStore(state => {
|
|
const team = activeTabId ? state.workbenchesBySession[activeTabId]?.snapshots.at(-1)?.team : undefined
|
|
if (!team) return false
|
|
const members = team.members.filter(member => !resolveTeamMemberIdentity(team, member.agentId).isLead)
|
|
return members.length > 0 && members.every(member => {
|
|
const work = getMemberWorkState(member)
|
|
return work === 'stopped' || work === 'exited'
|
|
})
|
|
})
|
|
const hasRunningTeam = teamPlanRunning && !teamMembersAllStopped
|
|
const workspaceState = getSessionWorkspaceState(activeSession)
|
|
const isWorkspaceMissing = workspaceState !== 'available'
|
|
// Both composer branches (hero and inline) and the drop handler share this:
|
|
// they used to spell the condition out separately, which is how one branch
|
|
// ends up locked while the other keeps accepting text.
|
|
const composerDisabled = isWorkspaceMissing || launchTransitioning || isPreparingTurn || questionPending
|
|
// A hidden composer keeps its state but is not where the user is looking, so
|
|
// dictation started in it must not survive the switch.
|
|
const dictation = useComposerDictation({
|
|
composerRef,
|
|
draft: input,
|
|
blocked: composerDisabled,
|
|
contextKey: visible ? activeTabId : null,
|
|
})
|
|
const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0
|
|
const isHeroComposer = variant === 'hero' && !isMemberSession && !compact
|
|
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
|
|
const showLaunchControls = !isMemberSession && !sideChat && messageCount === 0
|
|
const sideChatAvailable = Boolean(sideChat) || messageCount > 0
|
|
// `useCompactControls` is about room, so it asks the column how wide it is —
|
|
// never "is a side panel open". Until a measurement lands (jsdom, first
|
|
// paint) it defers to the caller's `compact`, which keeps the
|
|
// pre-measurement frame from flashing the wrong layout. (The card itself has
|
|
// one geometry everywhere; only the shell's outer padding follows `compact`.)
|
|
const fitsAtLeast = (minWidth: number) => shellWidth === null ? !compact : shellWidth >= minWidth
|
|
const useCompactControls = isMobileComposer || !fitsAtLeast(TOOLBAR_LOCATION_MIN_WIDTH)
|
|
const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '')
|
|
const referenceCwd = activeLaunchWorkDir || resolvedWorkDir || ''
|
|
const referenceProviderId = mentionProviderId(runtimeSelection)
|
|
const referenceContext = `${activeTabId ?? ''}\0${referenceCwd}\0${referenceProviderId ?? ''}`
|
|
const referenceCurrent = referenceState?.context === referenceContext ? referenceState : null
|
|
const composerReferences = useMemo(() => (referenceCurrent?.items ?? EMPTY_COMPOSER_REFERENCES).filter(isComposerReferenceVisible), [referenceCurrent?.items])
|
|
useEffect(() => {
|
|
let active = true
|
|
if (isMemberSession) return
|
|
setReferenceState({ context: referenceContext, items: [], loading: true, error: false })
|
|
void composerReferencesApi.list(referenceCwd || undefined, referenceProviderId).then(data => {
|
|
if (active) setReferenceState({ context: referenceContext, items: [...data.plugins, ...data.skills], loading: false, error: false })
|
|
}).catch(() => {
|
|
if (active) setReferenceState({ context: referenceContext, items: [], loading: false, error: true })
|
|
})
|
|
return () => { active = false }
|
|
}, [referenceContext, referenceCwd, referenceProviderId, isMemberSession, slashMenuOpen, fileSearchOpen, plusMenuOpen])
|
|
useEffect(() => {
|
|
setReferenceDetail(null)
|
|
setReferenceOptionId(undefined)
|
|
setFileSearchOpen(false)
|
|
setSlashMenuOpen(false)
|
|
}, [referenceContext])
|
|
|
|
// The run location lives in the toolbar on the wide desktop composer, and it
|
|
// stays there for the whole session: editable while the session is still a
|
|
// draft, read-only once the first message lands. It used to jump from inside
|
|
// the panel to a chip below it at that moment.
|
|
//
|
|
// Deliberately not keyed on `isHeroComposer`: ActiveSession only renders the
|
|
// hero variant while the session is empty, so keying on it moved the location
|
|
// out of the toolbar at the exact moment it was supposed to stay put — the
|
|
// first message swaps the variant and the draft state in the same render.
|
|
// The condition is the composer's width, not its variant — and now literally
|
|
// so. It used to read `compact`, which ActiveSession wires to "is the
|
|
// workspace panel open", so opening the panel dropped the location to a
|
|
// second line on columns with hundreds of pixels to spare.
|
|
const showLocationInToolbar = !useCompactControls && !isMemberSession
|
|
const embedLaunchControlsInToolbar = showLocationInToolbar && showLaunchControls
|
|
const pendingSlashUiAction = !isMemberSession && input.trim().startsWith('/')
|
|
? resolveSlashUiAction(input.trim().slice(1))
|
|
: null
|
|
const canSubmit = !isWorkspaceMissing &&
|
|
!launchTransitioning &&
|
|
!isPreparingTurn &&
|
|
(!showLaunchControls || launchReady || !!pendingSlashUiAction) &&
|
|
(input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences)))
|
|
const composerAttachments = useMemo(
|
|
() => [
|
|
...attachments,
|
|
...workspaceReferences.map(workspaceReferenceToAttachment),
|
|
],
|
|
[attachments, workspaceReferences],
|
|
)
|
|
const slashCommandCount = slashCommands.length
|
|
|
|
useEffect(() => {
|
|
inputRef.current = input
|
|
}, [input])
|
|
|
|
useEffect(() => {
|
|
attachmentsRef.current = attachments
|
|
}, [attachments])
|
|
|
|
useEffect(() => {
|
|
const previousActiveTabId = previousActiveTabIdRef.current
|
|
|
|
if (previousActiveTabId === activeTabId) return
|
|
|
|
if (previousActiveTabId) {
|
|
saveComposerDraft(previousActiveTabId)
|
|
}
|
|
|
|
const nextDraft = activeTabId ? useChatStore.getState().sessions[activeTabId]?.composerDraft : undefined
|
|
invalidatePendingPastes()
|
|
setComposerInput(nextDraft?.input ?? '', nextDraft?.mentions ?? [])
|
|
setComposerAttachments(nextDraft?.attachments ?? [])
|
|
setPlusMenuOpen(false)
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(false)
|
|
setLocalSlashPanel(null)
|
|
setSlashFilter('')
|
|
setAtFilter('')
|
|
setAtCursorPos(-1)
|
|
setEditingQueuedMessageId(null)
|
|
setEditingQueuedMessageText('')
|
|
previousActiveTabIdRef.current = activeTabId
|
|
}, [activeTabId, invalidatePendingPastes, saveComposerDraft, setComposerAttachments, setComposerInput])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
const currentActiveTabId = previousActiveTabIdRef.current
|
|
if (currentActiveTabId) saveComposerDraft(currentActiveTabId)
|
|
}
|
|
}, [saveComposerDraft])
|
|
|
|
useEffect(() => {
|
|
mentionsRef.current = mentions
|
|
}, [mentions])
|
|
|
|
useEffect(() => {
|
|
if (visible) composerRef.current?.focus()
|
|
}, [activeTabId, visible])
|
|
|
|
useEffect(() => {
|
|
if (visible) return
|
|
const focused = document.activeElement
|
|
if (focused instanceof HTMLElement && panelRef.current?.contains(focused)) focused.blur()
|
|
}, [visible])
|
|
|
|
useEffect(() => {
|
|
if (!composerPrefill || !activeTabId) return
|
|
|
|
const nextAttachments = (composerPrefill.attachments ?? [])
|
|
.filter((attachment) => attachment.path || attachment.data)
|
|
.map((attachment, index) => ({
|
|
id: `composer-prefill-${composerPrefill.nonce}-${index}`,
|
|
name: attachment.name,
|
|
type: attachment.type,
|
|
mimeType: attachment.mimeType,
|
|
path: attachment.path,
|
|
previewUrl: attachment.type === 'image' ? attachment.data : undefined,
|
|
data: attachment.data,
|
|
isDirectory: attachment.isDirectory,
|
|
lineStart: attachment.lineStart,
|
|
lineEnd: attachment.lineEnd,
|
|
diffSide: attachment.diffSide,
|
|
hunkId: attachment.hunkId,
|
|
note: attachment.note,
|
|
quote: attachment.quote,
|
|
}))
|
|
const contextAttachments = nextAttachments.filter((attachment) => attachment.type === 'file' && attachment.path && (
|
|
attachment.lineStart || attachment.note?.trim() || attachment.quote?.trim() || attachment.diffSide || attachment.hunkId
|
|
))
|
|
const uploads = nextAttachments.filter((attachment) => !contextAttachments.includes(attachment))
|
|
|
|
if (composerPrefill.mode === 'append') {
|
|
setComposerAttachments((previous) => [...previous, ...uploads])
|
|
} else {
|
|
let restored = { text: composerPrefill.text, mentions: [] as ComposerMention[] }
|
|
for (const reference of normalizeSessionReferences(composerPrefill.sessionReferences)) {
|
|
const title = useSessionStore.getState().sessions.find((session) => session.id === reference.sessionId)?.title
|
|
restored = insertMentionIntoText(restored.text, restored.mentions, restored.text.length, restored.text.length, {
|
|
kind: 'session', id: reference.sessionId, label: title || reference.sessionId,
|
|
path: '', isDirectory: false,
|
|
})
|
|
}
|
|
setComposerInput(restored.text, restored.mentions)
|
|
setComposerAttachments(uploads)
|
|
clearWorkspaceReferences(activeTabId)
|
|
}
|
|
for (const attachment of contextAttachments) {
|
|
addWorkspaceReference(activeTabId, {
|
|
...attachment,
|
|
path: attachment.path!,
|
|
kind: attachment.diffSide || attachment.hunkId ? 'code-comment' : attachment.lineStart ? 'code-selection' : 'file',
|
|
})
|
|
}
|
|
setPlusMenuOpen(false)
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(false)
|
|
setSlashFilter('')
|
|
setAtFilter('')
|
|
setAtCursorPos(-1)
|
|
|
|
requestAnimationFrame(() => {
|
|
if (visible) composerRef.current?.focus()
|
|
if (composerPrefill.mode !== 'append') {
|
|
composerRef.current?.setSelectionOffsets(composerPrefill.text.length)
|
|
}
|
|
})
|
|
clearComposerPrefill(activeTabId, composerPrefill.nonce)
|
|
}, [
|
|
activeTabId,
|
|
addWorkspaceReference,
|
|
clearComposerPrefill,
|
|
clearWorkspaceReferences,
|
|
composerPrefill,
|
|
setComposerAttachments,
|
|
setComposerInput,
|
|
visible,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!composerInsertion || !activeTabId || isMemberSession) return
|
|
|
|
const currentInput = inputRef.current
|
|
const offsets = composerRef.current?.getSelectionOffsets()
|
|
const start = offsets?.start ?? currentInput.length
|
|
const end = offsets?.end ?? start
|
|
const next = insertComposerTokenAtRange(currentInput, start, end, composerInsertion.text)
|
|
|
|
if (composerInsertion.reference) {
|
|
addWorkspaceReference(activeTabId, composerInsertion.reference)
|
|
}
|
|
setComposerInput(next.value)
|
|
setFileSearchOpen(false)
|
|
setSlashMenuOpen(false)
|
|
setAtFilter('')
|
|
setAtCursorPos(-1)
|
|
clearComposerInsertion(activeTabId, composerInsertion.nonce)
|
|
|
|
requestAnimationFrame(() => {
|
|
if (visible) composerRef.current?.focus()
|
|
composerRef.current?.setSelectionOffsets(next.cursorPos)
|
|
})
|
|
}, [
|
|
activeTabId,
|
|
addWorkspaceReference,
|
|
clearComposerInsertion,
|
|
composerInsertion,
|
|
visible,
|
|
isMemberSession,
|
|
setComposerInput,
|
|
])
|
|
|
|
const refreshGitInfo = useCallback(() => {
|
|
if (!activeTabId) {
|
|
setGitInfo(null)
|
|
return
|
|
}
|
|
if (isMemberSession) {
|
|
setGitInfo(null)
|
|
return
|
|
}
|
|
sessionsApi.getGitInfo(activeTabId).then(setGitInfo).catch(() => setGitInfo(null))
|
|
}, [activeTabId, isMemberSession])
|
|
|
|
useEffect(() => {
|
|
refreshGitInfo()
|
|
}, [refreshGitInfo])
|
|
|
|
useEffect(() => {
|
|
if (!activeTabId || isMemberSession || messageCount === 0) return
|
|
const timeout = setTimeout(refreshGitInfo, chatState === 'idle' ? 0 : 500)
|
|
return () => clearTimeout(timeout)
|
|
}, [activeTabId, chatState, isMemberSession, messageCount, refreshGitInfo, slashCommandCount])
|
|
|
|
useEffect(() => {
|
|
if (!isMemberSession) return
|
|
setComposerAttachments([])
|
|
setPlusMenuOpen(false)
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(false)
|
|
}, [isMemberSession, activeTabId])
|
|
|
|
useEffect(() => {
|
|
if (isMemberSession) {
|
|
setAgentSlashCommands([])
|
|
return
|
|
}
|
|
|
|
let cancelled = false
|
|
agentsApi.list(resolvedWorkDir)
|
|
.then(({ activeAgents }) => {
|
|
if (cancelled) return
|
|
setAgentSlashCommands(buildAgentSlashCommands(activeAgents))
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setAgentSlashCommands([])
|
|
})
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [isMemberSession, resolvedWorkDir])
|
|
|
|
useEffect(() => {
|
|
if (!activeTabId || !showLaunchControls) return
|
|
const nextWorkDir = getSessionSeedWorkDir(activeSession) || gitInfo?.workDir || ''
|
|
const chatStore = useChatStore.getState()
|
|
const current = chatStore.sessions[activeTabId]?.repositoryLaunchDraft
|
|
if (current?.workDir === nextWorkDir) return
|
|
chatStore.setRepositoryLaunchDraft(activeTabId, {
|
|
workDir: nextWorkDir,
|
|
branch: null,
|
|
useWorktree: false,
|
|
})
|
|
setLaunchReady(!nextWorkDir)
|
|
}, [activeSession?.workDir, activeSession?.projectRoot, activeSession?.workspaceState, activeTabId, gitInfo?.workDir, showLaunchControls])
|
|
|
|
useDismissable({
|
|
open: plusMenuOpen,
|
|
refs: [plusMenuRef],
|
|
onDismiss: () => setPlusMenuOpen(false),
|
|
})
|
|
|
|
useDismissable({
|
|
open: slashMenuOpen,
|
|
refs: [slashMenuRef, composerContainerRef],
|
|
onDismiss: () => setSlashMenuOpen(false),
|
|
})
|
|
|
|
useDismissable({
|
|
open: !!localSlashPanel,
|
|
refs: [slashMenuRef, composerContainerRef],
|
|
onDismiss: () => setLocalSlashPanel(null),
|
|
})
|
|
|
|
useDismissable({
|
|
open: fileSearchOpen,
|
|
refs: [composerContainerRef],
|
|
onDismiss: () => setFileSearchOpen(false),
|
|
// This menu is looked up by id rather than held in a ref. Returning true
|
|
// when it is absent preserves the original behavior: with no menu in the
|
|
// DOM, an outside press was ignored.
|
|
isExempt: (target) => {
|
|
const menu = document.getElementById(referenceMenuId)
|
|
if (!menu) return true
|
|
return target instanceof Node && menu.contains(target)
|
|
},
|
|
})
|
|
|
|
const allSlashCommands = useMemo(() => {
|
|
const commands = appendAgentSlashCommands(mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)), agentSlashCommands)
|
|
const names = new Set(commands.map(command => command.name.toLowerCase()))
|
|
for (const reference of composerReferences) {
|
|
const name = reference.kind === 'plugin' ? reference.id : reference.name
|
|
if (names.has(name.toLowerCase())) continue
|
|
names.add(name.toLowerCase())
|
|
commands.push({ name, description: reference.description, kind: reference.kind })
|
|
}
|
|
// `/btw` forks the conversation so far; a blank session has none to fork.
|
|
return commands.filter(command => isComposerSlashCommandVisible(command) && (sideChatAvailable || command.name !== 'btw'))
|
|
}, [agentSlashCommands, slashCommands, composerReferences, sideChatAvailable, t])
|
|
|
|
const filteredCommandGroups = useMemo(() => {
|
|
return groupSlashCommands(filterSlashCommands(allSlashCommands, slashFilter))
|
|
}, [allSlashCommands, slashFilter])
|
|
|
|
const filteredCommands = filteredCommandGroups.ordered
|
|
const isSlashMenuVisible = !isMemberSession && slashMenuOpen && filteredCommands.length > 0
|
|
const isReferenceMenuVisible = !isMemberSession && fileSearchOpen
|
|
|
|
const exactSlashCommand = useMemo(() => {
|
|
const normalized = slashFilter.trim().toLowerCase()
|
|
if (!normalized) return null
|
|
return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null
|
|
}, [filteredCommands, slashFilter])
|
|
|
|
useEffect(() => {
|
|
setSlashSelectedIndex(0)
|
|
}, [slashFilter])
|
|
|
|
useEffect(() => {
|
|
const activeItem = slashMenuOpen ? slashItemRefs.current[slashSelectedIndex] : null
|
|
if (activeItem && typeof activeItem.scrollIntoView === 'function') {
|
|
activeItem.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
}, [slashMenuOpen, slashSelectedIndex])
|
|
|
|
const detectSlashTrigger = useCallback((value: string, cursorPos: number) => {
|
|
const token = findSlashTrigger(value, cursorPos)
|
|
if (!token) {
|
|
setSlashMenuOpen(false)
|
|
return
|
|
}
|
|
|
|
setFileSearchOpen(false)
|
|
setSlashFilter(token.filter)
|
|
setSlashMenuOpen(true)
|
|
}, [])
|
|
|
|
// Detect @ trigger (file search). The scan runs on the projected text; an
|
|
// `@` that belongs to an existing mention pill never counts as a trigger.
|
|
const detectAtTrigger = useCallback((value: string, cursorPos: number, currentMentions: ComposerMention[]) => {
|
|
const textBeforeCursor = value.slice(0, cursorPos)
|
|
let pos = -1
|
|
|
|
for (let i = textBeforeCursor.length - 1; i >= 0; i--) {
|
|
const ch = textBeforeCursor[i]!
|
|
if (ch === '@') {
|
|
if (i === 0 || /\s/.test(textBeforeCursor[i - 1]!)) {
|
|
pos = i
|
|
break
|
|
}
|
|
break
|
|
}
|
|
if (/\s/.test(ch)) {
|
|
break
|
|
}
|
|
}
|
|
|
|
if (pos >= 0 && findMentionRanges(value, currentMentions).some((range) => pos >= range.start && pos < range.end)) {
|
|
pos = -1
|
|
}
|
|
|
|
if (pos < 0) {
|
|
setFileSearchOpen(false)
|
|
setAtFilter('')
|
|
setAtCursorPos(-1)
|
|
return
|
|
}
|
|
|
|
// Extract filter text after @
|
|
const filter = textBeforeCursor.slice(pos + 1)
|
|
setAtFilter(filter)
|
|
setAtCursorPos(pos)
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(true)
|
|
}, [])
|
|
|
|
const handleComposerChange = (text: string, nextMentions: ComposerMention[]) => {
|
|
setComposerInput(text, nextMentions)
|
|
if (isMemberSession) {
|
|
return
|
|
}
|
|
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? text.length
|
|
detectSlashTrigger(text, cursorPos)
|
|
detectAtTrigger(text, cursorPos, nextMentions)
|
|
}
|
|
|
|
const selectSlashCommand = useCallback((command: string) => {
|
|
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
|
|
const option = allSlashCommands.find(item => item.name === command)
|
|
let nextCursor: number
|
|
const reference = (option?.kind === 'skill' || option?.kind === 'plugin')
|
|
? composerReferences.find(item => item.kind === option.kind && (item.id === command || item.name === command))
|
|
: undefined
|
|
if (reference) {
|
|
const mention = composerReferenceToMention(reference)
|
|
const trigger = findSlashTrigger(input, cursorPos)
|
|
const inserted = insertMentionIntoText(input, mentions, trigger?.slashPos ?? cursorPos, cursorPos, mention)
|
|
setComposerInput(inserted.text, inserted.mentions)
|
|
nextCursor = inserted.cursorPos
|
|
} else {
|
|
const replacement = replaceSlashToken(input, cursorPos, command)
|
|
setComposerInput(replacement.value)
|
|
nextCursor = replacement.cursorPos
|
|
}
|
|
setSlashMenuOpen(false)
|
|
requestAnimationFrame(() => {
|
|
composerRef.current?.focus()
|
|
composerRef.current?.setSelectionOffsets(nextCursor)
|
|
})
|
|
}, [input, mentions, allSlashCommands, composerReferences, setComposerInput])
|
|
|
|
const replaceEmptySession = useCallback(async (
|
|
workDir: string,
|
|
repository?: { branch?: string | null; worktree?: boolean },
|
|
) => {
|
|
if (!activeTabId) return null
|
|
const oldId = activeTabId
|
|
const sessionStore = useSessionStore.getState()
|
|
const { createSession, deleteSession } = sessionStore
|
|
const { replaceTabSession } = useTabStore.getState()
|
|
const chatStore = useChatStore.getState()
|
|
const {
|
|
disconnectSession,
|
|
connectToSession,
|
|
setComposerDraft,
|
|
setRepositoryLaunchDraft,
|
|
} = chatStore
|
|
const repositoryLaunchDraft = chatStore.sessions[oldId]?.repositoryLaunchDraft
|
|
const permissionMode = sessionStore.sessions.find((session) => session.id === oldId)
|
|
?.permissionMode as PermissionMode | undefined
|
|
const createOptions = repository || permissionMode
|
|
? {
|
|
...(repository ? { repository } : {}),
|
|
...(permissionMode ? { permissionMode } : {}),
|
|
}
|
|
: undefined
|
|
const newId = await createSession(
|
|
workDir || undefined,
|
|
createOptions,
|
|
)
|
|
if (inputRef.current.length > 0 || attachmentsRef.current.length > 0) {
|
|
setComposerDraft(newId, {
|
|
input: inputRef.current,
|
|
attachments: attachmentsRef.current,
|
|
})
|
|
}
|
|
if (repositoryLaunchDraft) {
|
|
setRepositoryLaunchDraft(newId, repositoryLaunchDraft)
|
|
}
|
|
useSessionRuntimeStore.getState().moveSelection(oldId, newId)
|
|
disconnectSession(oldId)
|
|
replaceTabSession(oldId, newId)
|
|
connectToSession(newId)
|
|
deleteSession(oldId).catch(() => {})
|
|
return newId
|
|
}, [activeTabId])
|
|
|
|
const handleLaunchWorkDirChange = useCallback(async (newWorkDir: string) => {
|
|
updateRepositoryLaunchDraft(() => ({
|
|
workDir: newWorkDir,
|
|
branch: null,
|
|
useWorktree: false,
|
|
}))
|
|
setLaunchReady(!newWorkDir)
|
|
if (!activeTabId) return
|
|
|
|
setLaunchTransitioning(true)
|
|
try {
|
|
await replaceEmptySession(newWorkDir)
|
|
} catch (error) {
|
|
useUIStore.getState().addToast({
|
|
type: 'error',
|
|
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
|
})
|
|
} finally {
|
|
setLaunchTransitioning(false)
|
|
}
|
|
}, [activeTabId, replaceEmptySession, t, updateRepositoryLaunchDraft])
|
|
|
|
const handleSubmit = async () => {
|
|
// Belt and braces: the composer is disabled too, but every send path (Enter,
|
|
// slash-Enter, programmatic) funnels through here.
|
|
if (questionPending) return
|
|
const text = input.trim()
|
|
if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return
|
|
|
|
// Immediate local command: never enter the main turn, launch flow or queue.
|
|
const sideQuestion = !isMemberSession ? parseSideQuestionCommand(text) : null
|
|
if (sideQuestion !== null && activeTabId) {
|
|
void openSideChat(sideChat?.parentSessionId ?? activeTabId, { question: sideQuestion || undefined, submit: true })
|
|
setComposerInput('', [])
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(false)
|
|
setPlusMenuOpen(false)
|
|
return
|
|
}
|
|
|
|
if (pendingSlashUiAction?.type === 'panel') {
|
|
setLocalSlashPanel(pendingSlashUiAction.command as LocalSlashCommandName)
|
|
setComposerInput('', [])
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(false)
|
|
setPlusMenuOpen(false)
|
|
return
|
|
}
|
|
|
|
if (pendingSlashUiAction?.type === 'settings') {
|
|
useUIStore.getState().setPendingSettingsTab(pendingSlashUiAction.tab)
|
|
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
|
setComposerInput('', [])
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(false)
|
|
setPlusMenuOpen(false)
|
|
return
|
|
}
|
|
|
|
if (pendingSlashUiAction?.type === 'model') {
|
|
modelSelectorRef.current?.open()
|
|
setComposerInput('', [])
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(false)
|
|
setPlusMenuOpen(false)
|
|
return
|
|
}
|
|
|
|
if (showLaunchControls && (!launchReady || launchTransitioning)) return
|
|
|
|
const workspaceReferencePrompt = !isMemberSession
|
|
? formatWorkspaceReferencePrompt(workspaceReferences)
|
|
: ''
|
|
// Inline @-mentions travel as the `@"absolute path"` text the CLI already
|
|
// parses. Serialized from the live document — only the doc knows which
|
|
// `@label` is a pill and which is literal text the user typed.
|
|
const sessionReferences = getSessionReferences(input, mentions)
|
|
const serializedText = (composerRef.current?.getModelContent() ?? input).trim()
|
|
const contentForModel = [workspaceReferencePrompt, serializedText].filter(Boolean).join('\n\n')
|
|
const displayContent = text || (
|
|
workspaceReferences.length > 0
|
|
? t('chat.contextReferencesOnly', { count: workspaceReferences.length })
|
|
: ''
|
|
)
|
|
const uploadAttachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({
|
|
type: attachment.type,
|
|
name: attachment.name,
|
|
path: attachment.path,
|
|
data: attachment.data,
|
|
mimeType: attachment.mimeType,
|
|
lineStart: attachment.lineStart,
|
|
lineEnd: attachment.lineEnd,
|
|
note: attachment.note,
|
|
quote: attachment.quote,
|
|
}))
|
|
const workspaceAttachmentPayload: AttachmentRef[] = workspaceReferences
|
|
.filter((reference) => reference.kind !== 'chat-selection')
|
|
.map((reference) => ({
|
|
type: 'file' as const,
|
|
name: reference.name,
|
|
path: reference.absolutePath ?? reference.path,
|
|
isDirectory: reference.isDirectory,
|
|
lineStart: reference.lineStart,
|
|
lineEnd: reference.lineEnd,
|
|
note: reference.note,
|
|
quote: reference.quote,
|
|
}))
|
|
const visibleAttachmentPayload: AttachmentRef[] = [
|
|
...uploadAttachmentPayload,
|
|
...workspaceReferences.map((reference): AttachmentRef => ({
|
|
type: 'file' as const,
|
|
name: reference.name,
|
|
referenceKind: reference.kind === 'chat-selection' ? 'chat-selection' : undefined,
|
|
path: reference.kind === 'chat-selection' ? undefined : reference.path,
|
|
isDirectory: reference.isDirectory,
|
|
lineStart: reference.lineStart,
|
|
lineEnd: reference.lineEnd,
|
|
diffSide: reference.diffSide,
|
|
hunkId: reference.hunkId,
|
|
note: reference.note,
|
|
quote: reference.quote,
|
|
})),
|
|
]
|
|
|
|
let targetSessionId = activeTabId!
|
|
if (showLaunchControls && activeLaunchWorkDir && launchBranch) {
|
|
const shouldReplaceForRepositoryLaunch =
|
|
launchUseWorktree ||
|
|
(gitInfo?.branch ? launchBranch !== gitInfo.branch : true)
|
|
if (shouldReplaceForRepositoryLaunch) {
|
|
setLaunchTransitioning(true)
|
|
const placeholderSessionId = targetSessionId
|
|
setPreparingTurn(placeholderSessionId, true)
|
|
try {
|
|
const newSessionId = await replaceEmptySession(activeLaunchWorkDir, {
|
|
branch: launchBranch,
|
|
worktree: launchUseWorktree,
|
|
})
|
|
if (!newSessionId) {
|
|
setPreparingTurn(placeholderSessionId, false)
|
|
return
|
|
}
|
|
targetSessionId = newSessionId
|
|
// Keep the placeholder continuous across the old-id -> new-id swap.
|
|
// sendMessage clears it in the same turn that appends the user row.
|
|
setPreparingTurn(targetSessionId, true)
|
|
} catch (error) {
|
|
setPreparingTurn(placeholderSessionId, false)
|
|
useUIStore.getState().addToast({
|
|
type: 'error',
|
|
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
|
})
|
|
return
|
|
} finally {
|
|
setLaunchTransitioning(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
const targetChatState = useChatStore.getState().sessions[targetSessionId]?.chatState ?? 'idle'
|
|
if (!isMemberSession && targetChatState !== 'idle') {
|
|
queueUserMessage(targetSessionId, {
|
|
content: contentForModel,
|
|
sessionReferences,
|
|
attachments: [...uploadAttachmentPayload, ...workspaceAttachmentPayload],
|
|
displayContent,
|
|
displayAttachments: visibleAttachmentPayload,
|
|
})
|
|
} else {
|
|
sendMessage(targetSessionId, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], {
|
|
sessionReferences,
|
|
displayContent,
|
|
displayAttachments: visibleAttachmentPayload,
|
|
})
|
|
}
|
|
invalidatePendingPastes()
|
|
setComposerInput('', [])
|
|
setComposerAttachments([])
|
|
const chatStore = useChatStore.getState()
|
|
chatStore.clearComposerDraft(activeTabId!)
|
|
chatStore.clearRepositoryLaunchDraft(activeTabId!)
|
|
if (targetSessionId !== activeTabId) {
|
|
chatStore.clearComposerDraft(targetSessionId)
|
|
chatStore.clearRepositoryLaunchDraft(targetSessionId)
|
|
}
|
|
if (!isMemberSession) {
|
|
clearWorkspaceReferences(activeTabId!)
|
|
if (targetSessionId !== activeTabId) clearWorkspaceReferences(targetSessionId)
|
|
}
|
|
setPlusMenuOpen(false)
|
|
setSlashMenuOpen(false)
|
|
setFileSearchOpen(false)
|
|
setLocalSlashPanel(null)
|
|
}
|
|
|
|
const handleComposerKeyDown = (event: KeyboardEvent): boolean => {
|
|
// Ignore key events during IME composition (e.g. Chinese input method)
|
|
if (composingRef.current || event.isComposing || event.keyCode === 229) return false
|
|
|
|
// Route reference selection and directory navigation to the unified menu
|
|
if (fileSearchOpen) {
|
|
const key = event.key
|
|
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowRight' || key === 'Enter' || key === 'Tab' || key === 'Escape') {
|
|
event.preventDefault()
|
|
if (key === 'Escape') {
|
|
setFileSearchOpen(false)
|
|
setAtFilter('')
|
|
setAtCursorPos(-1)
|
|
return true
|
|
}
|
|
fileSearchRef.current?.handleKeyDown(event)
|
|
return true
|
|
}
|
|
// Other keys (typing) should go to the editor - let ProseMirror handle them
|
|
return false
|
|
}
|
|
|
|
if (localSlashPanel) {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
setLocalSlashPanel(null)
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (slashMenuOpen && filteredCommands.length > 0) {
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
setSlashSelectedIndex((prev) => (prev + 1) % filteredCommands.length)
|
|
return true
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
setSlashSelectedIndex((prev) => (prev - 1 + filteredCommands.length) % filteredCommands.length)
|
|
return true
|
|
}
|
|
if (event.key === 'Enter') {
|
|
const selected = filteredCommands[slashSelectedIndex]
|
|
if (
|
|
exactSlashCommand &&
|
|
!composerReferences.some(item => item.kind === selected?.kind && (item.id === selected?.name || item.name === selected?.name)) &&
|
|
selected?.name.toLowerCase() === exactSlashCommand.name.toLowerCase() &&
|
|
slashFilter.trim().toLowerCase() === exactSlashCommand.name.toLowerCase() &&
|
|
shouldSubmitOnEnter(event, chatSendBehavior)
|
|
) {
|
|
event.preventDefault()
|
|
void handleSubmit()
|
|
return true
|
|
}
|
|
event.preventDefault()
|
|
if (selected) selectSlashCommand(selected.name)
|
|
return true
|
|
}
|
|
if (event.key === 'Tab') {
|
|
event.preventDefault()
|
|
const selected = filteredCommands[slashSelectedIndex]
|
|
if (selected) selectSlashCommand(selected.name)
|
|
return true
|
|
}
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
setSlashMenuOpen(false)
|
|
return true
|
|
}
|
|
}
|
|
|
|
if (shouldSubmitOnEnter(event, chatSendBehavior)) {
|
|
event.preventDefault()
|
|
void handleSubmit()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
const handleComposerPaste = (event: ClipboardEvent): boolean => {
|
|
if (isMemberSession) return false
|
|
const files = event.clipboardData ? getDataTransferFiles(event.clipboardData) : []
|
|
if (files.length === 0) return false
|
|
|
|
event.preventDefault()
|
|
const pasteGeneration = pasteGenerationRef.current
|
|
const pastedSessionId = activeTabId
|
|
void filesToComposerAttachments(files)
|
|
.then((nextAttachments) => {
|
|
if (pasteGeneration !== pasteGenerationRef.current) return
|
|
if (pastedSessionId !== previousActiveTabIdRef.current) return
|
|
if (nextAttachments.length === 0) return
|
|
setComposerAttachments((prev) => [...prev, ...nextAttachments])
|
|
})
|
|
.catch((error) => {
|
|
console.warn('[attachments] Failed to read pasted files', error)
|
|
})
|
|
return true
|
|
}
|
|
|
|
const appendFiles = useCallback((files: FileList | File[]) => {
|
|
const generation = pasteGenerationRef.current
|
|
void filesToComposerAttachments(files)
|
|
.then((nextAttachments) => {
|
|
if (generation !== pasteGenerationRef.current) return
|
|
if (nextAttachments.length === 0) return
|
|
setComposerAttachments((prev) => [...prev, ...nextAttachments])
|
|
})
|
|
.catch((error) => {
|
|
console.warn('[attachments] Failed to read selected files', error)
|
|
})
|
|
}, [setComposerAttachments])
|
|
|
|
const appendAttachments = useCallback((nextAttachments: Attachment[]) => {
|
|
if (nextAttachments.length === 0) return
|
|
setComposerAttachments((prev) => [...prev, ...nextAttachments])
|
|
}, [setComposerAttachments])
|
|
|
|
const { isDragActive, dragHandlers } = useComposerFileDrop({
|
|
disabled: isMemberSession || composerDisabled,
|
|
panelRef,
|
|
onAttachments: appendAttachments,
|
|
onError: (error) => {
|
|
console.warn('[attachments] Failed to read dropped files', error)
|
|
},
|
|
})
|
|
|
|
const openAttachmentPicker = useCallback(() => {
|
|
setPlusMenuOpen(false)
|
|
if (!isDesktopRuntime()) {
|
|
fileInputRef.current?.click()
|
|
return
|
|
}
|
|
|
|
const generation = pasteGenerationRef.current
|
|
void selectNativeFileAttachments()
|
|
.then((nativeAttachments) => {
|
|
if (generation !== pasteGenerationRef.current) return
|
|
if (nativeAttachments) {
|
|
if (nativeAttachments.length > 0) {
|
|
setComposerAttachments((prev) => [...prev, ...nativeAttachments])
|
|
}
|
|
return
|
|
}
|
|
fileInputRef.current?.click()
|
|
})
|
|
}, [setComposerAttachments])
|
|
|
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (isMemberSession) return
|
|
const files = event.target.files
|
|
if (!files) return
|
|
|
|
appendFiles(files)
|
|
event.target.value = ''
|
|
}
|
|
|
|
const removeAttachment = (id: string) => {
|
|
setComposerAttachments((prev) => prev.filter((attachment) => attachment.id !== id))
|
|
if (activeTabId) removeWorkspaceReference(activeTabId, id)
|
|
}
|
|
|
|
const startEditingQueuedMessage = (messageId: string, content: string) => {
|
|
setEditingQueuedMessageId(messageId)
|
|
setEditingQueuedMessageText(content)
|
|
}
|
|
|
|
const saveQueuedMessageEdit = () => {
|
|
if (!activeTabId || !editingQueuedMessageId) return
|
|
const nextContent = editingQueuedMessageText.trim()
|
|
if (!nextContent) return
|
|
updateQueuedUserMessage(activeTabId, editingQueuedMessageId, nextContent)
|
|
setEditingQueuedMessageId(null)
|
|
setEditingQueuedMessageText('')
|
|
}
|
|
|
|
const cancelQueuedMessageEdit = () => {
|
|
setEditingQueuedMessageId(null)
|
|
setEditingQueuedMessageText('')
|
|
}
|
|
|
|
const insertSlashCommand = () => {
|
|
if (isMemberSession) return
|
|
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
|
|
const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false })
|
|
setComposerInput(replacement.value)
|
|
setPlusMenuOpen(false)
|
|
setSlashFilter('')
|
|
setSlashMenuOpen(true)
|
|
requestAnimationFrame(() => {
|
|
composerRef.current?.focus()
|
|
composerRef.current?.setSelectionOffsets(replacement.cursorPos)
|
|
})
|
|
}
|
|
|
|
// The "+" capability menu. The hook owns data loading and navigation
|
|
// actions; these handlers are only the composer-local edits (mention badge,
|
|
// slash text, prompt seed) plus the surfaces this composer already opens.
|
|
const capabilityMenu = useCapabilityMenu({
|
|
open: plusMenuOpen && !isMemberSession,
|
|
cwd: referenceCwd,
|
|
references: composerReferences,
|
|
handlers: {
|
|
onInsertMention: (reference) => {
|
|
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? inputRef.current.length
|
|
const mention = composerReferenceToMention(reference)
|
|
const inserted = insertMentionIntoText(inputRef.current, mentionsRef.current, cursorPos, cursorPos, mention)
|
|
setComposerInput(inserted.text, inserted.mentions)
|
|
requestAnimationFrame(() => {
|
|
composerRef.current?.focus()
|
|
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
|
|
})
|
|
},
|
|
onInsertSlashText: (command) => {
|
|
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? inputRef.current.length
|
|
const replacement = replaceSlashToken(inputRef.current, cursorPos, command)
|
|
setComposerInput(replacement.value)
|
|
requestAnimationFrame(() => {
|
|
composerRef.current?.focus()
|
|
composerRef.current?.setSelectionOffsets(replacement.cursorPos)
|
|
})
|
|
},
|
|
onInsertPromptSeed: (text) => {
|
|
const next = inputRef.current.trim() ? `${inputRef.current}\n${text}` : text
|
|
setComposerInput(next)
|
|
requestAnimationFrame(() => {
|
|
composerRef.current?.focus()
|
|
composerRef.current?.setSelectionOffsets(next.length)
|
|
})
|
|
},
|
|
onAttachment: openAttachmentPicker,
|
|
onSlashTrigger: insertSlashCommand,
|
|
onSaveWorkflow: () => setLocalSlashPanel('save-workflow'),
|
|
onClose: () => setPlusMenuOpen(false),
|
|
},
|
|
})
|
|
|
|
const composerPlaceholder =
|
|
isHeroComposer
|
|
? t('empty.placeholder')
|
|
: isWorkspaceMissing
|
|
? workspaceState === 'worktree_removed'
|
|
? t('chat.placeholderWorktreeRemoved')
|
|
: t('chat.placeholderMissing')
|
|
: questionPending
|
|
? t('chat.placeholderQuestionPending')
|
|
: isMemberSession
|
|
? t('teams.memberPlaceholder')
|
|
: t('chat.placeholder')
|
|
|
|
return (
|
|
<div
|
|
ref={shellRef}
|
|
data-testid="chat-input-shell"
|
|
data-session-id={activeTabId ?? undefined}
|
|
// The docked composer floats over the end of the transcript: no top
|
|
// divider, and `composer-fade` dissolves the last 28px of the thread into
|
|
// the page ground above the card instead of cutting it off. The hero
|
|
// composer sits in open space, so it needs neither.
|
|
className={
|
|
isHeroComposer
|
|
? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}`
|
|
: `composer-fade bg-[var(--color-surface)] ${
|
|
isMobileComposer
|
|
? 'px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-1'
|
|
: compact ? 'px-4 pb-4 pt-1' : 'px-6 pb-5 pt-1'
|
|
}`
|
|
}
|
|
>
|
|
<div
|
|
className={
|
|
isHeroComposer
|
|
? 'mx-auto flex w-full max-w-3xl flex-col'
|
|
// Match the transcript even while a side panel tightens padding.
|
|
: `${isMobileComposer ? 'mx-0 max-w-none' : 'mx-auto max-w-[var(--chat-content-max-width)]'}`
|
|
}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
data-testid="chat-input-panel"
|
|
// The floating card (「素」): `glass-panel--composer` supplies the
|
|
// opaque lifted fill, the hairline and the composer step of the shadow
|
|
// scale; `--radius-xl` is the composer corner. One geometry for every
|
|
// variant — the editor and the toolbar carry their own insets, so a
|
|
// draft turning into a live session moves nothing.
|
|
className={`glass-panel glass-panel--composer relative flex flex-col overflow-visible rounded-[var(--radius-xl)] p-2 ${isDragActive ? 'composer-drop-target-active' : ''}`}
|
|
{...dragHandlers}
|
|
>
|
|
{isDragActive && (
|
|
<ComposerDropOverlay
|
|
testId="chat-input-drop-overlay"
|
|
title={t('chat.dropFilesTitle')}
|
|
description={t('chat.dropFilesHint')}
|
|
/>
|
|
)}
|
|
|
|
{!isMemberSession && fileSearchOpen && (
|
|
<ComposerReferenceMenu
|
|
ref={fileSearchRef}
|
|
id={referenceMenuId}
|
|
references={composerReferences}
|
|
referencesLoading={referenceCurrent?.loading ?? true}
|
|
referencesError={referenceCurrent?.error}
|
|
onActiveChange={setReferenceOptionId}
|
|
cwd={activeLaunchWorkDir || resolvedWorkDir || ''}
|
|
filter={atFilter}
|
|
compact={isMobileComposer}
|
|
onNavigate={(relativePath) => {
|
|
if (atCursorPos < 0) return
|
|
const replacement = `@${relativePath}`
|
|
const tokenEnd = atCursorPos + 1 + atFilter.length
|
|
const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}`
|
|
const newCursorPos = atCursorPos + replacement.length
|
|
setComposerInput(newValue)
|
|
setAtFilter(relativePath)
|
|
requestAnimationFrame(() => {
|
|
composerRef.current?.focus()
|
|
composerRef.current?.setSelectionOffsets(newCursorPos)
|
|
})
|
|
}}
|
|
onSelect={(mention) => {
|
|
if (atCursorPos < 0) return
|
|
const tokenEnd = atCursorPos + 1 + atFilter.length
|
|
const inserted = insertMentionIntoText(input, mentions, atCursorPos, tokenEnd, mention)
|
|
setComposerInput(inserted.text, inserted.mentions)
|
|
setFileSearchOpen(false)
|
|
setAtFilter('')
|
|
setAtCursorPos(-1)
|
|
void composerRef.current?.focus()
|
|
requestAnimationFrame(() => {
|
|
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
|
|
})
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{!isMemberSession && localSlashPanel && (
|
|
<div ref={slashMenuRef}>
|
|
<LocalSlashCommandPanel
|
|
command={localSlashPanel}
|
|
sessionId={activeTabId ?? undefined}
|
|
cwd={activeLaunchWorkDir || resolvedWorkDir}
|
|
commands={allSlashCommands}
|
|
onClose={() => setLocalSlashPanel(null)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isSlashMenuVisible && (
|
|
<SlashCommandMenu
|
|
isSearching={Boolean(slashFilter.trim())}
|
|
ref={slashMenuRef}
|
|
id={slashMenuId}
|
|
groups={filteredCommandGroups}
|
|
references={composerReferences}
|
|
selectedIndex={slashSelectedIndex}
|
|
itemRefs={slashItemRefs}
|
|
onSelect={selectSlashCommand}
|
|
onHighlight={setSlashSelectedIndex}
|
|
showKeyboardHints={!isMobileComposer}
|
|
/>
|
|
)}
|
|
|
|
{!isMemberSession && activeTabId && queuedUserMessages.length > 0 && (
|
|
// Dashed outline cards stacked above the input, per §4 of the
|
|
// handoff. They used to be a full-bleed filled strip pinned to the
|
|
// panel's top edge, which read as part of the composer chrome
|
|
// rather than as messages waiting their turn.
|
|
<div
|
|
data-testid="pending-user-message-list"
|
|
className="flex flex-col gap-1.5 px-1 pb-1 pt-1"
|
|
>
|
|
{queuedUserMessages.map((message) => {
|
|
const isEditing = editingQueuedMessageId === message.id
|
|
return (
|
|
<div
|
|
key={message.id}
|
|
data-testid="pending-user-message"
|
|
className={[
|
|
'flex min-h-9 min-w-0 items-center gap-2.5 rounded-[var(--radius-md)] py-1 pl-3 pr-1',
|
|
// `--color-outline` rather than `--color-border`: a dashed
|
|
// line at the lighter weight all but disappears.
|
|
'border border-dashed border-[var(--color-outline)]',
|
|
'text-[13px] text-[var(--color-text-secondary)]',
|
|
].join(' ')}
|
|
>
|
|
{/* The handoff labels the row in words rather than with a
|
|
glyph; the arrow icon here was a stand-in from before
|
|
the string existed. */}
|
|
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">
|
|
{t('chat.pendingMessageQueuedLabel')}
|
|
</span>
|
|
{isEditing ? (
|
|
<>
|
|
<input
|
|
value={editingQueuedMessageText}
|
|
onChange={(event) => setEditingQueuedMessageText(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault()
|
|
saveQueuedMessageEdit()
|
|
}
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
cancelQueuedMessageEdit()
|
|
}
|
|
}}
|
|
aria-label={t('chat.pendingMessageEditInput')}
|
|
className="min-w-0 flex-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
|
|
autoFocus
|
|
/>
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={saveQueuedMessageEdit}
|
|
disabled={!editingQueuedMessageText.trim()}
|
|
className="shrink-0"
|
|
>
|
|
{t('common.save')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={cancelQueuedMessageEdit}
|
|
className="shrink-0"
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="min-w-0 flex-1 truncate font-medium" title={message.displayContent}>
|
|
{message.displayContent}
|
|
</span>
|
|
{/* The accent action of the three, per the handoff. Blocked
|
|
while a question is waiting: sending now would race the
|
|
prompt the model is blocked on. */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={questionPending}
|
|
onClick={() => sendQueuedUserMessage(activeTabId, message.id)}
|
|
aria-label={t('chat.pendingMessageGuideNow')}
|
|
title={t('chat.pendingMessageGuideNow')}
|
|
className="shrink-0"
|
|
icon={<CornerDownRight size={14} strokeWidth={1.75} aria-hidden="true" />}
|
|
>
|
|
{t('chat.pendingMessageGuide')}
|
|
</Button>
|
|
<IconButton
|
|
icon={<Pencil size={14} strokeWidth={1.75} aria-hidden="true" />}
|
|
label={t('chat.pendingMessageEdit')}
|
|
size="sm"
|
|
tone="muted"
|
|
onClick={() => startEditingQueuedMessage(message.id, message.displayContent)}
|
|
/>
|
|
<IconButton
|
|
icon={<Trash2 size={14} strokeWidth={1.75} aria-hidden="true" />}
|
|
label={t('chat.pendingMessageDelete')}
|
|
size="sm"
|
|
tone="muted"
|
|
hoverTone="danger"
|
|
onClick={() => removeQueuedUserMessage(activeTabId, message.id)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{composerAttachments.length > 0 && (
|
|
<div className="px-2 pt-2">
|
|
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
|
|
</div>
|
|
)}
|
|
|
|
{isHeroComposer ? (
|
|
<div className="flex items-start">
|
|
<MentionComposer
|
|
ref={composerRef}
|
|
rootRef={composerContainerRef}
|
|
value={input}
|
|
mentions={mentions}
|
|
onMentionClick={mention => {
|
|
if (mention.kind === 'session' && mention.id) useTabStore.getState().openTab(mention.id, mention.label)
|
|
else setReferenceDetail(mention)
|
|
}}
|
|
onChange={handleComposerChange}
|
|
onKeyDown={handleComposerKeyDown}
|
|
onPaste={handleComposerPaste}
|
|
onCompositionStart={() => { composingRef.current = true; dictation.compositionHandlers.onCompositionStart() }}
|
|
onCompositionEnd={() => { composingRef.current = false; dictation.compositionHandlers.onCompositionEnd() }}
|
|
placeholder={composerPlaceholder}
|
|
disabled={composerDisabled}
|
|
// `min-w-0`: a paragraph holding an unbreakable run (a long URL,
|
|
// a hash) has a huge min-content size, and `overflow-wrap:
|
|
// break-word` does not shrink it. Without this the flex item
|
|
// refuses to shrink and the whole editor paints past the panel.
|
|
className="flex-1 min-w-0"
|
|
// The new-session card gives the prompt room to start: a taller
|
|
// resting editor than the docked composer, same type.
|
|
editorClassName="chat-reading-text min-h-[72px] max-h-[200px] overflow-y-auto px-2.5 pb-1 pt-2 text-[var(--color-text-primary)]"
|
|
aria={{
|
|
role: isSlashMenuVisible || isReferenceMenuVisible ? 'combobox' : 'textbox',
|
|
'aria-autocomplete': isSlashMenuVisible || isReferenceMenuVisible ? 'list' : undefined,
|
|
'aria-expanded': isSlashMenuVisible || isReferenceMenuVisible ? 'true' : undefined,
|
|
'aria-controls': isReferenceMenuVisible ? referenceMenuId : isSlashMenuVisible ? slashMenuId : undefined,
|
|
'aria-activedescendant': isReferenceMenuVisible ? referenceOptionId : isSlashMenuVisible
|
|
? getSlashCommandOptionId(slashMenuId, slashSelectedIndex)
|
|
: undefined,
|
|
}}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<MentionComposer
|
|
ref={composerRef}
|
|
rootRef={composerContainerRef}
|
|
value={input}
|
|
mentions={mentions}
|
|
onMentionClick={mention => {
|
|
if (mention.kind === 'session' && mention.id) useTabStore.getState().openTab(mention.id, mention.label)
|
|
else setReferenceDetail(mention)
|
|
}}
|
|
onChange={handleComposerChange}
|
|
onKeyDown={handleComposerKeyDown}
|
|
onPaste={handleComposerPaste}
|
|
onCompositionStart={() => { composingRef.current = true; dictation.compositionHandlers.onCompositionStart() }}
|
|
onCompositionEnd={() => { composingRef.current = false; dictation.compositionHandlers.onCompositionEnd() }}
|
|
placeholder={composerPlaceholder}
|
|
disabled={composerDisabled}
|
|
// Type follows the reader's chat size (`chat-reading-text`); the
|
|
// insets put the first line 14px/16px in from the card edge.
|
|
editorClassName="chat-reading-text min-h-[36px] max-h-[200px] overflow-y-auto px-2 pb-1 pt-1.5 text-[var(--color-text-primary)]"
|
|
aria={{
|
|
role: isSlashMenuVisible || isReferenceMenuVisible ? 'combobox' : 'textbox',
|
|
'aria-autocomplete': isSlashMenuVisible || isReferenceMenuVisible ? 'list' : undefined,
|
|
'aria-expanded': isSlashMenuVisible || isReferenceMenuVisible ? 'true' : undefined,
|
|
'aria-controls': isReferenceMenuVisible ? referenceMenuId : isSlashMenuVisible ? slashMenuId : undefined,
|
|
'aria-activedescendant': isReferenceMenuVisible ? referenceOptionId : isSlashMenuVisible
|
|
? getSlashCommandOptionId(slashMenuId, slashSelectedIndex)
|
|
: undefined,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/*
|
|
One toolbar geometry for every variant. The draft and the live
|
|
session used to render two different rows (an inset divider versus
|
|
an edge-to-edge band), so the first message shifted every control.
|
|
The card has no divider now — the editor's own bottom inset and this
|
|
row's top inset separate the two — so the narrow layouts no longer
|
|
need a band of their own either.
|
|
*/}
|
|
<div
|
|
data-testid="chat-input-toolbar"
|
|
className={`flex min-w-0 items-center justify-between pt-1.5 ${isMobileComposer ? 'gap-1' : 'gap-2'}`}
|
|
>
|
|
<div
|
|
data-testid="chat-input-toolbar-leading"
|
|
className={`flex min-w-0 shrink-0 items-center gap-1 ${showLocationInToolbar ? 'max-w-[55%]' : ''}`}
|
|
>
|
|
{!isMemberSession && (
|
|
<>
|
|
<div ref={plusMenuRef} className="relative shrink-0">
|
|
{/*
|
|
Not `IconButton`: the mobile composer pins 44px touch
|
|
targets (`h-11 w-11`), and the component's largest size is
|
|
40px. Shrinking it would regress the very thing the
|
|
"larger icon-only mobile action buttons" test guards.
|
|
*/}
|
|
<button
|
|
type="button"
|
|
onClick={() => setPlusMenuOpen((value) => !value)}
|
|
aria-label={t('chat.composerTools')}
|
|
aria-haspopup="menu"
|
|
aria-expanded={plusMenuOpen}
|
|
// A quiet 28px square like every other toolbar control;
|
|
// the open state keeps the hover fill so the menu reads as
|
|
// anchored to it.
|
|
className={`inline-flex items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
|
|
plusMenuOpen ? 'bg-[var(--color-surface-hover)] text-[var(--color-text-primary)]' : ''
|
|
} ${isMobileComposer ? 'h-11 w-11' : 'h-7 w-7'}`}
|
|
>
|
|
<Plus size={isMobileComposer ? 18 : 16} strokeWidth={1.75} aria-hidden="true" />
|
|
</button>
|
|
|
|
{plusMenuOpen && (
|
|
<ComposerCapabilityMenu
|
|
cwd={referenceCwd}
|
|
referencesLoading={referenceCurrent?.loading ?? true}
|
|
referencesError={referenceCurrent?.error}
|
|
onSelectFile={mention => {
|
|
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? inputRef.current.length
|
|
const inserted = insertMentionIntoText(inputRef.current, mentionsRef.current, cursorPos, cursorPos, mention)
|
|
setComposerInput(inserted.text, inserted.mentions)
|
|
requestAnimationFrame(() => {
|
|
composerRef.current?.focus()
|
|
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
|
|
})
|
|
}}
|
|
id={capabilityMenuId}
|
|
sections={capabilityMenu.sections}
|
|
onAction={capabilityMenu.onAction}
|
|
onClose={() => setPlusMenuOpen(false)}
|
|
mobile={isMobileComposer}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0">
|
|
<PermissionModeSelector sessionId={activeTabId ?? undefined} workDir={resolvedWorkDir} compact={useCompactControls} />
|
|
</div>
|
|
|
|
{showLocationInToolbar && (
|
|
<div data-testid="chat-input-toolbar-location" className="min-w-0 flex-1">
|
|
{embedLaunchControlsInToolbar ? (
|
|
<RepositoryLaunchControls
|
|
workDir={activeLaunchWorkDir}
|
|
onWorkDirChange={handleLaunchWorkDirChange}
|
|
branch={launchBranch}
|
|
onBranchChange={setLaunchBranch}
|
|
useWorktree={launchUseWorktree}
|
|
onUseWorktreeChange={setLaunchUseWorktree}
|
|
onLaunchReadyChange={setLaunchReady}
|
|
disabled={isActive || launchTransitioning}
|
|
placement="toolbar"
|
|
/>
|
|
) : (
|
|
<ProjectContextChip
|
|
workDir={resolvedWorkDir}
|
|
projectRoot={activeSession?.projectRoot}
|
|
repoName={gitInfo?.repoName || null}
|
|
branch={gitInfo?.branch || null}
|
|
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
|
|
isWorktree={!!gitInfo?.worktree?.enabled}
|
|
worktreeSlug={gitInfo?.worktree?.slug || null}
|
|
worktreePath={gitInfo?.worktree?.path || gitInfo?.worktree?.plannedPath || null}
|
|
variant="toolbar"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
data-testid="chat-input-toolbar-trailing"
|
|
className="flex min-w-0 flex-1 items-center justify-end gap-1"
|
|
>
|
|
{!isMemberSession && activeTabId && (
|
|
<ContextUsageIndicator
|
|
sessionId={activeTabId}
|
|
chatState={chatState}
|
|
messageCount={messageCount}
|
|
runtimeSelectionKey={runtimeSelectionKey}
|
|
fallbackModelLabel={runtimeModelLabel}
|
|
compact={useCompactControls}
|
|
refreshNonce={
|
|
(sessionState?.compactCount ?? 0) +
|
|
(sessionState?.runtimeConfigReadyCount ?? 0)
|
|
}
|
|
/>
|
|
)}
|
|
{!isMemberSession && activeTabId && (
|
|
<ModelSelector
|
|
ref={modelSelectorRef}
|
|
runtimeKey={activeTabId}
|
|
lockedProviderId={sideChat ? runtimeSelection?.providerId ?? null : undefined}
|
|
disabled={isActive}
|
|
compact={useCompactControls}
|
|
fluid
|
|
/>
|
|
)}
|
|
<VoiceInputButton dictation={dictation} blocked={composerDisabled} mobile={isMobileComposer} />
|
|
{!isMemberSession && !isActive && (hasRunningSubagents || hasRunningTeam) ? (
|
|
// Stopping background work while the composer can still send:
|
|
// the same ink stop circle as a running turn, beside the send key.
|
|
<Button
|
|
variant="primary"
|
|
size="base"
|
|
shape="circle"
|
|
onClick={() => stopGeneration(activeTabId!)}
|
|
aria-label={t('common.stop')}
|
|
title={t('chat.stopTitle')}
|
|
className={`shrink-0 ${isMobileComposer ? 'h-11 w-11' : ''}`}
|
|
icon={<Square size={isMobileComposer ? 12 : 10} strokeWidth={2} fill="currentColor" aria-hidden="true" />}
|
|
/>
|
|
) : null}
|
|
{/* Same component, shape and icon as EmptySession's send button.
|
|
The two rendered mirror images of each other until it was
|
|
spotted in a walkthrough — the arrow led here and trailed
|
|
there, on what is the same button to the user.
|
|
|
|
A round icon-only target rather than a labelled pill: the
|
|
label said "run" while every other composer control on the row
|
|
is already icon-only, and the width it cost was the widest
|
|
fixed block in a toolbar that has to fit a model picker and a
|
|
location chip. The arrow points *up* — into the transcript the
|
|
message is being sent to — which is also what makes it read as
|
|
send without a word next to it. Dropping the label is why the
|
|
name now lives only in `aria-label`, on both breakpoints.
|
|
|
|
Terracotta (`accent`) is the send key's alone; mid-turn the
|
|
same circle turns ink (`primary`) with a filled square, so a
|
|
running turn does not read as an error. */}
|
|
<Button
|
|
variant={!isMemberSession && isActive ? 'primary' : 'accent'}
|
|
size="base"
|
|
shape="circle"
|
|
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
|
|
disabled={!isMemberSession && isActive ? false : !canSubmit}
|
|
aria-label={!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run')}
|
|
title={
|
|
!isMemberSession && isActive
|
|
? t('chat.stopTitle')
|
|
: isMemberSession
|
|
? t('common.send')
|
|
: t('common.run')
|
|
}
|
|
// 44px on touch is the platform minimum for a primary target;
|
|
// the desktop circle stays at the size's own 32px.
|
|
className={`shrink-0 ${isMobileComposer ? 'h-11 w-11' : ''}`}
|
|
icon={!isMemberSession && isActive
|
|
? <Square data-icon="stop" size={isMobileComposer ? 12 : 10} strokeWidth={2} fill="currentColor" aria-hidden="true" />
|
|
: <ArrowUp data-icon="send" size={isMobileComposer ? 18 : 16} strokeWidth={2} aria-hidden="true" />}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
|
|
|
|
{!isMemberSession && !showLocationInToolbar && (
|
|
<div className={useCompactControls ? 'mt-2 flex min-w-0 px-1' : 'mt-3 px-1'}>
|
|
{messageCount > 0 ? (
|
|
<ProjectContextChip
|
|
workDir={resolvedWorkDir}
|
|
projectRoot={activeSession?.projectRoot}
|
|
repoName={gitInfo?.repoName || null}
|
|
branch={gitInfo?.branch || null}
|
|
sourceWorkDir={gitInfo?.worktree?.sourceWorkDir || null}
|
|
isWorktree={!!gitInfo?.worktree?.enabled}
|
|
worktreeSlug={gitInfo?.worktree?.slug || null}
|
|
worktreePath={gitInfo?.worktree?.path || gitInfo?.worktree?.plannedPath || null}
|
|
compact={useCompactControls}
|
|
/>
|
|
) : (
|
|
<RepositoryLaunchControls
|
|
workDir={activeLaunchWorkDir}
|
|
onWorkDirChange={handleLaunchWorkDirChange}
|
|
branch={launchBranch}
|
|
onBranchChange={setLaunchBranch}
|
|
useWorktree={launchUseWorktree}
|
|
onUseWorktreeChange={setLaunchUseWorktree}
|
|
onLaunchReadyChange={setLaunchReady}
|
|
disabled={isActive || launchTransitioning}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<ComposerReferenceDetail mention={referenceDetail} onClose={() => setReferenceDetail(null)} />
|
|
</div>
|
|
)
|
|
}
|