diff --git a/app/e2e/fixtures.ts b/app/e2e/fixtures.ts index c8da8d4c..0e798b7a 100644 --- a/app/e2e/fixtures.ts +++ b/app/e2e/fixtures.ts @@ -16,7 +16,7 @@ import { type BrowserContext, expect, type Locator, type Page, test as base } fr // nav-, pane with data-pane-kind, chat, chat-item with data-kind, // composer, question-form, queued-reply, artifacts-chip/-popover, // attachment-chip, panel with data-panel, browser-pane with data-mode, -// chat-background, settings-nav-, settings-, theme-option with +// chat-background, header-picture with data-picture, header-picture-settings, settings-nav-, settings-, theme-option with // data-theme-id, plugin-row and market-plugin with data-plugin, // market-error, zoom-hud, leadline with data-leadline and data-state, and // its leadline-row, -detail, -notice, -box, database with data-state, and diff --git a/app/e2e/header-picture.spec.ts b/app/e2e/header-picture.spec.ts new file mode 100644 index 00000000..975bfba1 --- /dev/null +++ b/app/e2e/header-picture.spec.ts @@ -0,0 +1,129 @@ +import type { Locator } from "@playwright/test"; + +import { type App, expect, mockOnly, test } from "./fixtures"; + +// The header picture (Settings › Appearance › Header picture): the painting +// across the top of a new chat, an empty worktree and Home. The harbour by +// default; a plate, a picture of your own, or none. + +// An 8×4 PNG, sky over sand, as a picture of your own. +const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAgAAAAECAIAAAA8r+mnAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAGElEQVQImWOI6rmEFTHglDixqgIrwikBAAqpOiFhDrg6AAAAAElFTkSuQmCC", "base64"); + +// ?scene=first-prompt: the mock's agents at rest haven't been asked anything. +const OPEN = { params: { view: "conversation", scene: "first-prompt" } }; + +const band = (scope: Locator | App["page"]) => scope.locator("[data-testid=header-picture]:visible"); + +async function settings(app: App) { + const appearance = await app.openSettings("appearance"); + const section = appearance.getByTestId("header-picture-settings"); + await section.scrollIntoViewIfNeeded(); + return section; +} + +// A new chat: an agent that hasn't been asked anything yet. +async function newChat(app: App) { + await app.openWorktree("devl/order-export"); + const pane = app.page.locator("[data-testid=pane]:visible").first(); + await expect(pane.getByPlaceholder(/What should/).or(pane.getByRole("textbox")).first()).toBeVisible(); + return pane; +} + +test("a painting chosen in Settings tops a new chat, an empty worktree and Home, and stays after a reload", async ({ app }) => { + mockOnly("the first prompt is a mock scene"); + await app.open({ ...OPEN, theme: "berth-light" }); + const section = await settings(app); + const meadow = section.getByRole("group", { name: "Paintings" }).getByRole("button", { name: "Meadow" }); + // The harbour until chosen otherwise. + await expect(section.getByRole("button", { name: "Harbour, by the time of day" })).toHaveAttribute("aria-pressed", "true"); + await meadow.click(); + await expect(meadow).toHaveAttribute("aria-pressed", "true"); + await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ headerPicture: { source: "builtin", builtin: "meadow" } }); + + // A new chat. + const chat = await newChat(app); + await expect(band(chat)).toHaveAttribute("data-picture", "meadow"); + await expect(band(chat).locator("[data-drawn]")).toBeAttached(); + + // Home. + await app.page.getByTestId("nav-home").click(); + await expect(band(app.page)).toHaveAttribute("data-picture", "meadow"); + + // An empty worktree: its launcher, once its tabs are closed. + await app.openWorktree("devl/qa-deck"); + const tabs = app.page.locator("[data-tab-strip] [data-tab]:visible"); + await expect(tabs.first()).toBeVisible(); + while ((await tabs.count()) > 0) { + const n = await tabs.count(); + await tabs.first().click({ button: "middle" }); + await expect(tabs).toHaveCount(n - 1); + } + await expect(app.page.getByRole("button", { name: "New shell" })).toBeVisible(); + await expect(band(app.page)).toHaveAttribute("data-picture", "meadow"); + + // A reload keeps it. + await app.page.reload(); + await expect(app.page.getByTestId("nav-home")).toBeVisible(); + await app.page.getByTestId("nav-home").click(); + await expect(band(app.page)).toHaveAttribute("data-picture", "meadow"); +}); + +test("a dark theme shows a painting's night", async ({ app }) => { + mockOnly("the first prompt is a mock scene"); + const fetched: string[] = []; + app.page.on("request", (r) => /\/snowy-fjord/.test(r.url()) && fetched.push(r.url())); + await app.open({ ...OPEN, theme: "berth-dark", prefs: { headerPicture: { source: "builtin", builtin: "snowy-fjord" } } }); + const chat = await newChat(app); + await expect(band(chat)).toHaveAttribute("data-picture", "snowy-fjord"); + await expect(band(chat).locator("[data-drawn]")).toBeAttached(); + // The night painting, and not the day's. + expect(fetched.some((u) => /snowy-fjord-night-[^/]*\.webp/.test(u))).toBe(true); + expect(fetched.some((u) => /snowy-fjord-(?!night)[^/]*\.webp/.test(u))).toBe(false); +}); + +test("a picture of your own tops a new chat", async ({ app }) => { + mockOnly("the first prompt is a mock scene"); + await app.open(OPEN); + const section = await settings(app); + await section.getByLabel("Header picture file").setInputFiles({ name: "boat.png", mimeType: "image/png", buffer: PNG }); + await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ headerPicture: { source: "image", image: { name: "boat.png" } } }); + // It's a tile now, chosen, with where it sits. + await expect(section.getByRole("group", { name: "Custom" }).getByRole("button", { name: "boat.png", exact: true })).toHaveAttribute("aria-pressed", "true"); + await section.getByRole("button", { name: "Top", exact: true }).click(); + await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ headerPicture: { position: "top" } }); + // The chat background is its own choice, still none. + await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ chatBackground: { source: "none" } }); + + const chat = await newChat(app); + await expect(band(chat)).toHaveAttribute("data-picture", "image"); + // Read back from IndexedDB, as a blob: URL. + await expect(band(chat).locator("[data-drawn]")).toBeAttached({ timeout: 20_000 }); +}); + +test("None leaves the top of a new chat and Home plain", async ({ app }) => { + mockOnly("the first prompt is a mock scene"); + await app.open(OPEN); + const section = await settings(app); + await section.getByRole("button", { name: "None", exact: true }).click(); + await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ headerPicture: { source: "none" } }); + + const chat = await newChat(app); + await expect(chat.getByRole("textbox").first()).toBeVisible(); + await expect(band(chat)).toHaveCount(0); + await app.page.getByTestId("nav-home").click(); + await expect(app.page.getByRole("heading", { name: "What should your agents work on?" })).toBeVisible(); + await expect(band(app.page)).toHaveCount(0); + + await app.page.reload(); + await expect(app.page.getByRole("heading", { name: "What should your agents work on?" })).toBeVisible(); + await expect(band(app.page)).toHaveCount(0); +}); + +test("the plates are chat backgrounds too", async ({ app }) => { + await app.open({ params: { view: "conversation" } }); + const appearance = await app.openSettings("appearance"); + const scene = appearance.getByRole("group", { name: "Scenes" }).getByRole("button", { name: "Lavender fields" }); + await scene.click(); + await expect(scene).toHaveAttribute("aria-pressed", "true"); + await expect.poll(() => app.stored("berth.prefs")).toMatchObject({ chatBackground: { source: "builtin", builtin: "lavender-fields" }, headerPicture: { source: "builtin", builtin: "harbour" } }); +}); diff --git a/app/package.json b/app/package.json index f714db37..d78b6bf4 100644 --- a/app/package.json +++ b/app/package.json @@ -23,7 +23,7 @@ "perf:guard": "node perf/guard.mjs", "perf:soak": "node perf/soak.mjs", "perf:terminals": "node perf/terminals.mjs", - "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/orchestrate-core.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/markdown-html.test.ts src/lib/file-refs.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs src/lib/linear-issues.test.ts src/lib/marketplace.test.ts scripts/build-marketplace.test.mjs src/lib/leadline.test.ts src/lib/helper-path.test.ts src/lib/plugin-helpers.test.ts src/lib/database.test.ts src/lib/command-rank.test.ts src/lib/browser-tabs.test.ts src/lib/callouts.test.ts src/lib/repo-refs.test.ts src/lib/chat-table.test.ts src/lib/test-results.test.ts src/lib/box-defaults-core.test.ts src/lib/code-blocks.test.ts src/lib/art/mermaid.test.ts" + "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/orchestrate-core.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/header-picture.test.ts src/lib/file-match.test.ts src/lib/markdown-html.test.ts src/lib/file-refs.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs src/lib/linear-issues.test.ts src/lib/marketplace.test.ts scripts/build-marketplace.test.mjs src/lib/leadline.test.ts src/lib/helper-path.test.ts src/lib/plugin-helpers.test.ts src/lib/database.test.ts src/lib/command-rank.test.ts src/lib/browser-tabs.test.ts src/lib/callouts.test.ts src/lib/repo-refs.test.ts src/lib/chat-table.test.ts src/lib/test-results.test.ts src/lib/box-defaults-core.test.ts" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/app/src/components/art/chat-backgrounds.ts b/app/src/components/art/chat-backgrounds.ts index 4264dd8a..10664d27 100644 --- a/app/src/components/art/chat-backgrounds.ts +++ b/app/src/components/art/chat-backgrounds.ts @@ -3,6 +3,8 @@ import fog from "@/components/art/backgrounds/fog.webp"; import night from "@/components/art/backgrounds/night.webp"; import openSea from "@/components/art/backgrounds/open-sea.webp"; import { HARBOUR, type HarbourLight } from "@/components/art/harbour-art"; +import { plateSrc } from "@/components/art/plates"; +import { PLATES } from "@/lib/header-picture"; // The chat backgrounds Shipyard ships (lib/chat-background.ts). Patterns and // gradients are drawn in code, in the theme's colours @@ -34,6 +36,8 @@ export const BUILTINS: Builtin[] = [ { id: "night", name: "Moorings at night", kind: "scene", src: () => night }, { id: "open-sea", name: "Open sea", kind: "scene", src: () => openSea }, { id: "fog", name: "Fog", kind: "scene", src: () => fog }, + // The header picture's plates (lib/header-picture.ts), by day or night. + ...PLATES.map((p): Builtin => ({ id: p.id, name: p.name, kind: "scene", src: (l) => plateSrc(p.id, l) })), ]; export const builtin = (id: string) => BUILTINS.find((b) => b.id === id) ?? BUILTINS[0]; diff --git a/app/src/components/art/dither-band.tsx b/app/src/components/art/dither-band.tsx index 251673fc..50b5e096 100644 --- a/app/src/components/art/dither-band.tsx +++ b/app/src/components/art/dither-band.tsx @@ -6,7 +6,8 @@ import { cn } from "@/lib/utils"; // dither of its lightness, strongest along edges and in gradients and faint // in flat areas, so it reads as the painting first and the dots second. The // colour is left smooth (dithering each channel apart is what makes static). -// It can be muted toward the page's background, which calms a busy night. +// It can be muted toward the page's background, which calms a busy night, +// and dimmed, which brings a daylight picture into a dark theme. // Toward its foot the picture dissolves dot by dot (a threshold ramp, not a // fade) into whatever is behind it, so set the band on the page's background // and it melts into it in light and dark alike. @@ -35,10 +36,13 @@ export interface DitherBandProps { fade?: number; // How far to mute the picture toward the page's background, 0 to 1. mute?: number; + // How far to darken it first, 0 to 1: its light falls but its contrast + // and hues stay, where muting alone would only grey it. + dim?: number; className?: string; } -export function DitherBand({ src, position = 0.5, cell = 2, levels = 6, fade = 0.4, mute = 0, className }: DitherBandProps) { +export function DitherBand({ src, position = 0.5, cell = 2, levels = 6, fade = 0.4, mute = 0, dim = 0, className }: DitherBandProps) { const wrap = useRef(null); const canvas = useRef(null); const [drawn, setDrawn] = useState(false); @@ -94,7 +98,7 @@ export function DitherBand({ src, position = 0.5, cell = 2, levels = 6, fade = 0 soft.width = soft.height = 0; } const frame = ctx.getImageData(0, 0, w, h); - dither(frame, levels, fade, mute, backgroundOf(el)); + dither(frame, levels, fade, mute, dim, backgroundOf(el)); ctx.putImageData(frame, 0, 0); setDrawn(true); }; @@ -119,10 +123,10 @@ export function DitherBand({ src, position = 0.5, cell = 2, levels = 6, fade = 0 cv.width = 0; cv.height = 0; }; - }, [src, position, cell, levels, fade, mute]); + }, [src, position, cell, levels, fade, mute, dim]); return ( -
+
); @@ -144,12 +148,17 @@ function backgroundOf(el: HTMLElement): [number, number, number] { // gradients, about a third in flat areas. Then it clears the dots below a // ramp toward the foot, with the matrix shifted so the dissolve's pattern // does not line up with the grain's. -function dither(frame: ImageData, levels: number, fade: number, mute: number, bg: [number, number, number]) { +function dither(frame: ImageData, levels: number, fade: number, mute: number, dim: number, bg: [number, number, number]) { const { data, width: w, height: h } = frame; const L = levels - 1; const from = h * (1 - fade); const lum = new Float32Array(w * h); for (let i = 0, p = 0; p < w * h; i += 4, p++) { + if (dim) { + data[i] *= 1 - dim; + data[i + 1] *= 1 - dim; + data[i + 2] *= 1 - dim; + } if (mute) { data[i] += (bg[0] - data[i]) * mute; data[i + 1] += (bg[1] - data[i + 1]) * mute; diff --git a/app/src/components/art/harbour-art.ts b/app/src/components/art/harbour-art.ts index 6c0b6420..850fc621 100644 --- a/app/src/components/art/harbour-art.ts +++ b/app/src/components/art/harbour-art.ts @@ -15,9 +15,9 @@ export type HarbourLight = "dawn" | "day" | "dusk" | "night"; export const HARBOUR: Record = { dawn, day, dusk, night }; -// How far each light is muted toward the page in a DitherBand: the night -// most, so the stars and the moon's path stay calm. -export const HARBOUR_MUTE: Record = { dawn: 0.06, day: 0.04, dusk: 0.08, night: 0.3 }; +// How far each light is muted toward the page lives with the header +// picture's choice (lib/header-picture.ts), which tests can read. +export { HARBOUR_MUTE } from "@/lib/header-picture"; function byClock(d = new Date()): HarbourLight { const h = d.getHours(); diff --git a/app/src/components/art/header-band.tsx b/app/src/components/art/header-band.tsx new file mode 100644 index 00000000..ed2485c3 --- /dev/null +++ b/app/src/components/art/header-band.tsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from "react"; + +import { DitherBand } from "@/components/art/dither-band"; +import { HARBOUR, type HarbourLight, useHarbourLight } from "@/components/art/harbour-art"; +import { plateSrc } from "@/components/art/plates"; +import { useActiveTheme } from "@/hooks/use-theme"; +import { imageBlob } from "@/lib/chat-background"; +import { type HeaderArt, headerArt, type HeaderPicture } from "@/lib/header-picture"; +import { usePrefs } from "@/lib/prefs"; +import { cn } from "@/lib/utils"; + +// The header picture (lib/header-picture.ts) as the launcher, a new chat +// and Home draw it: one hook and one band, so the three never drift. + +export type HeaderShown = HeaderArt & { + // The painting's file, or a picture of your own as a blob: URL; missing + // while that is read from IndexedDB. + src?: string; + // What it is, for tests: "harbour", a plate's id, or "image". + name: string; +}; + +// useHeaderPicture is the chosen picture in this theme and light, or +// nothing for None. A screen lays itself out by whether there is one, and +// draws it with HeaderBand. +export function useHeaderPicture(choice?: HeaderPicture): HeaderShown | undefined { + const saved = usePrefs((p) => p.headerPicture); + const light = useHarbourLight(); + const dark = useActiveTheme().appearance === "dark"; + const art = headerArt(choice ?? saved, light, dark); + const url = useImageUrl(art?.kind === "image" ? art.id : undefined); + if (!art) return undefined; + return { ...art, src: srcOf(art, light, url), name: art.kind === "plate" ? art.id : art.kind }; +} + +function srcOf(art: HeaderArt, light: HarbourLight, url?: string) { + if (art.kind === "harbour") return HARBOUR[art.light]; + if (art.kind === "plate") return plateSrc(art.id, light); + return url; +} + +// HeaderBand draws it as a DitherBand: dissolving into the page at its +// foot, muted and, for a daylight picture in a dark theme, dimmed. +// position is where the screen likes a painting to sit; a picture of your +// own sits where its owner put it. +export function HeaderBand({ picture, position, fade, cell, className, style }: { picture: HeaderShown; position: number; fade: number; cell?: number; className?: string; style?: React.CSSProperties }) { + return ( +
+ {picture.src && } +
+ ); +} + +// Pictures of your own as blob: URLs, a few kept so the three screens (and +// Settings' tiles) share one each. One pushed out is let go: only the +// chosen picture is ever on a screen for long. +const urls = new Map>(); +const KEEP = 10; + +function imageUrl(id: string): Promise { + let p = urls.get(id); + if (!p) { + p = imageBlob(id).then((b) => { + if (!b) throw new Error("gone"); + return URL.createObjectURL(b); + }); + urls.set(id, p); + p.catch(() => urls.delete(id)); + while (urls.size > KEEP) { + const [oldest, old] = urls.entries().next().value as [string, Promise]; + urls.delete(oldest); + void old.then((u) => URL.revokeObjectURL(u), () => undefined); + } + } + return p; +} + +export function useImageUrl(id: string | undefined): string | undefined { + const [got, setGot] = useState<{ id: string; url: string }>(); + useEffect(() => { + if (!id) return; + let alive = true; + imageUrl(id).then((url) => alive && setGot({ id, url }), () => undefined); + return () => { + alive = false; + }; + }, [id]); + return got && got.id === id ? got.url : undefined; +} diff --git a/app/src/components/art/plates.ts b/app/src/components/art/plates.ts new file mode 100644 index 00000000..cdb07c77 --- /dev/null +++ b/app/src/components/art/plates.ts @@ -0,0 +1,33 @@ +import alpineLake from "@/components/art/plates/alpine-lake.webp"; +import alpineLakeNight from "@/components/art/plates/alpine-lake-night.webp"; +import desertMesa from "@/components/art/plates/desert-mesa.webp"; +import desertMesaNight from "@/components/art/plates/desert-mesa-night.webp"; +import lavenderFields from "@/components/art/plates/lavender-fields.webp"; +import lavenderFieldsNight from "@/components/art/plates/lavender-fields-night.webp"; +import meadow from "@/components/art/plates/meadow.webp"; +import meadowNight from "@/components/art/plates/meadow-night.webp"; +import seaOfClouds from "@/components/art/plates/sea-of-clouds.webp"; +import seaOfCloudsNight from "@/components/art/plates/sea-of-clouds-night.webp"; +import snowyFjord from "@/components/art/plates/snowy-fjord.webp"; +import snowyFjordNight from "@/components/art/plates/snowy-fjord-night.webp"; +import type { HarbourLight } from "@/components/art/harbour-art"; +import type { PlateId } from "@/lib/header-picture"; + +// The plates' paintings (lib/header-picture.ts names them), generated with +// Codex for Shipyard: the days in the harbour's style, and each night from +// its day, painted like the harbour's night, so the composition never +// moves and a dark theme gets the night as the harbour does. They are +// separate files, fetched only when shown. + +export const PLATE_ART: Record = { + "alpine-lake": { day: alpineLake, night: alpineLakeNight }, + meadow: { day: meadow, night: meadowNight }, + "desert-mesa": { day: desertMesa, night: desertMesaNight }, + "snowy-fjord": { day: snowyFjord, night: snowyFjordNight }, + "lavender-fields": { day: lavenderFields, night: lavenderFieldsNight }, + "sea-of-clouds": { day: seaOfClouds, night: seaOfCloudsNight }, +}; + +// plateSrc is a plate in the harbour's light: its night for the night, its +// day otherwise. +export const plateSrc = (id: PlateId, light: HarbourLight) => PLATE_ART[id][light === "night" ? "night" : "day"]; diff --git a/app/src/components/art/plates/alpine-lake-night.webp b/app/src/components/art/plates/alpine-lake-night.webp new file mode 100644 index 00000000..850e11f7 Binary files /dev/null and b/app/src/components/art/plates/alpine-lake-night.webp differ diff --git a/app/src/components/art/plates/alpine-lake.webp b/app/src/components/art/plates/alpine-lake.webp new file mode 100644 index 00000000..72133935 Binary files /dev/null and b/app/src/components/art/plates/alpine-lake.webp differ diff --git a/app/src/components/art/plates/desert-mesa-night.webp b/app/src/components/art/plates/desert-mesa-night.webp new file mode 100644 index 00000000..874f0a0f Binary files /dev/null and b/app/src/components/art/plates/desert-mesa-night.webp differ diff --git a/app/src/components/art/plates/desert-mesa.webp b/app/src/components/art/plates/desert-mesa.webp new file mode 100644 index 00000000..ad9022eb Binary files /dev/null and b/app/src/components/art/plates/desert-mesa.webp differ diff --git a/app/src/components/art/plates/lavender-fields-night.webp b/app/src/components/art/plates/lavender-fields-night.webp new file mode 100644 index 00000000..47568e22 Binary files /dev/null and b/app/src/components/art/plates/lavender-fields-night.webp differ diff --git a/app/src/components/art/plates/lavender-fields.webp b/app/src/components/art/plates/lavender-fields.webp new file mode 100644 index 00000000..c428a024 Binary files /dev/null and b/app/src/components/art/plates/lavender-fields.webp differ diff --git a/app/src/components/art/plates/meadow-night.webp b/app/src/components/art/plates/meadow-night.webp new file mode 100644 index 00000000..8aa19543 Binary files /dev/null and b/app/src/components/art/plates/meadow-night.webp differ diff --git a/app/src/components/art/plates/meadow.webp b/app/src/components/art/plates/meadow.webp new file mode 100644 index 00000000..ade4381a Binary files /dev/null and b/app/src/components/art/plates/meadow.webp differ diff --git a/app/src/components/art/plates/sea-of-clouds-night.webp b/app/src/components/art/plates/sea-of-clouds-night.webp new file mode 100644 index 00000000..599b2b3b Binary files /dev/null and b/app/src/components/art/plates/sea-of-clouds-night.webp differ diff --git a/app/src/components/art/plates/sea-of-clouds.webp b/app/src/components/art/plates/sea-of-clouds.webp new file mode 100644 index 00000000..fbc5b274 Binary files /dev/null and b/app/src/components/art/plates/sea-of-clouds.webp differ diff --git a/app/src/components/art/plates/snowy-fjord-night.webp b/app/src/components/art/plates/snowy-fjord-night.webp new file mode 100644 index 00000000..77baaf0d Binary files /dev/null and b/app/src/components/art/plates/snowy-fjord-night.webp differ diff --git a/app/src/components/art/plates/snowy-fjord.webp b/app/src/components/art/plates/snowy-fjord.webp new file mode 100644 index 00000000..fa4ef0f1 Binary files /dev/null and b/app/src/components/art/plates/snowy-fjord.webp differ diff --git a/app/src/components/conversation/conversation-pane.tsx b/app/src/components/conversation/conversation-pane.tsx index 43f99688..076c6413 100644 --- a/app/src/components/conversation/conversation-pane.tsx +++ b/app/src/components/conversation/conversation-pane.tsx @@ -2,10 +2,9 @@ import { ArrowUpIcon, ListPlusIcon, MessageSquareTextIcon, MessagesSquareIcon, R import { memo, useEffect, useMemo, useRef, useState } from "react"; import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; -import { DitherBand } from "@/components/art/dither-band"; +import { HeaderBand, useHeaderPicture } from "@/components/art/header-band"; import { TaskComposer } from "@/components/conversation/task-composer"; import { AttachmentChips, useAttachments } from "@/components/conversation/attachments"; -import { HARBOUR, HARBOUR_MUTE, useHarbourLight } from "@/components/art/harbour-art"; import { Scene, type SceneName } from "@/components/art/scenes"; import { ChatBackground } from "@/components/conversation/chat-background"; import { ChatControls } from "@/components/conversation/chat-controls"; @@ -890,17 +889,17 @@ function Reading() { const boxWord = (state?: string) => (state === "untrusted" ? "unreachable" : (state ?? "offline")); -// FirstPrompt is an agent that hasn't been asked anything yet: the harbour -// band, the worktree, and the composer for its first task, as everywhere -// work starts. +// FirstPrompt is an agent that hasn't been asked anything yet: the header +// picture (the harbour unless chosen otherwise), the worktree, and the +// composer for its first task, as everywhere work starts. function FirstPrompt({ box, session, agent, name, branch, onSend, onFail }: { box: string; session: string; agent?: string; name: string; branch?: string; onSend(text: string): Promise; onFail(err: unknown): void }) { - const light = useHarbourLight(); + const picture = useHeaderPicture(); return (
- -
+ {picture && } +
-
+

