Compare commits

...
7 changed files with 595 additions and 128 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,264 @@
import { expect, story } from "../../storybook/playwright/story"
story("maps grouped and collapsed switches to the timeline settings", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const grouped = shell.getByRole("switch", { name: "Shell grouped", exact: true })
const collapsed = shell.getByRole("switch", { name: "Shell collapsed", exact: true })
const value = component.locator('[data-slot="timeline-detail-fixture-value"]')
await expect(component.getByRole("switch")).toHaveCount(9)
await expect(component.getByText("Activity", { exact: true })).toHaveCount(0)
await expect(grouped).toBeChecked()
await expect(collapsed).toBeChecked()
await shell.locator('[data-field="placement"] [data-slot="switch-control"]').click()
await shell.locator('[data-field="details"] [data-slot="switch-control"]').click()
await expect(value).toContainText('"shell":{"placement":"separate","details":"expanded"}')
await grouped.focus()
await grouped.press("Space")
await collapsed.focus()
await collapsed.press("Space")
await expect(value).toContainText('"shell":{"placement":"grouped","details":"collapsed"}')
await expect(component.getByRole("group", { name: "Subagents", exact: true }).getByRole("switch")).toHaveCount(1)
})
story("replaces hidden switches with solid lines and restores options", async ({ mount, page }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const visibility = shell.getByRole("button", { name: "Shell visibility" })
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
const color = await label.evaluate((element) => getComputedStyle(element).color)
const iconColor = await visibility.evaluate((element) => getComputedStyle(element).color)
await shell.locator('[data-field="placement"] [data-slot="switch-control"]').click()
await shell.locator('[data-field="details"] [data-slot="switch-control"]').click()
await visibility.hover()
await expect(page.getByRole("tooltip")).toHaveText("Hide")
expect(await page.getByRole("tooltip").evaluate((element) => element.getBoundingClientRect().bottom)).toBeLessThan(
await visibility.evaluate((element) => element.getBoundingClientRect().top),
)
await visibility.click()
await expect(visibility).toHaveAttribute("aria-pressed", "false")
await expect(shell.getByRole("switch")).toHaveCount(0)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(2)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]').first()).toHaveCSS(
"border-top-style",
"solid",
)
await expect(component.locator('[data-slot="timeline-detail-fixture-value"]')).toContainText(
'"shell":{"placement":"hidden","details":"expanded"}',
)
await expect(label).not.toHaveCSS("color", color)
await expect(shell.locator('[data-slot="timeline-detail-eye-slash"]')).toHaveCount(1)
await component.getByRole("button", { name: "Advanced", exact: true }).hover()
await expect(visibility).not.toHaveCSS("color", iconColor)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]').first()).toHaveCSS(
"border-top-color",
await visibility.evaluate((element) => getComputedStyle(element).color),
)
await visibility.hover()
await expect(page.getByRole("tooltip")).toHaveText("Show")
expect(await page.getByRole("tooltip").evaluate((element) => element.getBoundingClientRect().bottom)).toBeLessThan(
await visibility.evaluate((element) => element.getBoundingClientRect().top),
)
await visibility.click()
await expect(visibility).toHaveAttribute("aria-pressed", "true")
await expect(label).toHaveCSS("color", color)
await expect(shell.locator('[data-slot="timeline-detail-eye-slash"]')).toHaveCount(0)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).toBeEnabled()
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).toBeEnabled()
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).not.toBeChecked()
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).not.toBeChecked()
})
story("toggles visibility by clicking the activity label", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
const visibility = shell.getByRole("button", { name: "Shell visibility" })
await expect(label).toHaveCSS("cursor", "default")
await label.click()
await expect(visibility).toHaveAttribute("aria-pressed", "false")
await expect(shell.getByRole("switch")).toHaveCount(0)
await expect(shell.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(2)
await label.click()
await expect(visibility).toHaveAttribute("aria-pressed", "true")
await expect(shell.getByRole("switch", { name: "Shell grouped", exact: true })).toBeChecked()
await expect(shell.getByRole("switch", { name: "Shell collapsed", exact: true })).toBeChecked()
})
story("only highlights the eye when hovering the icon, not its activity label", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const shell = component.getByRole("group", { name: "Shell", exact: true })
const visibility = shell.getByRole("button", { name: "Shell visibility" })
const label = shell.locator('[data-slot="timeline-detail-activity"] > label')
for (const hidden of [false, true]) {
if (hidden) await label.click()
await visibility.hover()
await expect(visibility).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await label.hover()
await expect(visibility).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
}
})
story("opens advanced on returning to custom settings but not presets", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
const advanced = component.getByRole("button", { name: "Advanced", exact: true })
await expect(advanced).toHaveAttribute("aria-expanded", "false")
await advanced.click()
await component.locator('[data-category="shell"][data-field="placement"] [data-slot="switch-control"]').click()
await expect(component.getByRole("slider")).toHaveAttribute("aria-valuetext", "Custom")
await advanced.click()
await expect(advanced).toHaveAttribute("aria-expanded", "false")
await component.getByRole("button", { name: "Leave settings" }).click()
await expect(advanced).toHaveCount(0)
await component.getByRole("button", { name: "Return to settings" }).click()
await expect(advanced).toHaveAttribute("aria-expanded", "true")
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).not.toBeChecked()
await component.getByRole("slider").press("End")
await expect(component.getByRole("slider")).toHaveAttribute("aria-valuetext", "Everything")
await component.getByRole("button", { name: "Leave settings" }).click()
await component.getByRole("button", { name: "Return to settings" }).click()
await expect(advanced).toHaveAttribute("aria-expanded", "false")
})
story("keeps visibility and switches in sync with the preset slider", async ({ mount }) => {
const component = await mount("settings-timeline-detail--interactive")
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const slider = component.getByRole("slider", { name: "Timeline detail" })
await slider.focus()
await slider.press("Home")
await expect(slider).toHaveAttribute("aria-valuetext", "Messages only")
await expect(component.getByRole("switch")).toHaveCount(0)
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(9)
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="false"]')).toHaveCount(6)
await component.getByRole("button", { name: "Shell visibility" }).click()
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeChecked()
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await slider.focus()
await slider.press("End")
await expect(slider).toHaveAttribute("aria-valuetext", "Everything")
await expect(component.getByRole("switch")).toHaveCount(9)
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="true"]')).toHaveCount(6)
await expect(component.getByRole("switch", { checked: true })).toHaveCount(0)
})
for (const direction of ["ltr", "rtl"]) {
for (const theme of ["light", "dark"]) {
story(`fits narrow and wide layouts in ${direction}, ${theme}`, async ({ mount, page }, testInfo) => {
await page.setViewportSize({ width: 900, height: 900 })
const component = await mount("settings-timeline-detail--interactive", { globals: { direction, theme } })
await expect(component.locator('[data-slot="timeline-detail-summary"]')).toHaveCSS(
"color",
await component
.locator('[data-slot="settings-row-title"]')
.evaluate((element) => getComputedStyle(element).color),
)
await component.getByRole("button", { name: "Advanced", exact: true }).click()
const track = component.locator('[data-slot="timeline-detail-track"]')
await expect(track).toHaveCSS(
"--timeline-detail-track-background",
await track.evaluate(
(element, theme) =>
getComputedStyle(element)
.getPropertyValue(theme === "light" ? "--v2-background-bg-layer-04" : "--v2-background-bg-layer-03")
.trim(),
theme,
),
)
if (theme === "light") {
await expect(track.locator("span").first()).toHaveCSS("background-image", "none")
await expect(track).toHaveCSS(
"--timeline-detail-marker-background",
await track.evaluate((element) => getComputedStyle(element).getPropertyValue("--v2-grey-500").trim()),
)
}
if (theme === "dark") {
await expect(track.locator("span").first()).not.toHaveCSS("background-image", "none")
}
await page.screenshot({ path: testInfo.outputPath(`timeline-${theme}-${direction}.png`) })
const list = component.locator('[data-slot="timeline-detail-list"]')
await expect(component.locator('[data-slot="timeline-detail-categories"]')).toHaveCSS("margin-top", "0px")
for (const [column, field] of [
[2, "placement"],
[3, "details"],
] as const) {
const heading = await component
.locator(`[data-slot="timeline-detail-columns"] > :nth-child(${column})`)
.evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
const toggle = await component
.locator(`[data-category="shell"][data-field="${field}"] [data-slot="switch-control"]`)
.evaluate((element) => {
const rect = element.getBoundingClientRect()
return rect.x + rect.width / 2
})
expect(Math.abs(heading - toggle)).toBeLessThan(1)
}
await expect(component.locator('[data-slot="timeline-detail-activity"]').first()).toHaveCSS("gap", "12px")
for (const width of [900, 320]) {
await page.setViewportSize({ width, height: 900 })
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toBeVisible()
expect(await list.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
const visibility = component.getByRole("button", { name: "Shell visibility" })
await visibility.focus()
await visibility.press("Space")
await expect(visibility).toHaveAttribute("aria-pressed", "false")
await expect(component.getByRole("switch", { name: "Shell grouped", exact: true })).toHaveCount(0)
await expect(
component
.getByRole("group", { name: "Shell", exact: true })
.locator('[data-slot="timeline-detail-unavailable"]'),
).toHaveCount(2)
await visibility.press("Space")
await expect(visibility).toHaveAttribute("aria-pressed", "true")
const slider = component.getByRole("slider", { name: "Timeline detail" })
const track = component.locator('[data-slot="timeline-detail-track"]')
expect(await track.evaluate((element) => element.getBoundingClientRect().width)).toBe(
await component
.locator('[data-slot="timeline-detail-scale"]')
.evaluate((element) => element.getBoundingClientRect().width),
)
await slider.focus()
await slider.press("Home")
for (const position of [0, 1, 2, 3, 4]) {
if (position > 0) await slider.press("ArrowUp")
await expect(track).toHaveCSS("--timeline-detail-progress", `${position * 25}%`)
const fill = await track.evaluate((element) => {
const style = getComputedStyle(element, "::before")
return {
fraction: parseFloat(style.width) / element.getBoundingClientRect().width,
start: style.getPropertyValue("inset-inline-start"),
color: style.backgroundColor,
remainder: getComputedStyle(element).backgroundColor,
}
})
expect(fill.fraction).toBeCloseTo(position / 4, 2)
expect(fill.start).toBe("0px")
expect(fill.color).not.toBe(fill.remainder)
const marker = await track.locator("span").nth(position).boundingBox()
const bounds = await track.boundingBox()
expect(marker).not.toBeNull()
expect(bounds).not.toBeNull()
expect((marker!.x + marker!.width / 2 - bounds!.x) / bounds!.width).toBeCloseTo(
direction === "rtl" ? 1 - position / 4 : position / 4,
2,
)
}
}
})
}
}
+15 -7
View File
@@ -981,24 +981,32 @@ export const dict = {
"settings.timeline.title": "Timeline",
"settings.timeline.detail": "Timeline detail",
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
"settings.timeline.description": "Choose how much detail appears in the session timeline.",
"settings.timeline.summary": "{{preset}}:",
"settings.timeline.preset.everything": "Everything",
"settings.timeline.preset.detailed": "Detailed",
"settings.timeline.preset.compact": "Compact",
"settings.timeline.preset.quiet": "Quiet",
"settings.timeline.preset.text-only": "Text only",
"settings.timeline.preset.text-only": "Messages only",
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
"settings.timeline.description.detailed":
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
"Expand shell output and edits. Show subagents separately and group other activity.",
"settings.timeline.description.compact": "Group all activity with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity.",
"settings.timeline.description.custom": "Uses advanced settings.",
"settings.timeline.custom": "Custom",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
"settings.timeline.activity": "Activity",
"settings.timeline.group": "Group",
"settings.timeline.collapse": "Collapse",
"settings.timeline.visibility.show": "Show",
"settings.timeline.visibility.hide": "Hide",
"settings.timeline.visibility.label": "{{activity}} visibility",
"settings.timeline.grouped.label": "{{activity}} grouped",
"settings.timeline.collapsed.label": "{{activity}} collapsed",
"settings.timeline.category.shell": "Shell",
"settings.timeline.category.edit": "Edits",
"settings.timeline.category.thinking": "Thinking",
+121 -57
View File
@@ -1,4 +1,6 @@
[data-component="timeline-detail-control"] {
--timeline-detail-track-background: var(--v2-background-bg-layer-04);
--timeline-detail-marker-background: var(--v2-grey-500);
position: relative;
display: flex;
min-width: 0;
@@ -10,48 +12,55 @@
letter-spacing: -0.04px;
container: timeline-detail / inline-size;
[data-slot="timeline-detail-heading"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 24px;
font-weight: 530;
line-height: var(--line-height-compact);
}
[data-slot="timeline-detail-current"] {
color: var(--v2-text-text-muted);
text-align: end;
}
p {
margin: 0;
color: var(--v2-text-text-muted);
}
[data-slot="timeline-detail-summary"] {
color: var(--v2-text-text-base);
}
[data-slot="timeline-detail-scale"] {
position: relative;
height: 28px;
margin-top: 24px;
margin-inline: 8px;
}
[data-slot="timeline-detail-track"] {
position: absolute;
inset-inline: 8px;
inset-inline: 0;
top: 12px;
height: 4px;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 2px;
background: var(--v2-background-bg-layer-03);
background: var(--timeline-detail-track-background);
pointer-events: none;
&::before {
content: "";
position: absolute;
inset-inline-start: 0;
width: var(--timeline-detail-progress);
height: 100%;
border-radius: inherit;
background: var(--v2-background-bg-accent);
}
span {
width: 4px;
height: 4px;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
box-sizing: content-box;
width: 6px;
height: 6px;
border: 2px solid var(--v2-background-bg-layer-01);
border-radius: 50%;
background: var(--v2-border-border-strong);
background: var(--timeline-detail-marker-background);
&:dir(rtl) {
transform: translate(50%, -50%);
}
}
}
@@ -59,9 +68,11 @@
appearance: none;
position: relative;
display: block;
width: 100%;
/* Native ranges reserve half the thumb width at each end of their track. */
width: calc(100% + 16px);
height: 28px;
margin: 0;
margin-inline: -8px;
background: transparent;
cursor: pointer;
border-radius: 4px;
@@ -81,9 +92,15 @@
width: 16px;
height: 16px;
margin-top: -6px;
border: 1px solid var(--v2-border-border-base);
border: 0;
border-radius: 50%;
background: var(--v2-background-bg-base);
background:
linear-gradient(
180deg,
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
),
var(--v2-grey-300);
box-shadow: var(--v2-elevation-button-neutral);
}
@@ -96,28 +113,34 @@
box-sizing: border-box;
width: 16px;
height: 16px;
border: 1px solid var(--v2-border-border-base);
border: 0;
border-radius: 50%;
background: var(--v2-background-bg-base);
background:
linear-gradient(
180deg,
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
),
var(--v2-grey-300);
box-shadow: var(--v2-elevation-button-neutral);
}
}
[data-slot="timeline-detail-advanced"] {
margin-top: 4px;
margin-top: 20px;
padding-top: 8px;
}
[data-slot="timeline-detail-advanced"] > [data-slot="collapsible-trigger"] {
width: fit-content;
height: 28px;
height: 24px;
align-self: flex-start;
gap: 6px;
font-size: inherit;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: inherit;
color: var(--v2-text-text-muted);
color: var(--v2-text-text-base);
[data-slot="collapsible-arrow"] {
width: 16px;
@@ -133,20 +156,28 @@
}
[data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 100px 108px;
--timeline-detail-columns: minmax(0, 1fr) 72px 88px;
display: flex;
flex-direction: column;
margin-top: 8px;
}
[data-slot="timeline-detail-explainer"] {
margin-bottom: 12px;
margin-top: 0;
}
[data-slot="timeline-detail-field-label"] {
display: none;
}
[data-slot="timeline-detail-unavailable"] {
width: 24px;
border-block-start: 1px solid var(--v2-icon-icon-muted);
opacity: 0.5;
}
[data-slot="timeline-detail-list"] {
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background-color: var(--v2-background-bg-layer-02);
}
[data-slot="timeline-detail-columns"],
[data-slot="timeline-detail-category"] {
display: grid;
@@ -155,6 +186,10 @@
column-gap: 8px;
line-height: var(--line-height-compact);
> :nth-child(3) {
margin-inline-start: 8px;
}
> span {
min-width: 0;
overflow-wrap: normal;
@@ -162,49 +197,78 @@
}
[data-slot="timeline-detail-columns"] {
min-height: 28px;
min-height: var(--line-height-compact);
margin-bottom: 8px;
padding-inline: 12.5px;
color: var(--v2-text-text-muted);
span:not(:first-child) {
padding-inline-start: 8px;
text-align: center;
}
}
[data-slot="timeline-detail-category"] {
min-height: 40px;
padding-block: 8px;
padding-inline: 12px;
border-bottom: 0.5px solid var(--v2-border-border-base);
&:last-child {
border-bottom: 0;
}
[data-component="select-v2-root"],
[data-slot="timeline-detail-placement"],
[data-slot="timeline-detail-expansion"] {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
[data-component="select-v2-root"][data-field] {
width: 100%;
&[data-hidden] [data-slot="timeline-detail-activity"] {
color: var(--v2-text-text-faint);
}
}
[data-slot="timeline-detail-activity"] {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
> label {
min-width: 0;
overflow-wrap: anywhere;
cursor: default;
}
[data-component="select-v2"][data-appearance="inline"] {
width: 100%;
[data-component="tooltip-v2-trigger"] {
flex-shrink: 0;
}
[data-action="timeline-detail-visibility"][aria-pressed="true"] {
color: var(--v2-icon-icon-base);
}
/* Labels also activate :hover on their associated button. Keep that feedback local to the icon. */
&:has(> label:hover) [data-action="timeline-detail-visibility"][data-variant="ghost-muted"] {
background-color: transparent;
}
}
}
[data-color-scheme="dark"] [data-component="timeline-detail-control"] {
--timeline-detail-track-background: var(--v2-background-bg-layer-03);
--timeline-detail-marker-background:
linear-gradient(
180deg,
var(--v2-overlay-gradient-depth-overlay-depth-top) 0%,
var(--v2-overlay-gradient-depth-overlay-depth-bot) 100%
),
var(--v2-grey-300);
}
@container timeline-detail (max-width: 250px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-heading"] {
flex-wrap: wrap;
gap: 4px 8px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-current"] {
margin-inline-start: auto;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"] {
display: none;
}
@@ -214,20 +278,20 @@
row-gap: 8px;
padding-block: 12px;
> span {
font-weight: 530;
> :nth-child(3) {
margin-inline-start: 0;
}
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-field-label"] {
display: block;
margin-bottom: 2px;
padding-inline-start: 8px;
font-size: 12px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-placement"]:empty,
[data-component="timeline-detail-control"] [data-slot="timeline-detail-expansion"]:empty {
display: none;
}
@@ -235,7 +299,7 @@
@container timeline-detail (max-width: 310px) {
[data-component="timeline-detail-control"] [data-slot="timeline-detail-categories"] {
--timeline-detail-columns: minmax(0, 1fr) 90px 96px;
--timeline-detail-columns: minmax(0, 1fr) 64px 80px;
}
[data-component="timeline-detail-control"] [data-slot="timeline-detail-columns"],
@@ -0,0 +1,40 @@
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { timelinePresets, type TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { SettingsList } from "./list"
import { TimelineDetailControl } from "./timeline-detail"
import "./settings.css"
export default {
title: "OpenCode/Settings/Timeline detail",
id: "settings-timeline-detail",
}
export const Interactive = {
render: () => {
const [state, setState] = createStore<{ value: TimelineDetail; visible: boolean }>({
value: structuredClone(timelinePresets[2].value),
visible: true,
})
return (
<div class="flex w-[560px] max-w-full flex-col gap-4">
<Show when={state.visible}>
<SettingsList>
<div class="py-5">
<TimelineDetailControl value={state.value} onChange={(value) => setState("value", value)} />
</div>
</SettingsList>
</Show>
<Button onClick={() => setState("visible", !state.visible)}>
{state.visible ? "Leave settings" : "Return to settings"}
</Button>
<Button onClick={() => setState("value", structuredClone(timelinePresets[2].value))}>Reset</Button>
<output data-slot="timeline-detail-fixture-value" hidden>
{JSON.stringify(state.value)}
</output>
</div>
)
},
}
+155 -64
View File
@@ -1,27 +1,32 @@
import { For, Show, createMemo, createUniqueId } from "solid-js"
import { createStore } from "solid-js/store"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { Select } from "@opencode-ai/ui/select"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import {
timelineCategories,
timelinePreset,
timelinePresets,
type TimelineCategory,
type TimelineDetail,
type TimelineExpansion,
type TimelinePlacement,
} from "@opencode-ai/session-ui/timeline/detail"
import { useLanguage } from "@/runtime/i18n/language"
import "./timeline-detail.css"
const placements: TimelinePlacement[] = ["separate", "grouped", "hidden"]
const expansions: TimelineExpansion[] = ["collapsed", "expanded"]
const presets = timelinePresets.toReversed()
export function TimelineDetailControl(props: { value: TimelineDetail; onChange: (value: TimelineDetail) => void }) {
const language = useLanguage()
const id = createUniqueId()
const [visiblePlacements, setVisiblePlacements] = createStore<
Partial<Record<TimelineCategory, Exclude<TimelinePlacement, "hidden">>>
>({})
const preset = createMemo(() => timelinePreset(props.value))
const position = () => {
const current = preset()
return current ? timelinePresets.indexOf(current) : 2
return current ? presets.indexOf(current) : 2
}
const label = () => {
const current = preset()
@@ -30,34 +35,42 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
return (
<div data-component="timeline-detail-control">
<div data-slot="timeline-detail-heading">
<label for={`${id}-slider`}>{language.t("settings.timeline.detail")}</label>
<span data-slot="timeline-detail-current" aria-live="polite">
{label()}
</span>
<div data-slot="settings-row-copy">
<label data-slot="settings-row-title" for={`${id}-slider`}>
{language.t("settings.timeline.detail")}
</label>
<div id={`${id}-description`} data-slot="settings-row-description">
{language.t("settings.timeline.description")}
</div>
</div>
<p id={`${id}-description`} class="sr-only">
{language.t("settings.timeline.description")}
</p>
<div data-slot="timeline-detail-scale">
<div data-slot="timeline-detail-track" aria-hidden="true">
<For each={timelinePresets}>{() => <span />}</For>
<div
data-slot="timeline-detail-track"
aria-hidden="true"
style={{ "--timeline-detail-progress": `${(position() / (presets.length - 1)) * 100}%` }}
>
<For each={presets}>
{(_, index) => <span style={{ "inset-inline-start": `${(index() / (presets.length - 1)) * 100}%` }} />}
</For>
</div>
<input
id={`${id}-slider`}
data-action="settings-timeline-detail"
type="range"
min="0"
max={timelinePresets.length - 1}
max={presets.length - 1}
step="1"
value={position()}
aria-valuetext={label()}
aria-describedby={`${id}-description ${id}-preset-description`}
onInput={(event) => props.onChange({ ...timelinePresets[event.currentTarget.valueAsNumber].value })}
onInput={(event) => props.onChange({ ...presets[event.currentTarget.valueAsNumber].value })}
/>
</div>
<p id={`${id}-preset-description`}>{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}</p>
<Collapsible variant="ghost" data-slot="timeline-detail-advanced">
<p id={`${id}-preset-description`} aria-live="polite">
<span data-slot="timeline-detail-summary">{language.t("settings.timeline.summary", { preset: label() })}</span>{" "}
{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}
</p>
<Collapsible variant="ghost" data-slot="timeline-detail-advanced" defaultOpen={!preset()}>
<Collapsible.Trigger>
<span>{language.t("settings.timeline.advanced")}</span>
<Collapsible.Arrow />
@@ -68,57 +81,135 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange:
role="group"
aria-label={language.t("settings.timeline.advanced.description")}
>
<p data-slot="timeline-detail-explainer">{language.t("settings.timeline.advanced.explainer")}</p>
<div data-slot="timeline-detail-columns">
<span>{language.t("settings.timeline.activity")}</span>
<span id={`${id}-placement`}>{language.t("settings.timeline.placement.title")}</span>
<span id={`${id}-expansion`}>{language.t("settings.timeline.expansion.title")}</span>
<span aria-hidden="true" />
<span>{language.t("settings.timeline.group")}</span>
<span>{language.t("settings.timeline.collapse")}</span>
</div>
<For each={timelineCategories}>
{(category) => (
<div data-slot="timeline-detail-category" role="group" aria-labelledby={`${id}-${category}`}>
<span id={`${id}-${category}`}>{language.t(`settings.timeline.category.${category}`)}</span>
<div data-slot="timeline-detail-placement">
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.placement.title")}
</span>
<Select
data-category={category}
data-field="placement"
aria-labelledby={`${id}-${category} ${id}-placement`}
options={placements}
current={props.value[category].placement}
label={(value) => language.t(`settings.timeline.placement.${value}`)}
onSelect={(placement) =>
placement &&
props.onChange({ ...props.value, [category]: { ...props.value[category], placement } })
}
/>
</div>
<div data-slot="timeline-detail-expansion">
{category === "shell" || category === "edit" || category === "thinking" ? (
<Show when={props.value[category].placement !== "hidden"}>
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.expansion.title")}
</span>
<Select
data-category={category}
data-field="details"
aria-labelledby={`${id}-${category} ${id}-expansion`}
options={expansions}
current={props.value[category].details}
label={(value) => language.t(`settings.timeline.expansion.${value}`)}
onSelect={(details) =>
details &&
props.onChange({ ...props.value, [category]: { ...props.value[category], details } })
<div data-slot="timeline-detail-list">
<For each={timelineCategories}>
{(category) => (
<div
data-slot="timeline-detail-category"
data-hidden={props.value[category].placement === "hidden" ? "" : undefined}
role="group"
aria-labelledby={`${id}-${category}`}
>
<div data-slot="timeline-detail-activity">
<Tooltip
placement="top"
value={language.t(
props.value[category].placement === "hidden"
? "settings.timeline.visibility.show"
: "settings.timeline.visibility.hide",
)}
>
<IconButton
id={`${id}-${category}-visibility`}
type="button"
variant="ghost-muted"
size="small"
data-action="timeline-detail-visibility"
aria-label={language.t("settings.timeline.visibility.label", {
activity: language.t(`settings.timeline.category.${category}`),
})}
aria-pressed={props.value[category].placement !== "hidden"}
onClick={() => {
const placement = props.value[category].placement
if (placement !== "hidden") setVisiblePlacements(category, placement)
props.onChange({
...props.value,
[category]: {
...props.value[category],
placement:
placement === "hidden" ? (visiblePlacements[category] ?? "grouped") : "hidden",
},
})
}}
icon={
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M2.5 10s3.33-5.42 7.5-5.42S17.5 10 17.5 10s-3.33 5.42-7.5 5.42S2.5 10 2.5 10Z" />
<circle cx="10" cy="10" r="2.5" />
<Show when={props.value[category].placement === "hidden"}>
<path data-slot="timeline-detail-eye-slash" d="M3 3 17 17" />
</Show>
</svg>
}
/>
</Tooltip>
<label id={`${id}-${category}`} for={`${id}-${category}-visibility`}>
{language.t(`settings.timeline.category.${category}`)}
</label>
</div>
<div data-slot="timeline-detail-placement">
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.group")}
</span>
<Show
when={props.value[category].placement !== "hidden"}
fallback={<span data-slot="timeline-detail-unavailable" aria-hidden="true" />}
>
<Switch
data-category={category}
data-field="placement"
hideLabel
checked={props.value[category].placement === "grouped"}
onChange={(checked) =>
props.onChange({
...props.value,
[category]: { ...props.value[category], placement: checked ? "grouped" : "separate" },
})
}
>
{language.t("settings.timeline.grouped.label", {
activity: language.t(`settings.timeline.category.${category}`),
})}
</Switch>
</Show>
) : null}
</div>
<div data-slot="timeline-detail-expansion">
{category === "shell" || category === "edit" || category === "thinking" ? (
<>
<span data-slot="timeline-detail-field-label" aria-hidden="true">
{language.t("settings.timeline.collapse")}
</span>
<Show
when={props.value[category].placement !== "hidden"}
fallback={<span data-slot="timeline-detail-unavailable" aria-hidden="true" />}
>
<Switch
data-category={category}
data-field="details"
hideLabel
checked={props.value[category].details === "collapsed"}
onChange={(checked) =>
props.onChange({
...props.value,
[category]: { ...props.value[category], details: checked ? "collapsed" : "expanded" },
})
}
>
{language.t("settings.timeline.collapsed.label", {
activity: language.t(`settings.timeline.category.${category}`),
})}
</Switch>
</Show>
</>
) : null}
</div>
</div>
</div>
)}
</For>
)}
</For>
</div>
</div>
</Collapsible.Content>
</Collapsible>