- {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 => (
-
- ))}
+
+
+ {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 => (
+
+ ))}
)
}
From 0db88bd58d833851addc07714030794b112f24f7 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Fri, 11 Sep 2026 02:13:43 +0800
Subject: [PATCH 3/6] refactor(conversation): move content component to its own
module
---
.../client/skeleton/ConversationContent.tsx | 285 ++++++++++++++++++
.../src/client/skeleton/ConversationRoot.tsx | 284 +----------------
2 files changed, 288 insertions(+), 281 deletions(-)
create mode 100644 packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
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/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
index 8a7bdd89c1..bbf81846b4 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
@@ -2,30 +2,15 @@
// 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 { 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 { useCallback, useRef } from 'react'
+import type { ConversationSlotProps } from '../contract/slots.ts'
import { conversationPhase } from '../contract/snapshot.ts'
-import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
+import { ConversationContent } from './ConversationContent.tsx'
import css from './ConversationRoot.module.css'
/** Full props composed from the slot contract. */
export type ConversationRootProps = ConversationSlotProps
-type ConversationPhase = 'settling' | 'hero' | 'active'
-
-type ConversationContentProps = Omit & {
- session: SessionSnapshot | undefined
- phase: ConversationPhase
- hero: boolean
- onHandleStart: () => number
- onHandleDrag: (width: number) => void
- onHandleCommit: (width: number) => void
- onHandleEnd: () => void
-}
-
/** 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. */
@@ -56,91 +41,6 @@ function resolveContentWidth(columnWidth: number, preference: number | null): nu
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(props: ConversationRootProps) {
return
}
@@ -250,181 +150,3 @@ function ConversationMainPanel(props: ConversationRootProps) {
)
}
-
-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 => (
-
- ))}
-
- )
-}
From 8f5b1d71e3c2256d7fddc4069ecbd7f9db4bdf4a Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Fri, 11 Sep 2026 02:16:25 +0800
Subject: [PATCH 4/6] refactor(conversation): move main panel to its own module
---
.../client/skeleton/ConversationMainPanel.tsx | 146 ++++++++++++++++++
.../src/client/skeleton/ConversationRoot.tsx | 141 +----------------
2 files changed, 147 insertions(+), 140 deletions(-)
create mode 100644 packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx
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 bbf81846b4..c57fe69936 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
@@ -2,151 +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, 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'
+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))
-}
-
export function ConversationRoot(props: ConversationRootProps) {
return
}
-
-function ConversationMainPanel(props: ConversationRootProps) {
- 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', {})}
-
-
- )
-}
From f4dbec93df83864adbdbacb24161651bfdbd4cd1 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Fri, 11 Sep 2026 02:18:17 +0800
Subject: [PATCH 5/6] refactor(conversation): extract default views component
---
.../src/client/skeleton/ConversationSession.tsx | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
index 803c53fc9a..5a089c9914 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
@@ -165,7 +165,11 @@ 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({
+export function ConversationSession(props: ConversationSessionProps) {
+ return
+}
+
+function DefaultConversationViews({
useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions,
renderSlot, bindDraftMirror, openView,
}: ConversationSessionProps) {
From 41915703cc47624e2ad17708495d433dd6cc143b Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Fri, 11 Sep 2026 02:20:21 +0800
Subject: [PATCH 6/6] refactor(conversation): move default views to its own
module
---
.../client/skeleton/ConversationSession.tsx | 35 +--------------
.../skeleton/DefaultConversationViews.tsx | 44 +++++++++++++++++++
2 files changed, 45 insertions(+), 34 deletions(-)
create mode 100644 packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
index 5a089c9914..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. */
@@ -168,36 +168,3 @@ export function ConversationSessionHeader({
export function ConversationSession(props: ConversationSessionProps) {
return
}
-
-function DefaultConversationViews({
- 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 })}
-
- )
-}
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 })}
+
+ )
+}