{name}

{branch && {branch}} diff --git a/app/src/components/workspace/launcher.tsx b/app/src/components/workspace/launcher.tsx index bcc5c263..b487ece8 100644 --- a/app/src/components/workspace/launcher.tsx +++ b/app/src/components/workspace/launcher.tsx @@ -2,9 +2,8 @@ import { ArchiveIcon, CodeXmlIcon, GlobeIcon, SquareTerminalIcon } from "lucide- import { useEffect, useRef, useState } from "react"; import { AgentIcon, StateGlyph } from "@/components/agent-glyph"; -import { DitherBand } from "@/components/art/dither-band"; +import { HeaderBand, useHeaderPicture } from "@/components/art/header-band"; import { TaskComposer } from "@/components/conversation/task-composer"; -import { HARBOUR, HARBOUR_MUTE, useHarbourLight } from "@/components/art/harbour-art"; import { Scene } from "@/components/art/scenes"; import { usePrefs } from "@/lib/prefs"; import { openEditor } from "@/components/editors/open"; @@ -48,7 +47,8 @@ interface Row { // to start an agent on a task here, then a compact command panel, like an // empty state in Raycast or Linear: one list of what to start here, each // with its shortcut, then the agents closed as tabs that are still running, -// to pick up again. Arrow keys move, Enter runs. Labs adds the harbour. +// to pick up again. Arrow keys move, Enter runs. Labs adds the header +// picture (the harbour unless chosen otherwise). export function Launcher({ worktree: ref }: { worktree: WorktreeRef }) { useStore((s) => s.boxes[ref.box]?.info); const loc = useStore((s) => s.boxes[ref.box]?.locations?.find((l) => l.name === ref.location)); @@ -61,7 +61,9 @@ export function Launcher({ worktree: ref }: { worktree: WorktreeRef }) { const list = useRef(null); const name = useTitleAt(ref.box, ref.path) ?? (ref.main ? ref.location : ref.worktree); const labs = usePrefs((p) => p.labs); - const light = useHarbourLight(); + // Labs puts the header picture (Settings › Appearance) across the top. + const picture = useHeaderPicture(); + const band = labs ? picture : undefined; const rows: Row[] = [ ...agentPresets(ref.box, ref.location).map((p) => ({ @@ -114,8 +116,8 @@ export function Launcher({ worktree: ref }: { worktree: WorktreeRef }) { return (
- {labs && } -
+ {band && } +
{/* A boat tied up and ready: the same drawing language as a pane whose session ended. Fixed size, so nothing below moves. */} @@ -124,7 +126,7 @@ export function Launcher({ worktree: ref }: { worktree: WorktreeRef }) {
)} -
+

