Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 {
dispose()
if (previousDockHeight)
document.documentElement.style.setProperty("--composer-dock-height", previousDockHeight)
else document.documentElement.style.removeProperty("--composer-dock-height")
}
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