chore: merge beta into MCP review

This commit is contained in:
usrnk1
2026-10-20 15:43:22 +02:00
Unverified
9 changed files with 315 additions and 177 deletions
@@ -148,6 +148,7 @@ const scenarios = [
for (const scenario of scenarios) {
for (const [width, direction] of [
[1400, "ltr"],
[900, "ltr"],
[390, "rtl"],
] as const) {
test(`${scenario.name}: file headers stay flush at ${width}px in ${direction}`, async ({ page }, info) => {
@@ -176,7 +177,7 @@ for (const scenario of scenarios) {
const scroller = page.locator('[data-slot="session-timeline-scroll"] .scroll-view__viewport')
const toolTitle = scroller.locator('[data-slot="collapsible-trigger"][data-locked]')
await expect(toolTitle).toHaveCount(scenario.title ? 1 : 0)
await expect(scroller.locator("[data-session-title]")).toHaveCount(width === 1400 ? 1 : 0)
await expect(scroller.locator("[data-session-title]")).toHaveCount(width >= 768 ? 1 : 0)
for (const file of scenario.files) {
const name = new RegExp(`${file}\\.ts`)
@@ -9,6 +9,7 @@ const tabs = (page: Page) => page.locator('[data-slot="titlebar-tabs"] a')
for (const direction of ["ltr", "rtl"] as const) {
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await mockStressTimeline(page)
await seed(page, {
tabs: [fixture.sourceID, fixture.targetID],
@@ -65,6 +66,36 @@ for (const direction of ["ltr", "rtl"] as const) {
await header.getByRole("textbox").press("Escape")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
for (const width of [900, 1440]) {
await page.setViewportSize({ width, height: 900 })
await expect
.poll(async () => {
const [location, title, menu, summary, sidebar] = await Promise.all(
[project, header.getByRole("heading"), more, details, review].map((control) => control.boundingBox()),
)
if (!location || !title || !menu || !summary || !sidebar) return false
const gap = direction === "ltr" ? menu.x - title.x - title.width : title.x - menu.x - menu.width
const aligned =
Math.abs(menu.y + menu.height / 2 - summary.y - summary.height / 2) <= 0.5 &&
Math.abs(menu.y + menu.height / 2 - sidebar.y - sidebar.height / 2) <= 0.5
return (
Math.abs(gap - 2) <= 0.5 &&
aligned &&
(width === 900
? Math.abs(location.y - menu.y - menu.height - 8) <= 0.5
: Math.abs(location.y + location.height / 2 - menu.y - menu.height / 2) <= 0.5)
)
})
.toBe(true)
const screenshot = test.info().outputPath(`header-${width}.png`)
await page.screenshot({ path: screenshot })
await test.info().attach(`header-${width}`, { path: screenshot, contentType: "image/png" })
}
await review.click()
await expect(review).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toBeVisible()
@@ -153,14 +153,14 @@ test("keeps the active nested session visible in a narrow desktop header", async
const separator = header.locator('[data-slot="session-title-ancestors"] + [data-slot="session-title-separator"]')
await expect(header.locator('[data-slot="session-title-ancestor"]')).toHaveText([parentTitle, childTitle])
await expect(heading).toBeInViewport()
await expect(separator).toBeInViewport()
await expect(separator).toBeHidden()
const bounds = await heading.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(820)
await page.evaluate(() => document.documentElement.setAttribute("dir", "rtl"))
await expect(heading).toBeInViewport()
await expect(separator).toBeInViewport()
await expect(separator).toBeHidden()
const root = header.locator(`[data-slot="session-title-ancestor"][data-session-id="${parentID}"]`)
await root.focus()
@@ -1,3 +1,91 @@
.session-options-menu [data-component="menu-v2-item"] {
padding-inline-end: 30px;
}
.session-header-layout {
display: flex;
align-items: center;
gap: 2px;
inline-size: 100%;
block-size: 48px;
}
.session-header-location {
display: flex;
align-items: center;
gap: 2px;
min-inline-size: 0;
max-inline-size: 60%;
}
.session-header-location > [data-slot="session-location-trigger"] {
max-inline-size: 100%;
}
.session-header-title {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-inline-size: 0;
}
.session-header-title > [data-slot="session-title-child"] {
min-inline-size: 0;
max-inline-size: 100%;
flex-shrink: 1;
}
.session-header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
margin-inline-start: 6px;
}
.session-header-fade {
top: 48px;
}
@container (max-width: 1000px) {
.session-header-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
block-size: 80px;
padding-block: 10px;
}
.session-header-title {
grid-column: 1;
grid-row: 1;
block-size: 28px;
}
.session-header-actions {
grid-column: 2;
grid-row: 1;
margin-inline-start: 0;
}
.session-header-location {
grid-column: 1 / -1;
grid-row: 2;
max-inline-size: 100%;
block-size: 24px;
}
.session-header-location > [data-slot="session-title-separator"]:last-child,
.session-header-location > div > [data-slot="session-title-separator"] {
display: none;
}
.session-header-fade {
top: 80px;
}
[data-slot="session-timeline-scroll"] {
--session-header-height: 80px;
}
}
@@ -8,7 +8,7 @@ import {
ProjectAvatar,
} from "@opencode/ui/project-avatar"
import { useNavigate } from "@solidjs/router"
import { createMemo, For, Show, type ComponentProps, type ParentProps } from "solid-js"
import { createMemo, For, Show, type ComponentProps, type JSX, type ParentProps } from "solid-js"
import { containsDirectory, getFilename } from "@opencode/util/path"
import { useServer } from "@/runtime/server/current"
import { ServerConnection, useServers } from "@/runtime/server/registry"
@@ -42,12 +42,24 @@ export function SessionTitleHeader(props: ParentProps) {
{/* Stuck timeline headers stack above this fade, so it only fades content scrolling under the bar. */}
<div
aria-hidden="true"
class="pointer-events-none sticky top-12 z-[5] -mt-4 h-4 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base),transparent)]"
class="session-header-fade pointer-events-none sticky z-[5] -mt-4 h-4 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base),transparent)]"
/>
</>
)
}
export function SessionHeaderLayout(props: ParentProps<{ location: JSX.Element; actions?: JSX.Element }>) {
return (
<div class="session-header-layout">
<div class="session-header-location">{props.location}</div>
<div class="session-header-title">{props.children}</div>
<Show when={props.actions}>
<div class="session-header-actions">{props.actions}</div>
</Show>
</div>
)
}
/**
* Where the session runs: the project and its worktree. Opens a card with the server, the full path and the location's
* actions.
@@ -163,10 +175,7 @@ export function SessionLocation(props: {
<Show when={!props.compact && worktree()}>
{(name) => (
<>
<span
class="shrink-0 ps-1 pe-0.5 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
<span class="shrink-0 ps-1 pe-0.5 text-[11px] font-medium text-v2-text-text-faint" aria-hidden="true">
/
</span>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
@@ -432,9 +441,9 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
return (
<Show when={title() || parentTitle()}>
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
<SessionHeaderLayout
location={
<>
<SessionLocation
project={project()}
directory={directory()}
@@ -451,22 +460,23 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
/>
)}
</Show>
<SessionWorkingIndicator sessionID={props.sessionID} />
<Show when={title()}>
{(value) => (
<h1
data-slot={parentID() ? "session-title-child" : undefined}
dir="auto"
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
>
{value()}
</h1>
)}
</Show>
</div>
</div>
</div>
</>
}
>
<SessionWorkingIndicator sessionID={props.sessionID} />
<Show when={title()}>
{(value) => (
<h1
data-slot={parentID() ? "session-title-child" : undefined}
dir="auto"
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
>
{value()}
</h1>
)}
</Show>
</SessionHeaderLayout>
</SessionTitleHeader>
</Show>
)
@@ -25,7 +25,12 @@ import { isWorkspaceDirectory } from "@/workspaces/paths"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { parseVoiceDelegation } from "./voice-delegation"
import { useCommand } from "@/shell/commands/command"
import { SessionAncestorTrail, SessionLocation, SessionTitleHeader } from "../session-identity-header"
import {
SessionAncestorTrail,
SessionHeaderLayout,
SessionLocation,
SessionTitleHeader,
} from "../session-identity-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
import { SessionWorkingIndicator } from "@/session/header/session-working-indicator"
@@ -451,9 +456,9 @@ function MessageTimelineView(
header={
<Show when={!props.hideHeader}>
<SessionTitleHeader>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
<SessionHeaderLayout
location={
<>
<SessionLocation
project={headerProject()}
directory={sessionDirectory()}
@@ -470,148 +475,151 @@ function MessageTimelineView(
/>
)}
</Show>
<Show when={parentID() && childTitle()}>
{(value) => (
<>
<SessionWorkingIndicator sessionID={sessionID()} />
<h1
data-slot="session-title-child"
dir="auto"
class="max-w-[45%] shrink-0 w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
>
{value()}
</h1>
</>
)}
</Show>
<Show when={!parentID() && (childTitle() || title.editing)}>
<Show
when={title.editing}
fallback={
<h1
data-slot="session-title-child"
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
onClick={openTitleEditor}
>
{childTitle()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
</>
}
actions={
<Show when={sessionID()} keyed>
{(_id) => (
<>
{props.search}
<ExtensionSlot
at="session.header"
input={{
get session() {
return props.view
},
get screen() {
return props.screen
},
get active() {
return props.active !== false
},
}}
data-slot="session-title-child"
dir="auto"
value={title.draft}
disabled={props.pending.rename()}
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
style={{
"--inline-input-shadow": "none",
"text-align": "start",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={() => void saveTitleEditor()}
/>
</Show>
</Show>
<Show when={!parentID() && sessionID()} keyed>
{(id) => (
<Menu
gutter={6}
placement="bottom-start"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<Menu.Trigger
as={IconButton}
icon={<Icon name="outline-dots" />}
variant="ghost-muted"
size="large"
class="shrink-0"
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen}
/>
<Menu.Portal>
<Menu.Content
class="session-options-menu w-max"
style={{ "min-width": "0" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<Show when={!parentID()}>
<Menu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={() => void props.action.export(id)}>
{language.t("common.export")}…
</Menu.Item>
</Show>
<Show when={!parentID()}>
{/* TODO: Need a session archive API. */}
<Menu.Separator />
<Menu.Item onSelect={() => props.action.showDelete(id)}>
{language.t("common.delete")}…
</Menu.Item>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
)}
</Show>
</div>
</div>
<Show when={sessionID()} keyed>
{(_id) => (
<div class="shrink-0 flex items-center gap-2">
{props.search}
<ExtensionSlot
at="session.header"
input={{
get session() {
return props.view
},
get screen() {
return props.screen
},
get active() {
return props.active !== false
},
}}
/>
<SessionHeaderSpacer visible={props.reserveReviewToggle} />
</div>
<SessionHeaderSpacer visible={props.reserveReviewToggle} />
</>
)}
</Show>
}
>
<Show when={parentID() && childTitle()}>
{(value) => (
<>
<SessionWorkingIndicator sessionID={sessionID()} />
<h1
data-slot="session-title-child"
dir="auto"
class="max-w-[45%] shrink-0 w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
>
{value()}
</h1>
</>
)}
</Show>
</div>
<Show when={!parentID() && (childTitle() || title.editing)}>
<Show
when={title.editing}
fallback={
<h1
data-slot="session-title-child"
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
onClick={openTitleEditor}
>
{childTitle()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
data-slot="session-title-child"
dir="auto"
value={title.draft}
disabled={props.pending.rename()}
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
style={{
"--inline-input-shadow": "none",
"text-align": "start",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) return
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={() => void saveTitleEditor()}
/>
</Show>
</Show>
<Show when={!parentID() && sessionID()} keyed>
{(id) => (
<Menu
gutter={6}
placement="bottom-start"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<Menu.Trigger
as={IconButton}
icon={<Icon name="outline-dots" />}
variant="ghost-muted"
size="large"
class="shrink-0"
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen}
/>
<Menu.Portal>
<Menu.Content
class="session-options-menu w-max"
style={{ "min-width": "0" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<Show when={!parentID()}>
<Menu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
{language.t("common.rename")}
</Menu.Item>
<Menu.Item onSelect={() => void props.action.export(id)}>
{language.t("common.export")}…
</Menu.Item>
</Show>
<Show when={!parentID()}>
{/* TODO: Need a session archive API. */}
<Menu.Separator />
<Menu.Item onSelect={() => props.action.showDelete(id)}>
{language.t("common.delete")}…
</Menu.Item>
</Show>
</Menu.Content>
</Menu.Portal>
</Menu>
)}
</Show>
</SessionHeaderLayout>
</SessionTitleHeader>
</Show>
}
@@ -924,7 +924,7 @@ export function createTimelineVirtualizer(input: Input) {
onScroll={handleListScroll}
onClick={input.onSelectionInteraction}
class="relative min-w-0 w-full h-full"
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
style={{ "--sticky-accordion-top": input.showHeader() ? "var(--session-header-height, 48px)" : "0px" }}
>
<Show when={input.showHeader()} fallback={<div aria-hidden="true" class="h-4 md:hidden" />}>
{props.header}
@@ -198,7 +198,7 @@
[data-slot="session-review-change"] {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
font-weight: 440;
}
[data-slot="session-review-summary-open-file"] {
@@ -207,11 +207,11 @@
}
[data-slot="session-review-change"][data-type="added"] {
color: var(--icon-diff-add-base);
color: var(--text-diff-add-base);
}
[data-slot="session-review-change"][data-type="removed"] {
color: var(--icon-diff-delete-base);
color: var(--text-diff-delete-base);
}
[data-slot="session-review-change"][data-type="modified"] {
@@ -633,7 +633,7 @@ export function createSessionTimelineRowRenderer(input: {
<Icon
name={outcomeNoticeIcon(outcome())}
size="normal"
class={`inline-block align-top me-2 ${outcomeNoticeColor(outcome())}`}
class={`inline-block align-top me-1.5 ${outcomeNoticeColor(outcome())}`}
/>
)}
</Show>
@@ -1064,7 +1064,7 @@ function outcomeNoticeIcon(outcome: Outcome) {
if (outcome === "cancelled") return "circle-ban-sign"
return "circle-check"
return "check"
}
function outcomeNoticeColor(outcome: Outcome | undefined) {
@@ -1074,7 +1074,7 @@ function outcomeNoticeColor(outcome: Outcome | undefined) {
if (outcome === "cancelled") return "text-v2-state-fg-warning"
return "text-v2-state-fg-info"
return "text-v2-text-text-code-accent"
}
function outcomeNoticeKey(state: string | undefined) {