{name}

diff --git a/app/src/lib/chat-background.ts b/app/src/lib/chat-background.ts index 47962bf1..0402bd9a 100644 --- a/app/src/lib/chat-background.ts +++ b/app/src/lib/chat-background.ts @@ -147,8 +147,10 @@ export const onImagesChanged = (fn: () => void) => { }; const notifyImages = () => imageListeners.forEach((fn) => fn()); -// keepImage shrinks a picture, keeps it, and forgets the oldest past KEEP. -export async function keepImage(file: Blob, meta: { name: string; prompt?: string }): Promise { +// keepImage shrinks a picture, keeps it, and forgets the oldest past KEEP, +// but never one in use: the chat background and the header picture +// (lib/header-picture.ts) choose from the same pictures, each its own. +export async function keepImage(file: Blob, meta: { name: string; prompt?: string }, inUse: (string | undefined)[] = []): Promise { if (file.size > 60 << 20) throw new Error("That file is over 60 MB; pick a smaller picture."); let bmp: ImageBitmap; try { @@ -169,7 +171,7 @@ export async function keepImage(file: Blob, meta: { name: string; prompt?: strin const bytes = await blob.arrayBuffer(); await tx("readwrite", (s) => s.put({ ...info, bytes, type: blob.type, at: Date.now() } satisfies StoredImage)); const all = await tx("readonly", (s) => s.getAll()); - for (const old of all.sort((a, b) => b.at - a.at).slice(KEEP)) await tx("readwrite", (s) => s.delete(old.id)); + for (const old of all.sort((a, b) => b.at - a.at).filter((s) => !inUse.includes(s.id)).slice(KEEP)) await tx("readwrite", (s) => s.delete(old.id)); notifyImages(); return info; } diff --git a/app/src/lib/header-picture.test.ts b/app/src/lib/header-picture.test.ts new file mode 100644 index 00000000..8b65f06b --- /dev/null +++ b/app/src/lib/header-picture.test.ts @@ -0,0 +1,63 @@ +// node --experimental-strip-types --test src/lib/header-picture.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { DEFAULT_HEADER_PICTURE, HARBOUR_MUTE, headerArt, IMAGE_DIM, IMAGE_MUTE, normalizeHeaderPicture, PLATE_MUTE, PLATES } from "./header-picture.ts"; + +const image = { id: "img-1", name: "boat.png", w: 800, h: 450 }; + +test("prefs from before there was a choice get the harbour", () => { + for (const saved of [undefined, null, {}, "harbour", 3, []]) assert.deepEqual(normalizeHeaderPicture(saved), { ...DEFAULT_HEADER_PICTURE, image: undefined }); + assert.deepEqual(DEFAULT_HEADER_PICTURE, { source: "builtin", builtin: "harbour", position: "center" }); +}); + +test("a saved choice keeps what is valid", () => { + assert.deepEqual(normalizeHeaderPicture({ source: "builtin", builtin: "meadow" }), { source: "builtin", builtin: "meadow", image: undefined, position: "center" }); + assert.deepEqual(normalizeHeaderPicture({ source: "none", builtin: "meadow" }).source, "none"); + assert.deepEqual(normalizeHeaderPicture({ source: "image", image, position: "top" }), { source: "image", builtin: "harbour", image, position: "top" }); + // Other fields, as a later Shipyard might save, are dropped. + assert.equal("extra" in normalizeHeaderPicture({ source: "none", extra: 1 }), false); +}); + +test("what isn't valid falls back to the harbour", () => { + // A plate Shipyard no longer ships. + assert.equal(normalizeHeaderPicture({ source: "builtin", builtin: "volcano" }).builtin, "harbour"); + assert.equal(normalizeHeaderPicture({ source: "sky" }).source, "builtin"); + // A picture of your own without the picture. + assert.equal(normalizeHeaderPicture({ source: "image" }).source, "builtin"); + assert.equal(normalizeHeaderPicture({ source: "image", image: { name: "x" } }).source, "builtin"); + assert.equal(normalizeHeaderPicture({ source: "builtin", position: "left" }).position, "center"); +}); + +test("every plate is a choice", () => { + assert.equal(PLATES.length, 6); + for (const p of PLATES) assert.equal(normalizeHeaderPicture({ source: "builtin", builtin: p.id }).builtin, p.id); +}); + +test("the harbour follows its light", () => { + for (const light of ["dawn", "day", "dusk", "night"] as const) { + assert.deepEqual(headerArt(DEFAULT_HEADER_PICTURE, light, light === "night"), { kind: "harbour", light, mute: HARBOUR_MUTE[light], dim: 0 }); + } +}); + +test("a plate shows its night in the night, its day otherwise", () => { + const meadow = normalizeHeaderPicture({ source: "builtin", builtin: "meadow" }); + assert.deepEqual(headerArt(meadow, "night", true), { kind: "plate", id: "meadow", night: true, mute: PLATE_MUTE.night, dim: 0 }); + for (const light of ["dawn", "day", "dusk"] as const) assert.deepEqual(headerArt(meadow, light, false), { kind: "plate", id: "meadow", night: false, mute: PLATE_MUTE.day, dim: 0 }); +}); + +test("a picture of your own is dimmed and muted in a dark theme", () => { + const own = normalizeHeaderPicture({ source: "image", image, position: "bottom" }); + assert.deepEqual(headerArt(own, "day", false), { kind: "image", id: "img-1", mute: IMAGE_MUTE.light, dim: 0, position: 1 }); + assert.deepEqual(headerArt(own, "night", true), { kind: "image", id: "img-1", mute: IMAGE_MUTE.dark, dim: IMAGE_DIM, position: 1 }); + assert.equal(headerArt({ ...own, position: "top" }, "day", false)?.position, 0); + assert.equal(headerArt({ ...own, position: "center" }, "day", false)?.position, 0.5); + assert.ok(IMAGE_MUTE.dark > IMAGE_MUTE.light && IMAGE_DIM > 0); +}); + +test("None draws nothing", () => { + assert.equal(headerArt({ ...DEFAULT_HEADER_PICTURE, source: "none" }, "day", false), undefined); + assert.equal(headerArt({ ...DEFAULT_HEADER_PICTURE, source: "none" }, "night", true), undefined); + // Nor does a picture that is gone, unnormalized. + assert.equal(headerArt({ ...DEFAULT_HEADER_PICTURE, source: "image" }, "day", false), undefined); +}); diff --git a/app/src/lib/header-picture.ts b/app/src/lib/header-picture.ts new file mode 100644 index 00000000..0c197b55 --- /dev/null +++ b/app/src/lib/header-picture.ts @@ -0,0 +1,92 @@ +import type { HarbourLight } from "@/components/art/harbour-art"; +import type { StoredImageInfo } from "@/lib/chat-background"; + +// The picture across the top of a new chat, the launcher and Home +// (Settings › Appearance › Header picture): the harbour by the time of day, +// as Shipyard always had, one of the painted plates, a picture of the +// person's own, or none. The choice is kept with the prefs (lib/prefs.ts, +// headerPicture); a picture of their own is kept with the chat +// background's (lib/chat-background.ts), in this webview's IndexedDB, and +// named here by id, so the two choices stay apart. components/art/ +// header-band.tsx draws it. This file imports nothing at run time, as +// prefs.ts reads it. + +// The plates, in the harbour's style: a landmark far left, a clean middle, +// a paler foot for the page to sit on. Each has a day and a night painting +// (components/art/plates.ts). +export const PLATES = [ + { id: "alpine-lake", name: "Alpine lake" }, + { id: "meadow", name: "Meadow" }, + { id: "desert-mesa", name: "Desert mesa" }, + { id: "snowy-fjord", name: "Snowy fjord" }, + { id: "lavender-fields", name: "Lavender fields" }, + { id: "sea-of-clouds", name: "Sea of clouds" }, +] as const; + +export type PlateId = (typeof PLATES)[number]["id"]; + +export interface HeaderPicture { + source: "builtin" | "image" | "none"; + // The painting when source is "builtin": "harbour" or a plate's id. + builtin: "harbour" | PlateId; + // The person's own picture, when source is "image". + image?: StoredImageInfo; + // Which part of a picture of your own shows when the band crops it. + position: "top" | "center" | "bottom"; +} + +export const DEFAULT_HEADER_PICTURE: HeaderPicture = { source: "builtin", builtin: "harbour", position: "center" }; + +const BUILTIN_IDS: readonly string[] = ["harbour", ...PLATES.map((p) => p.id)]; + +// normalizeHeaderPicture keeps what is valid of a saved choice and fills the +// rest with defaults. Prefs from before there was a choice have none, and +// get the harbour, as they always had; so does a plate Shipyard no longer +// ships. +export function normalizeHeaderPicture(saved: unknown): HeaderPicture { + const s = (saved && typeof saved === "object" ? saved : {}) as Record; + const d = DEFAULT_HEADER_PICTURE; + const image = s.image && typeof s.image === "object" && typeof (s.image as StoredImageInfo).id === "string" ? (s.image as StoredImageInfo) : undefined; + let source = s.source === "builtin" || s.source === "image" || s.source === "none" ? s.source : d.source; + // A picture of your own that is gone is the harbour again. + if (source === "image" && !image) source = d.source; + return { + source, + builtin: typeof s.builtin === "string" && BUILTIN_IDS.includes(s.builtin) ? (s.builtin as HeaderPicture["builtin"]) : d.builtin, + image, + position: s.position === "top" || s.position === "bottom" ? s.position : "center", + }; +} + +// How far each of the harbour's lights is muted toward the page in a +// DitherBand: the night most, so the stars and the moon's path stay calm. +export const HARBOUR_MUTE: Record = { dawn: 0.06, day: 0.04, dusk: 0.08, night: 0.3 }; + +// The plates' day and night, muted like the harbour's. +export const PLATE_MUTE = { day: 0.05, night: 0.3 }; + +// A picture of your own has no night, and in a dark theme a daylight one +// would glare. There it is dimmed by half, which keeps its hues and +// contrast, then muted a little toward the page; muting alone, far enough +// to calm it, only greys it. +export const IMAGE_MUTE = { light: 0.06, dark: 0.2 }; +export const IMAGE_DIM = 0.5; + +const POSITION = { top: 0, center: 0.5, bottom: 1 }; + +// HeaderArt is what the band draws for a choice in a theme: which painting +// (the harbour in a light, a plate by day or night, or a picture of your +// own by id), how far it is muted toward the page and dimmed, and, for a +// picture of your own, where it sits when cropped. +export type HeaderArt = ({ kind: "harbour"; light: HarbourLight } | { kind: "plate"; id: PlateId; night: boolean } | { kind: "image"; id: string }) & { mute: number; dim: number; position?: number }; + +// headerArt picks it: the harbour follows its light (useHarbourLight: a +// dark theme's is the night), a plate shows its night painting by the +// harbour's night, and None or a lost picture draws nothing. +export function headerArt(p: HeaderPicture, light: HarbourLight, dark: boolean): HeaderArt | undefined { + if (p.source === "none") return undefined; + if (p.source === "image") return p.image ? { kind: "image", id: p.image.id, mute: dark ? IMAGE_MUTE.dark : IMAGE_MUTE.light, dim: dark ? IMAGE_DIM : 0, position: POSITION[p.position] } : undefined; + if (p.builtin === "harbour") return { kind: "harbour", light, mute: HARBOUR_MUTE[light], dim: 0 }; + const night = light === "night"; + return { kind: "plate", id: p.builtin, night, mute: night ? PLATE_MUTE.night : PLATE_MUTE.day, dim: 0 }; +} diff --git a/app/src/lib/mock-conversation.ts b/app/src/lib/mock-conversation.ts index db3a63fa..9421be72 100644 --- a/app/src/lib/mock-conversation.ts +++ b/app/src/lib/mock-conversation.ts @@ -223,6 +223,12 @@ export function mockAnswer(box: string, session: string, req: { tool: string; an // seedTranscript gives an agent that is already working something to show // when it is opened. export function seedTranscript(box: string, session: string, state: string, worktree: string): TranscriptItem[] { + // ?scene=first-prompt: an agent at rest hasn't been asked anything yet, + // so it opens to the start of a chat (the header picture, the composer). + if (new URLSearchParams(location.search).get("scene") === "first-prompt" && state !== "running" && state !== "waiting") { + useConversations.setState((s) => (s.items[keyOf(box, session)] ? s : { items: { ...s.items, [keyOf(box, session)]: [] } })); + return []; + } // Messages from other agents and Claude Code (agent-message): a story, or // with ?scene=busy a teammate on a busy team. if (box === "gpu" && session === MESSAGES_SESSION) { diff --git a/app/src/lib/prefs.ts b/app/src/lib/prefs.ts index ead6a049..02f72e49 100644 --- a/app/src/lib/prefs.ts +++ b/app/src/lib/prefs.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { type ChatBackground, DEFAULT_CHAT_BACKGROUND, normalizeChatBackground } from "@/lib/chat-background"; +import { DEFAULT_HEADER_PICTURE, type HeaderPicture, normalizeHeaderPicture } from "@/lib/header-picture"; import type { HomeLayout } from "@/lib/home-layout"; import { clampWidth, SIDEBAR_DEFAULT } from "@/lib/sidebar-width"; import { load, save } from "@/lib/storage"; @@ -59,6 +60,9 @@ export interface Prefs { // The picture behind conversations and its effects (Settings › // Appearance › Chat background); none by default. chatBackground: ChatBackground; + // The picture across the top of a new chat, the launcher and Home + // (Settings › Appearance › Header picture); the harbour by default. + headerPicture: HeaderPicture; // How wide the conversation's column is (Settings › Appearance › Chat). chatWidth: ChatWidth; // Show the reply Claude is writing as it grows (lib/draft), read from its @@ -108,6 +112,7 @@ const DEFAULTS: Prefs = { zen: false, autoUpdateBoxes: false, chatBackground: DEFAULT_CHAT_BACKGROUND, + headerPicture: DEFAULT_HEADER_PICTURE, chatWidth: "default", chatDrafts: true, filesPanel: false, @@ -155,6 +160,7 @@ export const usePrefs = create()(() => ({ notify: { ...DEFAULTS.notify, ...saved.notify }, systemThemes: { ...DEFAULTS.systemThemes, ...saved.systemThemes }, chatBackground: normalizeChatBackground(saved.chatBackground), + headerPicture: normalizeHeaderPicture(saved.headerPicture), sidebarWidth: clampWidth(saved.sidebarWidth ?? SIDEBAR_DEFAULT), })); @@ -203,6 +209,10 @@ export function setChatBackground(patch: Partial) { usePrefs.setState((p) => ({ chatBackground: { ...p.chatBackground, ...patch } })); } +export function setHeaderPicture(patch: Partial) { + usePrefs.setState((p) => ({ headerPicture: { ...p.headerPicture, ...patch } })); +} + export function setTerminalPrefs(patch: Partial) { usePrefs.setState((p) => ({ terminal: { ...p.terminal, ...patch } })); } diff --git a/app/src/views/home/home-view.tsx b/app/src/views/home/home-view.tsx index 62e808d6..a8a2674a 100644 --- a/app/src/views/home/home-view.tsx +++ b/app/src/views/home/home-view.tsx @@ -1,5 +1,4 @@ -import { DitherBand } from "@/components/art/dither-band"; -import { HARBOUR, HARBOUR_MUTE, useHarbourLight } from "@/components/art/harbour-art"; +import { HeaderBand, useHeaderPicture } from "@/components/art/header-band"; import { Scene } from "@/components/art/scenes"; import { TaskComposer } from "@/components/conversation/task-composer"; import { cn } from "@/lib/utils"; @@ -10,28 +9,26 @@ import { TeamSuggestCards } from "@/views/home/team-suggest-card"; // HomeView is the workspace before any worktree is open: one composer to // start work (lib/composer), then the person's own grid of widgets // (views/home/widgets): the agents that need them, pull requests, their -// boxes, whatever they put there. Labs puts the harbour across the top, -// dissolving into the page, with the composer at the seam. +// boxes, whatever they put there. Labs puts the header picture (the +// harbour unless chosen otherwise) across the top, dissolving into the +// page, with the composer at the seam. const BAND = "clamp(220px, 50vh, 500px)"; export function HomeView() { const labs = usePrefs((p) => p.labs); - const light = useHarbourLight(); + const picture = useHeaderPicture(); + const band = labs ? picture : undefined; return (
- {labs && ( -
- -
- )} -
+ {band && } +
{!labs && (
)} -

