Merge remote-tracking branch 'origin/master' into HEAD
This commit is contained in:
commit
950bdd3c4d
5 files changed
+482
-418
No files matched your search
@@ -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<ConversationSlotProps, 'useSession' | 'useConversation'> & {
|
||||
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<number | null>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
className={css.widthHandle}
|
||||
data-side={props.side}
|
||||
data-width-handle={props.side}
|
||||
data-dragging={dragging || undefined}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onLostPointerCapture={onPointerCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<WorkspaceId | undefined>()
|
||||
const pickerAnchor = useRef<HTMLButtonElement>(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<ResizeObserver | null>(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 = (
|
||||
<div className={css.heroWorkspaceRow}>
|
||||
<WorkspaceChip
|
||||
buttonRef={pickerAnchor}
|
||||
label={chipTitle}
|
||||
menuOpen={pickerOpen}
|
||||
onClick={() => { 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', {})}
|
||||
</div>
|
||||
)
|
||||
|
||||
// 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 = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
{hero && <HeroShell t={t} renderSlot={renderSlot} />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{inputBar}
|
||||
</div>
|
||||
)
|
||||
|
||||
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 = (
|
||||
<div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
|
||||
{composer}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.body}>
|
||||
<div className={css.scrollBody} data-conversation-scroll="">
|
||||
{sessionId === undefined ? null : renderSlot('conversation.session', {})}
|
||||
{composerSeat}
|
||||
</div>
|
||||
{/* 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 => (
|
||||
<WidthHandle
|
||||
key={side}
|
||||
side={side}
|
||||
onStart={onHandleStart}
|
||||
onDrag={onHandleDrag}
|
||||
onCommit={onHandleCommit}
|
||||
onEnd={onHandleEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<HTMLDivElement | null>(null)
|
||||
const rootObserver = useRef<ResizeObserver | null>(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 (
|
||||
<div ref={rootResizeRef} className={css.root} data-phase={phase}>
|
||||
{sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
|
||||
<ConversationContent
|
||||
{...props}
|
||||
session={session}
|
||||
phase={phase}
|
||||
hero={hero}
|
||||
onHandleStart={onHandleStart}
|
||||
onHandleDrag={onHandleDrag}
|
||||
onHandleCommit={onHandleCommit}
|
||||
onHandleEnd={onHandleEnd}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<number | null>(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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
className={css.widthHandle}
|
||||
data-side={props.side}
|
||||
data-width-handle={props.side}
|
||||
data-dragging={dragging || undefined}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onLostPointerCapture={onPointerCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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<WorkspaceId | undefined>()
|
||||
const pickerAnchor = useRef<HTMLButtonElement>(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<ResizeObserver | null>(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<HTMLDivElement | null>(null)
|
||||
const rootObserver = useRef<ResizeObserver | null>(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 = (
|
||||
<div className={css.heroWorkspaceRow}>
|
||||
<WorkspaceChip
|
||||
buttonRef={pickerAnchor}
|
||||
label={chipTitle}
|
||||
menuOpen={pickerOpen}
|
||||
onClick={() => { 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', {})}
|
||||
</div>
|
||||
)
|
||||
|
||||
// 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 = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
{hero && <HeroShell t={t} renderSlot={renderSlot} />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{inputBar}
|
||||
</div>
|
||||
)
|
||||
|
||||
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 = (
|
||||
<div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
|
||||
{composer}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={rootResizeRef} className={css.root} data-phase={phase}>
|
||||
{sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
|
||||
<div className={css.body}>
|
||||
<div className={css.scrollBody} data-conversation-scroll="">
|
||||
{sessionId === undefined ? null : renderSlot('conversation.session', {})}
|
||||
{composerSeat}
|
||||
</div>
|
||||
{/* 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 => (
|
||||
<WidthHandle
|
||||
key={side}
|
||||
side={side}
|
||||
onStart={onHandleStart}
|
||||
onDrag={onHandleDrag}
|
||||
onCommit={onHandleCommit}
|
||||
onEnd={onHandleEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
export function ConversationRoot(props: ConversationRootProps) {
|
||||
return <ConversationMainPanel {...props} />
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {
|
||||
viewRequest,
|
||||
openView,
|
||||
completeViewRequest: actions.completeViewRequest,
|
||||
}, { only: active.id })}
|
||||
</div>
|
||||
)
|
||||
export function ConversationSession(props: ConversationSessionProps) {
|
||||
return <DefaultConversationViews {...props} />
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {
|
||||
viewRequest,
|
||||
openView,
|
||||
completeViewRequest: actions.completeViewRequest,
|
||||
}, { only: active.id })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user