ui: avoid session review header clipping

Move the session review header outside the scroll viewport and drop strict containment so shadows can render without being cropped.
This commit is contained in:
David Hill
2026-03-02 16:05:16 +00:00
parent d60696ded8
commit 633a3ba03a
2 changed files with 295 additions and 295 deletions

View File

@@ -3,11 +3,10 @@
flex-direction: column; flex-direction: column;
gap: 0px; gap: 0px;
height: 100%; height: 100%;
overflow-y: auto;
scrollbar-width: none; [data-slot="session-review-scroll"] {
contain: strict; flex: 1 1 auto;
&::-webkit-scrollbar { min-height: 0;
display: none;
} }
.scroll-view__viewport { .scroll-view__viewport {
@@ -21,8 +20,6 @@
} }
[data-slot="session-review-header"] { [data-slot="session-review-header"] {
position: sticky;
top: 0;
z-index: 120; z-index: 120;
background-color: var(--background-stronger); background-color: var(--background-stronger);
height: 40px; height: 40px;
@@ -63,7 +60,7 @@
} }
[data-component="sticky-accordion-header"] { [data-component="sticky-accordion-header"] {
--sticky-accordion-top: 40px; --sticky-accordion-top: 0px;
} }
[data-slot="session-review-accordion-item"][data-selected] [data-slot="session-review-accordion-item"][data-selected]

View File

