This commit is contained in:
07akioni
2026-07-29 21:03:40 +08:00
parent 2a12057345
commit 08d097077e
12 files changed
+117 -36

No files matched your search

@@ -119,13 +119,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
export interface ConversationSessionOwnerProps {
/**
* Wrap the view ring in the transcript scrollport that also hosts the
* sticky composer. Supplied for every real session (hero/settling/active)
* so the composer keeps one tree seat across the blank → active flip; the
* header stays outside that wrapper as ordinary column chrome
* (`flex: none`), while active CSS sticks the composer to the bottom of
* the same scrollport so wheel over the footer scrolls the flow.
* sticky composer seat (whole `'conversation.composer'` chain output).
* Supplied for every real session (hero/settling/active) so the composer
* keeps one tree seat across the blank → active flip; the header stays
* outside that wrapper as ordinary column chrome (`flex: none`), while
* active CSS sticks the seat to the bottom of the same scrollport so wheel
* over the footer scrolls the flow.
* @param view - the session view-ring content (null while blank chrome is hidden).
* @returns the scrollport containing `view` and the sticky composer.
* @returns the scrollport containing `view` and the sticky composer seat.
*/
wrapActiveBody?: (view: ReactNode) => ReactNode
}
@@ -133,9 +133,16 @@
flex-direction: column;
}
/* Common seat for the composer chain (fallback + elected overlay siblings). */
.composerSeat {
display: flex;
flex: none;
flex-direction: column;
}
/* Active phase: header is ordinary column chrome above the scrollport (not
sticky). The scroll body holds the transcript and the sticky composer so
wheel over the footer moves the flow. */
sticky). The scroll body holds the transcript and the sticky composer seat
so wheel over the footer moves the flow. */
.root[data-phase='active'] {
overflow: hidden;
}
@@ -157,13 +164,12 @@
min-height: auto;
}
.root[data-phase='active'] .composerStack {
.root[data-phase='active'] .composerSeat {
position: sticky;
bottom: 0;
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
paints under a sticking code header while scrolling. */
z-index: 7;
flex: none;
background: var(--dsw-alias-bg-base);
}
@@ -212,8 +218,8 @@
overflow-y: auto;
}
/* Settling (session replaying, hero/docked unknown): keep the composer
/* Settling (session replaying, hero/docked unknown): keep the composer seat
mounted but invisible so no wrong layout flashes before the phase lands. */
.root[data-phase='settling'] .composerStack {
.root[data-phase='settling'] .composerSeat {
visibility: hidden;
}
@@ -128,14 +128,24 @@ export function ConversationRoot({
{ fallback: composerBar, 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 Question/Approval panels at the content
// end off-screen when the user is not pinned to the floor.
const composerSeat = (
<div className={css.composerSeat} data-composer-seat="">
{composer}
</div>
)
// Header stays column chrome above this scrollport; the sticky composer
// lives inside it with the transcript. Always wrap while a session exists
// (hero/settling/active) so the composer keeps one tree seat across the
// blank → active flip — relocating it only in active remounted the textarea.
// seat lives inside it with the transcript. Always wrap while a session
// exists (hero/settling/active) so the composer keeps one tree seat across
// the blank → active flip — relocating it only in active remounted the textarea.
const wrapActiveBody = (view: ReactNode): ReactNode => (
<div className={css.scrollBody} data-conversation-scroll="">
{view}
{composer}
{composerSeat}
</div>
)
@@ -149,7 +159,7 @@ export function ConversationRoot({
'conversation.session',
{ wrapActiveBody },
)}
{sessionId === undefined ? composer : null}
{sessionId === undefined ? composerSeat : null}
</div>
)
}
@@ -75,15 +75,20 @@ export function InputBar({
if (!locked) inputRef.current?.focus()
}, [locked])
// Active conversation scrollport: never let the textarea become a nested
// wheel target; forward delta to `[data-conversation-scroll]` instead.
// Hero mounts have no host, so the textarea keeps native wheel scrolling.
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
// the native scroll; only at its own edge forward delta to the host so a
// short draft never traps the gesture and a long draft stays scrollable.
// Hero mounts have no host and keep native wheel scrolling.
useEffect(() => {
const el = inputRef.current
if (el === null) return
const onWheel = (e: WheelEvent): void => {
const host = el.closest('[data-conversation-scroll]')
if (!(host instanceof HTMLElement)) return
if (!(host instanceof HTMLElement) || e.deltaY === 0) return
const atTop = el.scrollTop <= 0
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atEnd)) return
e.preventDefault()
host.scrollTop += e.deltaY
}