Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
8585ca7
feat(ui): inputs slice 05 — TextField/Select/Switch redesign, remove …
Astro-Han May 5, 2026
a606681
fix(ui): post-smoke fixes for slice 05 inputs
Astro-Han May 5, 2026
a634787
fix(ui): smoke feedback — switch thumb, rename dialog, revert review/…
Astro-Han May 5, 2026
b3bab1d
fix(ui): align select tokens, rename dialog, and in-progress todo spi…
Astro-Han May 5, 2026
48b7a68
fix(ui): round-4 smoke — select tokens, dark borders, double-click re…
Astro-Han May 5, 2026
0a46ed8
fix(ui): align switch with inputs.html spec; fix todo spinner width jump
Astro-Han May 5, 2026
4914fdc
fix(ui): align select trigger with inputs.html spec
Astro-Han May 5, 2026
4e9b340
fix(ui): align TextField/Select inputs with inputs.html spec (round 5)
Astro-Han May 5, 2026
5413fb9
fix(ui): fix select icon, toast dark mode, and switch track visibility
Astro-Han May 5, 2026
d1049bc
fix(ui): fix settings trigger text clipping and list search border
Astro-Han May 6, 2026
5573fcd
fix(ui): suppress empty section header in ungrouped select
Astro-Han May 6, 2026
56feba7
fix(ui): fix select item height for CJK fonts
Astro-Han May 6, 2026
6f80c00
fix(ui): fix settings select width, hover, and descender clipping
Astro-Han May 6, 2026
33d852b
fix(ui): align select/textfield with preview specs
Astro-Han May 6, 2026
e691762
Merge remote-tracking branch 'origin/dev' into claude/i440-slice-05-i…
Astro-Han May 6, 2026
e9c063a
fix(ui): align list search bar with popover-embedded spec
Astro-Han May 6, 2026
e5e1671
fix(ui): remove divider under list search bar
Astro-Han May 6, 2026
2108fd0
fix(ui): lighten select trigger hover and align item to sidebar standard
Astro-Han May 6, 2026
4909456
fix(app): align sidebar row hover and active to session-row.html
Astro-Han May 6, 2026
1b4013b
test(app): rewrite session-rename e2e for dialog flow
Astro-Han May 6, 2026
95ef268
fix(ui): split select item hover and selected into two tiers
Astro-Han May 6, 2026
9ba7357
fix(app): split sidebar row hover and active into two tiers
Astro-Han May 6, 2026
981df8e
fix(app): align composer pickers to the cross-product hover token
Astro-Han May 6, 2026
c7a8f2a
fix(ui): bump hover-overlay-warm and add dark mirror
Astro-Han May 6, 2026
c511126
fix(ui,app): switch hover token to --hover-overlay-warm everywhere
Astro-Han May 6, 2026
ad8f52c
feat(ui): introduce --surface-list-hover token for two-tier hover
Astro-Han May 6, 2026
12a0bf5
fix(ui,app): apply --surface-list-hover everywhere a list row can hover
Astro-Han May 6, 2026
df2f5e7
chore(opencode): refresh prebuilt models snapshot
Astro-Han May 6, 2026
a7d0906
feat(ui,app): introduce row overlay tokens, decouple sidebar token, f…
Astro-Han May 6, 2026
6cc43e7
feat(ui): introduce picker contract — shared trigger/content/item CSS…
Astro-Han May 6, 2026
ca4b71b
refactor(ui): wire Select to picker contract
Astro-Han May 6, 2026
72eabfd
refactor(ui): wire List to picker contract, make selected check-icon …
Astro-Han May 6, 2026
493d517
refactor(app): align composer pickers to picker contract
Astro-Han May 6, 2026
c8bc205
fix(ui): align Switch primitive to ui_kits desktop mock
Astro-Han May 6, 2026
68cc4e1
fix(ui): textfield default background uses --surface-base, not cream
Astro-Han May 6, 2026
6820921
fix(app): flatten rename-session dialog — drop cream footer + separator
Astro-Han May 6, 2026
7079931
refactor(ui): remove unused review-filter Select variant
Astro-Han May 6, 2026
821b65d
fix(app): await rename promise instead of dropping it with void
Astro-Han May 6, 2026
22e1257
fix(app): catch onConfirm failure in DialogRenameSession
Astro-Han May 6, 2026
c7529c8
fix(app): drop redundant aria-haspopup override on variant Select
Astro-Han May 6, 2026
0e73287
fix(ui): restore visible focus-visible ring on picker items
Astro-Han May 6, 2026
024b5be
fix(ui): scope dark-mode switch thumb override to non-disabled
Astro-Han May 6, 2026
085bf18
test(ui): null-guard splitMatch regex in switch checked test
Astro-Han May 6, 2026
a6ce188
fix(app): use aria-label, drop label="" + hideLabel pattern
Astro-Han May 6, 2026
86fe995
test(app): tighten new inputs e2e specs
Astro-Han May 6, 2026
bf7bbfc
test(ui,app): loosen brittle source-text assertions
Astro-Han May 6, 2026
1fd031f
refactor(ui): drop redundant :first-child override on toast action
Astro-Han May 6, 2026
a190008
fix(app): wire diff-style toggle through review tab
Astro-Han May 6, 2026
e5bc191
test(app,e2e): align sidebar rename spec with Dialog flow
Astro-Han May 6, 2026
1230a8f
feat(app): toast on session rename failure
Astro-Han May 6, 2026
49fc07c
test(app,e2e): seed session storage in rename dialog spec
Astro-Han May 6, 2026
6d55114
test(app,e2e): match todo items by data-state attribute, not filter
Astro-Han May 6, 2026
cabaeb2
test(app,e2e): open review tab via overflow menu in diff-style spec
Astro-Han May 6, 2026
61af285
fix(ui): restore flex layout for message-part todo items
Astro-Han May 6, 2026
4e5ec7f
fix(app): block Escape during rename save to match disabled Cancel
Astro-Han May 6, 2026
d46719c
test(app,e2e): drop redundant trackSession in review-filter spec
Astro-Han May 6, 2026
7154319
test(ui): assert text-field css exposes [data-invalid] selector
Astro-Han May 6, 2026
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
95 changes: 95 additions & 0 deletions packages/app/e2e/inputs/select-review-filter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* select-review-filter.spec.ts @smoke
*
* Golden-path: review panel diff-style toggle via IconButton pair that replaced RadioGroup.
* Creates a real file diff, opens the review panel, then verifies the unified/split
* IconButtons toggle aria-pressed correctly.
*/
import type { Page } from "@playwright/test"
import { openRightPanel, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { bodyText } from "../prompt/mock"

async function openReviewPanel(page: Page) {
const panel = await openRightPanel(page)
const tabList = panel.getByRole("tablist").first()
const reviewTab = tabList.getByRole("tab", { name: "Review", exact: true })

if (await reviewTab.isVisible().catch(() => false)) {
await reviewTab.click()
} else {
await tabList.locator("button").last().click()
await page.getByRole("menuitem", { name: "Review" }).click()
}
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
}

test("review diff-style toggle switches between unified and split @smoke", async ({
page,
llm,
project,
}) => {
await project.open()

await withSession(project.sdk, "e2e inputs review filter toggle", async (session) => {
{
const PATCH_TEXT = [
"*** Begin Patch",
"*** Add File: review-filter-test.txt",
"+line one",
"+line two",
"*** End Patch",
].join("\n")

const callsBefore = await llm.calls()
await llm.toolMatch(
(hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
"apply_patch",
{ patchText: PATCH_TEXT },
)
await project.sdk.session.prompt({
sessionID: session.id,
agent: "build",
system: [
"You are seeding deterministic e2e UI state.",
"Your only valid response is one apply_patch tool call.",
`Use this JSON input: ${JSON.stringify({ patchText: PATCH_TEXT })}`,
"Do not call any other tools.",
"Do not output plain text.",
].join("\n"),
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
})

await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
await expect
.poll(
async () => {
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length
},
{ timeout: 60_000 },
)
.toBeGreaterThan(0)

await project.gotoSession(session.id)
await openReviewPanel(page)

const unifiedBtn = page.locator('[data-component="session-review"] [aria-label*="unified" i]').first()
const splitBtn = page.locator('[data-component="session-review"] [aria-label*="split" i]').first()

await expect(unifiedBtn).toBeVisible({ timeout: 10_000 })
await expect(splitBtn).toBeVisible()

await expect(unifiedBtn).toHaveAttribute("aria-pressed", "true")
await expect(splitBtn).toHaveAttribute("aria-pressed", "false")

await splitBtn.click()
await expect(splitBtn).toHaveAttribute("aria-pressed", "true")
await expect(unifiedBtn).toHaveAttribute("aria-pressed", "false")

await unifiedBtn.click()
await expect(unifiedBtn).toHaveAttribute("aria-pressed", "true")
await expect(splitBtn).toHaveAttribute("aria-pressed", "false")
}
})
})
43 changes: 43 additions & 0 deletions packages/app/e2e/inputs/session-rename-dialog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* session-rename-dialog.spec.ts @smoke
*
* Golden-path: sidebar rename opens a Dialog with a TextField, saves on Enter,
* and the new title shows up in the sidebar row.
*/
import { cleanupSession, openSidebar } from "../actions"
import { test, expect } from "../fixtures"
import { pawworkSidebarSelector } from "../selectors"

test("sidebar rename uses Dialog and saves on Enter @smoke", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const session = await sdk.session.create({ title: `Rename dialog test ${stamp}` }).then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")

try {
await gotoSession(session.id)
await openSidebar(page)

const sidebar = page.locator(pawworkSidebarSelector).first()
const row = sidebar.locator(`[data-session-id="${session.id}"]`).first()

await row.hover()
await row.locator('[data-action="session-row-menu"]').click()
await page.getByRole("menuitem", { name: /rename/i }).click()

const dialog = page.locator('[data-component="dialog"]')
await expect(dialog).toBeVisible()

const input = dialog.getByRole("textbox")
await expect(input).toBeVisible()
await expect(input).toBeFocused()

const newTitle = `Renamed dialog ${stamp}`
await input.fill(newTitle)
await input.press("Enter")

await expect(dialog).toBeHidden()
await expect(sidebar.locator(`[data-session-id="${session.id}"]`)).toContainText(newTitle)
} finally {
await cleanupSession({ sdk, sessionID: session.id })
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
90 changes: 90 additions & 0 deletions packages/app/e2e/inputs/todo-toggle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* todo-toggle.spec.ts @smoke
*
* Golden-path: todo dock renders circle / circle-check icons based on todo status,
* after replacing the Checkbox primitive with Icon in slice 05.
*/
import type { Page } from "@playwright/test"
import {
composerEvent,
type ComposerDriverState,
type ComposerWindow,
} from "../../src/testing/session-composer"
import { cleanupSession } from "../actions"
import { test, expect } from "../fixtures"

async function driverWrite(
page: Page,
sessionID: string,
driver: ComposerDriverState | undefined,
) {
await page.evaluate(
(input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
const win = window as ComposerWindow
const composer = win.__opencode_e2e?.composer
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
composer.sessions ??= {}
const prev = composer.sessions[input.sessionID] ?? {}
if (!input.driver) {
delete composer.sessions[input.sessionID]
} else {
composer.sessions[input.sessionID] = { ...prev, driver: input.driver }
}
window.sessionStorage.setItem("__opencode_e2e_composer_sessions", JSON.stringify(composer.sessions))
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
},
{ event: composerEvent, sessionID, driver },
)
}

test("todo dock shows circle for pending and circle-check for completed items @smoke", async ({
page,
project,
}) => {
await page.addInitScript(() => {
const win = window as ComposerWindow
const saved = window.sessionStorage.getItem("__opencode_e2e_composer_sessions")
const sessions = saved ? JSON.parse(saved) : {}
win.__opencode_e2e = { ...win.__opencode_e2e, composer: { enabled: true, sessions } }
})

await project.open()

const session = await project.sdk.session.create({ title: `e2e inputs todo icons` }).then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
project.trackSession(session.id)

try {
await project.gotoSession(session.id)

await driverWrite(page, session.id, {
todos: [
{ content: "pending task", status: "pending", priority: "medium" },
{ content: "done task", status: "completed", priority: "high" },
{ content: "in-progress task", status: "in_progress", priority: "low" },
],
})

const dock = page.locator('[data-component="session-todo-dock"]')
await expect(dock).toBeVisible({ timeout: 10_000 })

const toggleBtn = dock.locator('[data-action="session-todo-toggle"]').first()
await toggleBtn.click()

const list = dock.locator('[data-slot="session-todo-list"]')
await expect(list).toBeVisible({ timeout: 5_000 })

const items = list.locator('[data-slot="session-todo-item"]')
await expect(items).toHaveCount(3, { timeout: 5_000 })

const pendingItem = list.locator('[data-slot="session-todo-item"][data-state="pending"]').first()
const completedItem = list.locator('[data-slot="session-todo-item"][data-state="completed"]').first()

await expect(pendingItem.locator('[data-component="icon"][data-size]')).toBeVisible()
await expect(completedItem.locator('[data-component="icon"][data-size]')).toBeVisible()

await driverWrite(page, session.id, undefined)
} finally {
await cleanupSession({ sdk: project.sdk, sessionID: session.id })
}
})
2 changes: 1 addition & 1 deletion packages/app/e2e/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export const popoverBodySelector = '[data-slot="popover-body"]'

export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'

export const inlineInputSelector = '[data-component="inline-input"]'
export const inlineInputSelector = '[data-component="input"][data-variant="inline"]'

export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`

Expand Down
7 changes: 5 additions & 2 deletions packages/app/e2e/sidebar/sidebar-session-organization.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { openSidebar } from "../actions"
import { inlineInputSelector, pawworkSidebarSelector } from "../selectors"
import { pawworkSidebarSelector } from "../selectors"

test("users can pin, rename, and regroup sessions in the PawWork sidebar", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
Expand All @@ -24,11 +24,14 @@ test("users can pin, rename, and regroup sessions in the PawWork sidebar", async
await renameRow.hover()
await renameRow.locator('[data-action="session-row-menu"]').click()
await page.getByRole("menuitem", { name: /rename/i }).click()
const input = sidebar.locator(`[data-session-id="${one.id}"] ${inlineInputSelector}`)
const dialog = page.locator('[data-component="dialog"]')
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox")
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(`Ops weekly renamed ${stamp}`)
await input.press("Enter")
await expect(dialog).toBeHidden()
await expect(sidebar.locator(`[data-session-id="${one.id}"]`)).toContainText(`Ops weekly renamed ${stamp}`)

await sidebar.locator('[data-action="pawwork-sort-mode"]').click()
Expand Down
2 changes: 1 addition & 1 deletion packages/app/e2e/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"rootDir": "..",
"types": ["node", "bun"]
},
"include": ["./**/*.ts", "../src/testing/terminal.ts"]
"include": ["./**/*.ts", "../src/testing/terminal.ts", "../src/testing/session-composer.ts"]
}
76 changes: 76 additions & 0 deletions packages/app/src/components/dialog-rename-session.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { createSignal } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button"
import { TextField } from "@opencode-ai/ui/text-field"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"

export function DialogRenameSession(props: {
name: string
onConfirm: (name: string) => Promise<void> | void
}) {
const language = useLanguage()
const dialog = useDialog()
const [value, setValue] = createSignal(props.name)
const [saving, setSaving] = createSignal(false)

const handleSave = async () => {
const next = value().trim()
if (!next || saving()) return
setSaving(true)
try {
await props.onConfirm(next)
dialog.close()
} catch {
showToast({
title: language.t("toast.session.rename.failed.title"),
description: language.t("toast.session.rename.failed.description"),
variant: "error",
})
} finally {
setSaving(false)
}
}
Comment thread
Astro-Han marked this conversation as resolved.

return (
<Dialog title={language.t("session.rename.title")} fit class="w-full max-w-[420px] mx-auto">
<div class="px-6 pt-2 pb-6">
<TextField
aria-label={language.t("session.rename.title")}
autofocus
value={value()}
onInput={(e: InputEvent & { currentTarget: HTMLInputElement }) =>
setValue(e.currentTarget.value)
}
onKeyDown={(e: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
if (e.key === "Enter") {
e.preventDefault()
void handleSave()
}
if (e.key === "Escape") {
e.preventDefault()
if (saving()) return
dialog.close()
}
}}
onFocus={(e: FocusEvent & { currentTarget: HTMLInputElement }) =>
e.currentTarget.select()
}
/>
</div>
<div class="flex justify-end gap-2 px-6 pb-6">
<Button variant="secondary" onClick={() => dialog.close()} disabled={saving()}>
{language.t("common.cancel")}
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={saving() || !value().trim()}
>
{language.t("common.save")}
</Button>
</div>
</Dialog>
)
}
2 changes: 1 addition & 1 deletion packages/app/src/components/dialog-select-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export function ModelSelectorPopover(props: {
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
class="w-72 h-80 flex flex-col bg-surface-raised z-50 outline-none overflow-hidden"
class="w-72 h-80 flex flex-col bg-surface-base z-50 outline-none overflow-hidden"
style={{ "border-radius": "14px", "box-shadow": "var(--shadow-floating)" }}
onEscapeKeyDown={(event) => {
close("escape")
Expand Down
16 changes: 11 additions & 5 deletions packages/app/src/components/model-picker-hotfix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@ describe("model picker visual regression guard", () => {
expect(source).not.toContain("text-fg-on-brand")
})

test("model list active row uses a visible hover surface", async () => {
const source = await read("packages/ui/src/components/list.css")
test("model list active row uses a visible hover surface (picker contract)", async () => {
const list = await read("packages/ui/src/components/list.css")
const listSrc = await read("packages/ui/src/components/list.tsx")
const picker = await read("packages/ui/src/components/picker.css")

expect(source).toContain('&[data-active="true"]')
expect(source).toMatch(/&\[data-active="true"\]\s*\{[\s\S]*?background:\s*var\(--surface-sunken\)/)
expect(source).not.toMatch(/&\[data-active="true"\]\s*\{[\s\S]*?background:\s*var\(--surface-raised\)/)
// list-item opts into the picker contract; picker.css owns hover/selected.
expect(listSrc).toMatch(/\bdata-picker-item\b/)
expect(picker).toContain("--row-hover-overlay")
expect(picker).toContain("--row-active-overlay")

// list.css must not regress to invisible surface-raised on active.
expect(list).not.toMatch(/&\[data-active="true"\]\s*\{[\s\S]*?background:\s*var\(--surface-raised\)/)
})

test("prompt workspace and variant menu rows use a visible hover surface", async () => {
Expand Down
Loading
Loading