What should your agents work on?

+

What should your agents work on?

diff --git a/app/src/views/settings/appearance-section.tsx b/app/src/views/settings/appearance-section.tsx index 3083c0fa..08e05e19 100644 --- a/app/src/views/settings/appearance-section.tsx +++ b/app/src/views/settings/appearance-section.tsx @@ -11,6 +11,7 @@ import { cn } from "@/lib/utils"; import { berthThemes } from "@/themes/builtin"; import { ChatBackgroundSettings, ChatWidthSettings } from "@/views/settings/chat-background-section"; import { Segmented } from "@/views/settings/controls"; +import { HeaderPictureSettings } from "@/views/settings/header-picture-section"; import { Code, SettingsGroup, SettingsPage, SettingsRow } from "@/views/settings/rows"; import { IS_LINUX } from "@/lib/platform"; @@ -91,6 +92,8 @@ export function AppearanceSection() { + + ); } diff --git a/app/src/views/settings/chat-background-section.tsx b/app/src/views/settings/chat-background-section.tsx index c3adf1b0..e2d3d474 100644 --- a/app/src/views/settings/chat-background-section.tsx +++ b/app/src/views/settings/chat-background-section.tsx @@ -28,7 +28,8 @@ import { removeImage, type StoredImageInfo, } from "@/lib/chat-background"; -import { type ChatWidth, setChatBackground, setPrefs, usePrefs } from "@/lib/prefs"; +import { DEFAULT_HEADER_PICTURE } from "@/lib/header-picture"; +import { type ChatWidth, setChatBackground, setHeaderPicture, setPrefs, usePrefs } from "@/lib/prefs"; import { useStore } from "@/lib/store"; import { cn } from "@/lib/utils"; import { Segmented } from "@/views/settings/controls"; @@ -81,7 +82,7 @@ export function ChatBackgroundSettings() { const add = async (f: File) => { try { - const image = await keepImage(f, { name: f.name }); + const image = await keepImage(f, { name: f.name }, picturesInUse()); setChatBackground({ source: "image", image, ...PICTURE_PRESET }); } catch (err) { toastError(err, { title: "Couldn't use that picture" }); @@ -162,7 +163,7 @@ export function ChatBackgroundSettings() { ); } -function TileRow({ title, children }: { title: string; children: ReactNode }) { +export function TileRow({ title, children }: { title: string; children: ReactNode }) { return (
{title}
@@ -172,7 +173,7 @@ function TileRow({ title, children }: { title: string; children: ReactNode }) { } // Tile is one background to pick, as a small thumbnail with its name in a tip. -function Tile({ label, selected, onClick, children, extra }: { label: string; selected?: boolean; onClick(): void; children: ReactNode; extra?: ReactNode }) { +export function Tile({ label, selected, onClick, children, extra }: { label: string; selected?: boolean; onClick(): void; children: ReactNode; extra?: ReactNode }) { return (
@@ -194,7 +195,7 @@ function Tile({ label, selected, onClick, children, extra }: { label: string; se ); } -function TileWords({ icon, children }: { icon: ReactNode; children: ReactNode }) { +export function TileWords({ icon, children }: { icon: ReactNode; children: ReactNode }) { return ( {icon} @@ -217,10 +218,9 @@ function Thumb({ bg }: { bg: ChatBackground }) { ); } -// OwnImages are the pictures added or generated, newest first, each drawn -// as it would be and with a way to forget it. -function OwnImages({ selected }: { selected?: string }) { - const bg = usePrefs((p) => p.chatBackground); +// useOwnImages is the pictures added or generated, newest first: the chat +// background's and the header picture's alike. +export function useOwnImages() { const [images, setImages] = useState([]); useEffect(() => { let alive = true; @@ -235,11 +235,30 @@ function OwnImages({ selected }: { selected?: string }) { off(); }; }, []); - const forget = async (img: StoredImageInfo) => { - await removeImage(img.id); - const now = usePrefs.getState().chatBackground; - if (now.source === "image" && now.image?.id === img.id) setChatBackground({ source: "none", image: undefined }); - }; + return images; +} + +// picturesInUse are the pictures of your own chosen now, which keeping a +// new one never pushes out. +export function picturesInUse() { + const { chatBackground: bg, headerPicture: hp } = usePrefs.getState(); + return [bg.source === "image" ? bg.image?.id : undefined, hp.source === "image" ? hp.image?.id : undefined]; +} + +// forgetPicture removes a picture of your own; whatever showed it goes back +// to its default: no chat background, the harbour atop a new chat. +export async function forgetPicture(img: StoredImageInfo) { + await removeImage(img.id); + const { chatBackground: bg, headerPicture: hp } = usePrefs.getState(); + if (bg.source === "image" && bg.image?.id === img.id) setChatBackground({ source: "none", image: undefined }); + if (hp.source === "image" && hp.image?.id === img.id) setHeaderPicture({ ...DEFAULT_HEADER_PICTURE, image: undefined }); +} + +// OwnImages are the pictures added or generated, newest first, each drawn +// as it would be and with a way to forget it. +function OwnImages({ selected }: { selected?: string }) { + const bg = usePrefs((p) => p.chatBackground); + const images = useOwnImages(); // A picture not chosen yet is shown as it would land: Shipyard's preset. return images.map((img) => ( setChatBackground(selected === img.id ? {} : { source: "image", image: img, ...(bg.source === "image" ? {} : PICTURE_PRESET) })} - extra={ - - - - } + extra={} > )); } +// RemovePicture is a picture tile's ×, shown on hover or focus. +export function RemovePicture({ img }: { img: StoredImageInfo }) { + return ( + + + + ); +} + // Preview is a conversation in miniature over the background, as it // renders in the chat: the same drawing, scaled. function Preview({ bg }: { bg: ChatBackground }) { @@ -438,7 +462,7 @@ function Generate({ onClose }: { onClose(): void }) { setError(undefined); try { const img = await imageGenApi.generate(client, prompt.trim(), gen.id); - const image = await keepImage(generatedBlob(img), { name: `${gen.name}: ${img.prompt}`, prompt: img.prompt }); + const image = await keepImage(generatedBlob(img), { name: `${gen.name}: ${img.prompt}`, prompt: img.prompt }, picturesInUse()); setChatBackground({ source: "image", image, ...PICTURE_PRESET }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); diff --git a/app/src/views/settings/header-picture-section.tsx b/app/src/views/settings/header-picture-section.tsx new file mode 100644 index 00000000..b99f8af9 --- /dev/null +++ b/app/src/views/settings/header-picture-section.tsx @@ -0,0 +1,162 @@ +import { BanIcon, ImagePlusIcon, RotateCcwIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { HeaderBand, useHeaderPicture } from "@/components/art/header-band"; +import { toastError } from "@/components/error-note"; +import { Button } from "@/components/ui/button"; +import { keepImage } from "@/lib/chat-background"; +import { DEFAULT_HEADER_PICTURE, type HeaderPicture, PLATES } from "@/lib/header-picture"; +import { setHeaderPicture, usePrefs } from "@/lib/prefs"; +import { cn } from "@/lib/utils"; +import { picturesInUse, RemovePicture, Tile, TileRow, TileWords, useOwnImages } from "@/views/settings/chat-background-section"; +import { Segmented } from "@/views/settings/controls"; +import { SettingsGroup, SettingsRow } from "@/views/settings/rows"; + +// Settings › Appearance › Header picture: the painting across the top of a +// new chat, an empty worktree and Home (lib/header-picture.ts). The +// harbour, a plate, a picture of your own (kept with the chat background's, +// chosen apart from it), or none. Every tile is drawn as the band draws it, +// so in a dark theme the paintings show their night. + +const HARBOUR_CHOICE: HeaderPicture = { ...DEFAULT_HEADER_PICTURE }; + +export function HeaderPictureSettings() { + const hp = usePrefs((p) => p.headerPicture); + const [dragging, setDragging] = useState(false); + const file = useRef(null); + const images = useOwnImages(); + const custom = hp.source !== "builtin" || hp.builtin !== DEFAULT_HEADER_PICTURE.builtin; + + const add = async (f: File) => { + try { + const image = await keepImage(f, { name: f.name }, picturesInUse()); + setHeaderPicture({ source: "image", image, position: "center" }); + } catch (err) { + toastError(err, { title: "Couldn't use that picture" }); + } + }; + const builtin = (id: HeaderPicture["builtin"]) => hp.source === "builtin" && hp.builtin === id; + + return ( +
{ + if (!e.dataTransfer.types.includes("Files")) return; + e.preventDefault(); + setDragging(true); + }} + onDragLeave={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + const f = [...e.dataTransfer.files].find((x) => x.type.startsWith("image/")); + if (f) void add(f); + }} + > +
+
+

Header picture

+

Across the top of a new chat, an empty worktree and Home. In a dark theme the paintings show their night.

+
+ {custom && ( + + )} +
+
+ +
+ + setHeaderPicture({ source: "none" })}> + }>None + + setHeaderPicture({ source: "builtin", builtin: "harbour" })}> + + + {PLATES.map((p) => ( + setHeaderPicture({ source: "builtin", builtin: p.id })}> + + + ))} + + + {images.map((img) => ( + setHeaderPicture({ source: "image", image: img })} + extra={} + > + + + ))} + file.current?.click()}> + }>Add… + + { + const f = e.target.files?.[0]; + e.target.value = ""; + if (f) void add(f); + }} + /> + +
+
+ {hp.source === "image" && hp.image && ( +
+ + + setHeaderPicture({ position })} + /> + + +
+ )} +
+ ); +} + +// Thumb draws a choice as the band would, at a tile's size: finer dots and +// no dissolve, so the painting reads in 76 px. +function Thumb({ choice }: { choice: HeaderPicture }) { + const picture = useHeaderPicture(choice); + if (!picture) return null; + return ; +} + +// Preview is the top of a new chat in miniature, under the chosen picture. +function Preview() { + const picture = useHeaderPicture(); + return ( +
+ {picture && } +
+
+
checkout-fix
+
+ devl/checkout-fix +
+
+
What should this agent work on?
+
+
+ ); +} diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 2ced3cb5..359fdd01 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,15 @@ again. ## Unreleased +- **Choose the picture at the top of a new chat.** Settings › Appearance › + Header picture picks the painting across the top of a new chat, an empty + worktree and Home: the harbour (still the default), one of six new + paintings (Alpine lake, Meadow, Desert mesa, Snowy fjord, Lavender fields, + Sea of clouds), a picture of your own, or none. Each new painting has its + own night version for dark themes, as the harbour does, and a picture of + your own is dimmed there. +- **Six new chat backgrounds.** The same six paintings are scenes under + Settings › Appearance › Chat background, at night in a dark theme. - **Math in code blocks draws too.** A ` ```math ` block, as GitHub writes a formula, and a ` ```latex ` or ` ```tex ` block of equations now show as typeset math. A whole LaTeX document (with `\documentclass`, meant for diff --git a/docs/guides/labs.mdx b/docs/guides/labs.mdx index ed500b94..8be54294 100644 --- a/docs/guides/labs.mdx +++ b/docs/guides/labs.mdx @@ -17,7 +17,8 @@ whatever you put there. An empty worktree has a composer that starts an agent right there. Labs adds the harbour above them: a painting of the harbour dissolving into the page, in a halftone grain, at night in a dark theme, smaller in an empty -worktree. +worktree. [Settings → Appearance → Header picture](/guides/themes#header-picture) +swaps it for another painting or a picture of your own, or turns it off. The harbour is **Home**, the first place in the sidebar. Without Labs that place is the Agent Dashboard; with Labs the dashboard moves under **More** diff --git a/docs/guides/themes.mdx b/docs/guides/themes.mdx index a8f8fd98..7aa2a8a5 100644 --- a/docs/guides/themes.mdx +++ b/docs/guides/themes.mdx @@ -122,7 +122,8 @@ colours, between its page and its text, so it follows light and dark: - **Patterns**: chart contours, a dot grid, a nautical grid, soft waves. - **Gradients**: Dawn, Dusk and Deep sea, ordered-dithered like the harbour on a new agent. -- **Scenes**: the harbour paintings, dithered at a low contrast. +- **Scenes**: the harbour paintings and the header picture's six + landscapes (each at night in a dark theme), dithered at a low contrast. - **Custom**: **Add…** a picture of your own (or drop one on the section), or **Generate** one from a prompt. Shipyard runs Codex's CLI on your Mac with your Codex account (`codex exec`, read-only, in an empty folder), @@ -139,3 +140,19 @@ fit or crop it. Behind the conversation and the reply box the background always fades to the plain page, so it lives in the margins and only whispers under the text. A pane's **⋯** menu has **Chat background…** to come back here. + +## Header picture + +**Settings → Appearance → Header picture** chooses the painting across the +top of a new chat, an empty worktree and Home: + +- **Harbour**, the default: the harbour by the time of day. +- Six more paintings in the same style: **Alpine lake**, **Meadow**, + **Desert mesa**, **Snowy fjord**, **Lavender fields** and **Sea of + clouds**. Like the harbour, each has its own night painting, which a + dark theme shows. +- **Add…** a picture of your own, or drop one on the section, and choose + which part of it shows (top, center or bottom). It's kept with your chat + background pictures, but the two choices are separate. In a dark theme + it's dimmed so it doesn't glare. +- **None**: no picture, just the page.