diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx new file mode 100644 index 0000000000..d1a888ca31 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' +import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' +import css from './ConversationRoot.module.css' + +type ConversationContentProps = Omit & { + session: SessionSnapshot | undefined + phase: 'settling' | 'hero' | 'active' + hero: boolean + onHandleStart: () => number + onHandleDrag: (width: number) => void + onHandleCommit: (width: number) => void + onHandleEnd: () => void +} + +/** One transcript width handle: pointer capture + rAF-throttled symmetric + * resize (both sides write the one centered width, so outward travel widens + * by 2× the pointer distance). pointermove publishes the pointer's Y as a CSS + * variable so the glow indicator rides it. Mirrors ui-layout AppFrame's + * DragHandle capture model. */ +function WidthHandle(props: { + side: 'left' | 'right' + onStart: () => number + onDrag: (width: number) => void + onCommit: (width: number) => void + onEnd: () => void +}) { + const [dragging, setDragging] = useState(false) + const base = useRef(0) + const origin = useRef(0) + const latest = useRef(0) + const frame = useRef(null) + const callbacks = useRef(props) + callbacks.current = props + + const outwardWidth = () => { + const dx = latest.current - origin.current + const outward = callbacks.current.side === 'right' ? dx : -dx + return base.current + outward * 2 + } + const cancelFrame = () => { + if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null } + } + const onPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault() + e.currentTarget.setPointerCapture(e.pointerId) + origin.current = e.clientX + latest.current = e.clientX + base.current = callbacks.current.onStart() + setDragging(true) + }, []) + const onPointerMove = useCallback((e: React.PointerEvent) => { + const box = e.currentTarget.getBoundingClientRect() + e.currentTarget.style.setProperty('--dsh-width-handle-pointer-y', `${e.clientY - box.top}px`) + if (!e.currentTarget.hasPointerCapture(e.pointerId)) return + latest.current = e.clientX + frame.current ??= requestAnimationFrame(() => { + frame.current = null + callbacks.current.onDrag(outwardWidth()) + }) + }, []) + const onPointerUp = useCallback((e: React.PointerEvent) => { + if (!e.currentTarget.hasPointerCapture(e.pointerId)) return + e.currentTarget.releasePointerCapture(e.pointerId) + cancelFrame() + latest.current = e.clientX + // Only a gesture with actual travel commits: a press-and-release on a + // window-clamped width must not overwrite the wider stored preference + // with the clamped display value. + if (latest.current !== origin.current) callbacks.current.onCommit(outwardWidth()) + setDragging(false) + callbacks.current.onEnd() + }, []) + // Releasing the button outside the window delivers pointercancel (or drops + // the capture silently) instead of pointerup; without this the glow's + // data-dragging state sticks on. The gesture is abandoned uncommitted — + // onEnd republishes the stored preference. releasePointerCapture inside + // onPointerUp also fires lostpointercapture, so this runs (idempotently) + // after every normal drag end too; keep both paths. + const onPointerCancel = useCallback(() => { + cancelFrame() + setDragging(false) + callbacks.current.onEnd() + }, []) + + return ( +
+ ) +} + +/** + * Render the existing Conversation body, Composer, and width handles. + * @param props - original Conversation seats plus MainPanel-derived phase and width callbacks. + * @returns the unchanged Conversation body subtree. + */ +export function ConversationContent({ + sessionId, session, phase, hero, useSessions, useSessionPendingInteraction, + useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain, + selectWorkspace, t, onHandleStart, onHandleDrag, onHandleCommit, onHandleEnd, +}: ConversationContentProps) { + const pendingInteraction = useSessionPendingInteraction(snapshot => + sessionId === undefined ? undefined : snapshot.get(sessionId)) + const inputState = useInput(s => s) + const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd) + const workspaces = useWorkspaces(s => s) + // A plugin this package cannot import (ui-model-selection) says this session cannot + // send; its reason is already localized by whoever raised it. + const composerBlock = useComposerBlock(block => block) + + const [pickerOpen, setPickerOpen] = useState(false) + const [pendingWorkspaceId, setPendingWorkspaceId] = useState() + const pickerAnchor = useRef(null) + + // Publishes the two live measurements floating View chrome reads off the + // scroll body: the seat's height as --dsh-composer-height, so controls clear + // the composer as it grows, and the scrollport's own height as + // --dsh-conversation-viewport-height, so a control can sit in the band the + // seat leaves visible. Callback ref, not an effect; stable identity prevents + // observer churn while the first blank session fills the resident body + // outlet. + const seatObserver = useRef(null) + const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => { + seatObserver.current?.disconnect() + seatObserver.current = null + const scroller = seat?.parentElement ?? null + if (seat === null || scroller === null) return + seatObserver.current = new ResizeObserver(() => { + scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`) + scroller.style.setProperty( + '--dsh-conversation-viewport-height', + `${scroller.clientHeight}px`, + ) + }) + seatObserver.current.observe(seat) + seatObserver.current.observe(scroller) + }, []) + + const sessionWorkspace = sessionId === undefined + ? undefined + : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId)) + const pendingWorkspace = workspaces.items.find( + workspace => workspace.workspaceId === pendingWorkspaceId, + ) + + // Clear the pending pick once the session lands in it, or when the picked + // workspace disappears from a ready list (deleted from the sidebar). + useEffect(() => { + if (pendingWorkspaceId === undefined) return + if (sessionWorkspace?.workspaceId === pendingWorkspaceId + || (workspaces.phase === 'ready' && pendingWorkspace === undefined)) { + setPendingWorkspaceId(undefined) + } + }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace]) + + const zone: InputZone | undefined = + session === undefined || inputState === undefined ? undefined : { session, input: inputState } + + // The chip is a selector; label resolution walks the flow top-down: + // 1. a just-picked workspace (pending) → its title; + // 2. cold start, no session yet → placeholder ("Choose workspace"); + // 3. the blank session's workspace is in the list → its title; + // 4. list still loading → cwd folder name bridges so the title does not + // flash on refresh (empty cwd → placeholder); + // 5. list ready but no owning workspace (deleted from the sidebar) → + // placeholder, never the deleted folder's name via cwd. + const chipTitle = pendingWorkspace?.title + ?? (sessionId === undefined + ? undefined + : sessionWorkspace?.title + ?? (workspaces.phase === 'ready' || cwd === undefined || cwd === '' + ? undefined + : workspaceLabel(cwd))) + + const heroWorkspaceRow = ( +
+ { setPickerOpen(open => !open) }} + t={t} + /> + {renderSlot('conversation.hero.workspace', { + open: pickerOpen, + anchorRef: pickerAnchor, + selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId, + onPick: (workspaceId) => { + setPickerOpen(false) + setPendingWorkspaceId(workspaceId) + void selectWorkspace(workspaceId).catch(() => { + setPendingWorkspaceId(current => current === workspaceId ? undefined : current) + }) + }, + onClose: () => { setPickerOpen(false) }, + })} + {renderSlot('conversation.hero.agentPreset', {})} +
+ ) + + // The placeholder chip ("Choose workspace") and the Workspace-trigger input travel + // together: no workspace picked yet (cold start, no session at all), or a + // blank session whose workspace vanished (deleted from the sidebar). The + // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, + // not a different tree, so the textarea DOM survives the transition. + const inert = sessionId === undefined || (hero && chipTitle === undefined) + // A raised block is the same inert posture with the blocker's own reason: + // one disabled textarea, never a second tree. The no-workspace state wins + // when both hold — picking a workspace is the earlier prerequisite. + const blocked = !inert && composerBlock !== undefined + const inputBar = renderSlot('conversation.composer.bar', { + variant: hero ? 'hero' : 'composer', + ...(inert + ? { + disabled: true, + placeholder: t('placeholder.workspace'), + workspacePickerOpen: pickerOpen, + onRequestWorkspace: () => { setPickerOpen(true) }, + } + : blocked + // `blocked`, not `disabled`: the bar refuses input either way, but a + // block keeps the model seat live because choosing a model is how the + // user clears it. + ? { blocked: composerBlock, placeholder: composerBlock.reason } + : hero ? { placeholder: t('placeholder.hero') } : {}), + }) + + const composerBar = ( +
+ {hero && } + {hero && heroWorkspaceRow} + {zone !== undefined && renderSlot('conversation.input.dock', zone)} + {inputBar} +
+ ) + + const composer = renderSlotChain( + 'conversation.composer', + { sessionId, session, pendingInteraction }, + { fallback: composerBar, fallbackOnly: sessionId === undefined, overlay: true }, + ) + + // Sticky wraps the whole chain output (fallback + elected overlay), not + // only `.composerStack`: overlay:true renders those as siblings, and sticky + // on the fallback alone would leave a business-owned takeover at the content + // end off-screen when the user is not pinned to the floor. + const composerSeat = ( +
+ {composer} +
+ ) + + return ( +
+
+ {sessionId === undefined ? null : renderSlot('conversation.session', {})} + {composerSeat} +
+ {/* Width handles only while a transcript is on screen; the hero has no + content column to size. */} + {phase === 'active' && (['left', 'right'] as const).map(side => ( + + ))} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx new file mode 100644 index 0000000000..e6a91eff58 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx @@ -0,0 +1,146 @@ +import { useCallback, useRef } from 'react' +import type { ConversationSlotProps } from '../contract/slots.ts' +import { conversationPhase } from '../contract/snapshot.ts' +import { ConversationContent } from './ConversationContent.tsx' +import css from './ConversationRoot.module.css' + +/** localStorage key for the dragged transcript width preference (px). */ +const WIDTH_PREF_KEY = 'dsh.conversation.contentWidth' +/** Floor for a dragged content width; matches the layout center-column minimum. */ +const CONTENT_MIN = 640 +/** Column budget the content must leave free: 88px per side keeps the width + * handles fully placeable (24px inset + 40px strip + 24px safe zone) — a + * larger dragged width would push its own handles off the column and leave no + * way to drag back. */ +const CONTENT_EDGE_BUDGET = 176 + +/** Reads the persisted width preference; durable-storage boundary, so a + * missing or corrupt value resolves to "no preference". + * @returns the stored width in px, or null when unset or invalid. */ +function readWidthPreference(): number | null { + const raw = localStorage.getItem(WIDTH_PREF_KEY) + if (raw === null) return null + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? value : null +} + +/** Resolves the content width the CSS axis would show for a column width. + * @param columnWidth - the conversation column's rendered width in px. + * @param preference - the dragged preference, or null for the adaptive clamp. + * @returns the resolved content width in px (mirrors the CSS clamp). */ +function resolveContentWidth(columnWidth: number, preference: number | null): number { + const max = Math.max(CONTENT_MIN, columnWidth - CONTENT_EDGE_BUDGET) + if (preference !== null) return Math.min(Math.max(preference, CONTENT_MIN), max) + return Math.max(680, Math.min(columnWidth * 0.64, 920)) +} + +/** + * Render the existing main Conversation frame around the extracted content. + * @param props - the original `main.conversation` Slot props. + * @returns the unchanged root, Header, content, and width-control subtree. + */ +export function ConversationMainPanel(props: ConversationSlotProps) { + const { sessionId, useSession, useSessions, useConversation, renderSlot } = props + const session = useSession(s => s) + const conversation = useConversation(s => s) + const shellPhase = session === undefined || conversation === undefined + ? 'blank' + : conversationPhase(session, conversation) + const openState = session?.openState + const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank) + + // Publishes the column's live width as --dsh-conversation-column-width so + // the shared width axis can adapt (see the .root CSS), and re-clamps a + // dragged preference against the shrunken column WITHOUT rewriting the + // stored preference — widening the window restores it (the AppFrame + // sidebar-drag rule). Same callback-ref pattern as the seat observer. + const rootEl = useRef(null) + const rootObserver = useRef(null) + const publishWidths = useCallback((root: HTMLDivElement): void => { + const column = root.offsetWidth + root.style.setProperty('--dsh-conversation-column-width', `${column}px`) + const preference = readWidthPreference() + if (preference === null) { + root.style.removeProperty('--dsh-chat-user-width') + } else { + root.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`) + } + }, []) + const rootResizeRef = useCallback((root: HTMLDivElement | null): void => { + rootObserver.current?.disconnect() + rootObserver.current = null + rootEl.current = root + if (root === null) return + rootObserver.current = new ResizeObserver(() => { publishWidths(root) }) + rootObserver.current.observe(root) + publishWidths(root) + }, [publishWidths]) + + // Drag plumbing for the two width handles: onStart snapshots the resolved + // width (grabbing a clamped column must not jump back to the raw stored + // preference), onDrag publishes only the live clamped style, onCommit + // persists the width of a gesture that actually travelled, and onEnd + // republishes from storage — an uncommitted press leaves the stored + // preference untouched. + const onHandleStart = useCallback((): number => { + const root = rootEl.current + /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ + if (root === null) return 680 + return resolveContentWidth(root.offsetWidth, readWidthPreference()) + }, []) + const onHandleDrag = useCallback((width: number): void => { + const root = rootEl.current + /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ + if (root === null) return + const clamped = resolveContentWidth(root.offsetWidth, width) + root.style.setProperty('--dsh-chat-user-width', `${clamped}px`) + }, []) + const onHandleCommit = useCallback((width: number): void => { + const root = rootEl.current + /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ + if (root === null) return + localStorage.setItem(WIDTH_PREF_KEY, `${resolveContentWidth(root.offsetWidth, width)}`) + }, []) + const onHandleEnd = useCallback((): void => { + const root = rootEl.current + if (root !== null) publishWidths(root) + }, [publishWidths]) + + // While a session is still replaying (loading + blank) the hero/docked + // choice is unknowable — render the composer hidden instead of flashing + // the centered hero and snapping to the docked bar (or vice versa). + // Exemption: a session the list summary already proves blank can only + // land on the hero, so hiding would blank the column for the whole + // history round-trip (the startup auto-selection flash) for nothing. + // The exemption is deliberately open-state-wide, not loading-only: a + // summary-blank session is the hero before its open starts (`cold`) and + // after one fails (`error`) for the same reason — there is no history. + // A restored continuable subagent also stays settled until its eagerly + // loaded parent catalog establishes availability. This keeps the composer + // hidden instead of briefly rendering the parent-offline takeover. + const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable' + && session.subagent.parentAvailable === undefined + const settling = sessionId !== undefined && ( + (shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true) + || parentAvailabilityPending + ) + const hero = sessionId === undefined + || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true)) + const phase = settling ? 'settling' : hero ? 'hero' : 'active' + + return ( +
+ {sessionId === undefined ? null : renderSlot('conversation.session.header', {})} + +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index dfb515bde6..c57fe69936 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -2,394 +2,12 @@ // chain, AND the composer bar (session-maybe slot) stay mounted across // no-session/session transitions — the bar renders inert via owner props. -import { useCallback, useEffect, useRef, useState } from 'react' -import clsx from 'clsx' -import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' -import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' -import { conversationPhase } from '../contract/snapshot.ts' -import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' -import css from './ConversationRoot.module.css' +import type { ConversationSlotProps } from '../contract/slots.ts' +import { ConversationMainPanel } from './ConversationMainPanel.tsx' /** Full props composed from the slot contract. */ export type ConversationRootProps = ConversationSlotProps -/** localStorage key for the dragged transcript width preference (px). */ -const WIDTH_PREF_KEY = 'dsh.conversation.contentWidth' -/** Floor for a dragged content width; matches the layout center-column minimum. */ -const CONTENT_MIN = 640 -/** Column budget the content must leave free: 88px per side keeps the width - * handles fully placeable (24px inset + 40px strip + 24px safe zone) — a - * larger dragged width would push its own handles off the column and leave no - * way to drag back. */ -const CONTENT_EDGE_BUDGET = 176 - -/** Reads the persisted width preference; durable-storage boundary, so a - * missing or corrupt value resolves to "no preference". - * @returns the stored width in px, or null when unset or invalid. */ -function readWidthPreference(): number | null { - const raw = localStorage.getItem(WIDTH_PREF_KEY) - if (raw === null) return null - const value = Number(raw) - return Number.isFinite(value) && value > 0 ? value : null -} - -/** Resolves the content width the CSS axis would show for a column width. - * @param columnWidth - the conversation column's rendered width in px. - * @param preference - the dragged preference, or null for the adaptive clamp. - * @returns the resolved content width in px (mirrors the CSS clamp). */ -function resolveContentWidth(columnWidth: number, preference: number | null): number { - const max = Math.max(CONTENT_MIN, columnWidth - CONTENT_EDGE_BUDGET) - if (preference !== null) return Math.min(Math.max(preference, CONTENT_MIN), max) - return Math.max(680, Math.min(columnWidth * 0.64, 920)) -} - -/** One transcript width handle: pointer capture + rAF-throttled symmetric - * resize (both sides write the one centered width, so outward travel widens - * by 2× the pointer distance). pointermove publishes the pointer's Y as a CSS - * variable so the glow indicator rides it. Mirrors ui-layout AppFrame's - * DragHandle capture model. */ -function WidthHandle(props: { - side: 'left' | 'right' - onStart: () => number - onDrag: (width: number) => void - onCommit: (width: number) => void - onEnd: () => void -}) { - const [dragging, setDragging] = useState(false) - const base = useRef(0) - const origin = useRef(0) - const latest = useRef(0) - const frame = useRef(null) - const callbacks = useRef(props) - callbacks.current = props - - const outwardWidth = () => { - const dx = latest.current - origin.current - const outward = callbacks.current.side === 'right' ? dx : -dx - return base.current + outward * 2 - } - const cancelFrame = () => { - if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null } - } - const onPointerDown = useCallback((e: React.PointerEvent) => { - e.preventDefault() - e.currentTarget.setPointerCapture(e.pointerId) - origin.current = e.clientX - latest.current = e.clientX - base.current = callbacks.current.onStart() - setDragging(true) - }, []) - const onPointerMove = useCallback((e: React.PointerEvent) => { - const box = e.currentTarget.getBoundingClientRect() - e.currentTarget.style.setProperty('--dsh-width-handle-pointer-y', `${e.clientY - box.top}px`) - if (!e.currentTarget.hasPointerCapture(e.pointerId)) return - latest.current = e.clientX - frame.current ??= requestAnimationFrame(() => { - frame.current = null - callbacks.current.onDrag(outwardWidth()) - }) - }, []) - const onPointerUp = useCallback((e: React.PointerEvent) => { - if (!e.currentTarget.hasPointerCapture(e.pointerId)) return - e.currentTarget.releasePointerCapture(e.pointerId) - cancelFrame() - latest.current = e.clientX - // Only a gesture with actual travel commits: a press-and-release on a - // window-clamped width must not overwrite the wider stored preference - // with the clamped display value. - if (latest.current !== origin.current) callbacks.current.onCommit(outwardWidth()) - setDragging(false) - callbacks.current.onEnd() - }, []) - // Releasing the button outside the window delivers pointercancel (or drops - // the capture silently) instead of pointerup; without this the glow's - // data-dragging state sticks on. The gesture is abandoned uncommitted — - // onEnd republishes the stored preference. releasePointerCapture inside - // onPointerUp also fires lostpointercapture, so this runs (idempotently) - // after every normal drag end too; keep both paths. - const onPointerCancel = useCallback(() => { - cancelFrame() - setDragging(false) - callbacks.current.onEnd() - }, []) - - return ( -
- ) -} - -export function ConversationRoot({ - sessionId, useSession, useSessions, useSessionPendingInteraction, - useWorkspaces, useConversation, useInput, useComposerBlock, - renderSlot, renderSlotChain, selectWorkspace, t, -}: ConversationRootProps) { - const session = useSession(s => s) - const pendingInteraction = useSessionPendingInteraction(snapshot => - sessionId === undefined ? undefined : snapshot.get(sessionId)) - const conversation = useConversation(s => s) - const shellPhase = session === undefined || conversation === undefined - ? 'blank' - : conversationPhase(session, conversation) - const openState = session?.openState - const inputState = useInput(s => s) - const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd) - const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank) - const workspaces = useWorkspaces(s => s) - // A plugin this package cannot import (ui-model-selection) says this session cannot - // send; its reason is already localized by whoever raised it. - const composerBlock = useComposerBlock(block => block) - - const [pickerOpen, setPickerOpen] = useState(false) - const [pendingWorkspaceId, setPendingWorkspaceId] = useState() - const pickerAnchor = useRef(null) - - // Publishes the two live measurements floating View chrome reads off the - // scroll body: the seat's height as --dsh-composer-height, so controls clear - // the composer as it grows, and the scrollport's own height as - // --dsh-conversation-viewport-height, so a control can sit in the band the - // seat leaves visible. Callback ref, not an effect; stable identity prevents - // observer churn while the first blank session fills the resident body - // outlet. - const seatObserver = useRef(null) - const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => { - seatObserver.current?.disconnect() - seatObserver.current = null - const scroller = seat?.parentElement ?? null - if (seat === null || scroller === null) return - seatObserver.current = new ResizeObserver(() => { - scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`) - scroller.style.setProperty( - '--dsh-conversation-viewport-height', - `${scroller.clientHeight}px`, - ) - }) - seatObserver.current.observe(seat) - seatObserver.current.observe(scroller) - }, []) - - // Publishes the column's live width as --dsh-conversation-column-width so - // the shared width axis can adapt (see the .root CSS), and re-clamps a - // dragged preference against the shrunken column WITHOUT rewriting the - // stored preference — widening the window restores it (the AppFrame - // sidebar-drag rule). Same callback-ref pattern as the seat observer. - const rootEl = useRef(null) - const rootObserver = useRef(null) - const publishWidths = useCallback((root: HTMLDivElement): void => { - const column = root.offsetWidth - root.style.setProperty('--dsh-conversation-column-width', `${column}px`) - const preference = readWidthPreference() - if (preference === null) { - root.style.removeProperty('--dsh-chat-user-width') - } else { - root.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`) - } - }, []) - const rootResizeRef = useCallback((root: HTMLDivElement | null): void => { - rootObserver.current?.disconnect() - rootObserver.current = null - rootEl.current = root - if (root === null) return - rootObserver.current = new ResizeObserver(() => { publishWidths(root) }) - rootObserver.current.observe(root) - publishWidths(root) - }, [publishWidths]) - - // Drag plumbing for the two width handles: onStart snapshots the resolved - // width (grabbing a clamped column must not jump back to the raw stored - // preference), onDrag publishes only the live clamped style, onCommit - // persists the width of a gesture that actually travelled, and onEnd - // republishes from storage — an uncommitted press leaves the stored - // preference untouched. - const onHandleStart = useCallback((): number => { - const root = rootEl.current - /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ - if (root === null) return 680 - return resolveContentWidth(root.offsetWidth, readWidthPreference()) - }, []) - const onHandleDrag = useCallback((width: number): void => { - const root = rootEl.current - /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ - if (root === null) return - const clamped = resolveContentWidth(root.offsetWidth, width) - root.style.setProperty('--dsh-chat-user-width', `${clamped}px`) - }, []) - const onHandleCommit = useCallback((width: number): void => { - const root = rootEl.current - /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ - if (root === null) return - localStorage.setItem(WIDTH_PREF_KEY, `${resolveContentWidth(root.offsetWidth, width)}`) - }, []) - const onHandleEnd = useCallback((): void => { - const root = rootEl.current - if (root !== null) publishWidths(root) - }, [publishWidths]) - - const sessionWorkspace = sessionId === undefined - ? undefined - : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId)) - const pendingWorkspace = workspaces.items.find( - workspace => workspace.workspaceId === pendingWorkspaceId, - ) - - // Clear the pending pick once the session lands in it, or when the picked - // workspace disappears from a ready list (deleted from the sidebar). - useEffect(() => { - if (pendingWorkspaceId === undefined) return - if (sessionWorkspace?.workspaceId === pendingWorkspaceId - || (workspaces.phase === 'ready' && pendingWorkspace === undefined)) { - setPendingWorkspaceId(undefined) - } - }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace]) - - // While a session is still replaying (loading + blank) the hero/docked - // choice is unknowable — render the composer hidden instead of flashing - // the centered hero and snapping to the docked bar (or vice versa). - // Exemption: a session the list summary already proves blank can only - // land on the hero, so hiding would blank the column for the whole - // history round-trip (the startup auto-selection flash) for nothing. - // The exemption is deliberately open-state-wide, not loading-only: a - // summary-blank session is the hero before its open starts (`cold`) and - // after one fails (`error`) for the same reason — there is no history. - // A restored continuable subagent also stays settled until its eagerly - // loaded parent catalog establishes availability. This keeps the composer - // hidden instead of briefly rendering the parent-offline takeover. - const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable' - && session.subagent.parentAvailable === undefined - const settling = sessionId !== undefined && ( - (shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true) - || parentAvailabilityPending - ) - const hero = sessionId === undefined - || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true)) - const zone: InputZone | undefined = - session === undefined || inputState === undefined ? undefined : { session, input: inputState } - - // The chip is a selector; label resolution walks the flow top-down: - // 1. a just-picked workspace (pending) → its title; - // 2. cold start, no session yet → placeholder ("Choose workspace"); - // 3. the blank session's workspace is in the list → its title; - // 4. list still loading → cwd folder name bridges so the title does not - // flash on refresh (empty cwd → placeholder); - // 5. list ready but no owning workspace (deleted from the sidebar) → - // placeholder, never the deleted folder's name via cwd. - const chipTitle = pendingWorkspace?.title - ?? (sessionId === undefined - ? undefined - : sessionWorkspace?.title - ?? (workspaces.phase === 'ready' || cwd === undefined || cwd === '' - ? undefined - : workspaceLabel(cwd))) - - const heroWorkspaceRow = ( -
- { setPickerOpen(open => !open) }} - t={t} - /> - {renderSlot('conversation.hero.workspace', { - open: pickerOpen, - anchorRef: pickerAnchor, - selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId, - onPick: (workspaceId) => { - setPickerOpen(false) - setPendingWorkspaceId(workspaceId) - void selectWorkspace(workspaceId).catch(() => { - setPendingWorkspaceId(current => current === workspaceId ? undefined : current) - }) - }, - onClose: () => { setPickerOpen(false) }, - })} - {renderSlot('conversation.hero.agentPreset', {})} -
- ) - - // The placeholder chip ("Choose workspace") and the Workspace-trigger input travel - // together: no workspace picked yet (cold start, no session at all), or a - // blank session whose workspace vanished (deleted from the sidebar). The - // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, - // not a different tree, so the textarea DOM survives the transition. - const inert = sessionId === undefined || (hero && chipTitle === undefined) - // A raised block is the same inert posture with the blocker's own reason: - // one disabled textarea, never a second tree. The no-workspace state wins - // when both hold — picking a workspace is the earlier prerequisite. - const blocked = !inert && composerBlock !== undefined - const inputBar = renderSlot('conversation.composer.bar', { - variant: hero ? 'hero' : 'composer', - ...(inert - ? { - disabled: true, - placeholder: t('placeholder.workspace'), - workspacePickerOpen: pickerOpen, - onRequestWorkspace: () => { setPickerOpen(true) }, - } - : blocked - // `blocked`, not `disabled`: the bar refuses input either way, but a - // block keeps the model seat live because choosing a model is how the - // user clears it. - ? { blocked: composerBlock, placeholder: composerBlock.reason } - : hero ? { placeholder: t('placeholder.hero') } : {}), - }) - - const composerBar = ( -
- {hero && } - {hero && heroWorkspaceRow} - {zone !== undefined && renderSlot('conversation.input.dock', zone)} - {inputBar} -
- ) - - const phase = settling ? 'settling' : hero ? 'hero' : 'active' - const composer = renderSlotChain( - 'conversation.composer', - { sessionId, session, pendingInteraction }, - { fallback: composerBar, fallbackOnly: sessionId === undefined, overlay: true }, - ) - - // Sticky wraps the whole chain output (fallback + elected overlay), not - // only `.composerStack`: overlay:true renders those as siblings, and sticky - // on the fallback alone would leave a business-owned takeover at the content - // end off-screen when the user is not pinned to the floor. - const composerSeat = ( -
- {composer} -
- ) - - return ( -
- {sessionId === undefined ? null : renderSlot('conversation.session.header', {})} -
-
- {sessionId === undefined ? null : renderSlot('conversation.session', {})} - {composerSeat} -
- {/* Width handles only while a transcript is on screen; the hero has no - content column to size. */} - {phase === 'active' && (['left', 'right'] as const).map(side => ( - - ))} -
-
- ) +export function ConversationRoot(props: ConversationRootProps) { + return } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 803c53fc9a..f17da377a3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -1,6 +1,5 @@ /** Strict per-session header/body content inserted into the resident conversation layout. */ -import { useEffect } from 'react' import clsx from 'clsx' import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -9,6 +8,7 @@ import type { } from '../contract/slots.ts' import { conversationPhase } from '../contract/snapshot.ts' import { resolveActiveView } from '../view-selection.ts' +import { DefaultConversationViews } from './DefaultConversationViews.tsx' import css from './ConversationRoot.module.css' /** Full props composed from the strict session body contract. */ @@ -165,35 +165,6 @@ export function ConversationSessionHeader({ * @param props - Strict Session input/store, view ledger, and render shares. * @returns the active view area, or null while the Session remains blank. */ -export function ConversationSession({ - useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions, - renderSlot, bindDraftMirror, openView, -}: ConversationSessionProps) { - const tabs = useConversationViews(value => value) - const selectedId = useStore(s => s.view) - const active = resolveActiveView(tabs, selectedId) - const session = useSession(s => s) - const conversation = useConversation(s => s) - const inputState = useInput(s => s) - const storedDraft = useStore(s => s.draft) - const viewRequest = useStore(s => s.viewRequest ?? null) - - useEffect(() => { - if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft) - const unmirror = bindDraftMirror(actions.setDraft) - return () => { unmirror() } - // Mount-only (deps pinned to inputActions): later store writes come from - // the machine mirror, not this seed effect. - }, [inputActions]) - - if (session.blank && conversationPhase(session, conversation) === 'blank') return null - return ( -
- {active !== undefined && renderSlot('conversation.view', { - viewRequest, - openView, - completeViewRequest: actions.completeViewRequest, - }, { only: active.id })} -
- ) +export function ConversationSession(props: ConversationSessionProps) { + return } diff --git a/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx b/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx new file mode 100644 index 0000000000..640ca316f1 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react' +import type { ConversationSessionSlotProps } from '../contract/slots.ts' +import { conversationPhase } from '../contract/snapshot.ts' +import { resolveActiveView } from '../view-selection.ts' +import css from './ConversationRoot.module.css' + +/** + * Renders the active Session view inside the resident scrollport and keeps + * the input draft mirrored while blank Hero chrome is visible. + * @param props - Strict Session input/store, view ledger, and render shares. + * @returns the active view area, or null while the Session remains blank. + */ +export function DefaultConversationViews({ + useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions, + renderSlot, bindDraftMirror, openView, +}: ConversationSessionSlotProps) { + const tabs = useConversationViews(value => value) + const selectedId = useStore(s => s.view) + const active = resolveActiveView(tabs, selectedId) + const session = useSession(s => s) + const conversation = useConversation(s => s) + const inputState = useInput(s => s) + const storedDraft = useStore(s => s.draft) + const viewRequest = useStore(s => s.viewRequest ?? null) + + useEffect(() => { + if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft) + const unmirror = bindDraftMirror(actions.setDraft) + return () => { unmirror() } + // Mount-only (deps pinned to inputActions): later store writes come from + // the machine mirror, not this seed effect. + }, [inputActions]) + + if (session.blank && conversationPhase(session, conversation) === 'blank') return null + return ( +
+ {active !== undefined && renderSlot('conversation.view', { + viewRequest, + openView, + completeViewRequest: actions.completeViewRequest, + }, { only: active.id })} +
+ ) +}