mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 16:36:23 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59207d0daa | ||
|
|
0d7eaa0742 | ||
|
|
55be3a6531 |
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user