diff --git a/packages/app/component-tests/timeline-detail.spec.ts b/packages/app/component-tests/timeline-detail.spec.ts new file mode 100644 index 00000000000..09fafdf1dc9 --- /dev/null +++ b/packages/app/component-tests/timeline-detail.spec.ts @@ -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(visibility.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-eye-slash") + 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(visibility.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-eye") + 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, + ) + } + } + }) + } +} diff --git a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts index 82fc49a55b7..f1b0ce4d193 100644 --- a/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-reasoning-projection.spec.ts @@ -23,17 +23,21 @@ test("changes timeline presets and saves custom thinking details", async ({ page const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true }) await expect(slider).toBeEnabled() await slider.press("Home") - for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) { + for (const [index, name] of ["Messages only", "Quiet", "Compact", "Detailed", "Everything"].entries()) { if (index) await slider.press("ArrowRight") await expect(slider).toHaveValue(String(index)) await expect(slider).toHaveAttribute("aria-valuetext", name) } - await slider.press("Home") + await slider.press("End") await settings.getByRole("button", { name: "Advanced", exact: true }).click() - await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click() - await page.getByRole("option", { name: "Grouped", exact: true }).click() - await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click() - await page.getByRole("option", { name: "Collapsed", exact: true }).click() + const grouped = settings.getByRole("switch", { name: "Thinking grouped", exact: true }) + const collapsed = settings.getByRole("switch", { name: "Thinking collapsed", exact: true }) + await expect(grouped).not.toBeChecked() + await expect(collapsed).not.toBeChecked() + await settings.locator('[data-category="thinking"][data-field="placement"] [data-slot="switch-control"]').click() + await settings.locator('[data-category="thinking"][data-field="details"] [data-slot="switch-control"]').click() + await expect(grouped).toBeChecked() + await expect(collapsed).toBeChecked() await expect(slider).toHaveAttribute("aria-valuetext", "Custom") await expect .poll(() => diff --git a/packages/app/e2e/regression/settings-scroll.spec.ts b/packages/app/e2e/regression/settings-scroll.spec.ts index 0d414fa73f2..10bb455f23b 100644 --- a/packages/app/e2e/regression/settings-scroll.spec.ts +++ b/packages/app/e2e/regression/settings-scroll.spec.ts @@ -33,7 +33,7 @@ for (const viewport of [ const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true }) await expect(settings).toBeFocused() await page.setViewportSize(viewport) - await expect(slider).toHaveAccessibleDescription(/Choose how much activity appears in the timeline/) + await expect(slider).toHaveAccessibleDescription(/Choose how much detail appears in the session timeline/) await expect.poll(() => main.evaluate((el) => el.scrollHeight - el.clientHeight)).toBeLessThanOrEqual(1) // Wheel over the outer gutter must not move the entire settings screen. diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 45fe4ae654a..3fdba18ac35 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -994,37 +994,36 @@ 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", "settings.timeline.category.subagents": "Subagents", "settings.timeline.category.notices": "Notices", "settings.timeline.category.tools": "Other tools", - "settings.timeline.placement.title": "Placement", - "settings.timeline.placement.separate": "Separate", - "settings.timeline.placement.grouped": "Grouped", - "settings.timeline.placement.hidden": "Hidden", - "settings.timeline.expansion.title": "Details", - "settings.timeline.expansion.collapsed": "Collapsed", - "settings.timeline.expansion.expanded": "Expanded", "settings.general.row.language.title": "Language", "settings.general.row.language.description": "Change the display language for OpenCode", diff --git a/packages/app/src/settings/timeline-detail.css b/packages/app/src/settings/timeline-detail.css index 839f1836e83..1d1cfaab19b 100644 --- a/packages/app/src/settings/timeline-detail.css +++ b/packages/app/src/settings/timeline-detail.css @@ -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"], diff --git a/packages/app/src/settings/timeline-detail.stories.tsx b/packages/app/src/settings/timeline-detail.stories.tsx new file mode 100644 index 00000000000..7c0acda919b --- /dev/null +++ b/packages/app/src/settings/timeline-detail.stories.tsx @@ -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 ( +
+ + +
+ setState("value", value)} /> +
+
+
+ + + +
+ ) + }, +} diff --git a/packages/app/src/settings/timeline-detail.tsx b/packages/app/src/settings/timeline-detail.tsx index 4275d20ceae..d4dc49a15cd 100644 --- a/packages/app/src/settings/timeline-detail.tsx +++ b/packages/app/src/settings/timeline-detail.tsx @@ -1,27 +1,33 @@ 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 { Icon } from "@opencode-ai/ui/icon" +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>> + >({}) 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 +36,42 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange: return (
-
- - - {label()} - +
+ +
+ {language.t("settings.timeline.description")} +
-

- {language.t("settings.timeline.description")} -

- -

{language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)}

- +

+ {language.t("settings.timeline.summary", { preset: label() })}{" "} + {language.t(`settings.timeline.description.${preset()?.id ?? "custom"}`)} +

+ {language.t("settings.timeline.advanced")} @@ -68,57 +82,122 @@ export function TimelineDetailControl(props: { value: TimelineDetail; onChange: role="group" aria-label={language.t("settings.timeline.advanced.description")} > -

{language.t("settings.timeline.advanced.explainer")}

- {language.t("settings.timeline.activity")} - {language.t("settings.timeline.placement.title")} - {language.t("settings.timeline.expansion.title")} +
- - {(category) => ( -
- {language.t(`settings.timeline.category.${category}`)} -
- - language.t(`settings.timeline.expansion.${value}`)} - onSelect={(details) => - details && - props.onChange({ ...props.value, [category]: { ...props.value[category], details } }) +
+ + {(category) => ( +
+
+ + { + 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={ + } /> + + +
+
+ + - ) : null} +
+
+ {category === "shell" || category === "edit" || category === "thinking" ? ( + <> + + + + ) : null} +
-
- )} - + )} + +
diff --git a/packages/ui/src/icons/icon/icon.stories.tsx b/packages/ui/src/icons/icon/icon.stories.tsx index dc7301292ad..8d2783c64a7 100644 --- a/packages/ui/src/icons/icon/icon.stories.tsx +++ b/packages/ui/src/icons/icon/icon.stories.tsx @@ -19,6 +19,8 @@ const names = [ "outline-arrow-to-corner-top-right", "outline-copy", "outline-dots", + "outline-eye", + "outline-eye-slash", "outline-hexagonal-warning", "plus", "review", diff --git a/packages/ui/src/icons/icon/icon.tsx b/packages/ui/src/icons/icon/icon.tsx index 021f4c8b35a..50239eb80c7 100644 --- a/packages/ui/src/icons/icon/icon.tsx +++ b/packages/ui/src/icons/icon/icon.tsx @@ -184,6 +184,14 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "outline-eye": { + viewBox: "0 0 20 20", + body: ``, + }, + "outline-eye-slash": { + viewBox: "0 0 20 20", + body: ``, + }, reset: { viewBox: "0 0 20 20", body: ``,