@@ -554,20 +554,7 @@ export const SessionReview = (props: SessionReviewProps) => {
} }
return ( return (
<ScrollView <div data-component="session-review" class={props.class} classList={props.classList}>
data-component="session-review"
viewportRef={(el) => {
scroll = el
props.scrollRef?.(el)
}}
onScroll={props.onScroll as any}
onKeyDown={handleReviewKeyDown}
classList={{
...(props.classList ?? {}),
[props.classes?.root ?? ""]: !!props.classes?.root,
[props.class ?? ""]: !!props.class,
}}
>
<div data-slot="session-review-header" class={props.classes?.header}> <div data-slot="session-review-header" class={props.classes?.header}>
<div data-slot="session-review-title">{props.title ?? i18n.t("ui.sessionReview.title")}</div> <div data-slot="session-review-title">{props.title ?? i18n.t("ui.sessionReview.title")}</div>
<div data-slot="session-review-actions"> <div data-slot="session-review-actions">
@@ -599,301 +586,317 @@ export const SessionReview = (props: SessionReviewProps) => {
{props.actions} {props.actions}
</div> </div>
</div> </div>
<Show when={searchOpen()}>
<FileSearchBar
pos={searchPos}
query={searchQuery}
index={() => (searchHits().length ? Math.min(searchActive(), searchHits().length - 1) : 0)}
count={() => searchHits().length}
setInput={(el) => {
searchInput = el
}}
onInput={(value) => {
setSearchQuery(value)
setSearchActive(0)
}}
onKeyDown={(event) => handleSearchInputKeyDown(event)}
onClose={closeSearch}
onPrev={() => navigateSearch(-1)}
onNext={() => navigateSearch(1)}
/>
</Show>
<div data-slot="session-review-container" class={props.classes?.container}>
<Show when={hasDiffs()} fallback={props.empty}>
<Accordion multiple value={open()} onChange={handleChange}>
<For each={files()}>
{(file) => {
let wrapper: HTMLDivElement | undefined
const diff = createMemo(() => diffs().get(file)) <ScrollView
const item = () => diff()! data-slot="session-review-scroll"
viewportRef={(el) => {
scroll = el
props.scrollRef?.(el)
}}
onScroll={props.onScroll as any}
onKeyDown={handleReviewKeyDown}
classList={{
[props.classes?.root ?? ""]: !!props.classes?.root,
}}
>
<Show when={searchOpen()}>
<FileSearchBar
pos={searchPos}
query={searchQuery}
index={() => (searchHits().length ? Math.min(searchActive(), searchHits().length - 1) : 0)}
count={() => searchHits().length}
setInput={(el) => {
searchInput = el
}}
onInput={(value) => {
setSearchQuery(value)
setSearchActive(0)
}}
onKeyDown={(event) => handleSearchInputKeyDown(event)}
onClose={closeSearch}
onPrev={() => navigateSearch(-1)}
onNext={() => navigateSearch(1)}
/>
</Show>
const expanded = createMemo(() => open().includes(file)) <div data-slot="session-review-container" class={props.classes?.container}>
const force = () => !!store.force[file] <Show when={hasDiffs()} fallback={props.empty}>
<Accordion multiple value={open()} onChange={handleChange}>
<For each={files()}>
{(file) => {
let wrapper: HTMLDivElement | undefined
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file)) const diff = createMemo(() => diffs().get(file))
const commentedLines = createMemo(() => comments().map((c) => c.selection)) const item = () => diff()!
const beforeText = () => (typeof item().before === "string" ? item().before : "") const expanded = createMemo(() => open().includes(file))
const afterText = () => (typeof item().after === "string" ? item().after : "") const force = () => !!store.force[file]
const changedLines = () => item().additions + item().deletions
const mediaKind = createMemo(() => mediaKindFromPath(file))
const tooLarge = createMemo(() => { const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
if (!expanded()) return false const commentedLines = createMemo(() => comments().map((c) => c.selection))
if (force()) return false
if (mediaKind()) return false
return changedLines() > MAX_DIFF_CHANGED_LINES
})
const isAdded = () => item().status === "added" || (beforeText().length === 0 && afterText().length > 0) const beforeText = () => (typeof item().before === "string" ? item().before : "")
const isDeleted = () => const afterText = () => (typeof item().after === "string" ? item().after : "")
item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0) const changedLines = () => item().additions + item().deletions
const mediaKind = createMemo(() => mediaKindFromPath(file))
const selectedLines = createMemo(() => { const tooLarge = createMemo(() => {
const current = selection() if (!expanded()) return false
if (!current || current.file !== file) return null if (force()) return false
return current.range if (mediaKind()) return false
}) return changedLines() > MAX_DIFF_CHANGED_LINES
})
const draftRange = createMemo(() => { const isAdded = () =>
const current = commenting() item().status === "added" || (beforeText().length === 0 && afterText().length > 0)
if (!current || current.file !== file) return null const isDeleted = () =>
return current.range item().status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
})
const commentsUi = createLineCommentController<SessionReviewComment>({ const selectedLines = createMemo(() => {
comments, const current = selection()
label: i18n.t("ui.lineComment.submit"), if (!current || current.file !== file) return null
draftKey: () => file, return current.range
state: { })
opened: () => {
const current = opened() const draftRange = createMemo(() => {
if (!current || current.file !== file) return null const current = commenting()
return current.id if (!current || current.file !== file) return null
return current.range
})
const commentsUi = createLineCommentController<SessionReviewComment>({
comments,
label: i18n.t("ui.lineComment.submit"),
draftKey: () => file,
state: {
opened: () => {
const current = opened()
if (!current || current.file !== file) return null
return current.id
},
setOpened: (id) => setOpened(id ? { file, id } : null),
selected: selectedLines,
setSelected: (range) => setSelection(range ? { file, range } : null),
commenting: draftRange,
setCommenting: (range) => setCommenting(range ? { file, range } : null),
}, },
setOpened: (id) => setOpened(id ? { file, id } : null), getSide: selectionSide,
selected: selectedLines, clearSelectionOnSelectionEndNull: false,
setSelected: (range) => setSelection(range ? { file, range } : null), onSubmit: ({ comment, selection }) => {
commenting: draftRange, props.onLineComment?.({
setCommenting: (range) => setCommenting(range ? { file, range } : null), file,
}, selection,
getSide: selectionSide, comment,
clearSelectionOnSelectionEndNull: false, preview: selectionPreview(item(), selection),
onSubmit: ({ comment, selection }) => { })
props.onLineComment?.({ },
file, onUpdate: ({ id, comment, selection }) => {
selection, props.onLineCommentUpdate?.({
comment, id,
preview: selectionPreview(item(), selection), file,
}) selection,
}, comment,
onUpdate: ({ id, comment, selection }) => { preview: selectionPreview(item(), selection),
props.onLineCommentUpdate?.({ })
id, },
file, onDelete: (comment) => {
selection, props.onLineCommentDelete?.({
comment, id: comment.id,
preview: selectionPreview(item(), selection), file,
}) })
}, },
onDelete: (comment) => { editSubmitLabel: props.lineCommentActions?.saveLabel,
props.onLineCommentDelete?.({ renderCommentActions: props.lineCommentActions
id: comment.id, ? (comment, controls) => (
file, <ReviewCommentMenu
}) labels={props.lineCommentActions!}
}, onEdit={controls.edit}
editSubmitLabel: props.lineCommentActions?.saveLabel, onDelete={controls.remove}
renderCommentActions: props.lineCommentActions />
? (comment, controls) => ( )
<ReviewCommentMenu : undefined,
labels={props.lineCommentActions!} })
onEdit={controls.edit}
onDelete={controls.remove}
/>
)
: undefined,
})
onCleanup(() => { onCleanup(() => {
anchors.delete(file) anchors.delete(file)
readyFiles.delete(file) readyFiles.delete(file)
searchHandles.delete(file) searchHandles.delete(file)
if (highlightedFile === file) highlightedFile = undefined if (highlightedFile === file) highlightedFile = undefined
}) })
const handleLineSelected = (range: SelectedLineRange | null) => { const handleLineSelected = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return if (!props.onLineComment) return
commentsUi.onLineSelected(range) commentsUi.onLineSelected(range)
} }
const handleLineSelectionEnd = (range: SelectedLineRange | null) => { const handleLineSelectionEnd = (range: SelectedLineRange | null) => {
if (!props.onLineComment) return if (!props.onLineComment) return
commentsUi.onLineSelectionEnd(range) commentsUi.onLineSelectionEnd(range)
} }
return ( return (
<Accordion.Item <Accordion.Item
value={file} value={file}
id={diffId(file)} id={diffId(file)}
data-file={file} data-file={file}
data-slot="session-review-accordion-item" data-slot="session-review-accordion-item"
data-selected={props.focusedFile === file ? "" : undefined} data-selected={props.focusedFile === file ? "" : undefined}
> >
<StickyAccordionHeader> <StickyAccordionHeader>
<Accordion.Trigger> <Accordion.Trigger>
<div data-slot="session-review-trigger-content"> <div data-slot="session-review-trigger-content">
<div data-slot="session-review-file-info"> <div data-slot="session-review-file-info">
<FileIcon node={{ path: file, type: "file" }} /> <FileIcon node={{ path: file, type: "file" }} />
<div data-slot="session-review-file-name-container"> <div data-slot="session-review-file-name-container">
<Show when={file.includes("/")}> <Show when={file.includes("/")}>
<span data-slot="session-review-directory">{`\u202A${getDirectory(file)}\u202C`}</span> <span data-slot="session-review-directory">{`\u202A${getDirectory(file)}\u202C`}</span>
</Show> </Show>
<span data-slot="session-review-filename">{getFilename(file)}</span> <span data-slot="session-review-filename">{getFilename(file)}</span>
<Show when={props.onViewFile}> <Show when={props.onViewFile}>
<Tooltip value={openFileLabel()} placement="top" gutter={4}> <Tooltip value={openFileLabel()} placement="top" gutter={4}>
<button <button
data-slot="session-review-view-button" data-slot="session-review-view-button"
type="button" type="button"
aria-label={openFileLabel()} aria-label={openFileLabel()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
props.onViewFile?.(file) props.onViewFile?.(file)
}} }}
> >
<Icon name="open-file" size="small" /> <Icon name="open-file" size="small" />
</button> </button>
</Tooltip> </Tooltip>
</Show> </Show>
</div>
</div>
<div data-slot="session-review-trigger-actions">
<Switch>
<Match when={isAdded()}>
<div data-slot="session-review-change-group" data-type="added">
<span data-slot="session-review-change" data-type="added">
{i18n.t("ui.sessionReview.change.added")}
</span>
<DiffChanges changes={item()} />
</div>
</Match>
<Match when={isDeleted()}>
<span data-slot="session-review-change" data-type="removed">
{i18n.t("ui.sessionReview.change.removed")}
</span>
</Match>
<Match when={!!mediaKind()}>
<span data-slot="session-review-change" data-type="modified">
{i18n.t("ui.sessionReview.change.modified")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={item()} />
</Match>
</Switch>
<span data-slot="session-review-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div> </div>
</div> </div>
<div data-slot="session-review-trigger-actions"> </Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-review-accordion-content">
<div
data-slot="session-review-diff-wrapper"
ref={(el) => {
wrapper = el
anchors.set(file, el)
}}
>
<Show when={expanded()}>
<Switch> <Switch>
<Match when={isAdded()}> <Match when={tooLarge()}>
<div data-slot="session-review-change-group" data-type="added"> <div data-slot="session-review-large-diff">
<span data-slot="session-review-change" data-type="added"> <div data-slot="session-review-large-diff-title">
{i18n.t("ui.sessionReview.change.added")} {i18n.t("ui.sessionReview.largeDiff.title")}
</span> </div>
<DiffChanges changes={item()} /> <div data-slot="session-review-large-diff-meta">
{i18n.t("ui.sessionReview.largeDiff.meta", {
limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
current: changedLines().toLocaleString(),
})}
</div>
<div data-slot="session-review-large-diff-actions">
<Button
size="normal"
variant="secondary"
onClick={() => setStore("force", file, true)}
>
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
</Button>
</div>
</div> </div>
</Match> </Match>
<Match when={isDeleted()}>
<span data-slot="session-review-change" data-type="removed">
{i18n.t("ui.sessionReview.change.removed")}
</span>
</Match>
<Match when={!!mediaKind()}>
<span data-slot="session-review-change" data-type="modified">
{i18n.t("ui.sessionReview.change.modified")}
</span>
</Match>
<Match when={true}> <Match when={true}>
<DiffChanges changes={item()} /> <Dynamic
component={fileComponent}
mode="diff"
preloadedDiff={item().preloaded}
diffStyle={diffStyle()}
expansionLineCount={searchExpanded() ? Number.MAX_SAFE_INTEGER : 20}
onRendered={() => {
readyFiles.add(file)
props.onDiffRendered?.()
}}
enableLineSelection={props.onLineComment != null}
enableHoverUtility={props.onLineComment != null}
onLineSelected={handleLineSelected}
onLineSelectionEnd={handleLineSelectionEnd}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
search={{
shortcuts: "disabled",
showBar: false,
disableVirtualization: searchExpanded(),
register: (handle: FileSearchHandle | null) => {
if (!handle) {
searchHandles.delete(file)
readyFiles.delete(file)
if (highlightedFile === file) highlightedFile = undefined
return
}
searchHandles.set(file, handle)
},
}}
before={{
name: file,
contents: typeof item().before === "string" ? item().before : "",
}}
after={{
name: file,
contents: typeof item().after === "string" ? item().after : "",
}}
media={{
mode: "auto",
path: file,
before: item().before,
after: item().after,
readFile: props.readFile,
}}
/>
</Match> </Match>
</Switch> </Switch>
<span data-slot="session-review-diff-chevron"> </Show>
<Icon name="chevron-down" size="small" />
</span>
</div>
</div> </div>
</Accordion.Trigger> </Accordion.Content>
</StickyAccordionHeader> </Accordion.Item>
<Accordion.Content data-slot="session-review-accordion-content"> )
<div }}
data-slot="session-review-diff-wrapper" </For>
ref={(el) => { </Accordion>
wrapper = el </Show>
anchors.set(file, el) </div>
}} </ScrollView>
> </div>
<Show when={expanded()}>
<Switch>
<Match when={tooLarge()}>
<div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title">
{i18n.t("ui.sessionReview.largeDiff.title")}
</div>
<div data-slot="session-review-large-diff-meta">
{i18n.t("ui.sessionReview.largeDiff.meta", {
limit: MAX_DIFF_CHANGED_LINES.toLocaleString(),
current: changedLines().toLocaleString(),
})}
</div>
<div data-slot="session-review-large-diff-actions">
<Button
size="normal"
variant="secondary"
onClick={() => setStore("force", file, true)}
>
{i18n.t("ui.sessionReview.largeDiff.renderAnyway")}
</Button>
</div>
</div>
</Match>
<Match when={true}>
<Dynamic
component={fileComponent}
mode="diff"
preloadedDiff={item().preloaded}
diffStyle={diffStyle()}
expansionLineCount={searchExpanded() ? Number.MAX_SAFE_INTEGER : 20}
onRendered={() => {
readyFiles.add(file)
props.onDiffRendered?.()
}}
enableLineSelection={props.onLineComment != null}
enableHoverUtility={props.onLineComment != null}
onLineSelected={handleLineSelected}
onLineSelectionEnd={handleLineSelectionEnd}
onLineNumberSelectionEnd={commentsUi.onLineNumberSelectionEnd}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
search={{
shortcuts: "disabled",
showBar: false,
disableVirtualization: searchExpanded(),
register: (handle: FileSearchHandle | null) => {
if (!handle) {
searchHandles.delete(file)
readyFiles.delete(file)
if (highlightedFile === file) highlightedFile = undefined
return
}
searchHandles.set(file, handle)
},
}}
before={{
name: file,
contents: typeof item().before === "string" ? item().before : "",
}}
after={{
name: file,
contents: typeof item().after === "string" ? item().after : "",
}}
media={{
mode: "auto",
path: file,
before: item().before,
after: item().after,
readFile: props.readFile,
}}
/>
</Match>
</Switch>
</Show>
</div>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</Show>
</div>
</ScrollView>
) )
} }