* fix(sidechat): split context injection from the first user message The first composer message rode the same user/message event as the side boundary prompt (+ parked snapshot), and the transcript dropped that whole row by its boundary prefix — swallowing the user's first question. Deliver first contact as TWO messages: the boundary+snapshot rides agent.inject (queued next-step context, claimed first at the opening step, no wake) stamped with source kind 'plugin', and the question is the follow-up that wakes the driver. The log records injection and question as separate events; the transcript maps injections (plugin-sourced, or the legacy boundary-prefix blob of pre-split threads) onto a collapsible injection row and renders genuine user messages — the first one included — as user rows. boundaryDelivered still recognizes the injection by its unchanged boundary-prefix opening. * feat(sidechat): codex-style collapsible rows and creating shimmer Tool calls, thinking and context injections now share one quiet single-line chrome (chevron + label + a one-line argument summary) that expands into an indented body on a hairline thread — no cards or fills. Streaming labels and the creating hero shimmer (shimmer = generating); failed tools go danger; reduced-motion stills every loop. * fix(sidebar): debounce subagent auto-open past the title frame The host delivers a new child's origin and title in separate frames, so a Side Chat thread's first visible frame lacks its 'Side: ' label and tripped the 0→N auto-open — every thread creation popped the task page. The trigger now rearms for 500ms and re-evaluates the original baseline against the live snapshot, by which time the title filter recognizes the thread. Genuine subagents still auto-open, half a second later. * fix(sidechat): render legacy threads' first message as its own bubble Threads created before the host split still log the boundary (+snapshot) and the question as ONE 'user' message; the transcript mapped that row wholly onto the injection row, so the user's first question only appeared inside the expansion. The boundary prompt is a known constant, so the legacy row now splits THERE: the injection row keeps the prompt, the remainder renders as the user's real message (a pre-split snapshot, when present, rides along — the common case is a pure question). Row React keys become kind:seq so the split's two rows never collide.
645 lines
23 KiB
TypeScript
645 lines
23 KiB
TypeScript
/**
|
|
* Side Chat page: Codex-style side conversations for the current session.
|
|
*
|
|
* EVERY side conversation is its own sidebar tab (侧边对话1/2/3 …): the
|
|
* descriptor's createTab mints a fresh tab flagged `autoCreate` and this
|
|
* view creates the EMPTY thread on mount (one click = one conversation,
|
|
* exactly like the Codex app); the composer owns the first message (the
|
|
* host wraps it with the side boundary + the in-progress snapshot parked
|
|
* at creation, and the thread earns its real label — and the tab its
|
|
* title — from that first message). Closing the tab releases the thread's
|
|
* live agent (its history stays persisted); the header menu reopens any
|
|
* existing thread into a tab (deduped by threadId).
|
|
*
|
|
* Each side thread is a child session the plugin created itself with a
|
|
* custom seed (the parent's full log up to the click moment — see
|
|
* sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/
|
|
* info go through the plugin's own /sidebar/api sidechat.* routes
|
|
* (subagent-origin identities are fenced from the generic session RPCs);
|
|
* the transcript is polled from the generic session.history RPC (seed-cut
|
|
* at session/end-seed, boundary row dropped, chunk streaming accumulated)
|
|
* — see sidechat-transcript.ts.
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useSyncExternalStore } from 'react'
|
|
import clsx from 'clsx'
|
|
import {
|
|
IconChevronRightOutline14,
|
|
IconNewChatOutline16,
|
|
IconPlusOutline16,
|
|
IconSendOutline16,
|
|
IconStopFill16,
|
|
MarkdownText,
|
|
Menu,
|
|
StateDot,
|
|
type MenuEntry,
|
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
|
|
import type { Context, SidebarHistoryEntry } from '../context-types.ts'
|
|
import {
|
|
SIDE_LABEL_PREFIX,
|
|
SIDE_NEW_THREAD_TITLE,
|
|
sideThreadRows,
|
|
threadHasCompletedTurn,
|
|
threadTrailingPending,
|
|
type SidechatThreadInfo,
|
|
} from '../sidechat-core.ts'
|
|
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
|
|
import { api } from './api.ts'
|
|
import { t } from './locales.ts'
|
|
import type { SessionScope } from './api.ts'
|
|
import type { SidebarTab } from './state.ts'
|
|
import css from './SideChatView.module.css'
|
|
|
|
/** Tail-page size for one transcript poll (events per page). Small on
|
|
* purpose: streaming polls ride the tail and merge by seq. */
|
|
const PAGE_MESSAGES = 8
|
|
/** First-attach walk page size: cold reads re-expand chunk-rows into one
|
|
* event per streamed delta, so a single answer can be hundreds of events —
|
|
* the walk must page big or earlier tool/call rows fall out of the window. */
|
|
const WALK_PAGE_EVENTS = 200
|
|
/** Poll cadence while the selected thread is running and the tab visible. */
|
|
const POLL_MS = 2000
|
|
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
|
|
const COMPOSER_MAX_HEIGHT = 132
|
|
|
|
/** The thread a tab is bound to (durable in tab.meta across refreshes). */
|
|
export function sidechatThreadIdOf(tab: SidebarTab): string | undefined {
|
|
const meta = tab.meta as { threadId?: unknown } | undefined
|
|
return typeof meta?.threadId === 'string' ? meta.threadId : undefined
|
|
}
|
|
|
|
/** The parked reopen target consumed by the descriptor's createTab (the
|
|
* service's createTab receives no seed, so a thread-switch parks the id
|
|
* here and openTab picks it up synchronously — exactly one consume per
|
|
* park). */
|
|
let parkedReopen: string | undefined
|
|
|
|
/** Park a thread id for the NEXT sidechat openTab to reattach. */
|
|
export function parkSidechatReopen(threadId: string): void {
|
|
parkedReopen = threadId
|
|
}
|
|
|
|
/** Consume the parked reopen target (undefined = mint a fresh thread tab). */
|
|
export function consumeSidechatSeed(): string | undefined {
|
|
const value = parkedReopen
|
|
parkedReopen = undefined
|
|
return value
|
|
}
|
|
|
|
/** In-flight thread creations keyed by tab id (double-mount guard: React
|
|
* StrictMode / HMR must not mint two threads for one tab). */
|
|
const inFlightStarts = new Set<string>()
|
|
|
|
/** Per-thread transcript cache: seed boundary + thread-own events merged by
|
|
* seq (streaming polls never re-download the inherited seed). */
|
|
interface ThreadCache {
|
|
seedBoundary: number | null
|
|
entries: SidebarHistoryEntry[]
|
|
}
|
|
|
|
/** Row-render labels (locale-dependent, memoized once per mount). */
|
|
interface RowLabels {
|
|
copyLabel: string
|
|
copiedLabel: string
|
|
thinkLabel: string
|
|
injectionLabel: string
|
|
}
|
|
|
|
/** Merge history entries by event seq (newest wins), log order preserved. */
|
|
function mergeBySeq(
|
|
previous: readonly SidebarHistoryEntry[],
|
|
incoming: readonly SidebarHistoryEntry[],
|
|
): SidebarHistoryEntry[] {
|
|
const bySeq = new Map<number, SidebarHistoryEntry>()
|
|
for (const entry of previous) bySeq.set(entry.event.seq, entry)
|
|
for (const entry of incoming) bySeq.set(entry.event.seq, entry)
|
|
return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq)
|
|
}
|
|
|
|
/** The display title of a thread: the durable label minus the 'Side: '
|
|
* prefix, with the fresh-thread placeholder localized. */
|
|
function threadDisplayTitle(title: string): string {
|
|
if (title === SIDE_NEW_THREAD_TITLE) return t('sideChatUntitled')
|
|
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
|
|
}
|
|
|
|
/**
|
|
* One collapsible context row — the shared Codex-style chrome of tool
|
|
* calls, thinking and context injections: a single quiet line (chevron +
|
|
* label + one-line summary) that expands into an indented body hung on a
|
|
* hairline thread. Rows with nothing to reveal render as a static line.
|
|
*/
|
|
function CollapsibleRow(props: {
|
|
label: string
|
|
meta?: string
|
|
mono?: boolean
|
|
streaming?: boolean
|
|
failed?: boolean
|
|
children?: React.ReactNode
|
|
}): React.ReactNode {
|
|
const label = (
|
|
<span
|
|
className={clsx(
|
|
css.sidechatRowLabel,
|
|
props.mono === true && css.sidechatRowMono,
|
|
props.streaming === true && css.sidechatShimmerText,
|
|
)}
|
|
>
|
|
{props.label}
|
|
</span>
|
|
)
|
|
const meta = props.meta !== undefined && props.meta !== ''
|
|
? <span className={css.sidechatRowMeta}>{props.meta}</span>
|
|
: null
|
|
if (props.children === undefined) {
|
|
return (
|
|
<div className={clsx(css.sidechatRowLine, css.sidechatRowStatic, props.failed === true && css.sidechatRowFailed)}>
|
|
{label}
|
|
{meta}
|
|
</div>
|
|
)
|
|
}
|
|
return (
|
|
<details className={css.sidechatRow}>
|
|
<summary
|
|
className={clsx(
|
|
css.sidechatRowLine,
|
|
css.sidechatRowSummary,
|
|
props.failed === true && css.sidechatRowFailed,
|
|
)}
|
|
>
|
|
<span className={css.sidechatRowChevron}>
|
|
<IconChevronRightOutline14 size={12} />
|
|
</span>
|
|
{label}
|
|
{meta}
|
|
</summary>
|
|
<div className={css.sidechatRowBody}>{props.children}</div>
|
|
</details>
|
|
)
|
|
}
|
|
|
|
/** One row renderer (React keys ride the source event seq). */
|
|
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
|
|
switch (row.kind) {
|
|
case 'user':
|
|
return (
|
|
<div key={`${row.kind}:${row.seq}`} className={css.sidechatUser}>
|
|
<MarkdownText text={row.text} codeLabels={labels} />
|
|
</div>
|
|
)
|
|
case 'assistant':
|
|
return (
|
|
<div key={`${row.kind}:${row.seq}`} className={css.sidechatAssistant}>
|
|
<MarkdownText text={row.text} codeLabels={labels} />
|
|
</div>
|
|
)
|
|
case 'reasoning':
|
|
return (
|
|
<CollapsibleRow
|
|
key={`${row.kind}:${row.seq}`}
|
|
label={labels.thinkLabel}
|
|
streaming={!row.settled}
|
|
>
|
|
<div className={css.sidechatRowProse}>{row.text}</div>
|
|
</CollapsibleRow>
|
|
)
|
|
case 'injection':
|
|
return (
|
|
<CollapsibleRow key={`${row.kind}:${row.seq}`} label={labels.injectionLabel}>
|
|
<div className={css.sidechatRowProse}>{row.text}</div>
|
|
</CollapsibleRow>
|
|
)
|
|
case 'tool': {
|
|
const body = (
|
|
<>
|
|
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
|
|
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
|
|
</>
|
|
)
|
|
return (
|
|
<CollapsibleRow
|
|
key={`${row.kind}:${row.seq}`}
|
|
label={row.name}
|
|
meta={toolArgsSummary(row.args)}
|
|
mono
|
|
streaming={row.executing === true}
|
|
failed={row.failed}
|
|
{...(row.args === undefined && row.resultText === undefined ? {} : { children: body })}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** One side conversation tab (one thread per tab, Codex-style). */
|
|
export function SideChatView(props: {
|
|
ctx: Context
|
|
scope: SessionScope
|
|
tab: SidebarTab
|
|
visible: boolean
|
|
}): React.ReactNode {
|
|
const { ctx, scope, tab, visible } = props
|
|
const rowLabels = useMemo<RowLabels>(
|
|
() => ({
|
|
copyLabel: t('copy'),
|
|
copiedLabel: t('copied'),
|
|
thinkLabel: t('sideChatThink'),
|
|
injectionLabel: t('sideChatInjection'),
|
|
}),
|
|
[],
|
|
)
|
|
|
|
// The session list feed: thread rows (the header menu) + running states.
|
|
const list = useSyncExternalStore(
|
|
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
|
|
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
|
|
)
|
|
const threads = useMemo(
|
|
() => sideThreadRows(list.byId, scope.sessionId),
|
|
[list, scope.sessionId],
|
|
)
|
|
|
|
// The thread this tab is bound to rides tab.meta (refresh-restored).
|
|
const threadId = sidechatThreadIdOf(tab)
|
|
const autoCreate = (tab.meta as { autoCreate?: unknown } | undefined)?.autoCreate === true
|
|
|
|
const [composer, setComposer] = useState('')
|
|
const [busy, setBusy] = useState<'starting' | 'sending' | 'saving' | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [saved, setSaved] = useState(false)
|
|
const [revision, setRevision] = useState(0)
|
|
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
|
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
|
|
const cacheRef = useRef<ThreadCache>({ seedBoundary: null, entries: [] })
|
|
const controllerRef = useRef<AbortController | null>(null)
|
|
const scrollRef = useRef<HTMLDivElement | null>(null)
|
|
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
|
|
|
const summary = threadId === undefined ? undefined : list.byId[threadId]
|
|
const running = summary?.running === true
|
|
|
|
/** The agent-identity badge of the thread header (preset · model). */
|
|
const agentBadge = useMemo(() => {
|
|
if (info === null) return ''
|
|
return [info.preset, info.model ?? info.provider].filter(Boolean).join(' · ')
|
|
}, [info])
|
|
|
|
/** Create this tab's thread (immediate-create tabs and hero retries). */
|
|
const startThread = useCallback(async (): Promise<void> => {
|
|
if (inFlightStarts.has(tab.id)) return
|
|
inFlightStarts.add(tab.id)
|
|
setBusy('starting')
|
|
setError(null)
|
|
try {
|
|
const { childId } = await api.sidechatStart(scope.sessionId)
|
|
ctx.betterSidebar?.updateTab(tab.id, { meta: { threadId: childId } })
|
|
} catch (cause) {
|
|
setError(cause instanceof Error ? cause.message : String(cause))
|
|
} finally {
|
|
inFlightStarts.delete(tab.id)
|
|
setBusy(null)
|
|
}
|
|
}, [ctx, scope.sessionId, tab.id])
|
|
|
|
// Codex-style immediate create: an autoCreate tab spawns its thread as
|
|
// soon as it first renders.
|
|
useEffect(() => {
|
|
if (threadId !== undefined || !autoCreate || !visible) return
|
|
void startThread()
|
|
}, [threadId, autoCreate, visible, startThread])
|
|
|
|
// The tab title follows the thread's durable label (the first prompt
|
|
// renames the thread; the strip picks it up here).
|
|
useEffect(() => {
|
|
const display = summary?.displayTitle
|
|
if (display === undefined) return
|
|
const title = threadDisplayTitle(display)
|
|
if (title !== '' && title !== tab.title) {
|
|
try {
|
|
ctx.betterSidebar?.updateTab(tab.id, { title })
|
|
} catch {
|
|
// A stale title is cosmetic; the thread keeps working.
|
|
}
|
|
}
|
|
}, [summary, tab.id, tab.title, ctx])
|
|
|
|
/** One transcript pull: the first read walks back to the seed boundary
|
|
* (big pages — chunk deltas re-expand on cold reads), later reads fetch
|
|
* one tail page and merge (seq-deduped). */
|
|
const fetchThread = useCallback(async (childId: string): Promise<void> => {
|
|
controllerRef.current?.abort()
|
|
const controller = new AbortController()
|
|
controllerRef.current = controller
|
|
const cache = cacheRef.current
|
|
try {
|
|
if (cache.seedBoundary === null) {
|
|
const walk = await collectOwnEvents(async (beforeSeq) => {
|
|
const response = await ctx.connection.api.sessions.history(
|
|
{
|
|
sessionId: childId,
|
|
maxMessages: WALK_PAGE_EVENTS,
|
|
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
},
|
|
controller.signal,
|
|
)
|
|
if (!response.result.ok) throw new Error('history walk failed')
|
|
return response.result.value.events
|
|
})
|
|
cache.seedBoundary = walk.seedBoundary
|
|
cache.entries = mergeBySeq(cache.entries, walk.entries)
|
|
} else {
|
|
const response = await ctx.connection.api.sessions.history(
|
|
{ sessionId: childId, maxMessages: PAGE_MESSAGES },
|
|
controller.signal,
|
|
)
|
|
if (!response.result.ok) return
|
|
cache.entries = mergeBySeq(cache.entries, response.result.value.events)
|
|
}
|
|
setRevision(value => value + 1)
|
|
} catch {
|
|
// Aborted by a newer pull or a wire failure: keep the last rows.
|
|
}
|
|
}, [ctx])
|
|
|
|
/** The thread header badge pull (live state + preset/model identity). */
|
|
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
|
|
try {
|
|
setInfo(await api.sidechatInfo(childId))
|
|
} catch {
|
|
// The badge is decorative; a wire failure keeps the last value.
|
|
}
|
|
}, [])
|
|
|
|
// Reset the transcript cache whenever the binding changes, then focus
|
|
// the composer — it owns the first message of a fresh thread.
|
|
useEffect(() => {
|
|
cacheRef.current = { seedBoundary: null, entries: [] }
|
|
controllerRef.current?.abort()
|
|
setError(null)
|
|
setSaved(false)
|
|
setInfo(null)
|
|
if (threadId !== undefined) {
|
|
void fetchInfo(threadId)
|
|
window.setTimeout(() => composerRef.current?.focus(), 0)
|
|
}
|
|
}, [threadId, fetchInfo])
|
|
|
|
// Poll while the tab is visible and the thread runs.
|
|
useEffect(() => {
|
|
if (!visible || threadId === undefined) return
|
|
void fetchThread(threadId)
|
|
if (!running) return
|
|
const timer = window.setInterval(() => {
|
|
void fetchThread(threadId)
|
|
void fetchInfo(threadId)
|
|
}, POLL_MS)
|
|
return () => { window.clearInterval(timer) }
|
|
}, [visible, threadId, running, fetchThread, fetchInfo])
|
|
|
|
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
|
|
|
const rows = useMemo(
|
|
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
|
|
// The cache is a ref; revision bumps on every successful pull.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[threadId, revision],
|
|
)
|
|
const canSave = threadId !== undefined && threadHasCompletedTurn(cacheRef.current.entries)
|
|
const trailingPending = threadId !== undefined && threadTrailingPending(cacheRef.current.entries)
|
|
const freshThread = threadId !== undefined && rows.length === 0
|
|
|
|
// Follow the stream: stick to the bottom while the log grows.
|
|
useEffect(() => {
|
|
const scroller = scrollRef.current
|
|
if (scroller === null) return
|
|
scroller.scrollTop = scroller.scrollHeight
|
|
}, [rows.length, threadId])
|
|
|
|
/** Open a NEW thread tab (createTab mints the autoCreate tab; its view
|
|
* creates the thread on mount). */
|
|
const openNewThread = (): void => {
|
|
setMenuOpen(false)
|
|
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
|
|
}
|
|
|
|
/** Switch to an existing thread: parked for createTab, deduped to the
|
|
* already-open tab when there is one. */
|
|
const openExistingThread = (id: string): void => {
|
|
setMenuOpen(false)
|
|
if (id === threadId) return
|
|
parkSidechatReopen(id)
|
|
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
|
|
}
|
|
|
|
const menuItems = useMemo<MenuEntry[]>(() => {
|
|
const items: MenuEntry[] = [
|
|
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutline16 /> },
|
|
]
|
|
if (threads.length > 0) {
|
|
items.push({ type: 'separator', id: '$sep' })
|
|
for (const row of threads) {
|
|
items.push({
|
|
id: row.id,
|
|
label: threadDisplayTitle(row.title),
|
|
...(row.running ? { icon: <StateDot state="ongoing" size={8} /> } : {}),
|
|
})
|
|
}
|
|
}
|
|
return items
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [threads])
|
|
|
|
const growComposer = (): void => {
|
|
const field = composerRef.current
|
|
if (field === null) return
|
|
field.style.height = '0px'
|
|
field.style.height = `${Math.min(field.scrollHeight, COMPOSER_MAX_HEIGHT)}px`
|
|
}
|
|
|
|
const handleSend = async (): Promise<void> => {
|
|
const text = composer.trim()
|
|
if (text === '' || threadId === undefined || busy !== null) return
|
|
setBusy('sending')
|
|
setError(null)
|
|
try {
|
|
await api.sidechatPrompt(threadId, text)
|
|
setComposer('')
|
|
const field = composerRef.current
|
|
if (field !== null) field.style.height = ''
|
|
void fetchThread(threadId)
|
|
void fetchInfo(threadId)
|
|
} catch (cause) {
|
|
setError(cause instanceof Error ? cause.message : String(cause))
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
const handleCancel = async (): Promise<void> => {
|
|
if (threadId === undefined || busy !== null) return
|
|
try {
|
|
await api.sidechatCancel(threadId)
|
|
} catch (cause) {
|
|
setError(cause instanceof Error ? cause.message : String(cause))
|
|
}
|
|
}
|
|
|
|
const handleSave = async (): Promise<void> => {
|
|
if (threadId === undefined || !canSave || busy !== null) return
|
|
setBusy('saving')
|
|
setError(null)
|
|
setSaved(false)
|
|
try {
|
|
// NOTE: fork must stay a METHOD call — `ctx.sessions.fork` is the
|
|
// client-runtime sessions service, and an unbound reference loses
|
|
// `this` (its fork reads this.list for the title bump).
|
|
if (ctx.sessions.fork === undefined) throw new Error('session fork is unavailable')
|
|
const newId = await ctx.sessions.fork({ sessionId: threadId, increaseTitle: true })
|
|
const title = summary === undefined ? '' : threadDisplayTitle(summary.displayTitle).trim()
|
|
const binding = ctx.sessions.binding?.(newId)
|
|
if (binding !== undefined && title !== '') {
|
|
await binding.session.rename(title)
|
|
}
|
|
ctx.sessions.open?.(newId)
|
|
setSaved(true)
|
|
} catch (cause) {
|
|
setError(cause instanceof Error ? cause.message : String(cause))
|
|
} finally {
|
|
setBusy(null)
|
|
}
|
|
}
|
|
|
|
// ── unbound tab: the hero (fresh autoCreate tabs flash a creating state
|
|
// until the thread lands; legacy persisted tabs offer a manual start) ──
|
|
if (threadId === undefined) {
|
|
return (
|
|
<div className={css.sidechat}>
|
|
<div className={css.sidechatHero}>
|
|
<IconNewChatOutline16 />
|
|
<div
|
|
className={clsx(
|
|
css.sidechatHeroTitle,
|
|
busy === 'starting' && css.sidechatShimmerText,
|
|
)}
|
|
>
|
|
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
|
|
</div>
|
|
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
|
|
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
|
|
{busy !== 'starting' && (
|
|
<button
|
|
type="button"
|
|
className={css.sidechatPrimaryBtn}
|
|
onClick={() => void startThread()}
|
|
>
|
|
{error === null ? t('sideChatNew') : t('sideChatRetry')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={css.sidechat}>
|
|
<div className={css.sidechatDetailHeader}>
|
|
{running && <StateDot state="ongoing" size={8} className={css.sidechatHeaderDot} />}
|
|
{agentBadge !== '' && <span className={css.sidechatAgentBadge}>{agentBadge}</span>}
|
|
<span className={css.sidechatHeaderSpacer} />
|
|
<Menu
|
|
open={menuOpen}
|
|
anchor={(
|
|
<button
|
|
type="button"
|
|
className={css.sidechatIconBtn}
|
|
onClick={() => { setMenuOpen(value => !value) }}
|
|
title={t('sideChatThreads')}
|
|
>
|
|
<IconHistoryOutline16 />
|
|
</button>
|
|
)}
|
|
items={menuItems}
|
|
selectedId={threadId}
|
|
onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }}
|
|
onClose={() => { setMenuOpen(false) }}
|
|
align="end"
|
|
portal
|
|
dense
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={css.sidechatIconBtn}
|
|
onClick={() => void handleSave()}
|
|
disabled={!canSave || busy !== null}
|
|
title={`${t('sideChatSave')} — ${t('sideChatSaveTitle')}`}
|
|
>
|
|
<IconSaveOutline16 />
|
|
</button>
|
|
</div>
|
|
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
|
|
{canSave && trailingPending
|
|
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}
|
|
{saved && <div className={css.sidechatHint}>{t('sideChatSaved')}</div>}
|
|
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
|
|
<div ref={scrollRef} className={css.sidechatScroll}>
|
|
{rows.map(row => renderRow(row, rowLabels))}
|
|
</div>
|
|
{running && (
|
|
<div className={css.sidechatStatus}>
|
|
<StateDot state="ongoing" size={8} />
|
|
<span className={css.sidechatStatusText}>{t('sideChatThinking')}</span>
|
|
</div>
|
|
)}
|
|
<div className={css.sidechatComposer}>
|
|
<textarea
|
|
ref={composerRef}
|
|
className={css.sidechatComposerInput}
|
|
value={composer}
|
|
placeholder={freshThread ? t('sideChatFirstPlaceholder') : t('sideChatComposerPlaceholder')}
|
|
rows={1}
|
|
onChange={event => {
|
|
setComposer(event.target.value)
|
|
growComposer()
|
|
}}
|
|
onKeyDown={event => {
|
|
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
|
|
event.preventDefault()
|
|
void handleSend()
|
|
}}
|
|
/>
|
|
<div className={css.sidechatComposerBar}>
|
|
<span className={css.sidechatComposerMeta}>
|
|
{running ? '' : agentBadge}
|
|
</span>
|
|
{running ? (
|
|
<button
|
|
key="stop"
|
|
type="button"
|
|
className={css.sidechatSendBtn}
|
|
onClick={() => void handleCancel()}
|
|
disabled={busy !== null}
|
|
title={t('sideChatCancelTitle')}
|
|
>
|
|
<IconStopFill16 />
|
|
</button>
|
|
) : (
|
|
<button
|
|
key="send"
|
|
type="button"
|
|
className={css.sidechatSendBtn}
|
|
onClick={() => void handleSend()}
|
|
disabled={composer.trim() === '' || busy !== null}
|
|
title={t('sideChatSend')}
|
|
>
|
|
<IconSendOutline16 />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|