Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ export function MessageTimeline(props: {
class="min-w-0 w-full"
style={{
"padding-top": "1rem",
"padding-bottom": "calc(var(--composer-dock-height, 0px) + 16px)",
"padding-bottom": "calc(var(--composer-dock-height, 0px) + 32px)",
}}
>
<div
Expand Down
104 changes: 99 additions & 5 deletions packages/app/src/pages/session/use-session-scroll-dock.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import {
calculateSessionScrollState,
createSessionScrollDock,
shouldStickToBottomAfterDockResize,
syncComposerDockHeight,
} from "./use-session-scroll-dock"

function makeScroller(input: {
clientHeight: number
scrollHeight: number
scrollTop: number
}) {
function makeScroller(input: { clientHeight: number; scrollHeight: number; scrollTop: number }) {
const el = document.createElement("div") as HTMLDivElement
let top = input.scrollTop
let height = input.scrollHeight
Expand Down Expand Up @@ -43,6 +41,72 @@ function makeScroller(input: {
}
}

function makeMeasuredDiv(height: number) {
const el = document.createElement("div") as HTMLDivElement
let current = height

Object.defineProperty(el, "getBoundingClientRect", {
configurable: true,
value: () => ({
width: 720,
height: current,
top: 0,
right: 720,
bottom: current,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}),
})

return {
el,
setHeight(value: number) {
current = value
},
}
}

function withResizeObserver(callback: (trigger: (target: Element) => void) => void) {
const original = globalThis.ResizeObserver
const observed = new Map<Element, Set<(entries: ResizeObserverEntry[]) => void>>()

class TestResizeObserver {
private callback: (entries: ResizeObserverEntry[]) => void

constructor(callback: (entries: ResizeObserverEntry[]) => void) {
this.callback = callback
}

observe = (target: Element) => {
const callbacks = observed.get(target) ?? new Set()
callbacks.add(this.callback)
observed.set(target, callbacks)
}

unobserve = (target: Element) => {
observed.get(target)?.delete(this.callback)
}

disconnect = () => {
for (const callbacks of observed.values()) callbacks.delete(this.callback)
}
}

globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver

try {
callback((target) => {
const rect = target.getBoundingClientRect()
const entry = { target, contentRect: rect } as ResizeObserverEntry
for (const item of observed.get(target) ?? []) item([entry])
})
} finally {
globalThis.ResizeObserver = original
}
}

describe("session scroll dock", () => {
test("calculates bottom state with two-pixel tolerance", () => {
const state = calculateSessionScrollState({
Expand Down Expand Up @@ -170,4 +234,34 @@ describe("session scroll dock", () => {
expect(schedules).toHaveLength(1)
expect(fills).toHaveLength(1)
})

test("updates composer CSS height when the prompt dock resizes after mount", () => {
withResizeObserver((triggerResize) => {
createRoot((dispose) => {
const previousDockHeight = document.documentElement.style.getPropertyValue("--composer-dock-height")
const promptDock = makeMeasuredDiv(120)

try {
const scrollDock = createSessionScrollDock({
clearMessageHash: () => undefined,
clearActiveMessage: () => undefined,
fill: () => undefined,
})

scrollDock.setPromptDockRef(promptDock.el)
expect(document.documentElement.style.getPropertyValue("--composer-dock-height")).toBe("120px")

promptDock.setHeight(220)
triggerResize(promptDock.el)

expect(document.documentElement.style.getPropertyValue("--composer-dock-height")).toBe("220px")
} finally {
if (previousDockHeight)
document.documentElement.style.setProperty("--composer-dock-height", previousDockHeight)
else document.documentElement.style.removeProperty("--composer-dock-height")
dispose()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
})
})
})
34 changes: 18 additions & 16 deletions packages/app/src/pages/session/use-session-scroll-dock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
Expand Down Expand Up @@ -96,6 +95,8 @@ export function createSessionScrollDock(input: {
let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let promptDock: HTMLDivElement | undefined
let contentObserver: ResizeObserver | undefined
let promptDockObserver: ResizeObserver | undefined
let dockHeight = 0
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
Expand Down Expand Up @@ -133,9 +134,17 @@ export function createSessionScrollDock(input: {
}

const setContentRef = (el: HTMLDivElement | undefined) => {
contentObserver?.disconnect()
contentObserver = undefined
content = el
autoScroll.contentRef(el)
if (el && scroller) scheduleScrollState(scroller)
if (!el) return
contentObserver = new ResizeObserver(() => {
if (scroller) scheduleScrollState(scroller)
input.fill()
})
contentObserver.observe(el)
}

const updateDockHeight = (next: number) => {
Expand All @@ -154,10 +163,16 @@ export function createSessionScrollDock(input: {
const measurePromptDockHeight = () => Math.ceil(promptDock?.getBoundingClientRect().height ?? 0)

const setPromptDockRef = (el: HTMLDivElement | undefined) => {
promptDockObserver?.disconnect()
promptDockObserver = undefined
promptDock = el
if (!el) return
const next = measurePromptDockHeight()
if (next > 0) updateDockHeight(next)
promptDockObserver = new ResizeObserver(() => {
updateDockHeight(measurePromptDockHeight())
})
promptDockObserver.observe(el)
}

const resumeScroll = () => {
Expand All @@ -179,22 +194,9 @@ export function createSessionScrollDock(input: {
),
)

createResizeObserver(
() => content,
() => {
if (scroller) scheduleScrollState(scroller)
input.fill()
},
)

createResizeObserver(
() => promptDock,
() => {
updateDockHeight(measurePromptDockHeight())
},
)

onCleanup(() => {
contentObserver?.disconnect()
promptDockObserver?.disconnect()
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
document.documentElement.style.removeProperty("--composer-dock-height")
})
Expand Down
18 changes: 13 additions & 5 deletions packages/app/src/shell-frame-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ test("desktop shell shares titlebar height across titlebar and narrow sidebar ge
const sessionHeader = read("./components/session/session-header.tsx")
const pawworkTitlebar = read("./pages/layout/pawwork-titlebar.tsx")
const wideDesktopQuery = css.indexOf("@media (min-width: 1280px)")
const macMainSeamRule = css.indexOf('[data-component="desktop-shell-main"][data-platform="desktop"][data-os="macos"] {')
const wideFrameRule = css.indexOf('[data-component="desktop-shell-frame"][data-platform="desktop"][data-os="linux"] {')
const macMainSeamRule = css.indexOf(
'[data-component="desktop-shell-main"][data-platform="desktop"][data-os="macos"] {',
)
const wideFrameRule = css.indexOf(
'[data-component="desktop-shell-frame"][data-platform="desktop"][data-os="linux"] {',
)

expect(css).toContain('[data-component="desktop-shell"][data-platform="desktop"] {')
expect(css).toContain("--shell-titlebar-height: 44px;")
Expand Down Expand Up @@ -44,24 +48,28 @@ test("desktop shell shares titlebar height across titlebar and narrow sidebar ge
test("session composer is docked outside the scroll-clipped timeline region", () => {
const session = read("./pages/session.tsx")
const sessionMainView = read("./pages/session/session-main-view.tsx")
const messageTimeline = read("./pages/session/message-timeline.tsx")

expect(session).toContain("const renderComposerRegion = (")
expect(session).toContain('variant: "session" | "home"')
expect(sessionMainView).toContain('<div class="flex-1 min-h-0 overflow-hidden">')
expect(sessionMainView).toContain("</div>\n <Show when={props.activeSessionID}>{props.composerSession}</Show>")
expect(sessionMainView).toContain(
"</div>\n <Show when={props.activeSessionID}>{props.composerSession}</Show>",
)
expect(messageTimeline).toContain('"padding-bottom": "calc(var(--composer-dock-height, 0px) + 32px)"')
})

test("session header uses a view title on home and breadcrumb title in sessions", () => {
const sessionHeader = read("./components/session/session-header.tsx")

expect(sessionHeader).toContain('language.t("command.session.new")')
expect(sessionHeader).toContain('sync.session.get(params.id)')
expect(sessionHeader).toContain("sync.session.get(params.id)")
expect(sessionHeader).not.toContain('language.t("session.header.searchFiles")')
expect(sessionHeader).not.toContain('language.t("session.header.search.placeholder"')
})

test("titlebar drops Windows-only 138px placeholder and conditional drag region", () => {
const titlebar = read("./components/titlebar.tsx")
expect(titlebar).not.toContain('class="w-36 shrink-0"')
expect(titlebar).toContain('data-shell-drag-region={!windows() || undefined}')
expect(titlebar).toContain("data-shell-drag-region={!windows() || undefined}")
})
Loading