diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 49a8566..06c73c4 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -28,11 +28,20 @@ export type TerminalSettings = { letterSpacing: number; }; +export type ChatSettings = { + /** + * Show the whole work trail of a turn, windowed, rather than only the + * latest action. The old latest-only view is a window of one. + */ + showEveryStep: boolean; +}; + export type AppSettings = { theme: string; server: ServerSettings; editor: EditorSettings; terminal: TerminalSettings; + chat: ChatSettings; showHiddenFiles: boolean; hapticFeedback: boolean; }; @@ -103,11 +112,16 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = { letterSpacing: 1, }; +export const DEFAULT_CHAT_SETTINGS: ChatSettings = { + showEveryStep: true, +}; + const DEFAULT_SETTINGS: AppSettings = { theme: "dark", server: DEFAULT_SERVER_SETTINGS, editor: DEFAULT_EDITOR_SETTINGS, terminal: DEFAULT_TERMINAL_SETTINGS, + chat: DEFAULT_CHAT_SETTINGS, showHiddenFiles: false, hapticFeedback: true, }; @@ -209,6 +223,17 @@ function normalizeTerminalSettings( }; } +function normalizeChatSettings( + settings?: Partial | null, +): ChatSettings { + return { + showEveryStep: + typeof settings?.showEveryStep === "boolean" + ? settings.showEveryStep + : DEFAULT_CHAT_SETTINGS.showEveryStep, + }; +} + function normalizeSettings( settings: Partial | null | undefined, ): AppSettings { @@ -217,6 +242,7 @@ function normalizeSettings( server: normalizeServerSettings(settings?.server), editor: normalizeEditorSettings(settings?.editor), terminal: normalizeTerminalSettings(settings?.terminal), + chat: normalizeChatSettings(settings?.chat), showHiddenFiles: typeof settings?.showHiddenFiles === "boolean" ? settings.showHiddenFiles @@ -264,6 +290,10 @@ export async function saveSettings( ...current.terminal, ...settings.terminal, }, + chat: { + ...current.chat, + ...settings.chat, + }, }); await file.write(SETTINGS_PATH, JSON.stringify(next)); window.dispatchEvent( diff --git a/src/pages/chat/ChatComposer.tsx b/src/pages/chat/ChatComposer.tsx index a6f989b..d4ce881 100644 --- a/src/pages/chat/ChatComposer.tsx +++ b/src/pages/chat/ChatComposer.tsx @@ -80,6 +80,29 @@ type ChatComposerProps = { onStop: () => void; }; +// Four filled squares in a row read as a toolbar, which is the wrong weight +// for a surface that sits under the transcript on every screen. The controls +// are 40px of ink; the `after` pseudo-element extends the hit area to 44px +// without changing the layout, so the row can be lighter without being harder +// to hit. +const COMPOSER_CONTROL = + "haptic-trigger relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-[10px] border-0 bg-transparent p-0 text-[1.15rem] text-secondary-text transition-[background,opacity] duration-150 after:absolute after:-inset-0.5 after:content-[''] active:bg-surface-soft disabled:cursor-default disabled:opacity-35 [-webkit-tap-highlight-color:transparent]"; + +/** + * Send keeps its fill: it is the primary action on the surface. Stop takes the + * same slot while a turn runs and keeps the same weight, tinted rather than + * filled solid — a saturated block of red vibrates against a dark canvas. + * + * Both are 36px of fill inside a 44px target, which the `after` pseudo-element + * supplies without changing the layout. + */ +const COMPOSER_STOP = + "haptic-trigger relative flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-[color-mix(in_srgb,var(--danger)_20%,transparent)] p-0 text-[1.05rem] text-danger transition-opacity duration-150 after:absolute after:-inset-1 after:content-[''] [-webkit-tap-highlight-color:transparent]"; + +/** The glyph inside carries an optical nudge; see the render site. */ +const COMPOSER_SEND = + "haptic-trigger relative flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-button-background p-0 text-[1.05rem] text-button-text transition-opacity duration-150 after:absolute after:-inset-1 after:content-[''] disabled:opacity-35 [-webkit-tap-highlight-color:transparent]"; + export function ChatComposer({ inputBarRef, inputRef, @@ -270,7 +293,7 @@ export function ChatComposer({
)}
diff --git a/src/pages/chat/chat-bubble/components/ActivityRow.scss b/src/pages/chat/chat-bubble/components/ActivityRow.scss new file mode 100644 index 0000000..4e07686 --- /dev/null +++ b/src/pages/chat/chat-bubble/components/ActivityRow.scss @@ -0,0 +1,224 @@ +.activity-row { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + box-sizing: border-box; + color: var(--secondary-text); + + .chat-part-card-title { + display: flex; + align-items: flex-start; + gap: 7px; + width: 100%; + min-width: 0; + // 48px, not the 31px used elsewhere: below that the row is not a reliable + // touch target on a phone. Set as min-height, never height, so a + // user-forced line height (WCAG SC 1.4.12) cannot clip it. + min-height: 48px; + padding: 6px 2px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + + >em { + margin-left: 2px; + font-style: normal; + font-size: 10px; + opacity: 0.5; + flex-shrink: 0; + } + } + + >.chat-disclosure-root { + width: 100%; + min-width: 0; + } + + .chat-disclosure-panel { + width: 100%; + min-width: 0; + + >.chat-disclosure-panel-inner { + margin: 1px 0 5px 22px; + border-radius: 7px; + background: var(--surface-soft); + } + } + + &--failed { + color: var(--error); + + // The container colour alone does not reach the verb, which sets its own. + // The glyph already carries the state as a shape, per WCAG SC 1.4.1; this + // is so a failed row is findable while scrolling past twenty-five of them. + .activity-row-label strong { + color: var(--error); + } + } + + &--running .activity-row-icon { + color: var(--accent); + } +} + +.activity-row-icon { + width: 15px; + margin-top: 2px; + color: var(--secondary-text); + font-size: 13px; + text-align: center; + opacity: 0.72; + flex-shrink: 0; +} + +.activity-row-label { + display: flex; + align-items: baseline; + // The object drops to its own full-width line when the verb, glyph and + // status leave it too little room. At 390px the shared line offers roughly + // 25 to 30 characters, so this is the normal case, not the exception. + flex-wrap: wrap; + gap: 6px; + min-width: 0; + flex: 1; + + strong { + color: var(--primary-text); + font-weight: 600; + flex-shrink: 0; + } +} + +.activity-row-object { + min-width: 0; + color: var(--secondary-text); + // A description is a sentence and carries meaning at both ends, so it wraps + // rather than being cut. Measured: median 28 characters, p90 41. + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + &--mono { + font-family: 'JetBrainsMono Nerd Font', ui-monospace, Menlo, monospace; + font-size: 12px; + white-space: nowrap; + -webkit-line-clamp: 1; + text-overflow: ellipsis; + } +} + +// The unshortened value, kept in the accessibility tree only. Clipped rather +// than `display: none`, which would remove it from that tree as well. +.activity-row-object-full { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.activity-row-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 13px; + margin-top: 2px; + font-size: 12px; + flex-shrink: 0; + + .icon-check { + opacity: 0.7; + } +} + +.activity-row-detail { + // Pulled up under the row it belongs to: the tappable head is 48px tall for + // the touch target, which otherwise leaves the result floating midway + // between its own row and the next one. + margin: -9px 0 5px 22px; + color: var(--secondary-text); + font-size: 12px; + font-variant-numeric: tabular-nums; + opacity: 0.7; + + &--mono { + font-family: 'JetBrainsMono Nerd Font', ui-monospace, Menlo, monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.activity-row-peek { + position: relative; + margin: -7px 0 3px 22px; + padding: 8px 9px 10px; + max-height: 66px; + overflow: hidden; + border-radius: 7px; + background: var(--surface-soft); + color: var(--secondary-text); + font-family: 'JetBrainsMono Nerd Font', ui-monospace, Menlo, monospace; + font-size: 12px; + line-height: 1.55; + white-space: pre; + // Mask the overflow instead of adding a scroll region, which would be a + // touch trap inside a scrolling list. The mask sits over the cut end, so a + // block clipped at the top fades upward instead. + mask-image: linear-gradient(to bottom, #000 46px, transparent 66px); + -webkit-mask-image: linear-gradient(to bottom, #000 46px, transparent 66px); + + &--clipped-top { + mask-image: linear-gradient(to top, #000 46px, transparent 66px); + -webkit-mask-image: linear-gradient(to top, #000 46px, transparent 66px); + } + + &--failed { + background: color-mix(in srgb, var(--error) 9%, transparent); + color: var(--error); + } +} + +.activity-row-more { + margin: 2px 0 6px 22px; + color: var(--secondary-text); + font-size: 11px; + opacity: 0.6; +} + +.activity-row-chips { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin: -7px 0 7px 22px; +} + +.activity-row-chip { + max-width: 100%; + padding: 2px 6px; + border: 1px solid var(--card-border); + border-radius: 6px; + color: var(--secondary-text); + font-family: 'JetBrainsMono Nerd Font', ui-monospace, Menlo, monospace; + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &--more { + border-style: dashed; + opacity: 0.7; + } +} diff --git a/src/pages/chat/chat-bubble/components/ActivityRow.test.tsx b/src/pages/chat/chat-bubble/components/ActivityRow.test.tsx new file mode 100644 index 0000000..7e9858a --- /dev/null +++ b/src/pages/chat/chat-bubble/components/ActivityRow.test.tsx @@ -0,0 +1,154 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import type { ActivityRowModel } from "../lib/activityRow"; +import ActivityRow from "./ActivityRow"; + +afterEach(cleanup); + +const RAN: ActivityRowModel = { + kind: "execute", + verb: "Ran", + object: "Check branch state", + objectIsMono: false, + running: false, + failed: false, +}; + +describe("ActivityRow", () => { + it("renders the verb and the object", () => { + render(); + expect(screen.getByText("Ran")).toBeInTheDocument(); + expect(screen.getByText("Check branch state")).toBeInTheDocument(); + }); + + it("renders one line of output inline, without a peek block", () => { + const { container } = render( + , + ); + expect(screen.getByText("7 entries")).toBeInTheDocument(); + expect(container.querySelector(".activity-row-peek")).toBeNull(); + }); + + it("renders a peek block with the line count for longer output", () => { + const { container } = render( + , + ); + expect(container.querySelector(".activity-row-peek")).not.toBeNull(); + expect(screen.getByText("12 lines")).toBeInTheDocument(); + }); + + it("keeps the result visible without expanding the row", () => { + // The whole point of the detail line: an outcome the reader does not have + // to tap for. It must not land inside the disclosure panel. + render( + +
details
+
, + ); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.getByText("7 entries")).toBeInTheDocument(); + }); + + it("omits the verb for the other kind", () => { + render( + , + ); + expect(screen.queryByText("Ran")).toBeNull(); + expect( + screen.getByText("Load skill: superpowers:writing-plans"), + ).toBeInTheDocument(); + }); + + it("marks a failed row", () => { + const { container } = render( + , + ); + expect(container.querySelector(".activity-row--failed")).not.toBeNull(); + }); + + it("renders chips and the overflow count", () => { + render( + , + ); + expect(screen.getByText("one.md")).toBeInTheDocument(); + expect(screen.getByText("+3")).toBeInTheDocument(); + }); + + it("exposes an expandable row as a button with its state", () => { + render( + +
details
+
, + ); + expect(screen.getByRole("button")).toHaveAttribute( + "aria-expanded", + "false", + ); + }); + + it("opens a failed row by default so its output is visible", () => { + render( + +
details
+
, + ); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + }); + + it("keeps the unelided path in the accessibility tree", () => { + render( + , + ); + // The shortened form is hidden from assistive technology and the full + // path is rendered beside it, because a bare span has no role that + // supports `aria-label`. + expect( + screen.getByText("a/very/long/path/adrs/04-tax-basis.md"), + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/chat/chat-bubble/components/ActivityRow.tsx b/src/pages/chat/chat-bubble/components/ActivityRow.tsx new file mode 100644 index 0000000..5db7d60 --- /dev/null +++ b/src/pages/chat/chat-bubble/components/ActivityRow.tsx @@ -0,0 +1,180 @@ +import "./ActivityRow.scss"; +import type { ActivityKind, ActivityRowModel } from "../lib/activityRow"; +import type { OutputSummary } from "../lib/outputSummary"; +import ChatDisclosure from "./ChatDisclosure"; +import Status from "./Status"; + +interface Props { + row: ActivityRowModel; + output?: OutputSummary | null; + /** Pre-formatted `+84 −12`, from a file_change diff. */ + diffStat?: React.ReactNode; + chips?: string[]; + extraChips?: number; + stateKey?: string; + /** When present the row expands; without it the row is static. */ + children?: React.ReactNode; +} + +export default function ActivityRow({ + row, + output, + diffStat, + chips, + extraChips = 0, + stateKey, + children, +}: Props) { + const className = `activity-row${row.failed ? " activity-row--failed" : ""}${ + row.running ? " activity-row--running" : "" + }`; + // A failure's output is the reason to look at it, so the row starts open — + // and then the peek would only repeat what is expanded right underneath it. + const startsOpen = Boolean(children) && row.failed; + + const summary = ( + <> +