From ad0297bd267a63317dda88b8f06c9d8bcbb6195c Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 21 May 2026 16:39:44 +0000 Subject: [PATCH 01/36] feat: add appearance personalization settings contract --- packages/server/src/commands/settings.test.ts | 95 +++++++ packages/server/src/commands/settings.ts | 28 +++ packages/web/src/appearance/index.ts | 1 + .../src/appearance/personalization.test.ts | 43 ++++ .../web/src/appearance/personalization.ts | 237 ++++++++++++++++++ packages/web/src/atoms/app-ui.ts | 12 + 6 files changed, 416 insertions(+) create mode 100644 packages/web/src/appearance/index.ts create mode 100644 packages/web/src/appearance/personalization.test.ts create mode 100644 packages/web/src/appearance/personalization.ts diff --git a/packages/server/src/commands/settings.test.ts b/packages/server/src/commands/settings.test.ts index b9405cb6..89b76290 100644 --- a/packages/server/src/commands/settings.test.ts +++ b/packages/server/src/commands/settings.test.ts @@ -360,6 +360,78 @@ describe("settings commands", () => { expect(settingsRepo.get("appearance.desktopTerminalFontSize")).toBeUndefined(); }); + it("settings.update persists appearance.personalization common and device override keys", async () => { + const result = await dispatch( + { + kind: "command", + id: "settings-update-appearance-personalization", + op: "settings.update", + args: { + settings: { + appearance: { + personalization: { + version: 1, + common: { + backgroundMode: "image", + backgroundAssetId: "asset-common", + backgroundFit: "contain", + backgroundDimness: 32, + backgroundBlur: 8, + glassEnabled: false, + glassIntensity: 40, + surfaceOpacity: 92, + }, + desktop: { + backgroundAssetId: "asset-desktop", + glassEnabled: true, + }, + mobile: { + surfaceOpacity: 88, + }, + }, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(settingsRepo.get("appearance.personalization.common.backgroundMode")).toBe("image"); + expect(settingsRepo.get("appearance.personalization.desktop.glassEnabled")).toBe(true); + expect(settingsRepo.get("appearance.personalization.mobile.surfaceOpacity")).toBe(88); + }); + + it("settings.update rejects appearance.personalization values outside the supported ranges", async () => { + const result = await dispatch( + { + kind: "command", + id: "settings-update-appearance-personalization-invalid", + op: "settings.update", + args: { + settings: { + appearance: { + personalization: { + common: { + backgroundMode: "image", + backgroundBlur: 99, + }, + mobile: { + surfaceOpacity: 88, + }, + }, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("validation_error"); + expect(settingsRepo.get("appearance.personalization.common.backgroundBlur")).toBeUndefined(); + }); + it("settings.update persists provider startup command arguments into the file-backed provider config store", async () => { const result = await dispatch( { @@ -613,4 +685,27 @@ describe("settings commands", () => { expect(result.ok).toBe(true); expect(result.data?.["supervisor.evaluationTimeoutSec"]).toBe(600); }); + + it("settings.get returns persisted appearance.personalization keys unchanged", async () => { + settingsRepo.set("appearance.personalization.common.backgroundMode", "image"); + settingsRepo.set("appearance.personalization.desktop.glassEnabled", true); + settingsRepo.set("appearance.personalization.mobile.surfaceOpacity", 88); + + const result = await dispatch( + { + kind: "command", + id: "settings-get-appearance-personalization", + op: "settings.get", + args: {}, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.desktop.glassEnabled": true, + "appearance.personalization.mobile.surfaceOpacity": 88, + }); + }); }); diff --git a/packages/server/src/commands/settings.ts b/packages/server/src/commands/settings.ts index 9d43908f..ca122e1e 100644 --- a/packages/server/src/commands/settings.ts +++ b/packages/server/src/commands/settings.ts @@ -33,6 +33,15 @@ import { } from "../supervisor/settings.js"; import { registerCommand } from "../ws/dispatch.js"; +const PersonalizationOverridesSchema = z.object({ + backgroundAssetId: z.string().min(1).nullable().optional(), + backgroundDimness: z.number().int().min(0).max(100).optional(), + backgroundBlur: z.number().int().min(0).max(40).optional(), + glassEnabled: z.boolean().optional(), + glassIntensity: z.number().int().min(0).max(100).optional(), + surfaceOpacity: z.number().int().min(0).max(100).optional(), +}); + // Settings schema const SettingsSchema = z.object({ defaultProviderId: z.string().optional(), @@ -72,6 +81,25 @@ const SettingsSchema = z.object({ desktopTerminalFontSize: z.number().int().min(10).max(18).optional(), mobileTerminalFontSize: z.number().int().min(10).max(18).optional(), locale: z.enum(["zh", "en"]).optional(), + personalization: z + .object({ + version: z.literal(1).optional(), + common: z + .object({ + backgroundMode: z.enum(["none", "image"]).optional(), + backgroundAssetId: z.string().min(1).nullable().optional(), + backgroundFit: z.enum(["cover", "contain"]).optional(), + backgroundDimness: z.number().int().min(0).max(100).optional(), + backgroundBlur: z.number().int().min(0).max(40).optional(), + glassEnabled: z.boolean().optional(), + glassIntensity: z.number().int().min(0).max(100).optional(), + surfaceOpacity: z.number().int().min(0).max(100).optional(), + }) + .optional(), + desktop: PersonalizationOverridesSchema.optional(), + mobile: PersonalizationOverridesSchema.optional(), + }) + .optional(), }) .optional(), lsp: z diff --git a/packages/web/src/appearance/index.ts b/packages/web/src/appearance/index.ts new file mode 100644 index 00000000..4457e6b8 --- /dev/null +++ b/packages/web/src/appearance/index.ts @@ -0,0 +1 @@ +export * from "./personalization"; diff --git a/packages/web/src/appearance/personalization.test.ts b/packages/web/src/appearance/personalization.test.ts new file mode 100644 index 00000000..809f9301 --- /dev/null +++ b/packages/web/src/appearance/personalization.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_APPEARANCE_PERSONALIZATION, + resolveAppearancePersonalizationForViewport, + resolveAppearancePersonalizationSetting, +} from "./personalization"; + +describe("appearance personalization", () => { + it("falls back to the default contract when settings omit personalization", () => { + expect(resolveAppearancePersonalizationSetting({})).toEqual(DEFAULT_APPEARANCE_PERSONALIZATION); + }); + + it("ignores invalid numeric and enum values from server settings", () => { + expect( + resolveAppearancePersonalizationSetting({ + "appearance.personalization.common.backgroundMode": "video", + "appearance.personalization.common.backgroundBlur": 99, + "appearance.personalization.common.surfaceOpacity": -1, + }) + ).toEqual(DEFAULT_APPEARANCE_PERSONALIZATION); + }); + + it("merges common values with desktop overrides only for the supported fields", () => { + const personalization = resolveAppearancePersonalizationSetting({ + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + "appearance.personalization.common.glassEnabled": false, + "appearance.personalization.desktop.backgroundAssetId": "asset-desktop", + "appearance.personalization.desktop.glassEnabled": true, + }); + + expect(resolveAppearancePersonalizationForViewport(personalization, "desktop")).toMatchObject({ + backgroundMode: "image", + backgroundAssetId: "asset-desktop", + glassEnabled: true, + }); + expect(resolveAppearancePersonalizationForViewport(personalization, "mobile")).toMatchObject({ + backgroundMode: "image", + backgroundAssetId: "asset-common", + glassEnabled: false, + }); + }); +}); diff --git a/packages/web/src/appearance/personalization.ts b/packages/web/src/appearance/personalization.ts new file mode 100644 index 00000000..d4ab1a11 --- /dev/null +++ b/packages/web/src/appearance/personalization.ts @@ -0,0 +1,237 @@ +export type AppearanceViewport = "desktop" | "mobile"; +export type AppearanceBackgroundMode = "none" | "image"; +export type AppearanceBackgroundFit = "cover" | "contain"; + +export interface AppearancePersonalizationCommon { + backgroundMode: AppearanceBackgroundMode; + backgroundAssetId: string | null; + backgroundFit: AppearanceBackgroundFit; + backgroundDimness: number; + backgroundBlur: number; + glassEnabled: boolean; + glassIntensity: number; + surfaceOpacity: number; +} + +export interface AppearancePersonalizationOverrides { + backgroundAssetId?: string | null; + backgroundDimness?: number; + backgroundBlur?: number; + glassEnabled?: boolean; + glassIntensity?: number; + surfaceOpacity?: number; +} + +export interface AppearancePersonalization { + version: 1; + common: AppearancePersonalizationCommon; + desktop: AppearancePersonalizationOverrides; + mobile: AppearancePersonalizationOverrides; +} + +export const DEFAULT_APPEARANCE_PERSONALIZATION: AppearancePersonalization = { + version: 1, + common: { + backgroundMode: "none", + backgroundAssetId: null, + backgroundFit: "cover", + backgroundDimness: 24, + backgroundBlur: 0, + glassEnabled: false, + glassIntensity: 24, + surfaceOpacity: 96, + }, + desktop: {}, + mobile: {}, +}; + +const APPEARANCE_PERSONALIZATION_PREFIX = "appearance.personalization"; +const OVERRIDE_FIELDS = [ + "backgroundAssetId", + "backgroundDimness", + "backgroundBlur", + "glassEnabled", + "glassIntensity", + "surfaceOpacity", +] as const; + +type AppearanceOverrideField = (typeof OVERRIDE_FIELDS)[number]; + +function resolveEnumValue(value: unknown, allowed: readonly T[], fallback: T): T { + return typeof value === "string" && allowed.includes(value as T) ? (value as T) : fallback; +} + +function resolveNullableString(value: unknown, fallback: string | null): string | null { + if (value === null) { + return null; + } + + return typeof value === "string" && value.length > 0 ? value : fallback; +} + +function resolveNumberInRange(value: unknown, min: number, max: number, fallback: number): number { + return typeof value === "number" && + Number.isInteger(value) && + Number.isFinite(value) && + value >= min && + value <= max + ? value + : fallback; +} + +function resolveOptionalNumberInRange( + value: unknown, + min: number, + max: number +): number | undefined { + return typeof value === "number" && + Number.isInteger(value) && + Number.isFinite(value) && + value >= min && + value <= max + ? value + : undefined; +} + +function resolveOptionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function resolveOptionalNullableString(value: unknown): string | null | undefined { + if (value === null) { + return null; + } + + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function resolveOverrideField( + field: AppearanceOverrideField, + value: unknown +): string | number | boolean | null | undefined { + switch (field) { + case "backgroundAssetId": + return resolveOptionalNullableString(value); + case "backgroundDimness": + case "glassIntensity": + case "surfaceOpacity": + return resolveOptionalNumberInRange(value, 0, 100); + case "backgroundBlur": + return resolveOptionalNumberInRange(value, 0, 40); + case "glassEnabled": + return resolveOptionalBoolean(value); + } +} + +export function resolveAppearancePersonalizationSetting( + settings: Record +): AppearancePersonalization { + const commonDefaults = DEFAULT_APPEARANCE_PERSONALIZATION.common; + + const common: AppearancePersonalizationCommon = { + backgroundMode: resolveEnumValue( + settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.common.backgroundMode`], + ["none", "image"], + commonDefaults.backgroundMode + ), + backgroundAssetId: resolveNullableString( + settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.common.backgroundAssetId`], + commonDefaults.backgroundAssetId + ), + backgroundFit: resolveEnumValue( + settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.common.backgroundFit`], + ["cover", "contain"], + commonDefaults.backgroundFit + ), + backgroundDimness: resolveNumberInRange( + settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.common.backgroundDimness`], + 0, + 100, + commonDefaults.backgroundDimness + ), + backgroundBlur: resolveNumberInRange( + settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.common.backgroundBlur`], + 0, + 40, + commonDefaults.backgroundBlur + ), + glassEnabled: + typeof settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.common.glassEnabled`] === "boolean" + ? (settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.common.glassEnabled`] as boolean) + : commonDefaults.glassEnabled, + glassIntensity: resolveNumberInRange( + settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.common.glassIntensity`], + 0, + 100, + commonDefaults.glassIntensity + ), + surfaceOpacity: resolveNumberInRange( + settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.common.surfaceOpacity`], + 0, + 100, + commonDefaults.surfaceOpacity + ), + }; + + const desktop: AppearancePersonalizationOverrides = {}; + const mobile: AppearancePersonalizationOverrides = {}; + + for (const field of OVERRIDE_FIELDS) { + const desktopValue = resolveOverrideField( + field, + settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.desktop.${field}`] + ); + if (desktopValue !== undefined) { + if (field === "backgroundAssetId") { + desktop.backgroundAssetId = desktopValue; + } else if (field === "backgroundDimness") { + desktop.backgroundDimness = desktopValue; + } else if (field === "backgroundBlur") { + desktop.backgroundBlur = desktopValue; + } else if (field === "glassEnabled") { + desktop.glassEnabled = desktopValue; + } else if (field === "glassIntensity") { + desktop.glassIntensity = desktopValue; + } else if (field === "surfaceOpacity") { + desktop.surfaceOpacity = desktopValue; + } + } + + const mobileValue = resolveOverrideField( + field, + settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.mobile.${field}`] + ); + if (mobileValue !== undefined) { + if (field === "backgroundAssetId") { + mobile.backgroundAssetId = mobileValue; + } else if (field === "backgroundDimness") { + mobile.backgroundDimness = mobileValue; + } else if (field === "backgroundBlur") { + mobile.backgroundBlur = mobileValue; + } else if (field === "glassEnabled") { + mobile.glassEnabled = mobileValue; + } else if (field === "glassIntensity") { + mobile.glassIntensity = mobileValue; + } else if (field === "surfaceOpacity") { + mobile.surfaceOpacity = mobileValue; + } + } + } + + return { + version: 1, + common, + desktop, + mobile, + }; +} + +export function resolveAppearancePersonalizationForViewport( + personalization: AppearancePersonalization, + viewport: AppearanceViewport +): AppearancePersonalizationCommon { + return { + ...personalization.common, + ...personalization[viewport], + }; +} diff --git a/packages/web/src/atoms/app-ui.ts b/packages/web/src/atoms/app-ui.ts index 26745e1a..edd8fae9 100644 --- a/packages/web/src/atoms/app-ui.ts +++ b/packages/web/src/atoms/app-ui.ts @@ -7,6 +7,8 @@ import type { WorkspaceLastViewedTarget } from "@coder-studio/core"; import { atom } from "jotai"; import { atomWithStorage, createJSONStorage } from "jotai/utils"; +import type { AppearancePersonalization } from "../appearance"; +import { DEFAULT_APPEARANCE_PERSONALIZATION } from "../appearance"; import { resolveStoredThemeId } from "../theme"; const THEME_ID_STORAGE_KEY = "ui.themeId"; @@ -95,3 +97,13 @@ export const lastViewedTargetAtom = atom(null) * This is transient render state, not persisted workspace UI state. */ export const visibleMobileSessionIdAtom = atom(null); + +/** + * Server-hydrated appearance personalization settings. + * + * This mirrors the server-backed appearance contract and is not persisted + * locally. + */ +export const appearancePersonalizationAtom = atom( + DEFAULT_APPEARANCE_PERSONALIZATION +); From d8cf75c268da0cb6ee4e312966ed3f2163993a3b Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 21 May 2026 16:53:14 +0000 Subject: [PATCH 02/36] fix: support clearing appearance overrides --- packages/server/src/commands/settings.test.ts | 32 +++++++++++++ packages/server/src/commands/settings.ts | 46 +++++++++++++++++++ .../src/appearance/personalization.test.ts | 28 +++++++++++ .../web/src/appearance/personalization.ts | 11 ++++- 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/server/src/commands/settings.test.ts b/packages/server/src/commands/settings.test.ts index 89b76290..237c9940 100644 --- a/packages/server/src/commands/settings.test.ts +++ b/packages/server/src/commands/settings.test.ts @@ -432,6 +432,38 @@ describe("settings commands", () => { expect(settingsRepo.get("appearance.personalization.common.backgroundBlur")).toBeUndefined(); }); + it("settings.update clears persisted appearance.personalization device overrides when an override object is emptied", async () => { + settingsRepo.set("appearance.personalization.desktop.backgroundAssetId", "asset-desktop"); + settingsRepo.set("appearance.personalization.desktop.surfaceOpacity", 88); + settingsRepo.set("appearance.personalization.mobile.glassEnabled", true); + + const result = await dispatch( + { + kind: "command", + id: "settings-update-appearance-personalization-clear-device-overrides", + op: "settings.update", + args: { + settings: { + appearance: { + personalization: { + desktop: {}, + mobile: {}, + }, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect( + settingsRepo.get("appearance.personalization.desktop.backgroundAssetId") + ).toBeUndefined(); + expect(settingsRepo.get("appearance.personalization.desktop.surfaceOpacity")).toBeUndefined(); + expect(settingsRepo.get("appearance.personalization.mobile.glassEnabled")).toBeUndefined(); + }); + it("settings.update persists provider startup command arguments into the file-backed provider config store", async () => { const result = await dispatch( { diff --git a/packages/server/src/commands/settings.ts b/packages/server/src/commands/settings.ts index ca122e1e..2ff640e0 100644 --- a/packages/server/src/commands/settings.ts +++ b/packages/server/src/commands/settings.ts @@ -42,6 +42,16 @@ const PersonalizationOverridesSchema = z.object({ surfaceOpacity: z.number().int().min(0).max(100).optional(), }); +const PERSONALIZATION_OVERRIDE_BRANCHES = ["desktop", "mobile"] as const; +const PERSONALIZATION_OVERRIDE_FIELDS = [ + "backgroundAssetId", + "backgroundDimness", + "backgroundBlur", + "glassEnabled", + "glassIntensity", + "surfaceOpacity", +] as const; + // Settings schema const SettingsSchema = z.object({ defaultProviderId: z.string().optional(), @@ -184,10 +194,15 @@ registerCommand( ? (nextSettings.providers as Record) : undefined; const { providers: _providers, ...nonProviderSettings } = nextSettings; + const overrideKeysToDelete = resolveAppearancePersonalizationOverrideKeysToDelete(nextSettings); // Flatten settings to key-value pairs const flatSettings = flattenSettings(nonProviderSettings); + for (const key of overrideKeysToDelete) { + ctx.settingsRepo.delete(key); + } + for (const [key, value] of Object.entries(flatSettings)) { ctx.settingsRepo.set(key, value); } @@ -255,6 +270,37 @@ function flattenSettings(obj: Record, prefix = ""): Record +): string[] { + const appearance = settings.appearance; + if (!appearance || typeof appearance !== "object" || Array.isArray(appearance)) { + return []; + } + + const personalization = (appearance as Record).personalization; + if (!personalization || typeof personalization !== "object" || Array.isArray(personalization)) { + return []; + } + + const keysToDelete: string[] = []; + + for (const branch of PERSONALIZATION_OVERRIDE_BRANCHES) { + const overrides = (personalization as Record)[branch]; + if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) { + continue; + } + + for (const field of PERSONALIZATION_OVERRIDE_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(overrides, field)) { + keysToDelete.push(`appearance.personalization.${branch}.${field}`); + } + } + } + + return keysToDelete; +} + // settings.readConfigFile — read Codex or Claude config file content registerCommand( "settings.readConfigFile", diff --git a/packages/web/src/appearance/personalization.test.ts b/packages/web/src/appearance/personalization.test.ts index 809f9301..dcbe374b 100644 --- a/packages/web/src/appearance/personalization.test.ts +++ b/packages/web/src/appearance/personalization.test.ts @@ -40,4 +40,32 @@ describe("appearance personalization", () => { glassEnabled: false, }); }); + + it("collapses backgroundMode to none when the effective asset id is missing", () => { + const personalization = resolveAppearancePersonalizationSetting({ + "appearance.personalization.common.backgroundMode": "image", + }); + + expect(resolveAppearancePersonalizationForViewport(personalization, "desktop")).toMatchObject({ + backgroundMode: "none", + backgroundAssetId: null, + }); + }); + + it("collapses backgroundMode to none when a device override clears the asset id", () => { + const personalization = resolveAppearancePersonalizationSetting({ + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + "appearance.personalization.desktop.backgroundAssetId": null, + }); + + expect(resolveAppearancePersonalizationForViewport(personalization, "desktop")).toMatchObject({ + backgroundMode: "none", + backgroundAssetId: null, + }); + expect(resolveAppearancePersonalizationForViewport(personalization, "mobile")).toMatchObject({ + backgroundMode: "image", + backgroundAssetId: "asset-common", + }); + }); }); diff --git a/packages/web/src/appearance/personalization.ts b/packages/web/src/appearance/personalization.ts index d4ab1a11..873b5642 100644 --- a/packages/web/src/appearance/personalization.ts +++ b/packages/web/src/appearance/personalization.ts @@ -230,8 +230,17 @@ export function resolveAppearancePersonalizationForViewport( personalization: AppearancePersonalization, viewport: AppearanceViewport ): AppearancePersonalizationCommon { - return { + const resolved = { ...personalization.common, ...personalization[viewport], }; + + if (resolved.backgroundMode === "image" && resolved.backgroundAssetId === null) { + return { + ...resolved, + backgroundMode: "none", + }; + } + + return resolved; } From eec758d9d8d4d3afb77700f0e0cbf9713990d9ef Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 21 May 2026 17:10:20 +0000 Subject: [PATCH 03/36] feat: add appearance asset upload route --- packages/server/src/app.ts | 22 ++ .../src/routes/appearance-assets.test.ts | 164 ++++++++++++++ .../server/src/routes/appearance-assets.ts | 210 ++++++++++++++++++ packages/server/src/server.ts | 5 + .../appearance-asset-repo.test.ts | 80 +++++++ .../repositories/appearance-asset-repo.ts | 78 +++++++ 6 files changed, 559 insertions(+) create mode 100644 packages/server/src/routes/appearance-assets.test.ts create mode 100644 packages/server/src/routes/appearance-assets.ts create mode 100644 packages/server/src/storage/repositories/appearance-asset-repo.test.ts create mode 100644 packages/server/src/storage/repositories/appearance-asset-repo.ts diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index f4cd9095..5512464e 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -4,6 +4,7 @@ * Builds the Fastify application with all routes and middleware */ +import { dirname, join, resolve } from "node:path"; import compress from "@fastify/compress"; import cors from "@fastify/cors"; import multipart from "@fastify/multipart"; @@ -18,8 +19,13 @@ import { registerAuthStatusRoute, } from "./auth/index.js"; import type { ServerConfig } from "./config.js"; +import { registerAppearanceAssetsRoutes } from "./routes/appearance-assets.js"; import { registerFileAssetRoutes } from "./routes/file-asset.js"; import { registerUploadsRoute } from "./routes/uploads.js"; +import { + AppearanceAssetRepo, + type AppearanceAssetRepo as AppearanceAssetRepoType, +} from "./storage/repositories/appearance-asset-repo.js"; import type { AuthLoginBlockRepo } from "./storage/repositories/auth-login-block-repo.js"; import type { AuthSessionRepo } from "./storage/repositories/auth-session-repo.js"; import { MAX_FILE_BYTES, MAX_FILES_PER_BATCH } from "./uploads/constants.js"; @@ -34,6 +40,7 @@ interface AppDeps { config: ServerConfig; authSessionRepo: AuthSessionRepo; authLoginBlockRepo: AuthLoginBlockRepo; + appearanceAssetRepo?: AppearanceAssetRepoType; logger?: FastifyServerOptions["logger"]; } @@ -41,6 +48,16 @@ interface AppDeps { * Build Fastify application */ export async function buildFastifyApp(deps: AppDeps): Promise { + const stateRoot = + deps.config.dataDir === ":memory:" + ? resolve(deps.config.uploadsDir, "..") + : dirname(deps.config.dataDir); + const appearanceAssetRepo = + deps.appearanceAssetRepo ?? + new AppearanceAssetRepo({ + filePath: join(stateRoot, "state", "appearance-assets.json"), + }); + const app = Fastify({ logger: deps.logger ?? { level: "info", @@ -142,6 +159,11 @@ export async function buildFastifyApp(deps: AppDeps): Promise { workspaceMgr: deps.workspaceMgr, }); + registerAppearanceAssetsRoutes(app, { + uploadsDir: deps.config.uploadsDir, + repo: appearanceAssetRepo, + }); + registerUploadsRoute(app, { uploadsDir: deps.config.uploadsDir, workspaceMgr: deps.workspaceMgr, diff --git a/packages/server/src/routes/appearance-assets.test.ts b/packages/server/src/routes/appearance-assets.test.ts new file mode 100644 index 00000000..e6165161 --- /dev/null +++ b/packages/server/src/routes/appearance-assets.test.ts @@ -0,0 +1,164 @@ +import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import multipart from "@fastify/multipart"; +import Fastify, { type FastifyInstance } from "fastify"; +import FormData from "form-data"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AppearanceAssetRepo } from "../storage/repositories/appearance-asset-repo.js"; +import { registerAppearanceAssetsRoutes } from "./appearance-assets.js"; + +const PNG_BYTES = Buffer.from( + "89504E470D0A1A0A0000000D4948445200000001000000010806000000" + + "1F15C4890000000A49444154789C63000100000005000157CFC4A30000" + + "0000049454E44AE426082", + "hex" +); + +async function buildApp(deps: { + uploadsDir: string; + repo: AppearanceAssetRepo; +}): Promise { + const app = Fastify({ logger: false }); + await app.register(multipart, { + limits: { fileSize: 50 * 1024 * 1024, files: 1 }, + }); + registerAppearanceAssetsRoutes(app, deps); + await app.ready(); + return app; +} + +async function postAppearanceAsset( + app: FastifyInstance, + file: { name: string; buffer: Buffer; contentType: string } +) { + const form = new FormData(); + form.append("file", file.buffer, { + filename: file.name, + contentType: file.contentType, + }); + return app.inject({ + method: "POST", + url: "/api/appearance-assets", + headers: form.getHeaders(), + payload: form.getBuffer(), + }); +} + +async function listFilesRecursive(root: string): Promise { + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); + const files: string[] = []; + for (const entry of entries) { + const child = join(root, entry.name); + if (entry.isDirectory()) { + files.push(...(await listFilesRecursive(child))); + continue; + } + if (entry.isFile()) { + files.push(child); + } + } + return files; +} + +describe("appearance-assets routes", () => { + let tempDir: string; + let uploadsDir: string; + let repo: AppearanceAssetRepo; + let app: FastifyInstance; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "appearance-assets-route-")); + uploadsDir = join(tempDir, "uploads"); + repo = new AppearanceAssetRepo({ + filePath: join(tempDir, "appearance-assets.json"), + }); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + await rm(tempDir, { recursive: true, force: true }); + }); + + it("uploads a png appearance asset and returns asset metadata", async () => { + app = await buildApp({ uploadsDir, repo }); + const res = await postAppearanceAsset(app, { + name: "pixel.png", + buffer: PNG_BYTES, + contentType: "image/png", + }); + + expect(res.statusCode).toBe(200); + expect(res.json().asset.mime).toBe("image/png"); + expect(res.json().asset.url).toMatch(/^\/api\/appearance-assets\//); + + const [stored] = repo.list(); + expect(stored).toMatchObject({ + id: res.json().asset.assetId, + fileName: "pixel.png", + mime: "image/png", + size: PNG_BYTES.length, + }); + expect(await readFile(stored.storagePath)).toEqual(PNG_BYTES); + }); + + it("rejects non-image appearance uploads", async () => { + app = await buildApp({ uploadsDir, repo }); + const res = await postAppearanceAsset(app, { + name: "notes.txt", + buffer: Buffer.from("not-an-image"), + contentType: "text/plain", + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ ok: false, error: "invalid_file_type" }); + expect(repo.list()).toEqual([]); + expect(await listFilesRecursive(uploadsDir)).toEqual([]); + }); + + it("serves an uploaded asset back through GET /api/appearance-assets/:assetId", async () => { + app = await buildApp({ uploadsDir, repo }); + const uploadRes = await postAppearanceAsset(app, { + name: "pixel.png", + buffer: PNG_BYTES, + contentType: "image/png", + }); + const assetId = uploadRes.json().asset.assetId as string; + + const getRes = await app.inject({ + method: "GET", + url: `/api/appearance-assets/${assetId}`, + }); + + expect(getRes.statusCode).toBe(200); + expect(getRes.headers["content-type"]).toBe("image/png"); + expect(getRes.headers["cache-control"]).toBe("no-store"); + expect(getRes.rawPayload.equals(PNG_BYTES)).toBe(true); + }); + + it("deletes an asset and removes both metadata and file contents", async () => { + app = await buildApp({ uploadsDir, repo }); + const uploadRes = await postAppearanceAsset(app, { + name: "pixel.png", + buffer: PNG_BYTES, + contentType: "image/png", + }); + const assetId = uploadRes.json().asset.assetId as string; + const storedPath = repo.get(assetId)?.storagePath; + + expect(storedPath).toBeDefined(); + await stat(storedPath as string); + + const deleteRes = await app.inject({ + method: "DELETE", + url: `/api/appearance-assets/${assetId}`, + }); + + expect(deleteRes.statusCode).toBe(200); + expect(deleteRes.json()).toEqual({ ok: true }); + expect(repo.list()).toEqual([]); + await expect(stat(storedPath as string)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/packages/server/src/routes/appearance-assets.ts b/packages/server/src/routes/appearance-assets.ts new file mode 100644 index 00000000..8961f55e --- /dev/null +++ b/packages/server/src/routes/appearance-assets.ts @@ -0,0 +1,210 @@ +import { randomUUID } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { rm, stat } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { pipeline } from "node:stream/promises"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import type { + AppearanceAssetRecord, + AppearanceAssetRepo, +} from "../storage/repositories/appearance-asset-repo.js"; +import { ensureSafeUploadDir, sanitizeOriginalName } from "../uploads/paths.js"; + +const ALLOWED_APPEARANCE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); +const APPEARANCE_ASSET_BUCKET = "appearance/default"; + +interface Deps { + uploadsDir: string; + repo: AppearanceAssetRepo; +} + +interface AppearanceAssetParams { + assetId: string; +} + +function isAllowedAppearanceMime(mime: string): mime is AppearanceAssetRecord["mime"] { + return ALLOWED_APPEARANCE_MIME_TYPES.has(mime); +} + +function isPathInsideRoot(rootPath: string, targetPath: string): boolean { + const rel = relative(rootPath, targetPath); + return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); +} + +function resolveAssetStoragePath(uploadsDir: string, storagePath: string): string | null { + const resolvedUploadsDir = resolve(uploadsDir); + const resolvedStoragePath = resolve(storagePath); + return isPathInsideRoot(resolvedUploadsDir, resolvedStoragePath) ? resolvedStoragePath : null; +} + +async function cleanupWrittenFile(filePath: string | undefined): Promise { + if (!filePath) { + return; + } + + await rm(filePath, { force: true }); +} + +async function rejectAndCleanup( + reply: FastifyReply, + filePath: string | undefined, + statusCode: number, + error: string +) { + await cleanupWrittenFile(filePath); + return reply.status(statusCode).send({ ok: false, error }); +} + +export function registerAppearanceAssetsRoutes(app: FastifyInstance, deps: Deps): void { + app.post("/api/appearance-assets", async (request: FastifyRequest, reply: FastifyReply) => { + if (!request.isMultipart()) { + return reply.status(400).send({ ok: false, error: "expected_multipart" }); + } + + let writtenPath: string | undefined; + let pendingRecord: AppearanceAssetRecord | undefined; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type !== "file") { + continue; + } + + if (part.fieldname !== "file") { + part.file.resume(); + return rejectAndCleanup(reply, writtenPath, 400, "file_required"); + } + + if (pendingRecord) { + part.file.resume(); + return rejectAndCleanup(reply, writtenPath, 400, "too_many_files"); + } + + if (!isAllowedAppearanceMime(part.mimetype)) { + part.file.resume(); + return rejectAndCleanup(reply, writtenPath, 400, "invalid_file_type"); + } + + const assetId = randomUUID(); + const createdAt = Date.now(); + const dateStr = new Date(createdAt).toISOString().slice(0, 10); + const safeName = sanitizeOriginalName(part.filename || "file"); + const fileName = part.filename?.trim() ? part.filename.trim() : safeName; + const dir = join(deps.uploadsDir, APPEARANCE_ASSET_BUCKET, dateStr); + const storagePath = join(dir, `${assetId}-${safeName}`); + + try { + await ensureSafeUploadDir(deps.uploadsDir, dir); + await pipeline(part.file, createWriteStream(storagePath)); + } catch (error) { + request.log.warn({ err: error }, "appearance asset write failed"); + return rejectAndCleanup(reply, storagePath, 500, "write_failed"); + } + + if (part.file.truncated) { + return rejectAndCleanup(reply, storagePath, 413, "file_too_large"); + } + + let fileSize: number; + try { + const fileStat = await stat(storagePath); + fileSize = fileStat.size; + } catch (error) { + request.log.warn({ err: error }, "appearance asset stat failed"); + return rejectAndCleanup(reply, storagePath, 500, "write_failed"); + } + + writtenPath = storagePath; + pendingRecord = { + id: assetId, + fileName, + mime: part.mimetype, + size: fileSize, + storagePath, + createdAt, + }; + } + } catch (error) { + if ((error as { code?: string }).code === "FST_REQ_FILE_TOO_LARGE") { + return rejectAndCleanup(reply, writtenPath, 413, "file_too_large"); + } + + request.log.warn({ err: error }, "appearance asset parse failed"); + return rejectAndCleanup(reply, writtenPath, 400, "parse_failed"); + } + + if (!pendingRecord) { + return rejectAndCleanup(reply, writtenPath, 400, "file_required"); + } + + try { + deps.repo.set(pendingRecord); + } catch (error) { + request.log.warn({ err: error }, "appearance asset metadata write failed"); + return rejectAndCleanup(reply, writtenPath, 500, "write_failed"); + } + + return reply.send({ + ok: true, + asset: { + assetId: pendingRecord.id, + url: `/api/appearance-assets/${pendingRecord.id}`, + mime: pendingRecord.mime, + size: pendingRecord.size, + }, + }); + }); + + app.get( + "/api/appearance-assets/:assetId", + async (request: FastifyRequest<{ Params: AppearanceAssetParams }>, reply: FastifyReply) => { + const record = deps.repo.get(request.params.assetId); + if (!record) { + return reply.status(404).send({ ok: false, error: "not_found" }); + } + + const storagePath = resolveAssetStoragePath(deps.uploadsDir, record.storagePath); + if (!storagePath) { + return reply.status(404).send({ ok: false, error: "not_found" }); + } + + let fileSize: number; + try { + const fileStat = await stat(storagePath); + if (!fileStat.isFile()) { + return reply.status(404).send({ ok: false, error: "not_found" }); + } + fileSize = fileStat.size; + } catch { + return reply.status(404).send({ ok: false, error: "not_found" }); + } + + reply + .header("Content-Type", record.mime) + .header("Content-Length", String(fileSize)) + .header("Cache-Control", "no-store") + .header("X-Content-Type-Options", "nosniff"); + + return reply.send(createReadStream(storagePath)); + } + ); + + app.delete( + "/api/appearance-assets/:assetId", + async (request: FastifyRequest<{ Params: AppearanceAssetParams }>, reply: FastifyReply) => { + const record = deps.repo.get(request.params.assetId); + if (!record) { + return reply.status(404).send({ ok: false, error: "not_found" }); + } + + const storagePath = resolveAssetStoragePath(deps.uploadsDir, record.storagePath); + if (storagePath) { + await rm(storagePath, { force: true }); + } + + deps.repo.delete(request.params.assetId); + return reply.send({ ok: true }); + } + ); +} diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 45b254b0..e9c5009e 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -29,6 +29,7 @@ import { createE2EProviderMockOverrides } from "./provider-runtime/e2e-provider- import { ProviderInstallManager } from "./provider-runtime/install-manager.js"; import type { RuntimeStatusDeps } from "./provider-runtime/runtime-status.js"; import { SessionManager } from "./session/manager.js"; +import { AppearanceAssetRepo } from "./storage/repositories/appearance-asset-repo.js"; import { AuthLoginBlockRepo } from "./storage/repositories/auth-login-block-repo.js"; import { AuthSessionRepo } from "./storage/repositories/auth-session-repo.js"; import { ProviderConfigRepo } from "./storage/repositories/provider-config-repo.js"; @@ -182,6 +183,9 @@ export async function createServer( const authLoginBlockRepo = new AuthLoginBlockRepo({ filePath: join(stateRoot, "state", "auth-login-blocks.json"), }); + const appearanceAssetRepo = new AppearanceAssetRepo({ + filePath: join(stateRoot, "state", "appearance-assets.json"), + }); const app = await buildFastifyApp({ wsHub, @@ -190,6 +194,7 @@ export async function createServer( config, authSessionRepo, authLoginBlockRepo, + appearanceAssetRepo, logger: { level: "info", transport: { diff --git a/packages/server/src/storage/repositories/appearance-asset-repo.test.ts b/packages/server/src/storage/repositories/appearance-asset-repo.test.ts new file mode 100644 index 00000000..8b2056a9 --- /dev/null +++ b/packages/server/src/storage/repositories/appearance-asset-repo.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AppearanceAssetRepo } from "./appearance-asset-repo.js"; + +describe("AppearanceAssetRepo", () => { + let tempDir: string; + let repo: AppearanceAssetRepo; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "appearance-asset-repo-")); + repo = new AppearanceAssetRepo({ filePath: join(tempDir, "appearance-assets.json") }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("stores, reads, and deletes service-scoped appearance asset metadata", () => { + const createdAt = Date.now(); + repo.set({ + id: "asset-1", + fileName: "pixel.png", + mime: "image/png", + size: 68, + storagePath: join( + tempDir, + "uploads", + "appearance", + "default", + "2026-05-21", + "asset-1-pixel.png" + ), + createdAt, + }); + + const reloaded = new AppearanceAssetRepo({ + filePath: join(tempDir, "appearance-assets.json"), + }); + + expect(reloaded.get("asset-1")).toEqual({ + id: "asset-1", + fileName: "pixel.png", + mime: "image/png", + size: 68, + storagePath: join( + tempDir, + "uploads", + "appearance", + "default", + "2026-05-21", + "asset-1-pixel.png" + ), + createdAt, + }); + expect(reloaded.list()).toEqual([ + { + id: "asset-1", + fileName: "pixel.png", + mime: "image/png", + size: 68, + storagePath: join( + tempDir, + "uploads", + "appearance", + "default", + "2026-05-21", + "asset-1-pixel.png" + ), + createdAt, + }, + ]); + + reloaded.delete("asset-1"); + + expect(reloaded.get("asset-1")).toBeUndefined(); + expect(reloaded.list()).toEqual([]); + }); +}); diff --git a/packages/server/src/storage/repositories/appearance-asset-repo.ts b/packages/server/src/storage/repositories/appearance-asset-repo.ts new file mode 100644 index 00000000..4a1b9eed --- /dev/null +++ b/packages/server/src/storage/repositories/appearance-asset-repo.ts @@ -0,0 +1,78 @@ +import { readJsonFile, writeJsonFileAtomic } from "./json-file-store.js"; + +export interface AppearanceAssetRecord { + id: string; + fileName: string; + mime: "image/png" | "image/jpeg" | "image/webp"; + size: number; + storagePath: string; + createdAt: number; +} + +interface AppearanceAssetFileRecord { + version: 1; + assets: Record; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeAppearanceAssetFile(value: unknown): Record { + if (isRecord(value) && value.version === 1 && isRecord(value.assets)) { + return value.assets as Record; + } + + if (isRecord(value)) { + return value as Record; + } + + return {}; +} + +export class AppearanceAssetRepo { + constructor(private readonly options: { filePath: string }) {} + + private loadFileAssets(): Record { + const parsed = readJsonFile>( + this.options.filePath + ); + if (parsed !== undefined) { + return { ...normalizeAppearanceAssetFile(parsed) }; + } + + return {}; + } + + private saveFileAssets(assets: Record): void { + const payload: AppearanceAssetFileRecord = { + version: 1, + assets, + }; + writeJsonFileAtomic(this.options.filePath, payload); + } + + get(id: string): AppearanceAssetRecord | undefined { + return this.loadFileAssets()[id]; + } + + set(record: AppearanceAssetRecord): void { + const next = this.loadFileAssets(); + next[record.id] = record; + this.saveFileAssets(next); + } + + delete(id: string): void { + const next = this.loadFileAssets(); + if (!Object.prototype.hasOwnProperty.call(next, id)) { + return; + } + + delete next[id]; + this.saveFileAssets(next); + } + + list(): AppearanceAssetRecord[] { + return Object.values(this.loadFileAssets()); + } +} From 1824932052d92da7c0de8cd87887eb53b58434a8 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 21 May 2026 17:21:41 +0000 Subject: [PATCH 04/36] fix: preserve appearance override siblings on partial updates --- packages/server/src/commands/settings.test.ts | 42 +++++++++++++++++++ packages/server/src/commands/settings.ts | 15 +++++++ 2 files changed, 57 insertions(+) diff --git a/packages/server/src/commands/settings.test.ts b/packages/server/src/commands/settings.test.ts index 237c9940..c61ace7b 100644 --- a/packages/server/src/commands/settings.test.ts +++ b/packages/server/src/commands/settings.test.ts @@ -446,6 +446,17 @@ describe("settings commands", () => { settings: { appearance: { personalization: { + version: 1, + common: { + backgroundMode: "none", + backgroundAssetId: null, + backgroundFit: "cover", + backgroundDimness: 24, + backgroundBlur: 0, + glassEnabled: false, + glassIntensity: 24, + surfaceOpacity: 96, + }, desktop: {}, mobile: {}, }, @@ -464,6 +475,37 @@ describe("settings commands", () => { expect(settingsRepo.get("appearance.personalization.mobile.glassEnabled")).toBeUndefined(); }); + it("settings.update preserves persisted appearance.personalization sibling override keys during partial updates", async () => { + settingsRepo.set("appearance.personalization.desktop.backgroundAssetId", "asset-desktop"); + settingsRepo.set("appearance.personalization.desktop.surfaceOpacity", 88); + + const result = await dispatch( + { + kind: "command", + id: "settings-update-appearance-personalization-partial-device-overrides", + op: "settings.update", + args: { + settings: { + appearance: { + personalization: { + desktop: { + surfaceOpacity: 72, + }, + }, + }, + }, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(settingsRepo.get("appearance.personalization.desktop.backgroundAssetId")).toBe( + "asset-desktop" + ); + expect(settingsRepo.get("appearance.personalization.desktop.surfaceOpacity")).toBe(72); + }); + it("settings.update persists provider startup command arguments into the file-backed provider config store", async () => { const result = await dispatch( { diff --git a/packages/server/src/commands/settings.ts b/packages/server/src/commands/settings.ts index 2ff640e0..5afa1f00 100644 --- a/packages/server/src/commands/settings.ts +++ b/packages/server/src/commands/settings.ts @@ -283,6 +283,10 @@ function resolveAppearancePersonalizationOverrideKeysToDelete( return []; } + if (!isFullAppearancePersonalizationSnapshot(personalization)) { + return []; + } + const keysToDelete: string[] = []; for (const branch of PERSONALIZATION_OVERRIDE_BRANCHES) { @@ -301,6 +305,17 @@ function resolveAppearancePersonalizationOverrideKeysToDelete( return keysToDelete; } +function isFullAppearancePersonalizationSnapshot( + personalization: Record +): boolean { + return ( + Object.prototype.hasOwnProperty.call(personalization, "version") && + Object.prototype.hasOwnProperty.call(personalization, "common") && + Object.prototype.hasOwnProperty.call(personalization, "desktop") && + Object.prototype.hasOwnProperty.call(personalization, "mobile") + ); +} + // settings.readConfigFile — read Codex or Claude config file content registerCommand( "settings.readConfigFile", From c66cdd162220f51098c67d8fdaf3cbe8a65beac9 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 21 May 2026 17:21:57 +0000 Subject: [PATCH 05/36] feat: hydrate appearance personalization at runtime --- .../web/src/app/providers.lifecycle.test.tsx | 152 +++++++++++++++++- packages/web/src/app/providers.tsx | 117 +++++++++++++- packages/web/src/styles/base.css | 46 +++++- packages/web/src/styles/base.theme.test.ts | 26 ++- 4 files changed, 332 insertions(+), 9 deletions(-) diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx index ab9f4291..2b3275db 100644 --- a/packages/web/src/app/providers.lifecycle.test.tsx +++ b/packages/web/src/app/providers.lifecycle.test.tsx @@ -7,7 +7,7 @@ import { activationReasonAtom, activationStatusAtom, } from "../atoms/activation"; -import { authenticatedAtom, themeAtom } from "../atoms/app-ui"; +import { appearancePersonalizationAtom, authenticatedAtom, themeAtom } from "../atoms/app-ui"; import { authEnabledAtom, connectionStatusAtom } from "../atoms/connection"; import { sessionsAtom } from "../atoms/sessions"; import { @@ -1257,6 +1257,156 @@ describe("AppProviders lifecycle recovery", () => { }); }); + it("hydrates appearance.personalization from settings.get into the in-memory atom", async () => { + const store = createStore(); + setVisibilityState("visible"); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.personalization.version": 1, + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + "appearance.personalization.common.backgroundFit": "contain", + "appearance.personalization.common.backgroundDimness": 36, + "appearance.personalization.common.backgroundBlur": 6, + "appearance.personalization.common.glassEnabled": false, + "appearance.personalization.common.glassIntensity": 20, + "appearance.personalization.common.surfaceOpacity": 90, + "appearance.personalization.desktop.glassEnabled": true, + }; + } + + return undefined; + }); + wsState.client!.sendCommand = sendCommand; + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(store.get(appearancePersonalizationAtom)).toMatchObject({ + version: 1, + common: expect.objectContaining({ + backgroundMode: "image", + backgroundAssetId: "asset-common", + backgroundFit: "contain", + backgroundDimness: 36, + backgroundBlur: 6, + glassEnabled: false, + glassIntensity: 20, + surfaceOpacity: 90, + }), + desktop: { + glassEnabled: true, + }, + }); + }); + }); + + it("applies effective desktop personalization as document CSS variables", async () => { + const store = createStore(); + setVisibilityState("visible"); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.personalization.version": 1, + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + "appearance.personalization.common.backgroundFit": "cover", + "appearance.personalization.common.backgroundDimness": 36, + "appearance.personalization.common.backgroundBlur": 8, + "appearance.personalization.common.glassEnabled": false, + "appearance.personalization.common.glassIntensity": 18, + "appearance.personalization.common.surfaceOpacity": 88, + "appearance.personalization.desktop.backgroundAssetId": "asset-desktop", + "appearance.personalization.desktop.glassEnabled": true, + "appearance.personalization.desktop.glassIntensity": 30, + }; + } + + return undefined; + }); + wsState.client!.sendCommand = sendCommand; + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(document.documentElement.style.getPropertyValue("--app-bg-image")).toBe( + "url(/api/appearance-assets/asset-desktop)" + ); + expect(document.documentElement.style.getPropertyValue("--app-bg-fit")).toBe("cover"); + expect(document.documentElement.style.getPropertyValue("--app-bg-dim")).toBe("0.36"); + expect(document.documentElement.style.getPropertyValue("--app-bg-blur")).toBe("8px"); + expect(document.documentElement.style.getPropertyValue("--app-surface-opacity")).toBe("0.88"); + expect(document.documentElement.style.getPropertyValue("--app-surface-backdrop-filter")).toBe( + "blur(30px)" + ); + expect(document.documentElement.getAttribute("data-appearance-glass")).toBe("on"); + }); + }); + + it("weakens personalization when the active theme is high contrast", async () => { + const store = createStore(); + setVisibilityState("visible"); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.themeId": "hc-dark", + "appearance.personalization.version": 1, + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + "appearance.personalization.common.backgroundFit": "cover", + "appearance.personalization.common.backgroundDimness": 20, + "appearance.personalization.common.backgroundBlur": 18, + "appearance.personalization.common.glassEnabled": true, + "appearance.personalization.common.glassIntensity": 28, + "appearance.personalization.common.surfaceOpacity": 72, + }; + } + + return undefined; + }); + wsState.client!.sendCommand = sendCommand; + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(store.get(themeAtom)).toBe("hc-dark"); + expect(document.documentElement.style.getPropertyValue("--app-bg-blur")).toBe("0px"); + expect(document.documentElement.style.getPropertyValue("--app-surface-opacity")).toBe("1"); + expect(document.documentElement.style.getPropertyValue("--app-surface-backdrop-filter")).toBe( + "none" + ); + expect(document.documentElement.getAttribute("data-appearance-glass")).toBe("off"); + }); + }); + it("prefers server-provided appearance.themeId over legacy ui.theme localStorage", async () => { const store = createStore(); setVisibilityState("visible"); diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index 79679e89..738fb671 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -16,6 +16,13 @@ import type { import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import type { Store } from "jotai/vanilla/store"; import { useEffect, useRef } from "react"; +import { + type AppearancePersonalization, + type AppearanceViewport, + DEFAULT_APPEARANCE_PERSONALIZATION, + resolveAppearancePersonalizationForViewport, + resolveAppearancePersonalizationSetting, +} from "../appearance"; import { authEnabledAtom, connectionErrorAtom, @@ -37,7 +44,7 @@ import { activationReasonAtom, activationStatusAtom, } from "../atoms/activation"; -import { authenticatedAtom, themeAtom } from "../atoms/app-ui"; +import { appearancePersonalizationAtom, authenticatedAtom, themeAtom } from "../atoms/app-ui"; import type { DispatchCommand } from "../atoms/connection"; import { activeWorkspaceIdAtom } from "../atoms/workspaces"; import { type PaneNode, paneLayoutAtomFamily } from "../features/agent-panes/atoms/pane-layout"; @@ -91,6 +98,7 @@ interface WorkspaceActivityState { interface AppearanceSelectionVersion { theme: number; + personalization: number; } const DEFAULT_REFRESH_HINT: WorkspaceRefreshHint = { @@ -136,6 +144,53 @@ function applyResolvedTheme(themeId: unknown): string { return resolvedTheme.id; } +function resolveCurrentAppearanceViewport(): AppearanceViewport { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return "desktop"; + } + + return window.matchMedia("(max-width: 899px), (pointer: coarse)").matches ? "mobile" : "desktop"; +} + +function applyAppearancePersonalizationToDocument( + personalization: AppearancePersonalization, + themeId: string +): void { + const root = document.documentElement; + const effective = resolveAppearancePersonalizationForViewport( + personalization, + resolveCurrentAppearanceViewport() + ); + const isHighContrast = themeId === "hc-dark" || themeId === "hc-light"; + const glassEnabled = !isHighContrast && effective.glassEnabled; + const clampedBlur = isHighContrast ? 0 : Math.min(Math.max(effective.backgroundBlur, 0), 24); + const clampedOpacity = isHighContrast + ? 1 + : Math.min(Math.max(effective.surfaceOpacity, 0), 100) / 100; + const clampedGlassIntensity = glassEnabled + ? Math.min(Math.max(effective.glassIntensity, 0), 40) + : 0; + + root.style.setProperty( + "--app-bg-image", + effective.backgroundMode === "image" && effective.backgroundAssetId + ? `url(/api/appearance-assets/${effective.backgroundAssetId})` + : "none" + ); + root.style.setProperty("--app-bg-fit", effective.backgroundFit); + root.style.setProperty( + "--app-bg-dim", + String(Math.min(Math.max(effective.backgroundDimness, 0), 100) / 100) + ); + root.style.setProperty("--app-bg-blur", `${clampedBlur}px`); + root.style.setProperty("--app-surface-opacity", String(clampedOpacity)); + root.style.setProperty( + "--app-surface-backdrop-filter", + glassEnabled ? `blur(${clampedGlassIntensity}px)` : "none" + ); + root.setAttribute("data-appearance-glass", glassEnabled ? "on" : "off"); +} + export function resetAppProvidersSingletonsForTests() { if (pendingDisconnectTimer) { clearTimeout(pendingDisconnectTimer); @@ -294,6 +349,7 @@ export function AppProviders({ children }: AppProvidersProps) { }); const appearanceSelectionVersionRef = useRef({ theme: 0, + personalization: 0, }); const preferPersistedThemeOnFirstHydrationRef = useRef(false); @@ -459,6 +515,51 @@ export function AppProviders({ children }: AppProvidersProps) { localStorage.setItem(THEME_ID_STORAGE_KEY, JSON.stringify(resolvedTheme.id)); }, [theme]); + useEffect(() => { + const applyCurrentAppearance = () => { + applyAppearancePersonalizationToDocument( + store.get(appearancePersonalizationAtom), + store.get(themeAtom) + ); + }; + + applyCurrentAppearance(); + + const unsubscribeTheme = store.sub(themeAtom, applyCurrentAppearance); + const unsubscribePersonalization = store.sub( + appearancePersonalizationAtom, + applyCurrentAppearance + ); + + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return () => { + unsubscribeTheme(); + unsubscribePersonalization(); + }; + } + + const mediaQueryList = window.matchMedia("(max-width: 899px), (pointer: coarse)"); + const handleViewportChange = () => { + applyCurrentAppearance(); + }; + + if (typeof mediaQueryList.addEventListener === "function") { + mediaQueryList.addEventListener("change", handleViewportChange); + return () => { + unsubscribeTheme(); + unsubscribePersonalization(); + mediaQueryList.removeEventListener("change", handleViewportChange); + }; + } + + mediaQueryList.addListener(handleViewportChange); + return () => { + unsubscribeTheme(); + unsubscribePersonalization(); + mediaQueryList.removeListener(handleViewportChange); + }; + }, [store]); + useEffect(() => { if (connectionStatus !== "connected") { return; @@ -495,6 +596,13 @@ export function AppProviders({ children }: AppProvidersProps) { ); setTheme(resolvedThemeId); + + if ( + appearanceSelectionVersionRef.current.personalization === + appearanceSelectionVersionAtRequestStart.personalization + ) { + store.set(appearancePersonalizationAtom, resolveAppearancePersonalizationSetting(settings)); + } }; void hydrateTheme(); @@ -508,9 +616,16 @@ export function AppProviders({ children }: AppProvidersProps) { const unsubscribeTheme = store.sub(themeAtom, () => { appearanceSelectionVersionRef.current.theme += 1; }); + const unsubscribePersonalization = store.sub(appearancePersonalizationAtom, () => { + const next = store.get(appearancePersonalizationAtom); + if (next !== DEFAULT_APPEARANCE_PERSONALIZATION) { + appearanceSelectionVersionRef.current.personalization += 1; + } + }); return () => { unsubscribeTheme(); + unsubscribePersonalization(); }; }, [store]); diff --git a/packages/web/src/styles/base.css b/packages/web/src/styles/base.css index eba12761..e070ad40 100644 --- a/packages/web/src/styles/base.css +++ b/packages/web/src/styles/base.css @@ -27,6 +27,7 @@ body { font-weight: var(--type-body-3-weight); color: var(--text-primary); background-color: var(--bg-page); + background-image: none; overflow: hidden; } @@ -645,13 +646,42 @@ textarea::placeholder { /* ========== App Shell ========== */ .app { + position: relative; width: 100vw; height: 100dvh; display: flex; flex-direction: column; - background: var(--bg-page); + background: var(--surface-page-bg); color: var(--text-primary); overflow: hidden; + isolation: isolate; +} + +.app::before { + content: ""; + position: absolute; + inset: 0; + z-index: -2; + background-image: var(--app-bg-image, none); + background-position: center; + background-repeat: no-repeat; + background-size: var(--app-bg-fit, cover); + filter: blur(var(--app-bg-blur, 0px)); + transform: scale(1.04); + pointer-events: none; +} + +.app::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: color-mix( + in srgb, + var(--surface-page-bg) calc(var(--app-bg-dim, 0) * 100%), + transparent + ); + pointer-events: none; } .connection-banner { @@ -724,7 +754,12 @@ textarea::placeholder { display: grid; place-items: center; padding: var(--sp-8); - background: var(--surface-page-bg); + background: color-mix( + in srgb, + var(--surface-page-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); } .app-loading-card { @@ -732,7 +767,12 @@ textarea::placeholder { padding: var(--sp-8); border: 1px solid var(--border); border-radius: var(--radius-overlay); - background: var(--surface-overlay-bg); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); box-shadow: var(--surface-overlay-shadow); } diff --git a/packages/web/src/styles/base.theme.test.ts b/packages/web/src/styles/base.theme.test.ts index cbc84a47..46e9d7cb 100644 --- a/packages/web/src/styles/base.theme.test.ts +++ b/packages/web/src/styles/base.theme.test.ts @@ -34,8 +34,10 @@ describe("base.css theme-sensitive shells", () => { const shell = getRuleBlock(".app-loading-shell"); const card = getRuleBlock(".app-loading-card"); - expect(shell).toContain("background: var(--surface-page-bg)"); - expect(card).toContain("background: var(--surface-overlay-bg)"); + expect(shell).toContain("var(--surface-page-bg)"); + expect(shell).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(card).toContain("var(--surface-overlay-bg)"); + expect(card).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); expect(card).toContain("box-shadow: var(--surface-overlay-shadow)"); expect(card).toContain("border-radius: var(--radius-overlay)"); }); @@ -64,8 +66,8 @@ describe("base.css theme-sensitive shells", () => { expect(getRuleBlock(":focus-visible")).toContain( "outline: var(--state-focus-ring-width) solid var(--state-focus-ring-color)" ); - expect(getRuleBlock(".app-loading-shell")).toContain("background: var(--surface-page-bg)"); - expect(getRuleBlock(".app-loading-card")).toContain("background: var(--surface-overlay-bg)"); + expect(getRuleBlock(".app-loading-shell")).toContain("var(--surface-page-bg)"); + expect(getRuleBlock(".app-loading-card")).toContain("var(--surface-overlay-bg)"); expect(getRuleBlock(".app-loading-card")).toContain( "box-shadow: var(--surface-overlay-shadow)" ); @@ -73,6 +75,22 @@ describe("base.css theme-sensitive shells", () => { expect(getRuleBlock(".icon-chip")).toContain("border-radius: var(--radius-control)"); expect(getRuleBlock(".icon-surface-warning")).toContain("background: var(--state-warning-bg)"); }); + + it("defines app-shell background variables and appearance-aware loading shell hooks", () => { + const app = getRuleBlock(".app"); + const appBefore = getRuleBlock(".app::before"); + const appAfter = getRuleBlock(".app::after"); + const loadingShell = getRuleBlock(".app-loading-shell"); + + expect(app).toContain("background: var(--surface-page-bg)"); + expect(app).toContain("isolation: isolate"); + expect(appBefore).toContain("background-image: var(--app-bg-image, none)"); + expect(appBefore).toContain("background-size: var(--app-bg-fit, cover)"); + expect(appBefore).toContain("filter: blur(var(--app-bg-blur, 0px))"); + expect(appAfter).toContain("var(--app-bg-dim, 0)"); + expect(loadingShell).toContain("var(--app-surface-opacity, 0.96)"); + expect(loadingShell).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + }); }); describe("base.css viewport sizing", () => { From 0b53793edeeac50f8e2fa42727c14d720767288b Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 21 May 2026 18:21:45 +0000 Subject: [PATCH 06/36] feat: add appearance personalization settings UI --- packages/web/src/app/providers.tsx | 2 - packages/web/src/appearance/assets.test.ts | 75 ++ packages/web/src/appearance/assets.ts | 68 ++ packages/web/src/appearance/index.ts | 1 + .../web/src/appearance/personalization.ts | 62 +- .../components/settings-page.test.tsx | 208 +++++ .../settings/components/settings-page.tsx | 877 +++++++++++++++++- packages/web/src/locales/en.json | 22 + packages/web/src/locales/zh.json | 22 + 9 files changed, 1310 insertions(+), 27 deletions(-) create mode 100644 packages/web/src/appearance/assets.test.ts create mode 100644 packages/web/src/appearance/assets.ts diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index 738fb671..6d4eb734 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -53,10 +53,8 @@ import { useSessionNotifications } from "../features/notifications"; import { supervisorsAtom } from "../features/supervisor/atoms"; import { terminalMetaAtomFamily } from "../features/terminal-panel/atoms"; import { - DESKTOP_TERMINAL_FONT_SIZE_SETTING_KEY, hasExplicitTerminalFontSizeSetting, hasLegacyTerminalFontSizeSetting, - MOBILE_TERMINAL_FONT_SIZE_SETTING_KEY, resolveTerminalCopyOnSelectSetting, resolveTerminalFontSizeSetting, terminalPreferencesAtom, diff --git a/packages/web/src/appearance/assets.test.ts b/packages/web/src/appearance/assets.test.ts new file mode 100644 index 00000000..5b8baacb --- /dev/null +++ b/packages/web/src/appearance/assets.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AppearanceAssetError, deleteAppearanceAsset, uploadAppearanceAsset } from "./assets"; + +describe("appearance asset client", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("uploads an appearance asset and returns normalized metadata", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + asset: { + assetId: "asset-1", + url: "/api/appearance-assets/asset-1", + mime: "image/png", + size: 123, + }, + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const asset = await uploadAppearanceAsset( + new File(["png"], "wallpaper.png", { type: "image/png" }) + ); + + expect(asset).toEqual({ + assetId: "asset-1", + url: "/api/appearance-assets/asset-1", + mime: "image/png", + size: 123, + }); + expect(fetchMock).toHaveBeenCalledWith("/api/appearance-assets", { + method: "POST", + body: expect.any(FormData), + }); + }); + + it("throws a typed error when upload fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ + ok: false, + error: "invalid_file_type", + }), + }) + ); + + await expect( + uploadAppearanceAsset(new File(["bad"], "notes.txt", { type: "text/plain" })) + ).rejects.toEqual( + expect.objectContaining>({ + name: "AppearanceAssetError", + code: "invalid_file_type", + }) + ); + }); + + it("deletes an appearance asset by id", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + }); + vi.stubGlobal("fetch", fetchMock); + + await deleteAppearanceAsset("asset-1"); + + expect(fetchMock).toHaveBeenCalledWith("/api/appearance-assets/asset-1", { + method: "DELETE", + }); + }); +}); diff --git a/packages/web/src/appearance/assets.ts b/packages/web/src/appearance/assets.ts new file mode 100644 index 00000000..de147746 --- /dev/null +++ b/packages/web/src/appearance/assets.ts @@ -0,0 +1,68 @@ +export interface AppearanceAsset { + assetId: string; + url: string; + mime: string; + size: number; +} + +export class AppearanceAssetError extends Error { + readonly code: string; + + constructor(code: string, message?: string) { + super(message ?? code); + this.name = "AppearanceAssetError"; + this.code = code; + } +} + +function resolveErrorMessage(payload: unknown, fallback: string): string { + if ( + payload && + typeof payload === "object" && + "error" in payload && + typeof payload.error === "string" + ) { + return payload.error; + } + + return fallback; +} + +export async function uploadAppearanceAsset(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch("/api/appearance-assets", { + method: "POST", + body: formData, + }); + + const payload = (await response.json().catch(() => null)) as { + ok?: boolean; + error?: string; + asset?: AppearanceAsset; + } | null; + + if (!response.ok || !payload?.ok || !payload.asset) { + throw new AppearanceAssetError(resolveErrorMessage(payload, "appearance_asset_upload_failed")); + } + + return payload.asset; +} + +export async function deleteAppearanceAsset(assetId: string): Promise { + const response = await fetch(`/api/appearance-assets/${assetId}`, { + method: "DELETE", + }); + + if (response.ok) { + return; + } + + const payload = (await response.json().catch(() => null)) as { + ok?: boolean; + error?: string; + } | null; + + throw new AppearanceAssetError(resolveErrorMessage(payload, "appearance_asset_delete_failed")); +} diff --git a/packages/web/src/appearance/index.ts b/packages/web/src/appearance/index.ts index 4457e6b8..129ad672 100644 --- a/packages/web/src/appearance/index.ts +++ b/packages/web/src/appearance/index.ts @@ -1 +1,2 @@ +export * from "./assets"; export * from "./personalization"; diff --git a/packages/web/src/appearance/personalization.ts b/packages/web/src/appearance/personalization.ts index 873b5642..f702bc52 100644 --- a/packages/web/src/appearance/personalization.ts +++ b/packages/web/src/appearance/personalization.ts @@ -182,18 +182,25 @@ export function resolveAppearancePersonalizationSetting( settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.desktop.${field}`] ); if (desktopValue !== undefined) { - if (field === "backgroundAssetId") { - desktop.backgroundAssetId = desktopValue; - } else if (field === "backgroundDimness") { - desktop.backgroundDimness = desktopValue; - } else if (field === "backgroundBlur") { - desktop.backgroundBlur = desktopValue; - } else if (field === "glassEnabled") { - desktop.glassEnabled = desktopValue; - } else if (field === "glassIntensity") { - desktop.glassIntensity = desktopValue; - } else if (field === "surfaceOpacity") { - desktop.surfaceOpacity = desktopValue; + switch (field) { + case "backgroundAssetId": + desktop.backgroundAssetId = desktopValue as string | null; + break; + case "backgroundDimness": + desktop.backgroundDimness = desktopValue as number; + break; + case "backgroundBlur": + desktop.backgroundBlur = desktopValue as number; + break; + case "glassEnabled": + desktop.glassEnabled = desktopValue as boolean; + break; + case "glassIntensity": + desktop.glassIntensity = desktopValue as number; + break; + case "surfaceOpacity": + desktop.surfaceOpacity = desktopValue as number; + break; } } @@ -202,18 +209,25 @@ export function resolveAppearancePersonalizationSetting( settings[`${APPEARANCE_PERSONALIZATION_PREFIX}.mobile.${field}`] ); if (mobileValue !== undefined) { - if (field === "backgroundAssetId") { - mobile.backgroundAssetId = mobileValue; - } else if (field === "backgroundDimness") { - mobile.backgroundDimness = mobileValue; - } else if (field === "backgroundBlur") { - mobile.backgroundBlur = mobileValue; - } else if (field === "glassEnabled") { - mobile.glassEnabled = mobileValue; - } else if (field === "glassIntensity") { - mobile.glassIntensity = mobileValue; - } else if (field === "surfaceOpacity") { - mobile.surfaceOpacity = mobileValue; + switch (field) { + case "backgroundAssetId": + mobile.backgroundAssetId = mobileValue as string | null; + break; + case "backgroundDimness": + mobile.backgroundDimness = mobileValue as number; + break; + case "backgroundBlur": + mobile.backgroundBlur = mobileValue as number; + break; + case "glassEnabled": + mobile.glassEnabled = mobileValue as boolean; + break; + case "glassIntensity": + mobile.glassIntensity = mobileValue as number; + break; + case "surfaceOpacity": + mobile.surfaceOpacity = mobileValue as number; + break; } } } diff --git a/packages/web/src/features/settings/components/settings-page.test.tsx b/packages/web/src/features/settings/components/settings-page.test.tsx index e028c17e..35dc6731 100644 --- a/packages/web/src/features/settings/components/settings-page.test.tsx +++ b/packages/web/src/features/settings/components/settings-page.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import { createStore, Provider } from "jotai"; import { BrowserRouter, MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { appearancePersonalizationAtom } from "../../../atoms/app-ui"; import { type ConnectionStatus, connectionStatusAtom, @@ -38,10 +39,24 @@ const navigatorMocks = vi.hoisted(() => ({ displayModeStandalone: false, })); +const appearanceMocks = vi.hoisted(() => ({ + deleteAppearanceAsset: vi.fn(), + uploadAppearanceAsset: vi.fn(), +})); + vi.mock("../../../hooks/use-viewport", () => ({ useViewport: () => viewportMocks.viewport, })); +vi.mock("../../../appearance", async () => { + const actual = await vi.importActual("../../../appearance"); + return { + ...actual, + deleteAppearanceAsset: appearanceMocks.deleteAppearanceAsset, + uploadAppearanceAsset: appearanceMocks.uploadAppearanceAsset, + }; +}); + vi.mock("./config-editor", () => ({ ConfigEditor: ({ configType }: { configType: "claude" | "codex" }) => (
{configType}
@@ -138,6 +153,8 @@ describe("SettingsPage", () => { vi.clearAllMocks(); routerMocks.navigate.mockReset(); viewportMocks.viewport = "desktop"; + appearanceMocks.deleteAppearanceAsset.mockReset(); + appearanceMocks.uploadAppearanceAsset.mockReset(); window.localStorage.clear(); document.documentElement.removeAttribute("data-theme"); notificationMocks.permission = "default"; @@ -1214,6 +1231,197 @@ describe("SettingsPage", () => { expect(chineseLanguagePill).toHaveAttribute("aria-pressed", "true"); }); + it("hydrates appearance personalization controls from settings.get and syncs the global atom", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + "appearance.personalization.common.backgroundFit": "contain", + "appearance.personalization.common.backgroundDimness": 33, + "appearance.personalization.common.backgroundBlur": 8, + "appearance.personalization.common.glassEnabled": true, + "appearance.personalization.common.glassIntensity": 44, + "appearance.personalization.common.surfaceOpacity": 91, + "appearance.personalization.desktop.surfaceOpacity": 72, + "appearance.personalization.mobile.surfaceOpacity": 64, + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "外观" })); + + await waitFor(() => { + expect(store.get(appearancePersonalizationAtom)).toEqual({ + version: 1, + common: { + backgroundMode: "image", + backgroundAssetId: "asset-common", + backgroundFit: "contain", + backgroundDimness: 33, + backgroundBlur: 8, + glassEnabled: true, + glassIntensity: 44, + surfaceOpacity: 91, + }, + desktop: { + surfaceOpacity: 72, + }, + mobile: { + surfaceOpacity: 64, + }, + }); + }); + + expect(await screen.findByRole("spinbutton", { name: "背景压暗" })).toHaveValue(33); + expect(screen.getByRole("spinbutton", { name: "背景模糊" })).toHaveValue(8); + expect(screen.getByRole("spinbutton", { name: "毛玻璃强度" })).toHaveValue(44); + expect(document.getElementById("appearance-surface-opacity")).toHaveValue(91); + expect(document.getElementById("appearance-desktop-surface-opacity")).toHaveValue(72); + expect(document.getElementById("appearance-mobile-surface-opacity")).toHaveValue(64); + }); + + it("enables desktop and mobile appearance override groups through shared toggles", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "外观" })); + + fireEvent.click(await screen.findByRole("switch", { name: "桌面端覆盖" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + appearance: { + personalization: { + version: 1, + common: { + backgroundMode: "image", + backgroundAssetId: "asset-common", + backgroundFit: "cover", + backgroundDimness: 24, + backgroundBlur: 0, + glassEnabled: false, + glassIntensity: 24, + surfaceOpacity: 96, + }, + desktop: { + surfaceOpacity: 96, + }, + mobile: {}, + }, + }, + }, + }, + undefined + ); + }); + + expect(document.getElementById("appearance-desktop-surface-opacity")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("switch", { name: "移动端覆盖" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + appearance: { + personalization: { + version: 1, + common: { + backgroundMode: "image", + backgroundAssetId: "asset-common", + backgroundFit: "cover", + backgroundDimness: 24, + backgroundBlur: 0, + glassEnabled: false, + glassIntensity: 24, + surfaceOpacity: 96, + }, + desktop: { + surfaceOpacity: 96, + }, + mobile: { + surfaceOpacity: 96, + }, + }, + }, + }, + }, + undefined + ); + }); + + expect(document.getElementById("appearance-mobile-surface-opacity")).toBeInTheDocument(); + }); + + it("deletes the shared appearance background asset and persists a null background asset id", async () => { + appearanceMocks.deleteAppearanceAsset.mockResolvedValue(undefined); + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "外观" })); + fireEvent.click(await screen.findByRole("button", { name: "移除背景图" })); + + await waitFor(() => { + expect(appearanceMocks.deleteAppearanceAsset).toHaveBeenCalledWith("asset-common"); + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "settings.update", + { + settings: { + appearance: { + personalization: { + version: 1, + common: { + backgroundMode: "image", + backgroundAssetId: null, + backgroundFit: "cover", + backgroundDimness: 24, + backgroundBlur: 0, + glassEnabled: false, + glassIntensity: 24, + surfaceOpacity: 96, + }, + desktop: {}, + mobile: {}, + }, + }, + }, + }, + undefined + ); + }); + + expect(store.get(appearancePersonalizationAtom).common.backgroundAssetId).toBeNull(); + }); + it("renders terminal option groups through shared pills in general settings", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "settings.get") { diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx index 79b8c2af..f574f4ed 100644 --- a/packages/web/src/features/settings/components/settings-page.tsx +++ b/packages/web/src/features/settings/components/settings-page.tsx @@ -26,7 +26,16 @@ import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import { Check, ChevronRight } from "lucide-react"; import { useEffect, useId, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { localeAtom, themeAtom } from "../../../atoms/app-ui"; +import { + type AppearanceBackgroundFit, + type AppearanceBackgroundMode, + type AppearancePersonalization, + type AppearancePersonalizationOverrides, + deleteAppearanceAsset, + resolveAppearancePersonalizationSetting, + uploadAppearanceAsset, +} from "../../../appearance"; +import { appearancePersonalizationAtom, localeAtom, themeAtom } from "../../../atoms/app-ui"; import { connectionStatusAtom, dispatchCommandAtom, @@ -74,9 +83,21 @@ type SettingsNavigationState = }; type SettingsContentLayoutMode = "default" | "fill-height"; +type AppearanceAssetScope = "common" | "desktop" | "mobile"; +type AppearanceOverrideTarget = Exclude; const DEFAULT_SETTINGS_SECTION: SettingsSection = SETTINGS_SECTIONS[0].id; const TERMINAL_FONT_SIZE_SAVE_THROTTLE_MS = 500; +const PERSONALIZATION_OVERRIDE_FIELDS = [ + "backgroundAssetId", + "backgroundDimness", + "backgroundBlur", + "glassEnabled", + "glassIntensity", + "surfaceOpacity", +] as const; + +type PersonalizationOverrideField = (typeof PERSONALIZATION_OVERRIDE_FIELDS)[number]; function isStandaloneWebApp(): boolean { if (typeof window === "undefined") { @@ -252,6 +273,7 @@ export function SettingsPage() { const [settingsLoadError, setSettingsLoadError] = useState(null); const [settingsRefreshKey, setSettingsRefreshKey] = useState(0); const [locale, setLocaleState] = useAtom(localeAtom); + const [personalization, setPersonalization] = useAtom(appearancePersonalizationAtom); const [theme, setTheme] = useAtom(themeAtom); const terminalPreferences = useAtomValue(terminalPreferencesAtom); const setNotificationPreferences = useSetAtom(notificationPreferencesAtom); @@ -261,6 +283,7 @@ export function SettingsPage() { const settingsLoadFailedUnknownRef = useRef(settingsLoadFailedUnknown); const appearanceSelectionVersionRef = useRef({ theme: 0, + personalization: 0, locale: 0, lspRuntimeMode: 0, terminalRenderer: 0, @@ -423,6 +446,12 @@ export function SettingsPage() { setLocaleState(settings["appearance.locale"]); } } + if ( + appearanceSelectionVersionRef.current.personalization === + appearanceSelectionVersionAtRequestStart.personalization + ) { + setPersonalization(resolveAppearancePersonalizationSetting(settings)); + } const hasServerThemeSetting = Object.prototype.hasOwnProperty.call(settings, "appearance.themeId") || Object.prototype.hasOwnProperty.call(settings, "appearance.theme"); @@ -452,6 +481,7 @@ export function SettingsPage() { dispatch, setLocaleState, setNotificationPreferences, + setPersonalization, setHydratedLspRuntimeMode, setTerminalPreferences, setTheme, @@ -469,6 +499,28 @@ export function SettingsPage() { setTheme(value); }; + const saveAppearancePersonalization = async (next: AppearancePersonalization) => { + const previous = personalization; + appearanceSelectionVersionRef.current.personalization += 1; + setPersonalization(next); + + const result = await dispatch("settings.update", { + settings: { + appearance: { + personalization: next, + }, + }, + }); + + if (!result.ok) { + setPersonalization(previous); + setSettingsLoadError(result.error?.message ?? settingsLoadFailedUnknownRef.current); + return false; + } + + return true; + }; + const handleTerminalRendererSelection = (value: "standard" | "compatibility") => { appearanceSelectionVersionRef.current.terminalRenderer += 1; setTerminalRendererState(value); @@ -584,9 +636,11 @@ export function SettingsPage() { desktopTerminalFontSize={getTerminalFontSizePreference(terminalPreferences, "desktop")} mobileTerminalFontSize={getTerminalFontSizePreference(terminalPreferences, "mobile")} locale={locale} + personalization={personalization} setDesktopTerminalFontSize={handleDesktopTerminalFontSizeSelection} setLocale={handleLocaleSelection} setMobileTerminalFontSize={handleMobileTerminalFontSizeSelection} + savePersonalization={saveAppearancePersonalization} theme={theme} setTheme={handleThemeSelection} /> @@ -841,6 +895,37 @@ function parseTerminalFontSizeInput(value: string): number | null { return parsed; } +function parseBoundedInteger(value: string, min: number, max: number): number | null { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) { + return null; + } + + const parsed = Number(trimmed); + if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) { + return null; + } + + return parsed; +} + +function clearPersonalizationOverrides( + overrides: AppearancePersonalizationOverrides +): AppearancePersonalizationOverrides { + const next: AppearancePersonalizationOverrides = {}; + + for (const field of PERSONALIZATION_OVERRIDE_FIELDS) { + if (Object.prototype.hasOwnProperty.call(overrides, field)) { + const value = overrides[field]; + if (value !== undefined) { + next[field] = value; + } + } + } + + return next; +} + function GeneralSettings({ notificationsEnabled, setNotificationsEnabled, @@ -1479,9 +1564,11 @@ interface AppearanceSettingsProps { desktopTerminalFontSize: number; locale: string; mobileTerminalFontSize: number; + personalization: AppearancePersonalization; setDesktopTerminalFontSize: (value: number) => void; setLocale: (value: "zh" | "en") => void; setMobileTerminalFontSize: (value: number) => void; + savePersonalization: (value: AppearancePersonalization) => Promise; theme: string; setTheme: (value: string) => void; } @@ -1490,9 +1577,11 @@ function AppearanceSettings({ desktopTerminalFontSize, locale, mobileTerminalFontSize, + personalization, setDesktopTerminalFontSize, setLocale, setMobileTerminalFontSize, + savePersonalization, theme, setTheme, }: AppearanceSettingsProps) { @@ -1506,6 +1595,17 @@ function AppearanceSettings({ const desktopTerminalFontSizeDescId = useId(); const mobileTerminalFontSizeLabelId = useId(); const mobileTerminalFontSizeDescId = useId(); + const backgroundFileInputId = useId(); + const commonGlassLabelId = useId(); + const commonGlassDescId = useId(); + const desktopOverrideLabelId = useId(); + const desktopOverrideDescId = useId(); + const desktopGlassLabelId = useId(); + const desktopGlassDescId = useId(); + const mobileOverrideLabelId = useId(); + const mobileOverrideDescId = useId(); + const mobileGlassLabelId = useId(); + const mobileGlassDescId = useId(); const dispatch = useAtomValue(dispatchCommandAtom); const currentThemeId = resolveStoredThemeId(theme); const themeOptions = THEMES.map((registeredTheme) => ({ @@ -1524,6 +1624,36 @@ function AppearanceSettings({ const [mobileTerminalFontSizeError, setMobileTerminalFontSizeError] = useState( null ); + const [assetActionError, setAssetActionError] = useState(null); + const [backgroundDimnessDraft, setBackgroundDimnessDraft] = useState( + String(personalization.common.backgroundDimness) + ); + const [backgroundBlurDraft, setBackgroundBlurDraft] = useState( + String(personalization.common.backgroundBlur) + ); + const [glassIntensityDraft, setGlassIntensityDraft] = useState( + String(personalization.common.glassIntensity) + ); + const [surfaceOpacityDraft, setSurfaceOpacityDraft] = useState( + String(personalization.common.surfaceOpacity) + ); + const [backgroundDimnessError, setBackgroundDimnessError] = useState(null); + const [backgroundBlurError, setBackgroundBlurError] = useState(null); + const [glassIntensityError, setGlassIntensityError] = useState(null); + const [surfaceOpacityError, setSurfaceOpacityError] = useState(null); + const [desktopSurfaceOpacityDraft, setDesktopSurfaceOpacityDraft] = useState( + personalization.desktop.surfaceOpacity === undefined + ? String(personalization.common.surfaceOpacity) + : String(personalization.desktop.surfaceOpacity) + ); + const [mobileSurfaceOpacityDraft, setMobileSurfaceOpacityDraft] = useState( + personalization.mobile.surfaceOpacity === undefined + ? String(personalization.common.surfaceOpacity) + : String(personalization.mobile.surfaceOpacity) + ); + const [desktopSurfaceOpacityError, setDesktopSurfaceOpacityError] = useState(null); + const [mobileSurfaceOpacityError, setMobileSurfaceOpacityError] = useState(null); + const fileInputRef = useRef(null); const lastTerminalFontSizeCommitAtRef = useRef< Record<"desktopTerminalFontSize" | "mobileTerminalFontSize", number> >({ @@ -1551,6 +1681,19 @@ function AppearanceSettings({ setMobileTerminalFontSizeError(null); }, [mobileTerminalFontSize]); + useEffect(() => { + setBackgroundDimnessDraft(String(personalization.common.backgroundDimness)); + setBackgroundBlurDraft(String(personalization.common.backgroundBlur)); + setGlassIntensityDraft(String(personalization.common.glassIntensity)); + setSurfaceOpacityDraft(String(personalization.common.surfaceOpacity)); + setDesktopSurfaceOpacityDraft( + String(personalization.desktop.surfaceOpacity ?? personalization.common.surfaceOpacity) + ); + setMobileSurfaceOpacityDraft( + String(personalization.mobile.surfaceOpacity ?? personalization.common.surfaceOpacity) + ); + }, [personalization]); + const handleThemeChange = (nextThemeId: string) => { const resolvedTheme = getThemeById(nextThemeId); if (resolvedTheme.id === currentThemeId) { @@ -1562,6 +1705,246 @@ function AppearanceSettings({ void saveSettings({ appearance: { themeId: resolvedTheme.id } }); }; + const updateCommon = ( + key: K, + value: AppearancePersonalization["common"][K] + ) => { + return { + ...personalization, + common: { + ...personalization.common, + [key]: value, + }, + }; + }; + + const buildCommonForBackgroundMode = (mode: AppearanceBackgroundMode) => { + if (mode === "image") { + return personalization.common; + } + + return { + ...personalization.common, + backgroundAssetId: null, + }; + }; + + const updateOverride = ( + target: AppearanceOverrideTarget, + key: PersonalizationOverrideField, + value: string | number | boolean | null | undefined + ) => { + const nextOverrides = { + ...personalization[target], + [key]: value, + }; + + if (value === undefined) { + delete nextOverrides[key]; + } + + return { + ...personalization, + [target]: clearPersonalizationOverrides(nextOverrides), + }; + }; + + const isOverrideEnabled = (target: AppearanceOverrideTarget) => + Object.keys(personalization[target]).length > 0; + + const toggleOverride = (target: AppearanceOverrideTarget, enabled: boolean) => { + if (enabled) { + return { + ...personalization, + [target]: clearPersonalizationOverrides({ + ...personalization[target], + surfaceOpacity: + personalization[target].surfaceOpacity ?? personalization.common.surfaceOpacity, + }), + }; + } + + return { + ...personalization, + [target]: {}, + }; + }; + + const saveNextPersonalization = async (next: AppearancePersonalization) => { + setAssetActionError(null); + const saved = await savePersonalization(next); + return saved; + }; + + const commitBoundedCommonField = async ( + draft: string, + currentValue: number, + min: number, + max: number, + setDraft: (value: string) => void, + setError: (value: string | null) => void, + key: "backgroundDimness" | "backgroundBlur" | "glassIntensity" | "surfaceOpacity" + ) => { + const parsed = parseBoundedInteger(draft, min, max); + if (parsed === null) { + setDraft(String(currentValue)); + setError( + t("settings.terminal_font_size_validation_error", { + min, + max, + }) + ); + return; + } + + if (parsed === currentValue) { + setDraft(String(parsed)); + setError(null); + return; + } + + const saved = await saveNextPersonalization(updateCommon(key, parsed)); + if (!saved) { + setDraft(String(currentValue)); + setError(t("settings.config_files.save_failed")); + return; + } + + setDraft(String(parsed)); + setError(null); + }; + + const commitBoundedOverrideField = async ( + target: AppearanceOverrideTarget, + draft: string, + fallbackValue: number, + min: number, + max: number, + setDraft: (value: string) => void, + setError: (value: string | null) => void, + key: "backgroundDimness" | "backgroundBlur" | "glassIntensity" | "surfaceOpacity" + ) => { + const parsed = parseBoundedInteger(draft, min, max); + if (parsed === null) { + setDraft(String(fallbackValue)); + setError( + t("settings.terminal_font_size_validation_error", { + min, + max, + }) + ); + return; + } + + const saved = await saveNextPersonalization(updateOverride(target, key, parsed)); + if (!saved) { + setDraft(String(fallbackValue)); + setError(t("settings.config_files.save_failed")); + return; + } + + setDraft(String(parsed)); + setError(null); + }; + + const handleBackgroundFileSelection = async ( + event: React.ChangeEvent, + target: AppearanceAssetScope + ) => { + const [file] = Array.from(event.target.files ?? []); + event.target.value = ""; + if (!file) { + return; + } + + try { + const uploaded = await uploadAppearanceAsset(file); + if (target === "common") { + const saved = await saveNextPersonalization( + updateCommon("backgroundAssetId", uploaded.assetId) + ); + if (!saved) { + setAssetActionError(t("settings.config_files.save_failed")); + } + } else { + const saved = await saveNextPersonalization( + updateOverride(target, "backgroundAssetId", uploaded.assetId) + ); + if (!saved) { + setAssetActionError(t("settings.config_files.save_failed")); + } + } + } catch { + setAssetActionError(t("settings.appearance_asset_upload_failed")); + } + }; + + const openFilePicker = (target: AppearanceAssetScope) => { + if (fileInputRef.current) { + fileInputRef.current.dataset.scope = target; + fileInputRef.current.click(); + } + }; + + const removeBackgroundAsset = async (target: AppearanceAssetScope) => { + const currentAssetId = + target === "common" + ? personalization.common.backgroundAssetId + : personalization[target].backgroundAssetId; + + if (!currentAssetId) { + return; + } + + try { + await deleteAppearanceAsset(currentAssetId); + if (target === "common") { + const saved = await saveNextPersonalization(updateCommon("backgroundAssetId", null)); + if (!saved) { + setAssetActionError(t("settings.config_files.save_failed")); + } + } else { + const saved = await saveNextPersonalization( + updateOverride(target, "backgroundAssetId", null) + ); + if (!saved) { + setAssetActionError(t("settings.config_files.save_failed")); + } + } + } catch { + setAssetActionError(t("settings.appearance_asset_delete_failed")); + } + }; + + const renderAssetButtons = (target: AppearanceAssetScope, hasAsset: boolean) => ( +
+ + {hasAsset ? ( + + ) : null} +
+ ); + const commitTerminalFontSize = async ( draft: string, currentValue: number, @@ -1618,6 +2001,498 @@ function AppearanceSettings({ return (
+ { + const scope = + (event.currentTarget.dataset.scope as AppearanceAssetScope | undefined) ?? "common"; + void handleBackgroundFileSelection(event, scope); + }} + /> + +
+

{t("settings.appearance_background_material")}

+

{t("settings.appearance_background_material_hint")}

+ +
+ +
+ { + void saveNextPersonalization( + updateCommon("backgroundFit", value as AppearanceBackgroundFit) + ); + }} + /> +
+
+ +
+
+ + {t("settings.appearance_glass_enabled")} + + + {t("settings.appearance_uses_shared_value")} + +
+ { + void saveNextPersonalization(updateCommon("glassEnabled", nextValue)); + }} + /> +
+ +
+ +
+ { + void commitBoundedCommonField( + backgroundDimnessDraft, + personalization.common.backgroundDimness, + 0, + 100, + setBackgroundDimnessDraft, + setBackgroundDimnessError, + "backgroundDimness" + ); + }} + onChange={(event) => { + setBackgroundDimnessDraft(event.target.value); + setBackgroundDimnessError(null); + }} + /> +
+ {backgroundDimnessError ? ( + + {backgroundDimnessError} + + ) : null} +
+ +
+ +
+ { + void commitBoundedCommonField( + backgroundBlurDraft, + personalization.common.backgroundBlur, + 0, + 40, + setBackgroundBlurDraft, + setBackgroundBlurError, + "backgroundBlur" + ); + }} + onChange={(event) => { + setBackgroundBlurDraft(event.target.value); + setBackgroundBlurError(null); + }} + /> +
+ {backgroundBlurError ? ( + + {backgroundBlurError} + + ) : null} +
+ +
+ +
+ { + void commitBoundedCommonField( + glassIntensityDraft, + personalization.common.glassIntensity, + 0, + 100, + setGlassIntensityDraft, + setGlassIntensityError, + "glassIntensity" + ); + }} + onChange={(event) => { + setGlassIntensityDraft(event.target.value); + setGlassIntensityError(null); + }} + /> +
+ {glassIntensityError ? ( + + {glassIntensityError} + + ) : null} +
+ +
+ +
+ { + void commitBoundedCommonField( + surfaceOpacityDraft, + personalization.common.surfaceOpacity, + 0, + 100, + setSurfaceOpacityDraft, + setSurfaceOpacityError, + "surfaceOpacity" + ); + }} + onChange={(event) => { + setSurfaceOpacityDraft(event.target.value); + setSurfaceOpacityError(null); + }} + /> +
+ {surfaceOpacityError ? ( + + {surfaceOpacityError} + + ) : null} +
+ +
+
+ + {t("settings.appearance_override_desktop")} + + + {isOverrideEnabled("desktop") + ? t("settings.appearance_override_enabled") + : t("settings.appearance_uses_shared_value")} + +
+ { + void saveNextPersonalization(toggleOverride("desktop", nextValue)); + }} + /> +
+ + {isOverrideEnabled("desktop") ? ( +
+ {personalization.common.backgroundMode === "image" ? ( +
+
+ + {t("settings.appearance_override_desktop")} + + + {personalization.desktop.backgroundAssetId ?? + t("settings.appearance_uses_shared_value")} + +
+ {renderAssetButtons( + "desktop", + Object.prototype.hasOwnProperty.call(personalization.desktop, "backgroundAssetId") + )} +
+ ) : null} +
+
+ + {t("settings.appearance_glass_enabled")} + + + {t("settings.appearance_override_desktop")} + +
+ { + void saveNextPersonalization( + updateOverride("desktop", "glassEnabled", nextValue) + ); + }} + /> +
+
+ +
+ { + void commitBoundedOverrideField( + "desktop", + desktopSurfaceOpacityDraft, + personalization.desktop.surfaceOpacity ?? + personalization.common.surfaceOpacity, + 0, + 100, + setDesktopSurfaceOpacityDraft, + setDesktopSurfaceOpacityError, + "surfaceOpacity" + ); + }} + onChange={(event) => { + setDesktopSurfaceOpacityDraft(event.target.value); + setDesktopSurfaceOpacityError(null); + }} + /> +
+ {desktopSurfaceOpacityError ? ( + + {desktopSurfaceOpacityError} + + ) : null} +
+
+ ) : null} + +
+
+ + {t("settings.appearance_override_mobile")} + + + {isOverrideEnabled("mobile") + ? t("settings.appearance_override_enabled") + : t("settings.appearance_uses_shared_value")} + +
+ { + void saveNextPersonalization(toggleOverride("mobile", nextValue)); + }} + /> +
+ + {isOverrideEnabled("mobile") ? ( +
+ {personalization.common.backgroundMode === "image" ? ( +
+
+ + {t("settings.appearance_override_mobile")} + + + {personalization.mobile.backgroundAssetId ?? + t("settings.appearance_uses_shared_value")} + +
+ {renderAssetButtons( + "mobile", + Object.prototype.hasOwnProperty.call(personalization.mobile, "backgroundAssetId") + )} +
+ ) : null} +
+
+ + {t("settings.appearance_glass_enabled")} + + + {t("settings.appearance_override_mobile")} + +
+ { + void saveNextPersonalization(updateOverride("mobile", "glassEnabled", nextValue)); + }} + /> +
+
+ +
+ { + void commitBoundedOverrideField( + "mobile", + mobileSurfaceOpacityDraft, + personalization.mobile.surfaceOpacity ?? + personalization.common.surfaceOpacity, + 0, + 100, + setMobileSurfaceOpacityDraft, + setMobileSurfaceOpacityError, + "surfaceOpacity" + ); + }} + onChange={(event) => { + setMobileSurfaceOpacityDraft(event.target.value); + setMobileSurfaceOpacityError(null); + }} + /> +
+ {mobileSurfaceOpacityError ? ( + + {mobileSurfaceOpacityError} + + ) : null} +
+
+ ) : null} + + {assetActionError ? ( + + {assetActionError} + + ) : null} +
+

{t("settings.terminal_appearance")}

{t("settings.terminal_font_size_hint")}

diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 14e3c673..6d4f3435 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -552,6 +552,28 @@ "lsp_runtime_mode_hint": "Control code intelligence memory usage. Turning it off immediately stops active language service processes, so diagnostics, go-to-definition, hover, and similar features become temporarily unavailable.", "lsp_runtime_mode_auto": "Auto", "lsp_runtime_mode_off": "Off", + "appearance_background_material": "Background & Material", + "appearance_background_material_hint": "Configure account-synced wallpapers, glass effects, and surface opacity, with optional desktop and mobile overrides.", + "appearance_background_mode": "Background mode", + "appearance_background_mode_off": "Off", + "appearance_background_mode_image": "Image", + "appearance_background_upload": "Background image", + "appearance_background_replace": "Replace background image", + "appearance_background_remove": "Remove background image", + "appearance_background_fit": "Background fit", + "appearance_background_fit_cover": "Cover", + "appearance_background_fit_contain": "Contain", + "appearance_background_dimness": "Background dimness", + "appearance_background_blur": "Background blur", + "appearance_glass_enabled": "Enable glass effect", + "appearance_glass_intensity": "Glass intensity", + "appearance_surface_opacity": "Surface opacity", + "appearance_override_desktop": "Desktop override", + "appearance_override_mobile": "Mobile override", + "appearance_override_enabled": "Custom value enabled", + "appearance_uses_shared_value": "Using the shared value", + "appearance_asset_upload_failed": "Failed to upload background image", + "appearance_asset_delete_failed": "Failed to remove background image", "terminal_appearance": "Terminal Appearance", "copy_on_select": "Copy on select", "copy_on_select_hint": "Automatically copy selected text to the system clipboard", diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index 7a95cbda..f7c6fb77 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -552,6 +552,28 @@ "lsp_runtime_mode_hint": "控制代码智能的内存占用。关闭后会立即停止当前语言服务进程,诊断、跳转、hover 等能力将暂时不可用。", "lsp_runtime_mode_auto": "Auto", "lsp_runtime_mode_off": "Off", + "appearance_background_material": "背景与材质", + "appearance_background_material_hint": "为应用设置账号同步的背景图、毛玻璃和面板透明度,可按桌面端和移动端分别覆盖。", + "appearance_background_mode": "背景模式", + "appearance_background_mode_off": "关闭", + "appearance_background_mode_image": "背景图", + "appearance_background_upload": "背景图", + "appearance_background_replace": "更换背景图", + "appearance_background_remove": "移除背景图", + "appearance_background_fit": "背景适配", + "appearance_background_fit_cover": "铺满裁切", + "appearance_background_fit_contain": "完整显示", + "appearance_background_dimness": "背景压暗", + "appearance_background_blur": "背景模糊", + "appearance_glass_enabled": "启用毛玻璃", + "appearance_glass_intensity": "毛玻璃强度", + "appearance_surface_opacity": "面板不透明度", + "appearance_override_desktop": "桌面端覆盖", + "appearance_override_mobile": "移动端覆盖", + "appearance_override_enabled": "已启用独立设置", + "appearance_uses_shared_value": "当前使用共享设置", + "appearance_asset_upload_failed": "背景图上传失败", + "appearance_asset_delete_failed": "背景图移除失败", "terminal_appearance": "终端外观", "copy_on_select": "选中自动复制", "copy_on_select_hint": "选中文本后自动复制到系统剪贴板", From 03babcb2ce266300d4ce3c17fab9bc1a1d1c2de0 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 21 May 2026 18:39:21 +0000 Subject: [PATCH 07/36] feat: apply appearance personalization to shared surfaces --- packages/web/src/app/providers.tsx | 58 +---------- packages/web/src/appearance/document.ts | 56 +++++++++++ packages/web/src/appearance/index.ts | 1 + .../ui/workbench-layer/index.module.css | 4 +- packages/web/src/styles/components.css | 95 ++++++++++++++----- .../web/src/styles/components.theme.test.ts | 78 +++++++++++++-- packages/web/src/ui-preview/preview-store.ts | 25 ++++- .../web/src/ui-preview/scene-metadata.test.ts | 68 ++++++++++++- packages/web/src/ui-preview/scene-metadata.ts | 9 +- .../web/src/ui-preview/scenes/page-scenes.tsx | 26 +++++ 10 files changed, 321 insertions(+), 99 deletions(-) create mode 100644 packages/web/src/appearance/document.ts diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index 6d4eb734..fbba202b 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -17,10 +17,9 @@ import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; import type { Store } from "jotai/vanilla/store"; import { useEffect, useRef } from "react"; import { - type AppearancePersonalization, - type AppearanceViewport, + applyAppearancePersonalizationToDocument, + applyResolvedTheme, DEFAULT_APPEARANCE_PERSONALIZATION, - resolveAppearancePersonalizationForViewport, resolveAppearancePersonalizationSetting, } from "../appearance"; import { @@ -136,59 +135,6 @@ function readStoredThemePreference(): unknown { return undefined; } -function applyResolvedTheme(themeId: unknown): string { - const resolvedTheme = getThemeById(resolveStoredThemeId(themeId)); - document.documentElement.setAttribute("data-theme", resolvedTheme.documentThemeAttr); - return resolvedTheme.id; -} - -function resolveCurrentAppearanceViewport(): AppearanceViewport { - if (typeof window === "undefined" || typeof window.matchMedia !== "function") { - return "desktop"; - } - - return window.matchMedia("(max-width: 899px), (pointer: coarse)").matches ? "mobile" : "desktop"; -} - -function applyAppearancePersonalizationToDocument( - personalization: AppearancePersonalization, - themeId: string -): void { - const root = document.documentElement; - const effective = resolveAppearancePersonalizationForViewport( - personalization, - resolveCurrentAppearanceViewport() - ); - const isHighContrast = themeId === "hc-dark" || themeId === "hc-light"; - const glassEnabled = !isHighContrast && effective.glassEnabled; - const clampedBlur = isHighContrast ? 0 : Math.min(Math.max(effective.backgroundBlur, 0), 24); - const clampedOpacity = isHighContrast - ? 1 - : Math.min(Math.max(effective.surfaceOpacity, 0), 100) / 100; - const clampedGlassIntensity = glassEnabled - ? Math.min(Math.max(effective.glassIntensity, 0), 40) - : 0; - - root.style.setProperty( - "--app-bg-image", - effective.backgroundMode === "image" && effective.backgroundAssetId - ? `url(/api/appearance-assets/${effective.backgroundAssetId})` - : "none" - ); - root.style.setProperty("--app-bg-fit", effective.backgroundFit); - root.style.setProperty( - "--app-bg-dim", - String(Math.min(Math.max(effective.backgroundDimness, 0), 100) / 100) - ); - root.style.setProperty("--app-bg-blur", `${clampedBlur}px`); - root.style.setProperty("--app-surface-opacity", String(clampedOpacity)); - root.style.setProperty( - "--app-surface-backdrop-filter", - glassEnabled ? `blur(${clampedGlassIntensity}px)` : "none" - ); - root.setAttribute("data-appearance-glass", glassEnabled ? "on" : "off"); -} - export function resetAppProvidersSingletonsForTests() { if (pendingDisconnectTimer) { clearTimeout(pendingDisconnectTimer); diff --git a/packages/web/src/appearance/document.ts b/packages/web/src/appearance/document.ts new file mode 100644 index 00000000..d3f37cdc --- /dev/null +++ b/packages/web/src/appearance/document.ts @@ -0,0 +1,56 @@ +import { getThemeById, resolveStoredThemeId } from "../theme"; +import type { AppearancePersonalization } from "./personalization"; +import { resolveAppearancePersonalizationForViewport } from "./personalization"; + +export function resolveCurrentAppearanceViewport(): "desktop" | "mobile" { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return "desktop"; + } + + return window.matchMedia("(max-width: 899px), (pointer: coarse)").matches ? "mobile" : "desktop"; +} + +export function applyResolvedTheme(themeId: unknown): string { + const resolvedTheme = getThemeById(resolveStoredThemeId(themeId)); + document.documentElement.setAttribute("data-theme", resolvedTheme.documentThemeAttr); + return resolvedTheme.id; +} + +export function applyAppearancePersonalizationToDocument( + personalization: AppearancePersonalization, + themeId: string +): void { + const root = document.documentElement; + const effective = resolveAppearancePersonalizationForViewport( + personalization, + resolveCurrentAppearanceViewport() + ); + const isHighContrast = themeId === "hc-dark" || themeId === "hc-light"; + const glassEnabled = !isHighContrast && effective.glassEnabled; + const clampedBlur = isHighContrast ? 0 : Math.min(Math.max(effective.backgroundBlur, 0), 24); + const clampedOpacity = isHighContrast + ? 1 + : Math.min(Math.max(effective.surfaceOpacity, 0), 100) / 100; + const clampedGlassIntensity = glassEnabled + ? Math.min(Math.max(effective.glassIntensity, 0), 40) + : 0; + + root.style.setProperty( + "--app-bg-image", + effective.backgroundMode === "image" && effective.backgroundAssetId + ? `url(/api/appearance-assets/${effective.backgroundAssetId})` + : "none" + ); + root.style.setProperty("--app-bg-fit", effective.backgroundFit); + root.style.setProperty( + "--app-bg-dim", + String(Math.min(Math.max(effective.backgroundDimness, 0), 100) / 100) + ); + root.style.setProperty("--app-bg-blur", `${clampedBlur}px`); + root.style.setProperty("--app-surface-opacity", String(clampedOpacity)); + root.style.setProperty( + "--app-surface-backdrop-filter", + glassEnabled ? `blur(${clampedGlassIntensity}px)` : "none" + ); + root.setAttribute("data-appearance-glass", glassEnabled ? "on" : "off"); +} diff --git a/packages/web/src/appearance/index.ts b/packages/web/src/appearance/index.ts index 129ad672..2d1e8cef 100644 --- a/packages/web/src/appearance/index.ts +++ b/packages/web/src/appearance/index.ts @@ -1,2 +1,3 @@ export * from "./assets"; +export * from "./document"; export * from "./personalization"; diff --git a/packages/web/src/components/ui/workbench-layer/index.module.css b/packages/web/src/components/ui/workbench-layer/index.module.css index 2e2b37be..bd11f232 100644 --- a/packages/web/src/components/ui/workbench-layer/index.module.css +++ b/packages/web/src/components/ui/workbench-layer/index.module.css @@ -7,8 +7,8 @@ align-items: flex-start; justify-content: center; padding: var(--sp-16) var(--sp-4) var(--sp-4); - background: color-mix(in srgb, black 58%, transparent); - backdrop-filter: blur(8px); + background: var(--overlay-backdrop); + backdrop-filter: var(--app-surface-backdrop-filter, none); } .surface, diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index 3198557e..cc8b95d1 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -830,7 +830,11 @@ justify-content: center; padding: var(--sp-6); overflow-y: auto; - background: var(--bg-page); + background: color-mix( + in srgb, + var(--surface-page-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); } .settings-content--fill-height { @@ -852,10 +856,15 @@ min-width: 0; min-height: 100%; padding: var(--sp-6); - background: var(--bg-elevated); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); border-radius: var(--radius-xl); box-shadow: var(--shadow-lg); + backdrop-filter: var(--app-surface-backdrop-filter, none); } .settings-section { @@ -1781,7 +1790,11 @@ body.focus-mode-active .split-divider-h { width: 100%; height: 100%; overflow: hidden; - background: var(--bg); + background: color-mix( + in srgb, + var(--surface-page-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); } .workspace-page-focus { @@ -3044,11 +3057,16 @@ body.is-resizing-panels * { display: flex; align-items: center; padding: 0 var(--sp-2); - background: color-mix(in srgb, var(--bg-surface) 95%, var(--blue) 5%); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); border-bottom: 1px solid var(--border); gap: var(--gap-compact); user-select: none; flex-shrink: 0; + backdrop-filter: var(--app-surface-backdrop-filter, none); } .topbar-tabs { @@ -5811,7 +5829,11 @@ textarea.input { .workspace-page { background: radial-gradient(circle at top center, rgba(108, 182, 255, 0.06), transparent 32%), - var(--bg-page); + color-mix( + in srgb, + var(--surface-page-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); } .workspace-body { @@ -7930,10 +7952,15 @@ textarea.input { } .session-card { - background: var(--bg-page); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); border: none; border-radius: 0; box-shadow: none; + backdrop-filter: var(--app-surface-backdrop-filter, none); } .session-card.session-card--active { @@ -7949,8 +7976,12 @@ textarea.input { .session-header { padding: var(--gap-tight) var(--inset-control-inline); border-bottom: 1px solid var(--border); - background: var(--bg-page); - backdrop-filter: none; + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); } .session-card.session-card--active .session-header { @@ -8086,11 +8117,12 @@ textarea.input { flex: 1; min-height: 0; border: none; - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--bg-terminal) 96%, white 4%), - color-mix(in srgb, var(--bg-terminal) 88%, var(--bg-page) 12%) + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent ); + backdrop-filter: var(--app-surface-backdrop-filter, none); } .terminal-toolbar { @@ -8485,8 +8517,13 @@ textarea.input { .app-topbar { min-height: var(--desktop-topbar-height); - background: color-mix(in srgb, var(--bg-surface) 95%, var(--accent-blue) 5%); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); border-bottom-color: color-mix(in srgb, var(--border) 80%, var(--accent-blue) 20%); + backdrop-filter: var(--app-surface-backdrop-filter, none); } .topbar-tab.active { @@ -9204,10 +9241,10 @@ textarea.input { overflow: hidden; background: radial-gradient(circle at top left, rgba(108, 182, 255, 0.12), transparent 36%), - linear-gradient( - 180deg, - color-mix(in srgb, var(--bg-surface) 94%, black) 0%, - var(--bg-page) 100% + color-mix( + in srgb, + var(--surface-page-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent ); } @@ -9221,12 +9258,13 @@ textarea.input { gap: var(--sp-3); padding: calc(var(--mobile-safe-top) + var(--sp-1)) calc(var(--mobile-safe-right) + var(--sp-4)) var(--sp-1) calc(var(--mobile-safe-left) + var(--sp-4)); - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--bg-surface) 98%, transparent) 0%, - color-mix(in srgb, var(--bg-page) 96%, var(--bg-surface) 4%) 100% + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent ); border-bottom: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + backdrop-filter: var(--app-surface-backdrop-filter, none); } .mobile-topbar__workspace-button, @@ -9741,11 +9779,12 @@ textarea.input { gap: 0; padding: 0 0 var(--mobile-keyboard-inset) 0; border-top: 1px solid color-mix(in srgb, var(--border) 84%, transparent); - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--bg-surface) 98%, transparent) 0%, - color-mix(in srgb, var(--bg-page) 94%, var(--bg-surface) 6%) 100% + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent ); + backdrop-filter: var(--app-surface-backdrop-filter, none); transition: padding-bottom var(--mobile-shell-motion-enter-duration) var(--ease-out); } @@ -11749,6 +11788,12 @@ textarea.input { .settings-page--mobile > .settings-header { padding: calc(var(--mobile-safe-top) + var(--sp-1)) calc(var(--mobile-safe-right) + var(--sp-4)) var(--sp-1) calc(var(--mobile-safe-left) + var(--sp-4)); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); } .settings-header .mobile-page-header { diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index 76e7c3eb..e835a676 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -898,7 +898,9 @@ describe("components.css theme-sensitive surfaces", () => { ).join("\n"); const bottomTerminalShell = getLastRuleBlock(".workspace-bottom-panel > .bottom-terminal"); - expect(topbar).toContain("var(--bg-surface)"); + expect(topbar).toContain("var(--surface-overlay-bg)"); + expect(topbar).toContain("var(--app-surface-opacity, 0.96)"); + expect(topbar).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); expect(topbarTabs).toContain("gap: var(--gap-micro)"); expect(topbarTab).toContain("gap: var(--gap-tight)"); expect(topbarTab).toContain( @@ -922,6 +924,9 @@ describe("components.css theme-sensitive surfaces", () => { expect(sessionTerminal).not.toContain("rgba(11, 18, 24, 0.98)"); expect(sessionCard).toContain("border: none"); expect(sessionCard).not.toContain("border: 1px solid var(--border)"); + expect(sessionCard).toContain("var(--surface-overlay-bg)"); + expect(sessionCard).toContain("var(--app-surface-opacity, 0.96)"); + expect(sessionCard).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); expect(activeSessionCard).toContain("background: var(--bg-active)"); expect(activeSessionCard).toContain("box-shadow: inset 0 0 0 1px var(--border-focus)"); expect(activeSessionHeader).toContain( @@ -969,8 +974,11 @@ describe("components.css theme-sensitive surfaces", () => { expect(bottomPanel).toContain("padding: 0"); expect(bottomPanel).not.toContain("padding: 0 0 14px"); expect(bottomPanel).not.toContain("padding: 0 14px 14px"); - expect(bottomTerminalShellRules).toContain("var(--bg-terminal)"); - expect(bottomTerminalShellRules).not.toContain("rgba(17, 24, 31, 0.96)"); + expect(bottomTerminalShellRules).toContain("var(--surface-overlay-bg)"); + expect(bottomTerminalShellRules).toContain("var(--app-surface-opacity, 0.96)"); + expect(bottomTerminalShellRules).toContain( + "backdrop-filter: var(--app-surface-backdrop-filter, none)" + ); expect(bottomTerminalShell).toContain("border: none"); expect(bottomTerminalShellRules).toContain("border-radius: 0"); expect(bottomTerminalShellRules).not.toContain("border-radius: 14px"); @@ -1061,6 +1069,62 @@ describe("components.css theme-sensitive surfaces", () => { expect(mobileTabs).toContain("z-index: var(--z-inline)"); }); + it("routes settings and workspace shared surfaces through appearance-aware background tokens", () => { + const settingsContent = getLastRuleBlock(".settings-content"); + const settingsSurface = getRuleBlocksFrom(stylesheet, ".settings-content-surface").find( + (block) => block.includes("var(--surface-overlay-bg)") + ); + const appTopbar = getLastRuleBlock(".app-topbar"); + const workspacePage = getLastRuleBlock(".workspace-page"); + const sessionCard = getLastRuleBlock(".session-card"); + const bottomTerminal = getLastRuleBlock(".workspace-bottom-panel > .bottom-terminal"); + const mobileShell = getLastGroupedRuleBlock(/\.mobile-shell\s*\{([^}]*)\}/g); + const mobileTopbar = getLastRuleBlock(".mobile-topbar"); + const mobileBottomStack = getLastRuleBlock(".mobile-shell__bottom-stack"); + + expect(settingsContent).toContain("var(--app-surface-opacity, 0.96)"); + expect(settingsContent).toContain("var(--surface-page-bg)"); + expect(settingsSurface).toBeTruthy(); + expect(settingsSurface).toContain("var(--surface-overlay-bg)"); + expect(settingsSurface).toContain("var(--app-surface-opacity, 0.96)"); + expect(settingsSurface).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(appTopbar).toContain("var(--surface-overlay-bg)"); + expect(appTopbar).toContain("var(--app-surface-opacity, 0.96)"); + expect(appTopbar).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(workspacePage).toContain("var(--surface-page-bg)"); + expect(workspacePage).toContain("var(--app-surface-opacity, 0.96)"); + expect(sessionCard).toContain("var(--surface-overlay-bg)"); + expect(sessionCard).toContain("var(--app-surface-opacity, 0.96)"); + expect(sessionCard).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(bottomTerminal).toContain("var(--surface-overlay-bg)"); + expect(bottomTerminal).toContain("var(--app-surface-opacity, 0.96)"); + expect(bottomTerminal).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(mobileShell).toContain("var(--surface-page-bg)"); + expect(mobileShell).toContain("var(--app-surface-opacity, 0.96)"); + expect(mobileTopbar).toContain("var(--surface-overlay-bg)"); + expect(mobileTopbar).toContain("var(--app-surface-opacity, 0.96)"); + expect(mobileTopbar).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(mobileBottomStack).toContain("var(--surface-overlay-bg)"); + expect(mobileBottomStack).toContain("var(--app-surface-opacity, 0.96)"); + expect(mobileBottomStack).toContain( + "backdrop-filter: var(--app-surface-backdrop-filter, none)" + ); + }); + + it("keeps workbench backdrops and overlay cards on fallback-safe backdrop filters", () => { + const workbenchStyles = readFileSync( + `${process.cwd()}/src/components/ui/workbench-layer/index.module.css`, + "utf8" + ); + const backdrop = getLastRuleBlockFrom(workbenchStyles, ":global(.workbench-layer-backdrop)"); + const surface = getLastRuleBlockFrom(workbenchStyles, ":global(.workbench-layer)"); + + expect(backdrop).toContain("background: var(--overlay-backdrop)"); + expect(backdrop).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(surface).not.toContain("backdrop-filter:"); + expect(workbenchStyles).not.toContain("backdrop-filter: blur(8px)"); + }); + it("keeps terminal toolbar controls grouped at the far right", () => { const rightToolbar = getLastRuleBlock(".terminal-toolbar-right"); @@ -1755,7 +1819,8 @@ describe("components.css theme-sensitive surfaces", () => { expect(settingsContent).toContain("align-items: flex-start"); expect(settingsContent).toContain("justify-content: center"); expect(settingsContent).toContain("padding: var(--sp-6)"); - expect(settingsContent).toContain("background: var(--bg-page)"); + expect(settingsContent).toContain("var(--surface-page-bg)"); + expect(settingsContent).toContain("var(--app-surface-opacity, 0.96)"); expect(settingsContentFillHeight).toContain("justify-content: flex-start"); expect(settingsContentFillHeightSurface).toContain("display: flex"); expect(settingsContentFillHeightSurface).toContain("flex-direction: column"); @@ -1966,9 +2031,10 @@ describe("components.css theme-sensitive surfaces", () => { expect(topbarIconButton).toContain("border-radius: 10px"); expect(topbarIconButton).toContain("background: transparent"); expect(emptyStage).toContain("padding: clamp(34px, 9vh, 72px) var(--sp-4) var(--sp-3)"); - expect(bottomStack).toContain("background: linear-gradient("); + expect(bottomStack).toContain("var(--surface-overlay-bg)"); + expect(bottomStack).toContain("var(--app-surface-opacity, 0.96)"); expect(bottomStack).toContain("border-top: 1px solid color-mix("); - expect(bottomStack).not.toContain("backdrop-filter"); + expect(bottomStack).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); expect(dockShell).toContain( "padding: 3px calc(var(--mobile-safe-right) + var(--sp-4)) 0 calc(var(--mobile-safe-left) + var(--sp-4))" ); diff --git a/packages/web/src/ui-preview/preview-store.ts b/packages/web/src/ui-preview/preview-store.ts index 7e6f5a30..78bf7a0c 100644 --- a/packages/web/src/ui-preview/preview-store.ts +++ b/packages/web/src/ui-preview/preview-store.ts @@ -9,7 +9,18 @@ import type { WorktreeInfo, } from "@coder-studio/core"; import { createStore, type Store } from "jotai"; -import { authenticatedAtom, commandPaletteOpenAtom, localeAtom, themeAtom } from "../atoms/app-ui"; +import { + applyAppearancePersonalizationToDocument, + applyResolvedTheme, + resolveAppearancePersonalizationSetting, +} from "../appearance"; +import { + appearancePersonalizationAtom, + authenticatedAtom, + commandPaletteOpenAtom, + localeAtom, + themeAtom, +} from "../atoms/app-ui"; import { authEnabledAtom, connectionStatusAtom, @@ -394,8 +405,11 @@ export function buildUiPreviewStore(seed: UiPreviewSeed): Store { const store = createStore(); const dispatch = createPreviewDispatcher(seed); const workspaces = seed.workspaces ?? []; + const resolvedThemeId = resolveStoredThemeId(seed.theme); + const personalization = resolveAppearancePersonalizationSetting(seed.commands?.settingsGet ?? {}); - store.set(themeAtom, resolveStoredThemeId(seed.theme)); + store.set(themeAtom, resolvedThemeId); + store.set(appearancePersonalizationAtom, personalization); store.set(localeAtom, seed.locale); store.set(authEnabledAtom, seed.authEnabled === undefined ? false : seed.authEnabled); store.set(authenticatedAtom, seed.authenticated ?? true); @@ -458,6 +472,13 @@ export function buildUiPreviewStore(seed: UiPreviewSeed): Store { getStatus: () => "connected", } as never); + if (typeof document !== "undefined") { + applyResolvedTheme(resolvedThemeId); + document.documentElement.setAttribute("lang", seed.locale); + document.body.dataset.uiPreviewDevice = seed.device; + applyAppearancePersonalizationToDocument(personalization, resolvedThemeId); + } + for (const [workspaceId, layout] of Object.entries(seed.paneLayoutByWorkspaceId ?? {})) { store.set(paneLayoutAtomFamily(workspaceId), layout); } diff --git a/packages/web/src/ui-preview/scene-metadata.test.ts b/packages/web/src/ui-preview/scene-metadata.test.ts index 0ca81564..a5aad7e1 100644 --- a/packages/web/src/ui-preview/scene-metadata.test.ts +++ b/packages/web/src/ui-preview/scene-metadata.test.ts @@ -1,10 +1,8 @@ -// @vitest-environment node -import { readFileSync } from "node:fs"; +// @vitest-environment jsdom import { describe, expect, it } from "vitest"; import { THEME_IDS } from "../theme"; import { UI_PREVIEW_SCENE_METADATA } from "./scene-metadata"; - -const source = readFileSync(`${process.cwd()}/src/ui-preview/scene-metadata.ts`, "utf8"); +import { createPageScenes } from "./scenes/page-scenes"; describe("ui preview scene metadata", () => { it("registers icon-focused scenes for theme review", () => { @@ -48,7 +46,12 @@ describe("ui preview scene metadata", () => { }); it("enumerates concrete theme ids instead of dark/light buckets", () => { - expect(source).not.toContain('themeIdsForKinds("dark", "light")'); + expect( + UI_PREVIEW_SCENE_METADATA.every( + (scene) => + scene.themes.length > 0 && scene.themes.every((theme) => THEME_IDS.includes(theme)) + ) + ).toBe(true); }); it("limits the light-theme review scene to desktop light themes", () => { @@ -79,4 +82,59 @@ describe("ui preview scene metadata", () => { expect(reviewScene?.capture?.selector).toBe(".workspace-page"); expect(mobileScene?.capture?.selector).toBe("[data-testid='mobile-shell']"); }); + + it("registers appearance review coverage for both route-backed settings and workspace shells", () => { + const ids = UI_PREVIEW_SCENE_METADATA.map((scene) => scene.id); + const appearanceScene = UI_PREVIEW_SCENE_METADATA.find( + (scene) => scene.id === "settings-appearance" + ); + const desktopWorkspaceScene = UI_PREVIEW_SCENE_METADATA.find( + (scene) => scene.id === "workspace-desktop" + ); + const mobileWorkspaceScene = UI_PREVIEW_SCENE_METADATA.find( + (scene) => scene.id === "workspace-mobile" + ); + const pageScenes = createPageScenes(); + const settingsAppearancePageScene = pageScenes.find( + (scene) => scene.id === "settings-appearance" + ); + const workspaceDesktopPageScene = pageScenes.find((scene) => scene.id === "workspace-desktop"); + const workspaceMobilePageScene = pageScenes.find((scene) => scene.id === "workspace-mobile"); + const seedContext = { + theme: THEME_IDS[0], + locale: "en" as const, + device: "desktop" as const, + }; + const settingsSeed = settingsAppearancePageScene?.seed(seedContext); + const workspaceDesktopSeed = workspaceDesktopPageScene?.seed(seedContext); + const workspaceMobileSeed = workspaceMobilePageScene?.seed({ + ...seedContext, + device: "mobile", + }); + + expect(ids).toEqual( + expect.arrayContaining(["settings-appearance", "workspace-desktop", "workspace-mobile"]) + ); + expect(appearanceScene?.source).toBe("real-route"); + expect(appearanceScene?.capture?.settingsSection).toBe("appearance"); + expect(appearanceScene?.description.toLowerCase()).toContain("appearance"); + expect(desktopWorkspaceScene?.source).toBe("real-route"); + expect(desktopWorkspaceScene?.description.toLowerCase()).toContain("appearance"); + expect(mobileWorkspaceScene?.source).toBe("real-route"); + expect(mobileWorkspaceScene?.description.toLowerCase()).toContain("appearance"); + expect(settingsSeed?.commands?.settingsGet).toMatchObject({ + "appearance.personalization.version": 1, + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "preview-background", + "appearance.personalization.common.glassEnabled": true, + }); + expect(workspaceDesktopSeed?.commands?.settingsGet).toMatchObject({ + "appearance.personalization.version": 1, + "appearance.personalization.desktop.surfaceOpacity": 88, + }); + expect(workspaceMobileSeed?.commands?.settingsGet).toMatchObject({ + "appearance.personalization.version": 1, + "appearance.personalization.mobile.surfaceOpacity": 96, + }); + }); }); diff --git a/packages/web/src/ui-preview/scene-metadata.ts b/packages/web/src/ui-preview/scene-metadata.ts index e5f386b3..69d1ba52 100644 --- a/packages/web/src/ui-preview/scene-metadata.ts +++ b/packages/web/src/ui-preview/scene-metadata.ts @@ -66,7 +66,8 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ title: "Settings / Appearance", category: "page", source: "real-route", - description: "Settings appearance section using route-backed production UI.", + description: + "Settings appearance section using route-backed production UI with deterministic appearance personalization seed data.", devices: ["desktop", "mobile"], themes: allThemeIds(), locales: ["zh", "en"], @@ -121,7 +122,8 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ title: "Workspace / Desktop", category: "page", source: "real-route", - description: "Desktop workspace shell with seeded workspace, git status, and file tree.", + description: + "Desktop workspace shell with seeded workspace, git status, file tree, and appearance personalization coverage.", devices: ["desktop"], themes: allThemeIds(), locales: ["zh", "en"], @@ -132,7 +134,8 @@ export const UI_PREVIEW_SCENE_METADATA: UiPreviewSceneMetadata[] = [ title: "Workspace / Mobile", category: "page", source: "real-route", - description: "Mobile workspace shell with seeded workspace and no active sessions.", + description: + "Mobile workspace shell with seeded workspace, no active sessions, and appearance personalization coverage.", devices: ["mobile"], themes: allThemeIds(), locales: ["zh", "en"], diff --git a/packages/web/src/ui-preview/scenes/page-scenes.tsx b/packages/web/src/ui-preview/scenes/page-scenes.tsx index 77998349..efca5a97 100644 --- a/packages/web/src/ui-preview/scenes/page-scenes.tsx +++ b/packages/web/src/ui-preview/scenes/page-scenes.tsx @@ -57,6 +57,17 @@ function buildSettingsSeed(context: UiPreviewSceneContext) { "supervisor.evaluationTimeoutSec": 600, "appearance.locale": context.locale, "appearance.themeId": context.theme, + "appearance.personalization.version": 1, + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "preview-background", + "appearance.personalization.common.backgroundFit": "cover", + "appearance.personalization.common.backgroundDimness": 36, + "appearance.personalization.common.backgroundBlur": 8, + "appearance.personalization.common.glassEnabled": true, + "appearance.personalization.common.glassIntensity": 18, + "appearance.personalization.common.surfaceOpacity": 90, + "appearance.personalization.desktop.surfaceOpacity": 88, + "appearance.personalization.mobile.surfaceOpacity": 96, "appearance.terminalRenderer": "standard", "providers.claude.additionalArgs": ["--verbose"], "providers.codex.additionalArgs": ["--sandbox", "workspace-write"], @@ -98,6 +109,21 @@ function buildWorkspaceSeed(context: UiPreviewSceneContext, sessions: Session[] [workspace.id]: [], }, commands: { + settingsGet: { + "appearance.locale": context.locale, + "appearance.themeId": context.theme, + "appearance.personalization.version": 1, + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "preview-background", + "appearance.personalization.common.backgroundFit": "cover", + "appearance.personalization.common.backgroundDimness": 36, + "appearance.personalization.common.backgroundBlur": 8, + "appearance.personalization.common.glassEnabled": true, + "appearance.personalization.common.glassIntensity": 18, + "appearance.personalization.common.surfaceOpacity": 90, + "appearance.personalization.desktop.surfaceOpacity": 88, + "appearance.personalization.mobile.surfaceOpacity": 96, + }, workspaceList: [workspace], sessionListByWorkspaceId: { [workspace.id]: sessions }, gitStatusByWorkspaceId: { [workspace.id]: gitStatus }, From d88c9aa58e19b3863222d7772b5458ed6ac78059 Mon Sep 17 00:00:00 2001 From: Spencer Date: Sun, 24 May 2026 07:03:18 +0000 Subject: [PATCH 08/36] fix(server): restore running state on real PTY output --- .../src/__tests__/session-integration.test.ts | 51 ++ .../src/__tests__/session-manager-api.test.ts | 302 ++++++++++- packages/server/src/session/manager.ts | 512 +++++++++++++++++- 3 files changed, 842 insertions(+), 23 deletions(-) diff --git a/packages/server/src/__tests__/session-integration.test.ts b/packages/server/src/__tests__/session-integration.test.ts index 7ce0c977..e73c9d58 100644 --- a/packages/server/src/__tests__/session-integration.test.ts +++ b/packages/server/src/__tests__/session-integration.test.ts @@ -882,6 +882,57 @@ describe("Session Integration", () => { expect(result.ok).toBe(true); expect(sessionMgr.get(sessionId)?.state).toBe("running"); }); + + it("ignores typing echo but restores running when real PTY output follows", async () => { + vi.advanceTimersByTime(3050); + expect(sessionMgr.get(sessionId)?.state).toBe("idle"); + + const typingResult = await dispatch( + { + kind: "command", + id: "idle-test-typing-echo", + op: "terminal.input", + args: { + terminalId, + bytes: btoa("g"), + activity: "typing", + }, + }, + ctx + ); + + expect(typingResult.ok).toBe(true); + + triggerDataForProcessIndex(0, "g"); + expect(sessionMgr.get(sessionId)?.state).toBe("idle"); + + triggerDataForProcessIndex(0, "assistant working\n"); + expect(sessionMgr.get(sessionId)?.state).toBe("running"); + }); + + it("restores running when a recovered PTY stream mixes typing echo with real output", async () => { + vi.advanceTimersByTime(3050); + expect(sessionMgr.get(sessionId)?.state).toBe("idle"); + + const typingResult = await dispatch( + { + kind: "command", + id: "idle-test-mixed-typing-output", + op: "terminal.input", + args: { + terminalId, + bytes: btoa("g"), + activity: "typing", + }, + }, + ctx + ); + + expect(typingResult.ok).toBe(true); + + triggerDataForProcessIndex(0, "gassistant working\n"); + expect(sessionMgr.get(sessionId)?.state).toBe("running"); + }); }); describe("Session hydration", () => { diff --git a/packages/server/src/__tests__/session-manager-api.test.ts b/packages/server/src/__tests__/session-manager-api.test.ts index 876a93ca..2c6bad0b 100644 --- a/packages/server/src/__tests__/session-manager-api.test.ts +++ b/packages/server/src/__tests__/session-manager-api.test.ts @@ -250,7 +250,7 @@ describe("SessionManager session-level API", () => { expect(lifecycleEvents).toEqual([]); }); - it("does not promote an idle session back to running on PTY output alone", async () => { + it("promotes an idle session back to running when PTY emits real output", async () => { vi.useFakeTimers(); provider = { ...provider, @@ -282,11 +282,309 @@ describe("SessionManager session-level API", () => { vi.advanceTimersByTime(3000); expect(sessionMgr.get(session.id)?.state).toBe("idle"); - onData?.("prompt repaint\n"); + onData?.("assistant working\n"); + expect(sessionMgr.get(session.id)?.state).toBe("running"); + + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + }); + + it("ignores recent input echo while promoting real PTY output back to running", async () => { + vi.useFakeTimers(); + provider = { + ...provider, + idleHeuristics: { + idlePromptPatterns: [], + idleDebounceMs: 3000, + }, + } as ProviderDefinition; + sessionMgr = new SessionManager({ + terminalMgr, + eventBus, + db: { + insert: vi.fn(), + update: vi.fn(), + findById: vi.fn(), + findByWorkspaceId: vi.fn().mockReturnValue([]), + listHydratable: vi.fn().mockReturnValue([]), + delete: vi.fn(), + } as SessionDatabase, + broadcaster: { broadcast: vi.fn() } as Broadcaster, + providerRegistry: [provider], + providerConfigRepo: providerConfigRepoStub, + }); + + const session = await createSession(); + const onData = vi.mocked(mockPty.onData).mock.calls.at(-1)?.[0]; + + onData?.("booting up\n"); + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + sessionMgr.sendInput(session.id, Buffer.from("g"), "typing"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("g"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("assistant working\n"); + expect(sessionMgr.get(session.id)?.state).toBe("running"); + + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + }); + + it("restores running when PTY output contains remaining text beyond a recent typing echo", async () => { + vi.useFakeTimers(); + provider = { + ...provider, + idleHeuristics: { + idlePromptPatterns: [], + idleDebounceMs: 3000, + }, + } as ProviderDefinition; + sessionMgr = new SessionManager({ + terminalMgr, + eventBus, + db: { + insert: vi.fn(), + update: vi.fn(), + findById: vi.fn(), + findByWorkspaceId: vi.fn().mockReturnValue([]), + listHydratable: vi.fn().mockReturnValue([]), + delete: vi.fn(), + } as SessionDatabase, + broadcaster: { broadcast: vi.fn() } as Broadcaster, + providerRegistry: [provider], + providerConfigRepo: providerConfigRepoStub, + }); + + const session = await createSession(); + const onData = vi.mocked(mockPty.onData).mock.calls.at(-1)?.[0]; + + onData?.("booting up\n"); + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + sessionMgr.sendInput(session.id, Buffer.from("gen"), "typing"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("g"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("enerating...\n"); + expect(sessionMgr.get(session.id)?.state).toBe("running"); + }); + + it("restores running when a single PTY chunk mixes typing echo with real output", async () => { + vi.useFakeTimers(); + provider = { + ...provider, + idleHeuristics: { + idlePromptPatterns: [], + idleDebounceMs: 3000, + }, + } as ProviderDefinition; + sessionMgr = new SessionManager({ + terminalMgr, + eventBus, + db: { + insert: vi.fn(), + update: vi.fn(), + findById: vi.fn(), + findByWorkspaceId: vi.fn().mockReturnValue([]), + listHydratable: vi.fn().mockReturnValue([]), + delete: vi.fn(), + } as SessionDatabase, + broadcaster: { broadcast: vi.fn() } as Broadcaster, + providerRegistry: [provider], + providerConfigRepo: providerConfigRepoStub, + }); + + const session = await createSession(); + const onData = vi.mocked(mockPty.onData).mock.calls.at(-1)?.[0]; + + onData?.("booting up\n"); + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + sessionMgr.sendInput(session.id, Buffer.from("g"), "typing"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("gassistant working\n"); + expect(sessionMgr.get(session.id)?.state).toBe("running"); + + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + }); + + it("does not restore running for a typing-triggered line repaint split across chunks", async () => { + vi.useFakeTimers(); + provider = { + ...provider, + idleHeuristics: { + idlePromptPatterns: [], + idleDebounceMs: 3000, + }, + } as ProviderDefinition; + sessionMgr = new SessionManager({ + terminalMgr, + eventBus, + db: { + insert: vi.fn(), + update: vi.fn(), + findById: vi.fn(), + findByWorkspaceId: vi.fn().mockReturnValue([]), + listHydratable: vi.fn().mockReturnValue([]), + delete: vi.fn(), + } as SessionDatabase, + broadcaster: { broadcast: vi.fn() } as Broadcaster, + providerRegistry: [provider], + providerConfigRepo: providerConfigRepoStub, + }); + + const session = await createSession(); + const onData = vi.mocked(mockPty.onData).mock.calls.at(-1)?.[0]; + + onData?.("booting up\n"); + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + sessionMgr.sendInput(session.id, Buffer.from("git status"), "typing"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("\r\x1b[Kgit "); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("status"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + vi.advanceTimersByTime(100); expect(sessionMgr.get(session.id)?.state).toBe("idle"); + }); + + it("restores running when real output follows a typing repaint sequence within the aggregation window", async () => { + vi.useFakeTimers(); + provider = { + ...provider, + idleHeuristics: { + idlePromptPatterns: [], + idleDebounceMs: 3000, + }, + } as ProviderDefinition; + sessionMgr = new SessionManager({ + terminalMgr, + eventBus, + db: { + insert: vi.fn(), + update: vi.fn(), + findById: vi.fn(), + findByWorkspaceId: vi.fn().mockReturnValue([]), + listHydratable: vi.fn().mockReturnValue([]), + delete: vi.fn(), + } as SessionDatabase, + broadcaster: { broadcast: vi.fn() } as Broadcaster, + providerRegistry: [provider], + providerConfigRepo: providerConfigRepoStub, + }); + const session = await createSession(); + const onData = vi.mocked(mockPty.onData).mock.calls.at(-1)?.[0]; + + onData?.("booting up\n"); vi.advanceTimersByTime(3000); expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + sessionMgr.sendInput(session.id, Buffer.from("git status"), "typing"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("\r\x1b[Kgit "); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("status"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("\nassistant working\n"); + expect(sessionMgr.get(session.id)?.state).toBe("running"); + }); + + it("does not restore running for a pure control-triggered line repaint", async () => { + vi.useFakeTimers(); + provider = { + ...provider, + idleHeuristics: { + idlePromptPatterns: [], + idleDebounceMs: 3000, + }, + } as ProviderDefinition; + sessionMgr = new SessionManager({ + terminalMgr, + eventBus, + db: { + insert: vi.fn(), + update: vi.fn(), + findById: vi.fn(), + findByWorkspaceId: vi.fn().mockReturnValue([]), + listHydratable: vi.fn().mockReturnValue([]), + delete: vi.fn(), + } as SessionDatabase, + broadcaster: { broadcast: vi.fn() } as Broadcaster, + providerRegistry: [provider], + providerConfigRepo: providerConfigRepoStub, + }); + + const session = await createSession(); + const onData = vi.mocked(mockPty.onData).mock.calls.at(-1)?.[0]; + + onData?.("booting up\n"); + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + sessionMgr.sendInput(session.id, Buffer.from("\u001b[D"), "control"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + onData?.("\r\x1b[K$ git status"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + }); + + it("restores running when real output arrives after recent control input has aged out", async () => { + vi.useFakeTimers(); + provider = { + ...provider, + idleHeuristics: { + idlePromptPatterns: [], + idleDebounceMs: 3000, + }, + } as ProviderDefinition; + sessionMgr = new SessionManager({ + terminalMgr, + eventBus, + db: { + insert: vi.fn(), + update: vi.fn(), + findById: vi.fn(), + findByWorkspaceId: vi.fn().mockReturnValue([]), + listHydratable: vi.fn().mockReturnValue([]), + delete: vi.fn(), + } as SessionDatabase, + broadcaster: { broadcast: vi.fn() } as Broadcaster, + providerRegistry: [provider], + providerConfigRepo: providerConfigRepoStub, + }); + + const session = await createSession(); + const onData = vi.mocked(mockPty.onData).mock.calls.at(-1)?.[0]; + + onData?.("booting up\n"); + vi.advanceTimersByTime(3000); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + sessionMgr.sendInput(session.id, Buffer.from("\u001b[D"), "control"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); + + vi.advanceTimersByTime(250); + onData?.("assistant working\n"); + expect(sessionMgr.get(session.id)?.state).toBe("running"); }); it("emits turn_completed only after an armed submit returns to idle", async () => { diff --git a/packages/server/src/session/manager.ts b/packages/server/src/session/manager.ts index b269a9c9..e201c23f 100644 --- a/packages/server/src/session/manager.ts +++ b/packages/server/src/session/manager.ts @@ -67,6 +67,148 @@ const NOOP_SESSION_LOGGER: SessionLogger = { type TerminalExitedEvent = Extract; type TerminalOutputEvent = Extract; +const RECENT_INPUT_ECHO_WINDOW_MS = 3_000; +const RECENT_INPUT_ECHO_MAX_EVENTS = 12; +const RECENT_INPUT_ECHO_MAX_BYTES = 8_192; +const RECENT_OPAQUE_INPUT_ECHO_WINDOW_MS = 200; +const RESUME_OUTPUT_AGGREGATION_WINDOW_MS = 75; +const RESUME_OUTPUT_AGGREGATION_MAX_BYTES = 4_096; +const TERMINAL_ESCAPE_SEQUENCE_PATTERN = + /^\x1b(?:\[[0-9;?<>]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|P[\s\S]*?\x1b\\|[@-_])/; + +interface RecentInputEcho { + at: number; + activity: TerminalInputActivity; + byteLength: number; + opaque: boolean; + remainingVisibleText: string; +} + +interface TerminalOutputAssessment { + shouldResumeRunning: boolean; + countsAsTurnOutput: boolean; + shouldAggregateForResume: boolean; +} + +interface PendingResumeAggregation { + startedAt: number; + chunks: Buffer[]; + byteLength: number; + timer: NodeJS.Timeout | null; +} + +function readTerminalEscapeSequence(text: string, start: number): string | null { + const match = text.slice(start).match(TERMINAL_ESCAPE_SEQUENCE_PATTERN); + return match?.[0] ?? null; +} + +function classifyRecentInputEcho( + bytes: Buffer +): Pick { + const text = bytes.toString("utf8"); + let opaque = false; + let remainingVisibleText = ""; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]!; + + if (char === "\x1b") { + const escape = readTerminalEscapeSequence(text, index); + if (escape) { + opaque = true; + index += escape.length - 1; + continue; + } + } + + if (char === "\r" || char === "\n" || char === "\t") { + opaque = true; + continue; + } + + if (char === "\u007f" || char === "\b") { + opaque = true; + continue; + } + + if (char < " ") { + opaque = true; + continue; + } + + remainingVisibleText += char; + } + + return { opaque, remainingVisibleText }; +} + +function extractVisibleTerminalText(bytes: Buffer): string { + const text = bytes.toString("utf8"); + let visibleText = ""; + + for (let index = 0; index < text.length; index += 1) { + const char = text[index]!; + + if (char === "\x1b") { + const escape = readTerminalEscapeSequence(text, index); + if (escape) { + index += escape.length - 1; + continue; + } + continue; + } + + if (char === "\u007f" || char === "\b") { + visibleText = visibleText.slice(0, -1); + continue; + } + + if (char === "\r" || char === "\n") { + visibleText += "\n"; + continue; + } + + if (char === "\t") { + visibleText += "\t"; + continue; + } + + if (char < " ") { + continue; + } + + visibleText += char; + } + + return visibleText; +} + +function commonPrefixLength(left: string, right: string): number { + const maxLength = Math.min(left.length, right.length); + let index = 0; + + while (index < maxLength && left[index] === right[index]) { + index += 1; + } + + return index; +} + +function chunkHasLineRepaintControl(text: string): boolean { + return ( + text.includes("\r") || /\x1b\[[0-9;?]*K/.test(text) || /\x1b\[[0-9;?]*[ABCDGHF]/.test(text) + ); +} + +function visibleOutputHasMultipleLines(visibleOutput: string): boolean { + return ( + visibleOutput + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0).length > 1 + ); +} + export class SessionManager { private sessions = new Map(); private terminalToSession = new Map(); @@ -300,6 +442,9 @@ export class SessionManager { text: string | undefined, options: { armTurnCompletion: boolean } ): void { + const completedSynchronously = + !options.armTurnCompletion && session.state === "idle" && !session.awaitingTurnCompletion; + if (activity === "control" || activity === "typing") { return; } @@ -309,16 +454,10 @@ export class SessionManager { session.awaitingTurnCompletion = true; session.sawOutputSinceTurnStart = false; } - const prev = session.state; - if (session.state !== "running") { - session.state = "running"; - session.lastActiveAt = Date.now(); - this.deps.db.update(session.id, { - state: "running", - lastActiveAt: session.lastActiveAt, - }); - this.emitStateChanged(session, prev, "running"); + if (completedSynchronously) { + return; } + this.transitionSessionToRunning(session); return; } @@ -338,23 +477,15 @@ export class SessionManager { // here, but we still want to record the first instruction as the title. const titleChanged = this.maybeAssignTitle(session, submittedText); - const prev = session.state; const shouldResume = session.state === "idle" || session.state === "starting"; - if (shouldResume) { - session.state = "running"; - session.lastActiveAt = Date.now(); - - this.deps.db.update(session.id, { - state: "running", - lastActiveAt: session.lastActiveAt, - }); - - this.emitStateChanged(session, prev, "running"); + if (shouldResume && !completedSynchronously) { + this.transitionSessionToRunning(session); } else if (titleChanged) { // State stayed the same, but the DTO changed (title added) and the UI // subscribes via state.changed broadcasts — fire a no-op transition so // the fresh DTO is pushed to clients. + const prev = session.state; this.emitStateChanged(session, prev, session.state); } } @@ -378,10 +509,12 @@ export class SessionManager { ? (submittedText ?? bytes.toString("utf-8")) : undefined; const rollbackArm = this.armTurnCompletionBeforeWrite(session, activity); + const rollbackRecentInput = this.recordRecentInputBeforeWrite(session, bytes, activity); try { this.deps.terminalMgr.write(session.terminalId, bytes); } catch (error) { + rollbackRecentInput?.(); rollbackArm?.(); throw error; } @@ -457,6 +590,23 @@ export class SessionManager { return true; } + private transitionSessionToRunning(session: ActiveSession): void { + if (session.state === "running") { + return; + } + + const prev = session.state; + session.state = "running"; + session.lastActiveAt = Date.now(); + + this.deps.db.update(session.id, { + state: "running", + lastActiveAt: session.lastActiveAt, + }); + + this.emitStateChanged(session, prev, "running"); + } + /** * Handle terminal exit event */ @@ -540,6 +690,299 @@ export class SessionManager { }; } + private recordRecentInputBeforeWrite( + session: ActiveSession, + bytes: Buffer, + activity: TerminalInputActivity + ): (() => void) | null { + if (activity === "system") { + return null; + } + + const { opaque, remainingVisibleText } = classifyRecentInputEcho(bytes); + if (!opaque && remainingVisibleText.length === 0) { + return null; + } + + const recentInput: RecentInputEcho = { + at: Date.now(), + activity, + byteLength: bytes.byteLength, + opaque, + remainingVisibleText, + }; + + session.recentInputEchoes.push(recentInput); + this.pruneRecentInputEchoes(session, recentInput.at); + + return () => { + const index = session.recentInputEchoes.indexOf(recentInput); + if (index !== -1) { + session.recentInputEchoes.splice(index, 1); + } + }; + } + + private pruneRecentInputEchoes(session: ActiveSession, now: number = Date.now()): void { + session.recentInputEchoes = session.recentInputEchoes.filter( + (entry) => + now - entry.at <= RECENT_INPUT_ECHO_WINDOW_MS && + (entry.opaque || entry.remainingVisibleText.length > 0) + ); + + let totalBytes = session.recentInputEchoes.reduce((sum, entry) => sum + entry.byteLength, 0); + while ( + session.recentInputEchoes.length > RECENT_INPUT_ECHO_MAX_EVENTS || + totalBytes > RECENT_INPUT_ECHO_MAX_BYTES + ) { + const removed = session.recentInputEchoes.shift(); + if (!removed) { + break; + } + totalBytes -= removed.byteLength; + } + } + + private clearRecentInputEchoes(session: ActiveSession): void { + session.recentInputEchoes = []; + } + + private clearPendingResumeAggregation(session: ActiveSession): void { + if (session.pendingResumeAggregation?.timer) { + clearTimeout(session.pendingResumeAggregation.timer); + } + session.pendingResumeAggregation = null; + } + + private consumeRecentLiteralEcho( + session: ActiveSession, + visibleOutput: string + ): { matchedLiteralEcho: boolean; leftoverVisibleOutput: string } { + let offset = 0; + let matchedLiteralEcho = false; + + for (const entry of session.recentInputEchoes) { + if (offset >= visibleOutput.length) { + break; + } + + if (entry.remainingVisibleText.length === 0) { + continue; + } + + const currentVisibleText = entry.remainingVisibleText; + const matchLength = commonPrefixLength(visibleOutput.slice(offset), currentVisibleText); + if (matchLength === 0) { + break; + } + + entry.remainingVisibleText = currentVisibleText.slice(matchLength); + offset += matchLength; + matchedLiteralEcho = true; + + if (matchLength < currentVisibleText.length) { + break; + } + } + + return { + matchedLiteralEcho, + leftoverVisibleOutput: visibleOutput.slice(offset), + }; + } + + private hasRecentNonSubmitInput(session: ActiveSession, now: number): boolean { + return session.recentInputEchoes.some( + (entry) => + entry.activity !== "submit" && + entry.activity !== "internal_submit" && + now - entry.at <= RECENT_OPAQUE_INPUT_ECHO_WINDOW_MS + ); + } + + private hasRecentOpaqueNonSubmitInput(session: ActiveSession, now: number): boolean { + return session.recentInputEchoes.some( + (entry) => + entry.opaque && + entry.activity !== "submit" && + entry.activity !== "internal_submit" && + now - entry.at <= RECENT_OPAQUE_INPUT_ECHO_WINDOW_MS + ); + } + + private hasRecentControlInput(session: ActiveSession, now: number): boolean { + return session.recentInputEchoes.some( + (entry) => + entry.activity === "control" && now - entry.at <= RECENT_OPAQUE_INPUT_ECHO_WINDOW_MS + ); + } + + private getRecentVisibleInputText(session: ActiveSession): string { + return session.recentInputEchoes + .filter((entry) => entry.activity !== "submit" && entry.activity !== "internal_submit") + .map((entry) => entry.remainingVisibleText) + .join(""); + } + + private isLikelyPureInputRepaint( + session: ActiveSession, + chunk: Buffer, + visibleOutput: string, + now: number + ): boolean { + if (!this.hasRecentNonSubmitInput(session, now)) { + return false; + } + + if (!visibleOutput.trim()) { + return true; + } + + const chunkText = chunk.toString("utf8"); + if (chunkText.includes("\n")) { + return false; + } + + if (visibleOutputHasMultipleLines(visibleOutput)) { + return false; + } + + if (!chunkHasLineRepaintControl(chunkText)) { + return false; + } + + if (this.hasRecentOpaqueNonSubmitInput(session, now)) { + return true; + } + + const recentVisibleInputText = this.getRecentVisibleInputText(session); + const trimmedVisibleOutput = visibleOutput.trim(); + if (!recentVisibleInputText || !trimmedVisibleOutput) { + return false; + } + + return ( + recentVisibleInputText === trimmedVisibleOutput || + recentVisibleInputText.startsWith(trimmedVisibleOutput) || + trimmedVisibleOutput.endsWith(recentVisibleInputText) + ); + } + + private assessTerminalOutput(session: ActiveSession, chunk: Buffer): TerminalOutputAssessment { + const now = Date.now(); + this.pruneRecentInputEchoes(session, now); + + if (session.recentInputEchoes.length === 0) { + const hasVisibleText = extractVisibleTerminalText(chunk).trim().length > 0; + return { + shouldResumeRunning: hasVisibleText, + countsAsTurnOutput: hasVisibleText, + shouldAggregateForResume: false, + }; + } + + const visibleOutput = extractVisibleTerminalText(chunk); + const { matchedLiteralEcho, leftoverVisibleOutput } = this.consumeRecentLiteralEcho( + session, + visibleOutput + ); + const hasRecentNonSubmitInput = this.hasRecentNonSubmitInput(session, now); + const hasRecentControlInput = this.hasRecentControlInput(session, now); + + this.pruneRecentInputEchoes(session, now); + + const trimmedLeftoverVisibleOutput = leftoverVisibleOutput.trim(); + const trimmedVisibleOutput = visibleOutput.trim(); + const suppressImmediateResumeAfterControl = hasRecentControlInput && !matchedLiteralEcho; + const chunkText = chunk.toString("utf8"); + const shouldAggregateForResume = + session.state === "idle" && + hasRecentNonSubmitInput && + !matchedLiteralEcho && + trimmedVisibleOutput.length > 0 && + chunkHasLineRepaintControl(chunkText) && + !visibleOutputHasMultipleLines(visibleOutput); + + const shouldResumeRunning = suppressImmediateResumeAfterControl + ? false + : trimmedLeftoverVisibleOutput.length > 0 + ? !this.isLikelyPureInputRepaint(session, chunk, leftoverVisibleOutput, now) + : !matchedLiteralEcho && + trimmedVisibleOutput.length > 0 && + !this.isLikelyPureInputRepaint(session, chunk, visibleOutput, now); + + const countsAsTurnOutput = + !suppressImmediateResumeAfterControl && + (trimmedLeftoverVisibleOutput.length > 0 + ? !this.isLikelyPureInputRepaint(session, chunk, leftoverVisibleOutput, now) + : !matchedLiteralEcho && + trimmedVisibleOutput.length > 0 && + !this.isLikelyPureInputRepaint(session, chunk, visibleOutput, now)); + + return { + shouldResumeRunning, + countsAsTurnOutput, + shouldAggregateForResume, + }; + } + + private flushPendingResumeAggregation(session: ActiveSession): void { + const pending = session.pendingResumeAggregation; + if (!pending) { + return; + } + + this.clearPendingResumeAggregation(session); + if (session.state !== "idle") { + return; + } + + const combinedChunk = Buffer.concat(pending.chunks, pending.byteLength); + const outputAssessment = this.assessTerminalOutput(session, combinedChunk); + if (outputAssessment.countsAsTurnOutput) { + session.sawOutputSinceTurnStart = true; + } + if (outputAssessment.shouldResumeRunning && session.state === "idle") { + this.transitionSessionToRunning(session); + } + } + + private schedulePendingResumeAggregation(session: ActiveSession, chunk: Buffer): void { + const pending = session.pendingResumeAggregation; + if (!pending) { + const nextPending: PendingResumeAggregation = { + startedAt: Date.now(), + chunks: [chunk], + byteLength: chunk.byteLength, + timer: null, + }; + nextPending.timer = setTimeout(() => { + const activeSession = this.sessions.get(session.id); + if (!activeSession) { + return; + } + this.flushPendingResumeAggregation(activeSession); + }, RESUME_OUTPUT_AGGREGATION_WINDOW_MS); + session.pendingResumeAggregation = nextPending; + return; + } + + pending.chunks.push(chunk); + pending.byteLength += chunk.byteLength; + + const combinedChunk = Buffer.concat(pending.chunks, pending.byteLength); + const visibleOutput = extractVisibleTerminalText(combinedChunk); + const combinedText = combinedChunk.toString("utf8"); + const shouldFlushNow = + pending.byteLength >= RESUME_OUTPUT_AGGREGATION_MAX_BYTES || + combinedText.includes("\n") || + visibleOutputHasMultipleLines(visibleOutput); + + if (shouldFlushNow) { + this.flushPendingResumeAggregation(session); + } + } + private flushPendingPtyIdle(session: ActiveSession): void { const ptyState = this.comparators.get(session.id)?.snapshot().ptyState; if (ptyState !== "idle") { @@ -550,6 +993,7 @@ export class SessionManager { } private transitionSessionToIdle(activeSession: ActiveSession): void { + this.clearPendingResumeAggregation(activeSession); const prev = activeSession.state; if (prev !== "running" && prev !== "starting") { return; @@ -621,9 +1065,32 @@ export class SessionManager { } const activeSession = this.sessions.get(session.id); - if (activeSession?.awaitingTurnCompletion) { + if (!activeSession) { + return; + } + + if (activeSession.pendingResumeAggregation && activeSession.state !== "idle") { + this.clearPendingResumeAggregation(activeSession); + } + + if (activeSession.pendingResumeAggregation) { + this.schedulePendingResumeAggregation(activeSession, event.chunk); + detector.feed(event.chunk); + return; + } + + const outputAssessment = this.assessTerminalOutput(activeSession, event.chunk); + if (outputAssessment.shouldAggregateForResume) { + this.schedulePendingResumeAggregation(activeSession, event.chunk); + detector.feed(event.chunk); + return; + } + if (outputAssessment.countsAsTurnOutput) { activeSession.sawOutputSinceTurnStart = true; } + if (outputAssessment.shouldResumeRunning && activeSession.state === "idle") { + this.transitionSessionToRunning(activeSession); + } detector.feed(event.chunk); }); @@ -642,6 +1109,7 @@ export class SessionManager { } private finishSession(session: ActiveSession, exitCode: number | undefined): void { + this.clearPendingResumeAggregation(session); const prev = session.state; session.state = "ended"; session.endedAt = Date.now(); @@ -679,6 +1147,8 @@ class ActiveSession { latestSubmittedUserInput?: string; awaitingTurnCompletion = false; sawOutputSinceTurnStart = false; + recentInputEchoes: RecentInputEcho[] = []; + pendingResumeAggregation: PendingResumeAggregation | null = null; constructor(data: { id: string; From dc4ff7f9a3c12da4925e50935215f650dae79ad4 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 15:15:46 +0800 Subject: [PATCH 09/36] Stop retrying terminal recovery after manual retry failure --- .../__tests__/xterm-host.test.tsx | 70 +++++++++++++++++++ .../features/terminal-panel/replay-state.ts | 1 + .../views/shared/xterm-host.tsx | 15 +++- packages/web/src/locales/en.json | 2 +- packages/web/src/locales/zh.json | 2 +- 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx index 257e220d..f1ca9d4d 100644 --- a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +++ b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx @@ -1440,6 +1440,76 @@ describe("XtermHost", () => { global.cancelAnimationFrame = originalCancelAnimationFrame; }); + it("removes the retry action after a manual retry also fails", async () => { + const store = createStore(); + const sendCommand = vi.fn().mockImplementation((op: string) => { + if (op === "terminal.snapshot") { + return Promise.resolve({ status: "unsupported" }); + } + + if (op === "terminal.replay") { + return Promise.reject(new Error("Command timeout: terminal.replay")); + } + + return Promise.resolve({ status: "ok" }); + }); + const subscribe = vi.fn(() => vi.fn()); + const rafCallbacks: FrameRequestCallback[] = []; + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + mockTerminal.cols = 132; + mockTerminal.rows = 36; + + global.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = vi.fn() as typeof cancelAnimationFrame; + + store.set(localeAtom, "zh"); + store.set(wsClientAtom, { + sendCommand, + subscribe, + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + await act(async () => { + const callback = rafCallbacks.shift(); + callback?.(16); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "重试恢复" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "重试恢复" })); + + await waitFor(() => { + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.snapshot")).toHaveLength(2); + expect(sendCommand.mock.calls.filter(([op]) => op === "terminal.replay")).toHaveLength(2); + }); + + await waitFor(() => { + expect(screen.queryByRole("button", { name: "重试恢复" })).not.toBeInTheDocument(); + }); + expect(screen.getByText("终端历史暂未恢复")).toBeInTheDocument(); + expect(document.querySelector(".xterm-replay-overlay")).toBeFalsy(); + + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; + }); + it("retries local gap recovery from the original missing-history seq", async () => { const store = createStore(); const initialReplayChunk = new TextEncoder().encode("snapshot\n"); diff --git a/packages/web/src/features/terminal-panel/replay-state.ts b/packages/web/src/features/terminal-panel/replay-state.ts index 3d46f3a6..19ccd7e9 100644 --- a/packages/web/src/features/terminal-panel/replay-state.ts +++ b/packages/web/src/features/terminal-panel/replay-state.ts @@ -19,6 +19,7 @@ export type TerminalReplayUiState = | { kind: "unavailable" } | { kind: "truncated" } | { kind: "retryable_failure"; reason: "timeout" | "failed" } + | { kind: "failed"; reason: "timeout" | "failed" } | { kind: "unrecoverable_history"; reason: "too_old_no_snapshot" }; export function classifyReplayFailure(error: unknown): "timeout" | "failed" { diff --git a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx index 1f879ec0..a20d7284 100644 --- a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx +++ b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx @@ -448,6 +448,7 @@ export function XtermHost({ const showUnrecoverableHistoryRef = useRef<(() => Promise) | null>(null); const showUnavailableTerminalRef = useRef<(() => Promise) | null>(null); const retryHistoricalRecoveryRef = useRef<(() => void) | null>(null); + const manualRecoveryRetryAttemptedRef = useRef(false); const coldStartStateRef = useRef<"idle" | "in-flight" | "done">("idle"); const activeHistoricalRecoveryModeRef = useRef<"initial" | "reconnect" | null>(null); const latestRenderedSeqRef = useRef(0); @@ -1671,7 +1672,12 @@ export function XtermHost({ } activeRecoveryUiModeRef.current = "error"; - setReplayUiState({ kind: "retryable_failure", reason: classifyReplayFailure(error) }); + const reason = classifyReplayFailure(error); + setReplayUiState( + manualRecoveryRetryAttemptedRef.current + ? { kind: "failed", reason } + : { kind: "retryable_failure", reason } + ); releaseHydration(); await flushHistoricalRecovery(); }; @@ -1713,6 +1719,7 @@ export function XtermHost({ coldStartStateRef.current = "in-flight"; activeHistoricalRecoveryModeRef.current = "reconnect"; activeRecoveryUiModeRef.current = "silent"; + manualRecoveryRetryAttemptedRef.current = false; setReplayUiState({ kind: "ready" }); releaseHydration(); await flushHistoricalRecovery({ @@ -1731,6 +1738,7 @@ export function XtermHost({ coldStartStateRef.current = "in-flight"; activeHistoricalRecoveryModeRef.current = "initial"; activeRecoveryUiModeRef.current = "silent"; + manualRecoveryRetryAttemptedRef.current = false; setReplayUiState({ kind: "ready" }); releaseHydration(); await flushHistoricalRecovery({ @@ -2424,6 +2432,7 @@ export function XtermHost({ }, []); const handleRetryRecovery = useCallback(() => { + manualRecoveryRetryAttemptedRef.current = true; setReplayUiState({ kind: "loading" }); if (recoveryCoordinator) { @@ -2459,6 +2468,7 @@ export function XtermHost({ canShowRecoverySurface; const showInlineRecoveryNotice = replayUiState.kind === "retryable_failure" || + replayUiState.kind === "failed" || replayUiState.kind === "unrecoverable_history" || replayUiState.kind === "truncated"; @@ -2508,6 +2518,9 @@ export function XtermHost({ {t("terminal.replay.retry_action")} ); + } else if (replayUiState.kind === "failed") { + noticeTitle = t("terminal.replay.failed_title"); + noticeBody = t("terminal.replay.failed_body"); } else if (replayUiState.kind === "unrecoverable_history") { noticeTitle = t("terminal.replay.unrecoverable_title"); noticeBody = t("terminal.replay.unrecoverable_body"); diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 6521764a..bb12c665 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -342,7 +342,7 @@ "loading_title": "Restoring terminal output...", "loading_body": "This terminal is unavailable while history is being restored. Please wait until recovery finishes before continuing. Larger histories may take longer to restore.", "failed_title": "Terminal history was not restored yet", - "failed_body": "The terminal is still usable, but older output was not filled back in this time. You can retry recovery; if the server still retains the history, it may still come back later or after a refresh.", + "failed_body": "The terminal is still usable, but older output still could not be filled back in. Try again later or after a refresh; if the server still retains the history, it may still come back.", "retryable_title": "Terminal history was not restored yet", "retryable_body": "The terminal is still usable, but older output was not filled back in this time. You can retry recovery; if the server still retains the history, it may still come back later or after a refresh.", "retry_action": "Retry recovery", diff --git a/packages/web/src/locales/zh.json b/packages/web/src/locales/zh.json index cf99b546..57f7eff9 100644 --- a/packages/web/src/locales/zh.json +++ b/packages/web/src/locales/zh.json @@ -342,7 +342,7 @@ "loading_title": "正在恢复终端内容…", "loading_body": "恢复期间暂时无法使用当前终端;请耐心等待,历史内容恢复完成后再继续。内容较多时可能需要更久。", "failed_title": "终端历史暂未恢复", - "failed_body": "当前终端可以继续使用,但较早输出这次没有补齐。你可以重试恢复;如果服务端仍保留历史,稍后或刷新页面后仍可能找回。", + "failed_body": "当前终端可以继续使用,但较早输出这次仍未补齐。请稍后或刷新页面后再看;如果服务端仍保留历史,后续仍可能找回。", "retryable_title": "终端历史暂未恢复", "retryable_body": "当前终端可以继续使用,但较早输出这次没有补齐。你可以重试恢复;如果服务端仍保留历史,稍后或刷新页面后仍可能找回。", "retry_action": "重试恢复", From 15690a942933050f259069bd0e9d4dc4fadf24ef Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 15:26:19 +0800 Subject: [PATCH 10/36] fix(web): stop auto-opening git diffs --- .../actions/use-code-editor-actions.ts | 7 ++- .../src/features/code-editor/index.test.tsx | 54 +++++++++++++++++++ .../workspace/actions/use-git-actions.ts | 32 +++++------ .../actions/use-open-editors-actions.ts | 25 ++++++++- .../workspace/views/shared/git-panel.test.tsx | 26 ++++----- 5 files changed, 107 insertions(+), 37 deletions(-) diff --git a/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts b/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts index 14509c2c..5a623b4c 100644 --- a/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts +++ b/packages/web/src/features/code-editor/actions/use-code-editor-actions.ts @@ -11,6 +11,7 @@ import { editorRefreshTokenAtomFamily, type GitDiffPreview, gitDiffPreviewAtomFamily, + gitDiffPreviewDismissedAtomFamily, gitStateAtomFamily, type OpenFile, openFilesAtomFamily, @@ -59,6 +60,9 @@ export function useCodeEditorActions() { const workspaceRootPath = workspace?.path; const dispatch = useAtomValue(dispatchCommandAtom); const setDiffPreview = useSetAtom(gitDiffPreviewAtomFamily(workspace?.id ?? "")); + const setDiffPreviewDismissed = useSetAtom( + gitDiffPreviewDismissedAtomFamily(workspace?.id ?? "") + ); const [savingPaths, setSavingPaths] = useState>(() => new Set()); const [saveError, setSaveError] = useState<{ path: string; message: string } | null>(null); @@ -697,10 +701,11 @@ export function useCodeEditorActions() { ...(result.data.originalRevision ? { originalRevision: result.data.originalRevision } : {}), ...(result.data.modifiedRevision ? { modifiedRevision: result.data.modifiedRevision } : {}), }; + setDiffPreviewDismissed(false); setDiffPreview(nextPreview); setMode("diff"); return true; - }, [currentFile, dispatch, setDiffPreview, setMode, workspaceId]); + }, [currentFile, dispatch, setDiffPreview, setDiffPreviewDismissed, setMode, workspaceId]); const isTextFile = currentFile?.kind === "text"; const isImageFile = currentFile?.kind === "image"; diff --git a/packages/web/src/features/code-editor/index.test.tsx b/packages/web/src/features/code-editor/index.test.tsx index dc522993..f7561185 100644 --- a/packages/web/src/features/code-editor/index.test.tsx +++ b/packages/web/src/features/code-editor/index.test.tsx @@ -20,6 +20,7 @@ import { editorModeAtomFamily, editorRefreshTokenAtomFamily, gitDiffPreviewAtomFamily, + gitDiffPreviewDismissedAtomFamily, gitStateAtomFamily, type OpenFile, openFilesAtomFamily, @@ -1414,6 +1415,59 @@ describe("CodeEditorHost", () => { expect(result.current.hasUnsavedChangesOutsideDiff).toBe(true); }); + it("marks file diff preview dismissed when closing the active diff editor", async () => { + const { store } = setupStore({ + activePath: "src/app.ts", + openFiles: { + "src/app.ts": { + kind: "text", + path: "src/app.ts", + content: "export const app = 2;", + savedContent: "export const app = 1;", + baseHash: "hash-app", + isDirty: true, + }, + }, + }); + store.set(editorModeAtomFamily("ws-1"), "diff"); + store.set(gitStateAtomFamily("ws-1"), { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [{ path: "src/app.ts", status: "modified" }], + deleted: [], + untracked: [], + }); + store.set(gitDiffPreviewAtomFamily("ws-1"), { + path: "src/app.ts", + diff: "diff --git a/src/app.ts b/src/app.ts", + staged: false, + source: "file", + renderAs: "text", + originalContent: "export const app = 1;", + modifiedContent: "export const app = 2;", + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("monaco-diff-host")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + await waitFor(() => { + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); + expect(store.get(gitDiffPreviewDismissedAtomFamily("ws-1"))).toBe(true); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + }); + }); + it("shows the save tooltip on desktop for a text buffer", async () => { const { store } = setupStore({ activePath: "src/save.ts", diff --git a/packages/web/src/features/workspace/actions/use-git-actions.ts b/packages/web/src/features/workspace/actions/use-git-actions.ts index 00807729..6f45c25f 100644 --- a/packages/web/src/features/workspace/actions/use-git-actions.ts +++ b/packages/web/src/features/workspace/actions/use-git-actions.ts @@ -647,21 +647,18 @@ export function useGitPanelActions({ return; } - const nextPreviewTarget = - (diffPreview ? getChangeByPath(result.data, diffPreview.path) : null) ?? - getFirstChange(result.data); + if (!diffPreview) { + return; + } - if (!nextPreviewTarget) { + const currentPreviewTarget = getChangeByPath(result.data, diffPreview.path); + if (!currentPreviewTarget) { updatePreview(null); return; } - if ( - !diffPreview || - diffPreview.path !== nextPreviewTarget.change.path || - Boolean(diffPreview.staged) !== (nextPreviewTarget.type === "staged") - ) { - await requestDiff(nextPreviewTarget.change, nextPreviewTarget.type); + if (Boolean(diffPreview.staged) !== (currentPreviewTarget.type === "staged")) { + await requestDiff(currentPreviewTarget.change, currentPreviewTarget.type); } } finally { isLoadingRef.current = false; @@ -711,20 +708,19 @@ export function useGitPanelActions({ return; } - if (diffPreview) { - const currentChange = getChangeByPath(gitState, diffPreview.path); - if (currentChange && Boolean(diffPreview.staged) === (currentChange.type === "staged")) { - return; - } + if (!diffPreview) { + return; } - const firstChange = getFirstChange(gitState); - if (!firstChange) { + const currentChange = getChangeByPath(gitState, diffPreview.path); + if (!currentChange) { updatePreview(null); return; } - void requestDiff(firstChange.change, firstChange.type); + if (Boolean(diffPreview.staged) !== (currentChange.type === "staged")) { + void requestDiff(currentChange.change, currentChange.type); + } }, [diffPreview, diffPreviewDismissed, gitState, requestDiff, updatePreview]); useEffect(() => { diff --git a/packages/web/src/features/workspace/actions/use-open-editors-actions.ts b/packages/web/src/features/workspace/actions/use-open-editors-actions.ts index ba0dbbd4..e7ab6160 100644 --- a/packages/web/src/features/workspace/actions/use-open-editors-actions.ts +++ b/packages/web/src/features/workspace/actions/use-open-editors-actions.ts @@ -1,4 +1,4 @@ -import { useAtom, useAtomValue } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useCallback } from "react"; import { workspaceByIdAtomFamily } from "../../../atoms/workspaces"; import { @@ -12,6 +12,7 @@ import { editorModeAtomFamily, type GitDiffPreview, gitDiffPreviewAtomFamily, + gitDiffPreviewDismissedAtomFamily, openFilesAtomFamily, } from "../atoms"; import { resolveOpenEditorsClose } from "./open-editors-close"; @@ -46,7 +47,8 @@ export function useOpenEditorsActions(workspaceId: string, options?: UseOpenEdit const workspaceRootPath = options?.workspaceRootPath ?? workspace?.path; const [activeFilePath, setActiveFilePath] = useAtom(activeFilePathAtomFamily(workspaceId)); const [openFiles, setOpenFiles] = useAtom(openFilesAtomFamily(workspaceId)); - const [, setDiffPreview] = useAtom(gitDiffPreviewAtomFamily(workspaceId)); + const [diffPreview, setDiffPreview] = useAtom(gitDiffPreviewAtomFamily(workspaceId)); + const setDiffPreviewDismissed = useSetAtom(gitDiffPreviewDismissedAtomFamily(workspaceId)); const [, setEditorMode] = useAtom(editorModeAtomFamily(workspaceId)); const closePath = useCallback( @@ -89,6 +91,13 @@ export function useOpenEditorsActions(workspaceId: string, options?: UseOpenEdit setEditorMode("edit"); } + const shouldDismissPreview = + diffPreview?.source === "file" && + shouldClearDiffPreview(diffPreview, resolution.removedPaths, resolution.shouldExitEditor); + if (shouldDismissPreview) { + setDiffPreviewDismissed(true); + } + setDiffPreview((current) => shouldClearDiffPreview(current, resolution.removedPaths, resolution.shouldExitEditor) ? null @@ -97,9 +106,11 @@ export function useOpenEditorsActions(workspaceId: string, options?: UseOpenEdit }, [ activeFilePath, + diffPreview, openFiles, setActiveFilePath, setDiffPreview, + setDiffPreviewDismissed, setEditorMode, setOpenFiles, workspaceId, @@ -137,6 +148,14 @@ export function useOpenEditorsActions(workspaceId: string, options?: UseOpenEdit setActiveFilePath(null); setEditorMode("edit"); + const shouldDismissPreview = + diffPreview?.source === "file" && + shouldClearDiffPreview(diffPreview, resolution.removedPaths, resolution.shouldExitEditor, { + preserveCommitPreviewOnExit: true, + }); + if (shouldDismissPreview) { + setDiffPreviewDismissed(true); + } setDiffPreview((current) => shouldClearDiffPreview(current, resolution.removedPaths, resolution.shouldExitEditor, { preserveCommitPreviewOnExit: true, @@ -146,9 +165,11 @@ export function useOpenEditorsActions(workspaceId: string, options?: UseOpenEdit ); }, [ activeFilePath, + diffPreview, openFiles, setActiveFilePath, setDiffPreview, + setDiffPreviewDismissed, setEditorMode, setOpenFiles, workspaceId, diff --git a/packages/web/src/features/workspace/views/shared/git-panel.test.tsx b/packages/web/src/features/workspace/views/shared/git-panel.test.tsx index e180210c..adcc4e23 100644 --- a/packages/web/src/features/workspace/views/shared/git-panel.test.tsx +++ b/packages/web/src/features/workspace/views/shared/git-panel.test.tsx @@ -1032,7 +1032,7 @@ describe("GitPanel", () => { expect(dispatchEventSpy).not.toHaveBeenCalled(); }); - it("auto-selects the first change from hydrated state without emitting a workspace diff event", async () => { + it("does not auto-select the first change from hydrated state", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string, args: unknown) => { if (op === "git.diff") { return { @@ -1069,23 +1069,17 @@ describe("GitPanel", () => { ); await waitFor(() => { - expect(sendCommand).toHaveBeenCalledWith( - "git.diff", - { - workspaceId: "ws-test", - path: "src/auth/AuthGate.tsx", - staged: true, - }, - undefined - ); + expect(screen.getByText("AuthGate.tsx")).toBeInTheDocument(); }); - expect(store.get(gitDiffPreviewAtomFamily("ws-test"))).toEqual({ - path: "src/auth/AuthGate.tsx", - diff: expect.stringContaining("diff --git a/src/auth/AuthGate.tsx b/src/auth/AuthGate.tsx"), - staged: true, - source: "file", - }); + expect(sendCommand).not.toHaveBeenCalledWith( + "git.diff", + expect.objectContaining({ + workspaceId: "ws-test", + }) + ); + expect(store.get(gitDiffPreviewAtomFamily("ws-test"))).toBeNull(); + expect(document.querySelector(".git-row.active")).toBeNull(); expect(dispatchEventSpy).not.toHaveBeenCalled(); }); From e6f928d1d64e6ccd0bb4760db2c306d7ec1f6c89 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 15:36:00 +0800 Subject: [PATCH 11/36] fix(web): stop auto-activating next editor on close --- .../src/features/code-editor/index.test.tsx | 35 ++++++++----------- .../actions/open-editors-close.test.ts | 18 +++++----- .../workspace/actions/open-editors-close.ts | 10 ++---- .../mobile/workspace-mobile-view.test.tsx | 7 ++-- 4 files changed, 30 insertions(+), 40 deletions(-) diff --git a/packages/web/src/features/code-editor/index.test.tsx b/packages/web/src/features/code-editor/index.test.tsx index f7561185..9b1181b7 100644 --- a/packages/web/src/features/code-editor/index.test.tsx +++ b/packages/web/src/features/code-editor/index.test.tsx @@ -477,7 +477,7 @@ describe("CodeEditorHost", () => { }); }); - it("closing a pending active file from the header reactivates the remaining loaded editor and ignores the late load", async () => { + it("closing a pending active file from the header exits the editor and ignores the late load", async () => { const pendingRead = createDeferred<{ kind: "text"; content: string; @@ -519,7 +519,7 @@ describe("CodeEditorHost", () => { fireEvent.click(screen.getByRole("button", { name: "Close" })); - expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/a.ts"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); expect(store.get(openFilesAtomFamily("ws-1"))).toMatchObject({ "src/a.ts": expect.objectContaining({ content: "alpha" }), }); @@ -534,12 +534,12 @@ describe("CodeEditorHost", () => { }); await waitFor(() => { - expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/a.ts"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); expect(store.get(openFilesAtomFamily("ws-1"))["src/b.ts"]).toBeUndefined(); }); }); - it("closing a pending active file from the shared open editors list reactivates the remaining loaded editor", async () => { + it("closing a pending active file from the shared open editors list exits the editor", async () => { const pendingRead = createDeferred<{ kind: "text"; content: string; @@ -585,7 +585,7 @@ describe("CodeEditorHost", () => { .closest(".workspace-open-editors__row") as HTMLElement; fireEvent.click(within(activeRow).getByRole("button", { name: "Close src/b.ts" })); - expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/a.ts"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); await act(async () => { pendingRead.resolve({ @@ -597,12 +597,12 @@ describe("CodeEditorHost", () => { }); await waitFor(() => { - expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/a.ts"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); expect(store.get(openFilesAtomFamily("ws-1"))["src/b.ts"]).toBeUndefined(); }); }); - it("closing the lexicographically last active editor from open editors reactivates the previous sorted editor", async () => { + it("closing the active editor from open editors exits the editor instead of reactivating another tab", async () => { const { store } = setupStore({ activePath: "src/c.ts", openFiles: { @@ -647,8 +647,8 @@ describe("CodeEditorHost", () => { .closest(".workspace-open-editors__row") as HTMLElement; fireEvent.click(within(activeRow).getByRole("button", { name: "Close src/c.ts" })); - expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/b.ts"); - expect(screen.getByTestId("monaco-host")).toHaveTextContent("beta"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); + expect(screen.queryByTestId("monaco-host")).not.toBeInTheDocument(); }); it("cancels an older pending load when switching to a different path so it cannot resurrect after the newer path closes", async () => { @@ -749,7 +749,7 @@ describe("CodeEditorHost", () => { expect(sendCommand).not.toHaveBeenCalled(); }); - it("closing the active editor from the header switches to the next sorted open file", async () => { + it("closing the active editor from the header exits to the empty editor state", async () => { const { store } = setupStore({ activePath: "src/c.ts", openFiles: { @@ -803,15 +803,10 @@ describe("CodeEditorHost", () => { fireEvent.click(closeBtn); - expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/d.ts"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); expect(store.get(openFilesAtomFamily("ws-1"))["src/c.ts"]).toBeUndefined(); expect(store.get(editorModeAtomFamily("ws-1"))).toBe("edit"); - expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toEqual({ - path: "src/unrelated.ts", - diff: "diff --git a/src/unrelated.ts b/src/unrelated.ts", - staged: false, - source: "file", - }); + expect(store.get(gitDiffPreviewAtomFamily("ws-1"))).toBeNull(); expect(mockRegistryDisposeFile).toHaveBeenCalledWith("/tmp/ws", "src/c.ts"); }); @@ -1582,7 +1577,7 @@ describe("CodeEditorHost", () => { expect(screen.queryByText(/changed on disk/i)).not.toBeInTheDocument(); }); - it("clears a stale save error after closing the failed file from the sidebar and switching active file", async () => { + it("clears a stale save error after closing the failed file from the sidebar without switching active file", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string, args?: { path?: string }) => { if (op === "file.write" && args?.path === "src/a.ts") { throw new Error("Save failed on A"); @@ -1639,11 +1634,11 @@ describe("CodeEditorHost", () => { fireEvent.click(within(activeRow).getByRole("button", { name: "Close src/a.ts" })); await waitFor(() => { - expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/b.ts"); + expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull(); }); expect(screen.queryByText("Save failed on A")).not.toBeInTheDocument(); - expect(screen.getByTestId("monaco-host")).toHaveTextContent("saved b"); + expect(screen.queryByTestId("monaco-host")).not.toBeInTheDocument(); }); it("keeps save state scoped to the active file when switching during an in-flight save", async () => { diff --git a/packages/web/src/features/workspace/actions/open-editors-close.test.ts b/packages/web/src/features/workspace/actions/open-editors-close.test.ts index 31e2bb5b..87b72a3d 100644 --- a/packages/web/src/features/workspace/actions/open-editors-close.test.ts +++ b/packages/web/src/features/workspace/actions/open-editors-close.test.ts @@ -46,7 +46,7 @@ describe("resolveOpenEditorsClose", () => { }); }); - it("closing the active file selects the next editor when available later in sorted order", () => { + it("closing the active file exits the editor instead of selecting the next editor", () => { expect( resolveOpenEditorsClose({ openFiles: { @@ -60,12 +60,12 @@ describe("resolveOpenEditorsClose", () => { ).toEqual({ orderedPaths: ["src/a.ts", "src/b.ts", "src/c.ts"], removedPaths: ["src/b.ts"], - nextActiveFilePath: "src/c.ts", - shouldExitEditor: false, + nextActiveFilePath: null, + shouldExitEditor: true, }); }); - it("closing the active last item selects the previous editor", () => { + it("closing the active last item exits the editor", () => { expect( resolveOpenEditorsClose({ openFiles: { @@ -79,12 +79,12 @@ describe("resolveOpenEditorsClose", () => { ).toEqual({ orderedPaths: ["src/a.ts", "src/b.ts", "src/c.ts"], removedPaths: ["src/c.ts"], - nextActiveFilePath: "src/b.ts", - shouldExitEditor: false, + nextActiveFilePath: null, + shouldExitEditor: true, }); }); - it("closing a pending active editor selects the previous loaded editor by shared sorted order", () => { + it("closing a pending active editor exits the editor", () => { expect( resolveOpenEditorsClose({ openFiles: { @@ -97,8 +97,8 @@ describe("resolveOpenEditorsClose", () => { ).toEqual({ orderedPaths: ["src/a.ts", "src/b.ts"], removedPaths: ["src/b.ts"], - nextActiveFilePath: "src/a.ts", - shouldExitEditor: false, + nextActiveFilePath: null, + shouldExitEditor: true, }); }); diff --git a/packages/web/src/features/workspace/actions/open-editors-close.ts b/packages/web/src/features/workspace/actions/open-editors-close.ts index 9dd1202e..2f17610a 100644 --- a/packages/web/src/features/workspace/actions/open-editors-close.ts +++ b/packages/web/src/features/workspace/actions/open-editors-close.ts @@ -62,16 +62,10 @@ export function resolveOpenEditorsClose( }; } - const closingIndex = resolvedOrderedPaths.indexOf(targetPath); - const remainingPaths = resolvedOrderedPaths.filter((path) => path !== targetPath); - - const nextActiveFilePath = - remainingPaths[closingIndex] ?? remainingPaths[closingIndex - 1] ?? null; - return { orderedPaths: resolvedOrderedPaths, removedPaths: [targetPath], - nextActiveFilePath, - shouldExitEditor: nextActiveFilePath === null, + nextActiveFilePath: null, + shouldExitEditor: true, }; } diff --git a/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx b/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx index 172cc022..5b5e4b47 100644 --- a/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx +++ b/packages/web/src/features/workspace/views/mobile/workspace-mobile-view.test.tsx @@ -250,7 +250,7 @@ describe("WorkspaceMobileView", () => { vi.restoreAllMocks(); }); - it("rebinds the mobile files detail route to the next active editor when the mobile header closes the current file", async () => { + it("closes the mobile files sheet when the mobile header closes the current file", async () => { const store = renderMobileView({ activePath: "src/a.ts", openFiles: { @@ -281,8 +281,9 @@ describe("WorkspaceMobileView", () => { fireEvent.click(screen.getByRole("button", { name: "Close" })); await waitFor(() => { - expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/b.ts"); - expect(screen.getByRole("heading", { level: 2, name: "b.ts" })).toBeInTheDocument(); + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBeNull(); + expect(screen.queryByTestId("mobile-files-sheet-root")).not.toBeInTheDocument(); + expect(screen.queryByTestId("mobile-files-sheet-detail")).not.toBeInTheDocument(); }); }); From e36584d5fcaf75b5b138a0806d42ee7c8ca691bc Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 15:47:22 +0800 Subject: [PATCH 12/36] docs: add bilingual release notes for v0.4.1-v0.4.4 --- docs/promotion/releases/README.md | 4 ++ docs/promotion/releases/v0.4.1.md | 85 +++++++++++++++++++++++++++++ docs/promotion/releases/v0.4.2.md | 85 +++++++++++++++++++++++++++++ docs/promotion/releases/v0.4.3.md | 73 +++++++++++++++++++++++++ docs/promotion/releases/v0.4.4.md | 89 +++++++++++++++++++++++++++++++ 5 files changed, 336 insertions(+) create mode 100644 docs/promotion/releases/v0.4.1.md create mode 100644 docs/promotion/releases/v0.4.2.md create mode 100644 docs/promotion/releases/v0.4.3.md create mode 100644 docs/promotion/releases/v0.4.4.md diff --git a/docs/promotion/releases/README.md b/docs/promotion/releases/README.md index 9c9247fb..7dff80f0 100644 --- a/docs/promotion/releases/README.md +++ b/docs/promotion/releases/README.md @@ -18,6 +18,10 @@ Add a release narrative when a version is published to GitHub and npm, especiall ## Current releases +- [v0.4.4](v0.4.4.md) +- [v0.4.3](v0.4.3.md) +- [v0.4.2](v0.4.2.md) +- [v0.4.1](v0.4.1.md) - [v0.4.0](v0.4.0.md) - [v0.3.6](v0.3.6.md) - [v0.3.5](v0.3.5.md) diff --git a/docs/promotion/releases/v0.4.1.md b/docs/promotion/releases/v0.4.1.md new file mode 100644 index 00000000..571aa04d --- /dev/null +++ b/docs/promotion/releases/v0.4.1.md @@ -0,0 +1,85 @@ +# Coder Studio v0.4.1 + +## 中文 + +### 这个版本为什么重要 + +`v0.4.1` 从 package changelog 看起来像一次 CI 修补版,但实际发布出去的产品变化更宽。这个版本继续推进统一编辑器工作流,把应用内更新发现入口放进工作区底栏,并补了一轮 session 可视化、设置页和移动端细节打磨,最后再把 Windows 打包和 CI 构建问题收口。 + +实际体验上,这个版本意味着: + +- 文件预览、编辑和 diff 切换更一致,编辑器工具栏行为更连贯 +- 工作区底栏可以发现新版本,并在支持的安装形态下进入应用内更新流程 +- workspace 标签更容易辨认真实 session 状态,回到旧工作区时上下文识别更直观 +- 设置页、移动端底栏、弹层层级和 LSP 路径处理都更稳,同时修复了 Windows 发布构建链路 + +### 这个版本包含哪些变化 + +- 统一 editor mode state,并继续收拢 preview、edit、diff 三种编辑器模式的切换行为 +- 修正代码编辑器预览工具栏交互,减少模式切换时的割裂感 +- 新增应用内更新流程、底栏 update rail,以及相关状态目录迁移基础 +- 在 workspace 标签里显示更真实的 session mini-map 和布局背景提示 +- 打磨 Settings About 控件、设置导航对齐、移动端工作区底栏,以及 sheet 和共享 modal 的层级关系 +- 修复通过 workspace symlink 打开的文档在 LSP 路径解析上的问题 +- 改进 GitHub Wiki 发布脚本与维护者发布流程 +- 修复 CI 流水线和 standalone server build 校验,恢复 Windows 包构建稳定性 + +### 谁最受益 + +- 频繁在预览、编辑和 diff 之间切换的开发者 +- 希望在产品内部看到新版本并触发更新流程的自托管用户 +- 同时管理多个 workspace,或隔一段时间再返回旧 session 的用户 +- 依赖 Windows 打包链路和项目发布流程稳定性的贡献者 + +### 安装或升级 + +```bash +npm install -g @spencer-kit/coder-studio +coder-studio open +``` + +### 完整变更 + +- [Compare `v0.4.0...v0.4.1`](https://github.com/spencerkit/coder-studio/compare/v0.4.0...v0.4.1) + +## English + +### Why this release matters + +`v0.4.1` looks like a CI repair patch from the package changelog, but the shipped product changes are broader. This release keeps pushing the unified editor workflow forward, moves update discovery into the workspace footer rail, and adds another round of polish to session visibility, settings surfaces, and mobile behavior before closing out the Windows packaging and CI build issues. + +In practice, this release means: + +- preview, edit, and diff transitions feel more consistent inside the editor surface +- the workspace footer can surface new releases and start an in-app update flow where the install shape supports it +- workspace tabs make live session state easier to recognize when returning to an older workspace +- settings, mobile footer layout, overlay layering, and LSP path handling are steadier, while the Windows release build path is repaired + +### Included in v0.4.1 + +- unify editor mode state and continue aligning preview, edit, and diff behavior inside the editor surface +- fix code editor preview toolbar behavior so mode transitions feel less fragmented +- add the in-app update flow, the footer update rail, and related state-directory migration groundwork +- show real session presence more clearly in workspace tabs with a session mini-map and layout background treatment +- polish Settings About controls, settings navigation alignment, the mobile workspace footer, and sheet-versus-modal layering +- resolve LSP document paths through workspace symlinks +- improve the GitHub wiki publish workflow for maintainers +- repair the CI pipeline and standalone server build verification so Windows package publishing works again + +### Who benefits most + +- developers moving frequently between preview, edit, and diff workflows +- self-hosters who want in-product visibility into new releases and supported update paths +- users juggling multiple workspaces or returning to an older session later +- contributors who depend on stable Windows packaging and cleaner project publishing flows + +### Install or upgrade + +```bash +npm install -g @spencer-kit/coder-studio +coder-studio open +``` + +### Full changelog + +- [Compare `v0.4.0...v0.4.1`](https://github.com/spencerkit/coder-studio/compare/v0.4.0...v0.4.1) diff --git a/docs/promotion/releases/v0.4.2.md b/docs/promotion/releases/v0.4.2.md new file mode 100644 index 00000000..4631fbc9 --- /dev/null +++ b/docs/promotion/releases/v0.4.2.md @@ -0,0 +1,85 @@ +# Coder Studio v0.4.2 + +## 中文 + +### 这个版本为什么重要 + +`v0.4.2` 把 Markdown 和 HTML 预览真正带进了产品,而不是停留在设计稿里。它同时继续统一终端恢复链路,并补了一轮诊断、刷新恢复、移动端模式切换和 Windows 路径安全修正,让“读文档、看页面、断线恢复、刷新继续”这些高频动作更连贯。 + +实际体验上,这个版本意味着: + +- workspace 里可以直接预览 Markdown 文档和静态 HTML 文件,不必总是切回本地工具 +- 终端恢复协议和 websocket 恢复流程更统一,刷新或重连后的恢复语义更清晰 +- 运行时诊断、supervisor 刷新恢复、关闭 session 后的提示态和移动端文件细节切换都更稳 +- Windows 路径安全和 server CI 构建检查进一步收紧,减少跨平台发布意外 + +### 这个版本包含哪些变化 + +- 新增 preview session store、resource loader、HTML preview routes 和 Markdown 渲染链路 +- 在 web 端加入 preview API、文件类型识别,以及 Markdown / HTML 预览界面 +- 收紧 preview asset 路径解析,并补齐 preview 相关 runtime 依赖声明 +- 为终端恢复补上共享协议类型,并统一 websocket recovery flow +- 细化 diagnostics runtime checks,提升对本地运行时环境的识别质量 +- 修复 supervisor 刷新后的状态 hydration、关闭 session 后的 recovery overlay,以及窄容器下状态 chips 抖动问题 +- 统一移动端文件 detail 和 mode switching 行为,并恢复 standalone UI preview 入口 +- 加固 Windows 路径安全检查,并恢复 server CI build + +### 谁最受益 + +- 希望直接在 workspace 中阅读 Markdown 文档或预览静态 HTML 的用户 +- 依赖浏览器终端持续工作的开发者 +- 经常刷新页面、重连 session 或在移动端查看文件的用户 +- 需要更稳定 Windows 路径处理和 CI 构建链路的维护者 + +### 安装或升级 + +```bash +npm install -g @spencer-kit/coder-studio +coder-studio open +``` + +### 完整变更 + +- [Compare `v0.4.1...v0.4.2`](https://github.com/spencerkit/coder-studio/compare/v0.4.1...v0.4.2) + +## English + +### Why this release matters + +`v0.4.2` turns Markdown and HTML preview into a shipped product capability instead of a design-only idea. It also keeps tightening terminal recovery, diagnostics, refresh restore, mobile mode switching, and Windows path safety so the common flow of reading docs, previewing pages, reconnecting, and resuming work feels more coherent. + +In practice, this release means: + +- you can preview Markdown documents and static HTML files directly inside the workspace +- terminal recovery protocol and websocket recovery behavior are more unified, making refresh and reconnect semantics easier to trust +- runtime diagnostics, supervisor refresh restore, closed-session recovery states, and mobile file detail switching are more dependable +- Windows path safety and server CI build checks are stricter, reducing cross-platform release surprises + +### Included in v0.4.2 + +- add the preview session store, resource loader, HTML preview routes, and Markdown rendering pipeline on the server +- add the preview API, file classification, and Markdown / HTML preview surfaces on the web side +- tighten preview asset path resolution and declare the required preview runtime dependencies +- add shared terminal recovery protocol types and unify the websocket recovery flow +- refine runtime diagnostics checks for clearer local environment detection +- fix supervisor state hydration after refresh, improve the closed-session recovery overlay, and stabilize status chips in narrow containers +- unify mobile file detail and mode switching behavior, and restore the standalone UI preview entry +- harden Windows path safety checks and restore the server CI build + +### Who benefits most + +- users who want to read Markdown docs or preview static HTML directly inside the workspace +- developers relying on long-lived browser terminal sessions +- users who refresh, reconnect, or inspect files from mobile frequently +- maintainers who need steadier Windows path handling and CI builds + +### Install or upgrade + +```bash +npm install -g @spencer-kit/coder-studio +coder-studio open +``` + +### Full changelog + +- [Compare `v0.4.1...v0.4.2`](https://github.com/spencerkit/coder-studio/compare/v0.4.1...v0.4.2) diff --git a/docs/promotion/releases/v0.4.3.md b/docs/promotion/releases/v0.4.3.md new file mode 100644 index 00000000..f2595cac --- /dev/null +++ b/docs/promotion/releases/v0.4.3.md @@ -0,0 +1,73 @@ +# Coder Studio v0.4.3 + +## 中文 + +### 这个版本为什么重要 + +`v0.4.3` 是一次非常聚焦的可靠性补丁,目标只有一个:让终端恢复在边界情况下也不要把输出弄丢。它修复了 noop / closed reconcile 时 live chunk 被卡住的问题,也修掉了 snapshot hydration 后补刷 live chunk 时意外清空终端内容的回放错误,并顺手加固了更新后的重启交接。 + +实际体验上,这个版本意味着: + +- 短暂断线或恢复后,终端输出更不容易卡住或消失 +- 快照恢复后的后续 live 输出不会再把已有终端内容清空 +- 通过应用内更新触发重启时,交接行为更稳 + +### 这个版本包含哪些变化 + +- 完成 terminal recovery replay 对 noop 和 closed reconcile 决策的覆盖 +- 修复 snapshot hydration 之后队列中的 live chunks 回放时重置 xterm 的问题 +- 修复 auto-update restart handoff +- 补上针对 noop recovery 与 snapshot-plus-pending-chunks 场景的回归覆盖 + +### 谁最受益 + +- 运行长时间 AI 编码任务的用户 +- 经常遇到短暂网络抖动、刷新或恢复场景的开发者 +- 使用应用内更新流程并希望重启切换更平滑的自托管用户 + +### 安装或升级 + +```bash +npm install -g @spencer-kit/coder-studio +coder-studio open +``` + +### 完整变更 + +- [Compare `v0.4.2...v0.4.3`](https://github.com/spencerkit/coder-studio/compare/v0.4.2...v0.4.3) + +## English + +### Why this release matters + +`v0.4.3` is a tightly focused reliability patch with one clear goal: terminal recovery should not lose output in edge cases. It fixes the case where live chunks could get stranded after noop or closed reconcile decisions, prevents snapshot-following live replay from wiping the terminal body, and hardens the restart handoff used by the update flow. + +In practice, this release means: + +- terminal output is less likely to stall or disappear after a short disconnect or recovery cycle +- live output flushed after snapshot hydration no longer clears the terminal body +- restart handoff is steadier when an in-app update triggers a restart + +### Included in v0.4.3 + +- complete terminal recovery replay handling for noop and closed reconcile decisions +- fix the xterm reset path for queued live chunks flushed after snapshot hydration +- repair the auto-update restart handoff +- add regression coverage for noop recovery and snapshot-plus-pending-chunks recovery paths + +### Who benefits most + +- users running long AI-assisted coding tasks +- developers frequently hitting reconnect, refresh, or recovery scenarios +- self-hosters using the in-app update flow and expecting smoother restart transitions + +### Install or upgrade + +```bash +npm install -g @spencer-kit/coder-studio +coder-studio open +``` + +### Full changelog + +- [Compare `v0.4.2...v0.4.3`](https://github.com/spencerkit/coder-studio/compare/v0.4.2...v0.4.3) diff --git a/docs/promotion/releases/v0.4.4.md b/docs/promotion/releases/v0.4.4.md new file mode 100644 index 00000000..13fb145e --- /dev/null +++ b/docs/promotion/releases/v0.4.4.md @@ -0,0 +1,89 @@ +# Coder Studio v0.4.4 + +## 中文 + +### 这个版本为什么重要 + +`v0.4.4` 是一轮围绕 workspace 导航和 open editors 管理的集中整理。它把 desktop workbench sidebar、sidebar search、quick open 和新的移动端搜索/资源管理器面板拼成了一套更统一的导航模型,同时把 Open Editors 真正做成一个可依赖的控制面,让桌面端和移动端在“找文件、切文件、关文件、回到原工作区”这些高频动作上更接近同一个产品。 + +实际体验上,这个版本意味着: + +- 桌面端和移动端都拥有更清晰的 workspace 导航,包含 sidebar search、quick open、mobile search panel 和 explorer sections +- workspace tab 实例隔离更明确,路由切换后保留当前 active workspace 的能力更稳 +- Open Editors 在桌面端和移动端的排序、路径展示、关闭动作和重开行为更一致,也更不容易被异步加载竞态打乱 +- terminal recovery overlay 的出现时机和状态表达更克制,supervisor 的提示词也更强调计划和交付质量 + +### 这个版本包含哪些变化 + +- 新增 desktop workbench sidebar shell、sidebar search、quick open,以及可折叠的搜索结果分组 +- 打磨 quick open 行、workspace search chrome 和 quick jump 搜索可达性 +- 新增 mobile explorer sections、mobile search panel variant,并让移动端三视图结构向桌面端对齐 +- 隔离 workspace tab instances,并修复 route change 后 active workspace 恢复问题 +- 为 Open Editors 新增共享 close decisions 与 close actions,并统一桌面端和移动端控制行为 +- 收紧 Open Editors 的 deterministic ordering、路径截断和相关回归覆盖 +- 修复没有 open buffer 时的关闭回退、晚到的 closed editor load,以及 pending-load reopen races +- 延后 terminal recovery overlay 的显示时机,并细化 recovery UX states +- 将 settings activation errors 收口到 session gate,并修复 update restart handoff 的 process tree 问题 +- 将 supervisor prompt 提升为 planner-supervisor 导向,强化 execution planning 和 delivery quality bar + +### 谁最受益 + +- 大量依赖 sidebar、quick open 和搜索在工作区里高频穿梭的用户 +- 在桌面端和移动端之间来回切换的开发者 +- 经常同时打开多个文件、频繁关闭和重开的用户 +- 依赖更稳定恢复体验和更明确 supervisor 执行导向的重度使用者 + +### 安装或升级 + +```bash +npm install -g @spencer-kit/coder-studio +coder-studio open +``` + +### 完整变更 + +- [Compare `v0.4.3...v0.4.4`](https://github.com/spencerkit/coder-studio/compare/v0.4.3...v0.4.4) + +## English + +### Why this release matters + +`v0.4.4` is a concentrated workspace navigation and open-editors release. It combines the desktop workbench sidebar, sidebar search, quick open, and new mobile search / explorer panels into a more unified navigation model, while turning Open Editors into a control surface you can rely on across desktop and mobile. The result is a steadier experience around finding files, switching files, closing files, and returning to the right workspace. + +In practice, this release means: + +- both desktop and mobile get clearer workspace navigation with sidebar search, quick open, a mobile search panel, and explorer sections +- workspace tab instances are isolated more cleanly, and the active workspace survives route changes more reliably +- Open Editors behaves more consistently across desktop and mobile for ordering, path display, close actions, and reopen behavior, with better protection against async race conditions +- terminal recovery overlay timing and recovery states feel calmer, while supervisor guidance pushes harder on planning and delivery quality + +### Included in v0.4.4 + +- add the desktop workbench sidebar shell, sidebar search, quick open, and collapsible search result groups +- refine quick open rows, workspace search chrome, and quick jump accessibility +- add mobile explorer sections and a mobile search panel variant, and align the mobile three-view structure more closely with desktop +- isolate workspace tab instances and preserve the active workspace across route changes +- add shared open-editor close decisions and close actions, then unify the controls across desktop and mobile +- tighten deterministic open-editor ordering, path truncation, and regression coverage +- fix close fallback without an open buffer, ignore late closed-editor loads, and repair pending-load reopen races +- delay terminal recovery overlay presentation and refine recovery UX states +- route settings activation errors through the session gate and fix the update restart-handoff process tree +- rework the supervisor prompt toward a planner-supervisor model with a stronger execution and delivery quality bar + +### Who benefits most + +- users who move through the workspace heavily with sidebar navigation, quick open, and search +- developers switching between desktop and mobile browser sessions +- users who keep many files open and frequently close and reopen editors +- heavy users who depend on steadier recovery behavior and clearer supervisor execution guidance + +### Install or upgrade + +```bash +npm install -g @spencer-kit/coder-studio +coder-studio open +``` + +### Full changelog + +- [Compare `v0.4.3...v0.4.4`](https://github.com/spencerkit/coder-studio/compare/v0.4.3...v0.4.4) From 0ff2dbb7726285028e568eccbaa2daad8ec2f386 Mon Sep 17 00:00:00 2001 From: Spencer Date: Sun, 24 May 2026 09:41:47 +0000 Subject: [PATCH 13/36] docs: add desktop file tree drag-to-terminal design --- ...sktop-file-tree-drag-to-terminal-design.md | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-24-desktop-file-tree-drag-to-terminal-design.md diff --git a/docs/superpowers/specs/2026-05-24-desktop-file-tree-drag-to-terminal-design.md b/docs/superpowers/specs/2026-05-24-desktop-file-tree-drag-to-terminal-design.md new file mode 100644 index 00000000..75f8776b --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-desktop-file-tree-drag-to-terminal-design.md @@ -0,0 +1,203 @@ +# Desktop File Tree Drag To Terminal — Design + +Date: 2026-05-24 +Status: Draft +Owner: codex + +## Problem + +桌面端左侧资源树目前只支持点击、展开、右键等操作,不支持把文件或文件夹直接拖到终端。用户在终端里经常需要输入某个 workspace 内文件的路径;逐字输入路径慢,右键菜单再找“复制路径”也比直接拖放多一步。 + +项目已经支持“外部文件拖入终端后上传并把路径写入终端”,但这条链路面向本地文件,不适合处理 workspace 内已存在的树节点。对内部树节点来说,正确行为应该是:不上传、不改文件系统,只把相对 workspace 根目录的路径直接注入到当前活动终端。 + +## Goals + +- 桌面端左侧文件树中的 `file` 和 `dir` 节点可以拖入当前活动终端。 +- drop 成功后,终端输入区写入相对 workspace 根目录的路径。 +- 路径使用现有 shell 单引号转义逻辑,末尾追加一个空格,和现有上传文件后的行为一致。 +- 复用现有终端输入通路,不新增后端接口、不改 PTY 协议。 +- 不影响现有“外部文件拖入终端上传”的能力。 + +## Non-Goals + +- 不覆盖移动端。 +- 不覆盖搜索结果列表。 +- 不覆盖 `Open Editors` 区域。 +- 不做“相对当前终端 cwd”的路径计算;v1 只基于 workspace 根目录。 +- 不做多选拖拽。 +- 不做新的拖拽浮层、插入预览或复杂视觉反馈。 + +## User Flow + +1. 用户在桌面端左侧文件树中按住一个文件或文件夹节点开始拖拽。 +2. 文件树节点在 `dragstart` 时向 `dataTransfer` 写入一个 app 内部自定义 payload,并同步写入纯文本路径作为兜底。 +3. 用户把节点拖到当前活动终端区域。 +4. 终端在 `dragover` 检测到这是文件树路径拖拽后允许 drop。 +5. 终端在 `drop` 时读取该 payload,校验它属于当前 workspace。 +6. 终端将路径交给现有 `sendTextToTerminal()`,写入 `'relative/path' ` 这样的文本。 +7. 如果 payload 无效或 workspace 不匹配,则忽略此次 drop,并给出轻量错误提示;终端不插入任何文本。 + +## Architecture + +``` +┌──────────────────── packages/web / workspace ────────────────────┐ +│ FileTreePanel │ +│ FileTreeNode (desktop only, draggable) │ +│ dragstart │ +│ ├─ setData("application/x-coder-studio-workspace-path", …) │ +│ └─ setData("text/plain", relativePath) │ +└───────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────── packages/web / terminal ─────────────────────┐ +│ usePasteDropUpload │ +│ dragover │ +│ ├─ Files => existing upload path │ +│ └─ workspace-path mime => allow drop │ +│ drop │ +│ ├─ Files => existing upload path │ +│ └─ workspace-path mime => quote + sendTextToTerminal() │ +└───────────────────────────────────────────────────────────────────┘ +``` + +这次改动只在 web 侧完成。服务端、WebSocket 协议、终端 session 管理逻辑都不需要变更。 + +## Drag Payload Contract + +新增一个前端共享的 drag/drop helper,集中定义 MIME type 与序列化逻辑,避免文件树和终端两边硬编码字符串。 + +### MIME Type + +`application/x-coder-studio-workspace-path` + +### Payload Shape + +```json +{ + "workspaceId": "ws_123", + "path": "packages/web/src/main.tsx", + "kind": "file" +} +``` + +字段约束: + +- `workspaceId`: 当前资源树所属 workspace id。 +- `path`: 相对 workspace 根目录的路径,直接复用文件树节点现有 `node.path`。 +- `kind`: `"file"` 或 `"dir"`,当前版本主要用于调试和后续扩展;drop 行为暂时一致。 + +### Plain Text Fallback + +同时写入 `text/plain = path`。这不是 v1 的主要解析通道,但保留它有两个价值: + +- 便于浏览器拖拽调试和开发者工具排查。 +- 为后续把同一拖拽源接到别的消费方预留一个低门槛文本格式。 + +终端 v1 只在检测到自定义 MIME 时才走“内部路径插入”逻辑;不会因为任意 `text/plain` drop 就抢占文本拖拽行为。 + +## Frontend Changes + +### File Tree + +变更目标文件: + +- `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` + +设计要求: + +- 仅 `variant === "desktop"` 的树节点启用 `draggable`。 +- `file` 和 `dir` 节点都可拖拽。 +- `dragstart` 时写入自定义 payload 与 `text/plain`。 +- 不改变现有点击、展开、右键菜单、移动端长按逻辑。 + +实现上建议抽一个很小的 helper,例如: + +- `serializeWorkspacePathDragPayload(...)` +- `setWorkspacePathDragData(dataTransfer, payload)` + +这样文件树只负责提供 `workspaceId` 和 `node` 数据,不关心终端侧解析细节。 + +### Terminal Drop Handling + +变更目标文件: + +- `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts` + +设计要求: + +- 保留现有 `Files` drop 处理顺序与上传链路。 +- 新增对自定义 MIME 的检测、解析与校验。 +- 解析成功后直接调用现有 `sendTextToTerminal()`。 +- 生成终端输入文本时复用现有 `quoteShellSingle()`。 +- 末尾追加一个空格:`'packages/web/src/main.tsx' ` + +推荐处理顺序: + +1. 如果 `dataTransfer.files.length > 0`,继续走现有上传流程。 +2. 否则检查 `application/x-coder-studio-workspace-path`。 +3. 如果 payload 可解析且 `workspaceId === current workspaceId`,则写入路径。 +4. 其它情况直接返回,不拦截普通非文件文本拖拽。 + +`dragover` 也要同步更新: + +- `types` 含 `Files` 时,维持现有 `preventDefault()`。 +- `types` 含自定义 MIME 时,也执行 `preventDefault()`,让浏览器允许 drop。 + +## Error Handling + +内部路径拖拽是同步前端操作,不涉及上传和服务端失败。主要错误面只有三类: + +1. payload 缺失或 JSON 非法 +2. payload 字段不完整 +3. payload 的 `workspaceId` 与当前终端不一致 + +处理策略: + +- 不向终端写入任何字符。 +- 不进入“upload busy”状态。 +- 使用现有 toast 机制提示用户“无法插入该路径”或“只能拖入当前 workspace 的文件”。 + +这是故障安全的默认行为:宁可忽略,也不要把错误文本注入到终端。 + +## Interaction Notes + +- 文件和文件夹行为一致,都只插入路径。 +- 不自动追加换行;用户仍然可以继续补命令参数。 +- 不新增 drop overlay,因为这个操作不需要等待网络或后台响应。 +- 首版不要求文件树行在拖拽时展示特殊样式,浏览器原生拖拽光标已足够表达交互。 + +## Testing + +### Frontend Unit / Component + +1. `file-tree-panel.test.tsx` + - 桌面端文件节点会设置 `draggable` + - `dragstart` 写入正确的自定义 MIME payload + - `dragstart` 同时写入 `text/plain` + - 移动端节点不启用拖拽 + +2. `use-paste-drop-upload.test.tsx` + - 内部 workspace-path drop 会调用 `sendTextToTerminal("'path' ")` + - 内部 drop 不会调用 `uploadFiles` + - workspace 不匹配时不会发送终端输入,并触发错误 toast + - 非 `Files` 且非自定义 MIME 的 drop 继续忽略,保持现有穿透行为 + +3. 回归 + - 现有外部文件 drop 上传测试继续通过 + - 多文件上传顺序与 quoted path 拼接行为不受影响 + +## Rollout + +- 一次合入,无 feature flag。 +- 只影响桌面端左侧资源树与终端之间的拖拽交互。 +- 由于不改后端,无迁移、无数据兼容负担。 + +## Risks + +- HTML5 Drag and Drop 在测试环境需要手工 mock `dataTransfer`,测试代码会比普通 click 稍繁琐。 +- 如果未来把同一能力扩展到搜索结果或 `Open Editors`,应复用同一个 drag helper,避免 payload 格式分叉。 +- 如果后续产品要支持“相对当前终端 cwd”,现有 payload 仍可复用,但终端侧解析逻辑需要增加 cwd 感知与路径换算。 + +## Open Questions + +无。v1 范围、路径基准和入口范围都已确认。 From f62fdab050d672cd6be6ec60be9c24ec3d46d745 Mon Sep 17 00:00:00 2001 From: Spencer Date: Sun, 24 May 2026 09:50:07 +0000 Subject: [PATCH 14/36] docs: add desktop file tree drag-to-terminal plan --- ...5-24-desktop-file-tree-drag-to-terminal.md | 737 ++++++++++++++++++ 1 file changed, 737 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-24-desktop-file-tree-drag-to-terminal.md diff --git a/docs/superpowers/plans/2026-05-24-desktop-file-tree-drag-to-terminal.md b/docs/superpowers/plans/2026-05-24-desktop-file-tree-drag-to-terminal.md new file mode 100644 index 00000000..c89d94a3 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-desktop-file-tree-drag-to-terminal.md @@ -0,0 +1,737 @@ +# Desktop File Tree Drag To Terminal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow desktop users to drag file-tree rows into the active terminal to insert shell-quoted workspace-relative paths without uploading anything. + +**Architecture:** Define one shared drag payload contract in `packages/web/src/lib`, then teach the terminal drop hook to recognize that payload before the existing file-upload path, and finally make desktop `FileTreeNode` rows emit the payload on `dragstart`. Keep the change web-only, leave search/mobile/open-editors untouched, and reuse the existing `quoteShellSingle()` plus `sendTextToTerminal()` path so terminal input stays consistent. + +**Tech Stack:** TypeScript, React 19, Vitest, Testing Library, Jotai, DOM Drag and Drop APIs + +**Spec reference:** `docs/superpowers/specs/2026-05-24-desktop-file-tree-drag-to-terminal-design.md` + +--- + +## File Structure + +**New files:** +- `packages/web/src/lib/workspace-path-drag.ts` +- `packages/web/src/lib/workspace-path-drag.test.ts` + +**Modified files:** +- `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts` +- `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx` +- `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` +- `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` + +**No changes in this plan:** +- `packages/server/**` +- `packages/web/src/features/workspace/views/shared/open-editors-section.tsx` +- `packages/web/src/features/workspace/views/shared/file-search*` +- `packages/web/src/features/workspace/views/mobile/**` +- terminal WebSocket protocol or PTY host code + +### Task 1: Add A Shared Workspace Path Drag Payload Helper + +**Files:** +- Create: `packages/web/src/lib/workspace-path-drag.ts` +- Test: `packages/web/src/lib/workspace-path-drag.test.ts` + +- [ ] **Step 1: Write the failing helper tests** + +Create `packages/web/src/lib/workspace-path-drag.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { + WORKSPACE_PATH_DRAG_MIME, + getWorkspacePathDragPayload, + hasWorkspacePathDragType, + setWorkspacePathDragData, +} from "./workspace-path-drag"; + +describe("workspace-path-drag", () => { + it("writes the custom mime payload and plain text path", () => { + const setData = vi.fn(); + const dataTransfer = { + effectAllowed: "none", + setData, + } as unknown as DataTransfer; + + setWorkspacePathDragData(dataTransfer, { + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }); + + expect(dataTransfer.effectAllowed).toBe("copy"); + expect(setData).toHaveBeenNthCalledWith( + 1, + WORKSPACE_PATH_DRAG_MIME, + JSON.stringify({ + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }) + ); + expect(setData).toHaveBeenNthCalledWith(2, "text/plain", "src/app.tsx"); + }); + + it("reads a valid payload only when the custom mime type is present", () => { + const dataTransfer = { + types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"], + getData: vi.fn((type: string) => + type === WORKSPACE_PATH_DRAG_MIME + ? JSON.stringify({ + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }) + : "src/app.tsx" + ), + } as unknown as DataTransfer; + + expect(hasWorkspacePathDragType(dataTransfer)).toBe(true); + expect(getWorkspacePathDragPayload(dataTransfer)).toEqual({ + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }); + }); + + it("returns null for invalid payloads", () => { + expect( + getWorkspacePathDragPayload({ + types: [WORKSPACE_PATH_DRAG_MIME], + getData: () => "{bad json", + } as unknown as DataTransfer) + ).toBeNull(); + + expect( + getWorkspacePathDragPayload({ + types: [WORKSPACE_PATH_DRAG_MIME], + getData: () => JSON.stringify({ workspaceId: "ws-1", path: "", kind: "file" }), + } as unknown as DataTransfer) + ).toBeNull(); + + expect( + getWorkspacePathDragPayload({ + types: ["text/plain"], + getData: () => "src/app.tsx", + } as unknown as DataTransfer) + ).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the helper tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/lib/workspace-path-drag.test.ts +``` + +Expected: FAIL because `src/lib/workspace-path-drag.ts` does not exist yet. + +- [ ] **Step 3: Write the minimal helper implementation** + +Create `packages/web/src/lib/workspace-path-drag.ts`: + +```ts +export const WORKSPACE_PATH_DRAG_MIME = "application/x-coder-studio-workspace-path"; + +export interface WorkspacePathDragPayload { + workspaceId: string; + path: string; + kind: "file" | "dir"; +} + +function isWorkspacePathDragPayload(value: unknown): value is WorkspacePathDragPayload { + if (!value || typeof value !== "object") { + return false; + } + + const payload = value as Record; + return ( + typeof payload.workspaceId === "string" && + payload.workspaceId.length > 0 && + typeof payload.path === "string" && + payload.path.length > 0 && + (payload.kind === "file" || payload.kind === "dir") + ); +} + +export function hasWorkspacePathDragType( + dataTransfer: Pick | null | undefined +): boolean { + return Array.from(dataTransfer?.types ?? []).includes(WORKSPACE_PATH_DRAG_MIME); +} + +export function setWorkspacePathDragData( + dataTransfer: Pick, + payload: WorkspacePathDragPayload +): void { + dataTransfer.effectAllowed = "copy"; + dataTransfer.setData(WORKSPACE_PATH_DRAG_MIME, JSON.stringify(payload)); + dataTransfer.setData("text/plain", payload.path); +} + +export function getWorkspacePathDragPayload( + dataTransfer: Pick | null | undefined +): WorkspacePathDragPayload | null { + if (!hasWorkspacePathDragType(dataTransfer)) { + return null; + } + + try { + const raw = dataTransfer?.getData(WORKSPACE_PATH_DRAG_MIME) ?? ""; + const parsed: unknown = JSON.parse(raw); + return isWorkspacePathDragPayload(parsed) ? parsed : null; + } catch { + return null; + } +} +``` + +- [ ] **Step 4: Run the helper tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/lib/workspace-path-drag.test.ts +``` + +Expected: PASS with 3 tests in `workspace-path-drag.test.ts`. + +- [ ] **Step 5: Commit the helper** + +Run: + +```bash +git add packages/web/src/lib/workspace-path-drag.ts packages/web/src/lib/workspace-path-drag.test.ts +git commit -m "feat: add workspace path drag payload helper" +``` + +Expected: a commit containing only the new helper and its tests. + +### Task 2: Teach Terminal Drop Handling About Internal Workspace Paths + +**Files:** +- Modify: `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts` +- Modify: `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx` + +- [ ] **Step 1: Write the failing drop-hook tests** + +Add the custom drag helpers near the existing `fireDrop()` utility in `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx`: + +```ts +import { WORKSPACE_PATH_DRAG_MIME } from "../../../lib/workspace-path-drag"; + +function fireWorkspacePathDragOver( + target: HTMLElement, + payload: { workspaceId: string; path: string; kind: "file" | "dir" } +) { + const event = new Event("dragover", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { + value: { + files: [], + types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"], + items: [], + getData: (type: string) => + type === WORKSPACE_PATH_DRAG_MIME ? JSON.stringify(payload) : payload.path, + }, + }); + target.dispatchEvent(event); + return event; +} + +function fireWorkspacePathDrop( + target: HTMLElement, + payload: { workspaceId: string; path: string; kind: "file" | "dir" } +) { + const event = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "dataTransfer", { + value: { + files: [], + types: [WORKSPACE_PATH_DRAG_MIME, "text/plain"], + items: [], + getData: (type: string) => + type === WORKSPACE_PATH_DRAG_MIME ? JSON.stringify(payload) : payload.path, + }, + }); + target.dispatchEvent(event); + return event; +} +``` + +Add these tests in the same file: + +```ts +it("prevents default for internal workspace drags and inserts a quoted relative path", async () => { + const store = createStore(); + const { result } = renderHook( + () => + usePasteDropUpload({ + containerRef: { current: container }, + workspaceId: "ws-1", + sendTextToTerminal: sendInput, + enabled: true, + }), + { wrapper: makeWrapper(store) } + ); + + const dragOver = fireWorkspacePathDragOver(container, { + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }); + expect(dragOver.defaultPrevented).toBe(true); + + await act(async () => { + const drop = fireWorkspacePathDrop(container, { + workspaceId: "ws-1", + path: "src/app.tsx", + kind: "file", + }); + expect(drop.defaultPrevented).toBe(true); + await flushAsyncWork(); + }); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(sendInput).toHaveBeenCalledWith("'src/app.tsx' "); + expect(result.current.busy).toBe(false); +}); + +it("rejects internal workspace drops from another workspace", async () => { + const store = createStore(); + const { result } = renderHook( + () => + usePasteDropUpload({ + containerRef: { current: container }, + workspaceId: "ws-1", + sendTextToTerminal: sendInput, + enabled: true, + }), + { wrapper: makeWrapper(store) } + ); + + await act(async () => { + fireWorkspacePathDrop(container, { + workspaceId: "ws-2", + path: "src/app.tsx", + kind: "file", + }); + await flushAsyncWork(); + }); + + expect(sendInput).not.toHaveBeenCalled(); + expect(store.get(toastsAtom)).toContainEqual( + expect.objectContaining({ + kind: "error", + title: "Drop failed", + }) + ); + expect(result.current.busy).toBe(false); +}); +``` + +- [ ] **Step 2: Run the terminal drop-hook tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx +``` + +Expected: FAIL on the new workspace-path drag tests because the hook still ignores the custom MIME payload, so `defaultPrevented` stays `false` and `sendTextToTerminal()` is never called. + +- [ ] **Step 3: Implement internal workspace-path drop parsing without touching upload flow** + +Update the imports and helpers in `packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts`: + +```ts +import { + getWorkspacePathDragPayload, + hasWorkspacePathDragType, +} from "../../../lib/workspace-path-drag"; +``` + +Add this callback next to the existing `handleText()` callback: + +```ts + const handleWorkspacePathDrop = useCallback( + async (dataTransfer: DataTransfer | null | undefined) => { + const payload = getWorkspacePathDragPayload(dataTransfer); + if (!payload) { + pushToast({ + kind: "error", + title: "Drop failed", + body: "Could not read the dragged workspace path.", + duration: 3_000, + }); + return; + } + + if (payload.workspaceId !== workspaceId) { + pushToast({ + kind: "error", + title: "Drop failed", + body: "You can only drop paths from the current workspace.", + duration: 3_000, + }); + return; + } + + try { + await sendTextToTerminal(`${quoteShellSingle(payload.path)} `); + } catch (error) { + console.debug("Workspace path drop failed:", error); + pushToast({ + kind: "error", + title: "Drop failed", + body: "Could not insert the dragged path into the terminal.", + duration: 3_000, + }); + } + }, + [pushToast, sendTextToTerminal, workspaceId] + ); +``` + +Replace the `drop` and `dragover` handlers inside the effect with: + +```ts + const onDrop = (event: DragEvent) => { + const files = event.dataTransfer?.files; + if (files && files.length > 0) { + event.preventDefault(); + event.stopPropagation(); + void handleFiles(Array.from(files)); + return; + } + + if (!hasWorkspacePathDragType(event.dataTransfer)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + void handleWorkspacePathDrop(event.dataTransfer); + }; + + const onDragOver = (event: DragEvent) => { + if (hasWorkspacePathDragType(event.dataTransfer)) { + event.preventDefault(); + return; + } + + const types = Array.from(event.dataTransfer?.types ?? []); + if (types.includes("Files")) { + event.preventDefault(); + } + }; +``` + +Update the effect dependency list to include the new callback: + +```ts + }, [containerRef, enabled, handleFiles, handleWorkspacePathDrop]); +``` + +- [ ] **Step 4: Run the terminal drop-hook tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx +``` + +Expected: PASS, including the new internal workspace-path drag tests and all existing upload regression tests. + +- [ ] **Step 5: Commit the terminal drop support** + +Run: + +```bash +git add packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.ts packages/web/src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx +git commit -m "feat: support workspace path drops in terminal" +``` + +Expected: a commit containing only the terminal drop-hook changes and tests. + +### Task 3: Make Desktop File Tree Rows Emit Workspace Path Drag Data + +**Files:** +- Modify: `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` + +- [ ] **Step 1: Write the failing desktop tree drag tests** + +Add this helper near the top of `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx`: + +```ts +import { WORKSPACE_PATH_DRAG_MIME } from "../../../../lib/workspace-path-drag"; + +function createDragDataTransfer() { + const values = new Map(); + const dataTransfer = { + effectAllowed: "none", + setData: vi.fn((type: string, value: string) => { + values.set(type, value); + }), + } as unknown as DataTransfer; + + return { dataTransfer, values }; +} +``` + +Add these tests: + +```ts +it("marks desktop tree rows draggable and writes workspace path drag data on dragstart", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [ + ".", + [ + { path: "README.md", name: "README.md", kind: "file" }, + { path: "src", name: "src", kind: "dir", children: [] }, + ], + ], + ]) + ); + + render( + + + + ); + + const fileRow = screen.getByText("README.md").closest(".tree-item"); + const folderRow = screen.getByText("src").closest(".tree-item"); + expect(fileRow).toHaveAttribute("draggable", "true"); + expect(folderRow).toHaveAttribute("draggable", "true"); + + const { dataTransfer, values } = createDragDataTransfer(); + fireEvent.dragStart(fileRow!, { dataTransfer }); + + expect(values.get(WORKSPACE_PATH_DRAG_MIME)).toBe( + JSON.stringify({ + workspaceId: "ws-test", + path: "README.md", + kind: "file", + }) + ); + expect(values.get("text/plain")).toBe("README.md"); +}); + +it("keeps mobile tree rows non-draggable", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set( + fileTreeAtomFamily("ws-test"), + new Map([ + [".", [{ path: "README.md", name: "README.md", kind: "file" }]], + ]) + ); + + render( + + + + ); + + expect(screen.getByText("README.md").closest(".tree-item")).not.toHaveAttribute( + "draggable", + "true" + ); +}); +``` + +- [ ] **Step 2: Run the file-tree tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/file-tree-panel.test.tsx +``` + +Expected: FAIL on the new drag assertions because `FileTreeNode` rows are not draggable and never write any `dataTransfer` payload. + +- [ ] **Step 3: Implement desktop dragstart on `FileTreeNode` only** + +Add the new imports in `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx`: + +```ts +import type { + DragEvent as ReactDragEvent, + FC, + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, +} from "react"; +import { setWorkspacePathDragData } from "../../../../lib/workspace-path-drag"; +``` + +Thread `workspaceId` through the tree-node props: + +```ts +interface FileTreeNodeProps { + workspaceId: string; + node: FileNode; + depth: number; + variant: "desktop" | "mobile"; + // ...existing props... +} +``` + +Pass the prop at both render sites: + +```tsx + +``` + +```tsx + +``` + +Inside `FileTreeNode`, add the drag handler and wire it to the row: + +```ts + const handleDragStart = (event: ReactDragEvent) => { + if (variant !== "desktop" || !event.dataTransfer) { + return; + } + + setWorkspacePathDragData(event.dataTransfer, { + workspaceId, + path: node.path, + kind: node.kind, + }); + }; +``` + +```tsx +
onOpenContextMenu(event, node, "tree") : undefined + } + onPointerDown={ + variant === "mobile" ? (event) => onBeginLongPress(event, node, "mobile") : undefined + } + onPointerMove={variant === "mobile" ? onUpdateLongPress : undefined} + onPointerCancel={ + variant === "mobile" ? (event) => onCancelLongPress(event.pointerId) : undefined + } + onPointerUp={ + variant === "mobile" ? (event) => onCancelLongPress(event.pointerId) : undefined + } + style={{ paddingLeft }} + > +``` + +- [ ] **Step 4: Run the file-tree tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/file-tree-panel.test.tsx +``` + +Expected: PASS, including the new desktop/mobile drag coverage and all existing file-tree behavior tests. + +- [ ] **Step 5: Commit the file-tree drag source** + +Run: + +```bash +git add packages/web/src/features/workspace/views/shared/file-tree-panel.tsx packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx +git commit -m "feat: add desktop file tree drag-to-terminal source" +``` + +Expected: a commit containing only the file-tree drag source changes and tests. + +### Task 4: Run Targeted Regression Coverage For The Whole Flow + +**Files:** +- No code changes required unless a test reveals a regression. + +- [ ] **Step 1: Run the shared helper, terminal, and file-tree tests together** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/lib/workspace-path-drag.test.ts src/features/terminal-panel/uploads/use-paste-drop-upload.test.tsx src/features/workspace/views/shared/file-tree-panel.test.tsx +``` + +Expected: PASS across all three suites with no newly failing upload or file-tree regressions. + +- [ ] **Step 2: Inspect the final diff to verify scope stayed inside the approved files** + +Run: + +```bash +git diff --stat HEAD~3..HEAD +git status --short +``` + +Expected: only the six approved web files changed, plus no accidental edits outside the feature scope. + +- [ ] **Step 3: If Task 4 required no code fixes, create a lightweight verification checkpoint commit** + +Run: + +```bash +git commit --allow-empty -m "test: verify desktop file tree drag-to-terminal flow" +``` + +Expected: an empty verification commit only if you want a visible checkpoint after the targeted regression run; skip this step if the team dislikes empty commits. From 870676d4df2fb663fca77765dc208efa2e753a33 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 16:08:58 +0800 Subject: [PATCH 15/36] fix(web): split mobile sessions by widest column --- .../agent-panes/actions/use-pane-actions.ts | 9 ++ .../agent-panes/pane-layout-tree.test.ts | 52 +++++++ .../features/agent-panes/pane-layout-tree.ts | 127 ++++++++++++++++++ .../actions/use-workspace-screen-model.ts | 10 +- 4 files changed, 193 insertions(+), 5 deletions(-) diff --git a/packages/web/src/features/agent-panes/actions/use-pane-actions.ts b/packages/web/src/features/agent-panes/actions/use-pane-actions.ts index f0531843..8e04a470 100644 --- a/packages/web/src/features/agent-panes/actions/use-pane-actions.ts +++ b/packages/web/src/features/agent-panes/actions/use-pane-actions.ts @@ -5,6 +5,7 @@ import type { PaneNode } from "../atoms/pane-layout"; import { paneLayoutAtomFamily } from "../atoms/pane-layout"; import { appendSessionToLayout, + appendSessionToWidestColumn, assignSessionToPane, closeDraftPaneById, closePaneBySessionId, @@ -103,8 +104,16 @@ export function usePaneActions(workspaceId: string) { [applyLayout] ); + const appendSessionToMobileColumn = useCallback( + (sessionId: string) => { + applyLayout((current) => appendSessionToWidestColumn(current, sessionId)); + }, + [applyLayout] + ); + return { appendSession, + appendSessionToMobileColumn, assignSession, closeDraftPane, closeSessionPane, diff --git a/packages/web/src/features/agent-panes/pane-layout-tree.test.ts b/packages/web/src/features/agent-panes/pane-layout-tree.test.ts index bab0e4b9..39fe8c6d 100644 --- a/packages/web/src/features/agent-panes/pane-layout-tree.test.ts +++ b/packages/web/src/features/agent-panes/pane-layout-tree.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import type { PaneNode } from "./atoms/pane-layout"; import { appendSessionToLayout, + appendSessionToWidestColumn, assignSessionToPane, closeDraftPaneById, closePaneBySessionId, @@ -184,6 +185,57 @@ describe("pane-layout-tree", () => { }); }); + it("appends a new session by splitting the widest column horizontally", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.3, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { + id: "right-column", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "right-top", type: "leaf", sessionId: "sess_2" }, + { id: "right-bottom", type: "leaf", sessionId: "sess_3" }, + ], + }, + ], + }; + + expect(appendSessionToWidestColumn(layout, "sess_4")).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.3, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { + id: expect.stringMatching(/^split-right-column-horizontal-/), + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { + id: "right-column", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "right-top", type: "leaf", sessionId: "sess_2" }, + { id: "right-bottom", type: "leaf", sessionId: "sess_3" }, + ], + }, + expect.objectContaining({ type: "leaf", sessionId: "sess_4" }), + ], + }, + ], + }); + }); + it("creates a fallback pane layout that includes all live sessions", () => { expect(createFallbackPaneLayout(["sess_1", "sess_2", "sess_3"])).toEqual({ id: "split-fallback-1", diff --git a/packages/web/src/features/agent-panes/pane-layout-tree.ts b/packages/web/src/features/agent-panes/pane-layout-tree.ts index 4cedbb63..534ee1b1 100644 --- a/packages/web/src/features/agent-panes/pane-layout-tree.ts +++ b/packages/web/src/features/agent-panes/pane-layout-tree.ts @@ -368,6 +368,20 @@ export function appendSessionToLayout( }; } +export function appendSessionToWidestColumn(node: PaneNode, sessionId: string): PaneNode { + const draftFilled = assignFirstDraftPane(node, sessionId); + if (draftFilled) { + return draftFilled; + } + + const widestColumnSplit = splitWidestColumnForNewSession(node, sessionId); + if (widestColumnSplit) { + return widestColumnSplit; + } + + return appendSessionToLayout(node, sessionId, undefined, "horizontal"); +} + export function createFallbackPaneLayout(sessionIds: string[]): PaneNode { if (sessionIds.length === 0) { return { id: "root", type: "leaf" }; @@ -498,6 +512,119 @@ function splitLeafForNewSession( return null; } +interface ColumnCandidate { + path: number[]; + width: number; +} + +function splitWidestColumnForNewSession(node: PaneNode, sessionId: string): PaneNode | null { + const candidate = findWidestColumnCandidate(node); + if (!candidate) { + return null; + } + + return replaceNodeAtPath(node, candidate.path, (target) => { + const splitId = `split-${target.id}-horizontal-${Date.now()}`; + return { + id: splitId, + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [{ ...target }, createSessionLeaf(`${splitId}-session`, sessionId)], + }; + }); +} + +function findWidestColumnCandidate( + node: PaneNode, + width = 1, + path: number[] = [] +): ColumnCandidate | null { + if (node.type === "leaf") { + if (!node.sessionId) { + return null; + } + + return { + path, + width, + }; + } + + const children = node.children ?? []; + if (children.length === 0) { + return null; + } + + if (node.direction === "vertical") { + return subtreeHasSession(node) + ? { + path, + width, + } + : null; + } + + const ratio = node.ratio ?? 0.5; + const firstWidth = width * ratio; + const secondWidth = width * (1 - ratio); + + const firstCandidate = findWidestColumnCandidate(children[0]!, firstWidth, [...path, 0]); + const secondCandidate = findWidestColumnCandidate(children[1]!, secondWidth, [...path, 1]); + + return chooseWiderCandidate(firstCandidate, secondCandidate); +} + +function subtreeHasSession(node: PaneNode): boolean { + if (node.type === "leaf") { + return Boolean(node.sessionId); + } + + return (node.children ?? []).some((child) => subtreeHasSession(child)); +} + +function chooseWiderCandidate( + ...candidates: Array +): ColumnCandidate | null { + let best: ColumnCandidate | null = null; + + for (const candidate of candidates) { + if (!candidate) { + continue; + } + + if (!best || candidate.width > best.width) { + best = candidate; + } + } + + return best; +} + +function replaceNodeAtPath( + node: PaneNode, + path: number[], + replace: (target: PaneNode) => PaneNode +): PaneNode { + if (path.length === 0) { + return replace(node); + } + + if (node.type === "leaf") { + return node; + } + + const [index, ...rest] = path; + const children = node.children ?? []; + + return { + ...node, + children: children.map((child, childIndex) => + childIndex === index ? replaceNodeAtPath(child, rest, replace) : child + ), + }; +} + function createFallbackPaneLayoutBranch(sessionIds: string[], startIndex: number): PaneNode { if (sessionIds.length === 1) { return { diff --git a/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts b/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts index 3f74cd97..1a69edc5 100644 --- a/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts +++ b/packages/web/src/features/workspace/actions/use-workspace-screen-model.ts @@ -211,7 +211,7 @@ export function useWorkspaceScreenModel() { !orderedSessions.some((session) => session.id === sessionId) && sessions.some((session) => session.id === sessionId && session.state !== "draft") ) { - paneActions.appendSession(sessionId, mobileActiveSessionId, "vertical"); + paneActions.appendSessionToMobileColumn(sessionId); } setScreenState((current) => ({ @@ -225,14 +225,14 @@ export function useWorkspaceScreenModel() { const handleMobileSessionCreated = useCallback( (sessionId: string) => { - paneActions.appendSession(sessionId, mobileActiveSessionId, "vertical"); + paneActions.appendSessionToMobileColumn(sessionId); setScreenState((current) => ({ ...current, mobileSelectionVersion: current.mobileSelectionVersion + 1, mobileActiveSessionId: sessionId, })); }, - [mobileActiveSessionId, paneActions, setScreenState] + [paneActions, setScreenState] ); const closeMobileSession = useCallback( @@ -295,7 +295,7 @@ export function useWorkspaceScreenModel() { !orderedSessions.some((session) => session.id === sessionId) && sessions.some((session) => session.id === sessionId && session.state !== "draft") ) { - paneActions.appendSession(sessionId, mobileActiveSessionId, "vertical"); + paneActions.appendSessionToMobileColumn(sessionId); } setScreenState((current) => ({ @@ -304,7 +304,7 @@ export function useWorkspaceScreenModel() { mobileActiveSessionId: sessionId, })); }, - [mobileActiveSessionId, orderedSessions, paneActions, sessions, setScreenState] + [orderedSessions, paneActions, sessions, setScreenState] ); const openMobileSheet = useCallback( From e857fcd5dbffa2ee101b2bec04f60c4dea405712 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 16:13:09 +0800 Subject: [PATCH 16/36] fix(web): restore personalized appearance hydration --- .../web/src/app/providers.lifecycle.test.tsx | 67 +++++++++++++++++++ packages/web/src/app/providers.tsx | 28 +++----- packages/web/src/styles/components.css | 22 ++++-- .../web/src/styles/components.theme.test.ts | 15 +++-- 4 files changed, 104 insertions(+), 28 deletions(-) diff --git a/packages/web/src/app/providers.lifecycle.test.tsx b/packages/web/src/app/providers.lifecycle.test.tsx index 5245421e..7df48dcc 100644 --- a/packages/web/src/app/providers.lifecycle.test.tsx +++ b/packages/web/src/app/providers.lifecycle.test.tsx @@ -1888,6 +1888,73 @@ describe("AppProviders lifecycle recovery", () => { expect(localStorage.getItem("ui.themeId")).toBe(JSON.stringify("graphite-dark")); }); + it("still hydrates appearance personalization when a persisted local theme is preserved", async () => { + const store = createStore(); + setVisibilityState("visible"); + localStorage.setItem("ui.themeId", JSON.stringify("graphite-dark")); + + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.themeId": "nord-light", + "appearance.personalization.version": 1, + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + "appearance.personalization.common.backgroundFit": "cover", + "appearance.personalization.common.backgroundDimness": 36, + "appearance.personalization.common.backgroundBlur": 8, + "appearance.personalization.common.glassEnabled": true, + "appearance.personalization.common.glassIntensity": 30, + "appearance.personalization.common.surfaceOpacity": 88, + }; + } + + return undefined; + }); + wsState.client!.sendCommand = sendCommand; + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + await vi.waitFor(() => { + expect(document.documentElement.getAttribute("data-theme")).toBe("graphite-dark"); + expect(store.get(themeAtom)).toBe("graphite-dark"); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(store.get(appearancePersonalizationAtom)).toMatchObject({ + version: 1, + common: expect.objectContaining({ + backgroundMode: "image", + backgroundAssetId: "asset-common", + backgroundFit: "cover", + backgroundDimness: 36, + backgroundBlur: 8, + glassEnabled: true, + glassIntensity: 30, + surfaceOpacity: 88, + }), + }); + expect(document.documentElement.style.getPropertyValue("--app-bg-image")).toBe( + "url(/api/appearance-assets/asset-common)" + ); + expect(document.documentElement.style.getPropertyValue("--app-surface-backdrop-filter")).toBe( + "blur(30px)" + ); + expect(document.documentElement.getAttribute("data-appearance-glass")).toBe("on"); + }); + + expect(document.documentElement.getAttribute("data-theme")).toBe("graphite-dark"); + expect(store.get(themeAtom)).toBe("graphite-dark"); + }); + it("preserves a newer local terminal copy-on-select update when startup hydration resolves later", async () => { const store = createStore(); setVisibilityState("visible"); diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index 7321e6a3..5141eda3 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -561,27 +561,21 @@ export function AppProviders({ children }: AppProvidersProps) { return; } - if ( - appearanceSelectionVersionRef.current.theme !== - appearanceSelectionVersionAtRequestStart.theme - ) { - return; - } - + const settings = result.data; + const shouldHydrateTheme = + appearanceSelectionVersionRef.current.theme === + appearanceSelectionVersionAtRequestStart.theme; if (preferPersistedThemeOnFirstHydrationRef.current) { preferPersistedThemeOnFirstHydrationRef.current = false; - return; + } else if (shouldHydrateTheme) { + const resolvedThemeId = resolveStoredThemeId( + settings["appearance.themeId"] ?? + settings["appearance.theme"] ?? + readStoredThemePreference() + ); + setTheme(resolvedThemeId); } - const settings = result.data; - const resolvedThemeId = resolveStoredThemeId( - settings["appearance.themeId"] ?? - settings["appearance.theme"] ?? - readStoredThemePreference() - ); - - setTheme(resolvedThemeId); - if ( appearanceSelectionVersionRef.current.personalization === appearanceSelectionVersionAtRequestStart.personalization diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index a77a98be..db8583c1 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -771,7 +771,7 @@ flex-direction: column; min-height: 100vh; height: 100vh; - background: var(--bg-page); + background: transparent; } .page-header { @@ -880,21 +880,31 @@ .settings-header { padding: var(--sp-1) var(--sp-4); - background: var(--bg-surface); - border-bottom: 1px solid var(--border); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ); + border-bottom: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + backdrop-filter: var(--app-surface-backdrop-filter, none); } .settings-body { display: flex; flex: 1; min-height: 0; align-items: stretch; - background: var(--bg-page); + background: transparent; } .settings-sidebar { width: 240px; - background: var(--bg-panel); - border-right: 1px solid var(--border); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 100%), + transparent + ); + border-right: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + backdrop-filter: var(--app-surface-backdrop-filter, none); padding: var(--sp-4); } diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index eae05927..0705e259 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -1884,14 +1884,19 @@ describe("components.css theme-sensitive surfaces", () => { expect(settingsPage).toContain("display: flex"); expect(settingsPage).toContain("min-height: 100vh"); - expect(settingsPage).toContain("background: var(--bg-page)"); - expect(baseSettingsHeader).toContain("background: var(--bg-surface)"); - expect(baseSettingsHeader).toContain("border-bottom: 1px solid var(--border)"); + expect(settingsPage).toContain("background: transparent"); + expect(baseSettingsHeader).toContain("var(--surface-overlay-bg)"); + expect(baseSettingsHeader).toContain("var(--app-surface-opacity, 0.96)"); + expect(baseSettingsHeader).toContain( + "backdrop-filter: var(--app-surface-backdrop-filter, none)" + ); expect(baseSettingsHeader).toContain("padding: var(--sp-1) var(--sp-4)"); expect(desktopSettingsHeader).toContain("min-height: 48px"); expect(settingsBody).toContain("align-items: stretch"); - expect(settingsBody).toContain("background: var(--bg-page)"); - expect(settingsSidebar).toContain("background: var(--bg-panel)"); + expect(settingsBody).toContain("background: transparent"); + expect(settingsSidebar).toContain("var(--surface-overlay-bg)"); + expect(settingsSidebar).toContain("var(--app-surface-opacity, 0.88)"); + expect(settingsSidebar).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); expect(settingsSidebar).toContain("padding: var(--sp-4)"); expect(settingsSidebar).toContain("width: 240px"); expect(settingsContent).toContain("display: flex"); From a229cb3cecee7d7719a53032a0e065a93de58b7f Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 16:16:53 +0800 Subject: [PATCH 17/36] feat(web): refine git panel worktree list --- .../workspace/views/shared/git-panel.test.tsx | 219 +++++++++++++++++- .../workspace/views/shared/git-panel.tsx | 184 +++++++++++---- packages/web/src/styles/components.css | 45 ++-- 3 files changed, 390 insertions(+), 58 deletions(-) diff --git a/packages/web/src/features/workspace/views/shared/git-panel.test.tsx b/packages/web/src/features/workspace/views/shared/git-panel.test.tsx index adcc4e23..8ed7ba50 100644 --- a/packages/web/src/features/workspace/views/shared/git-panel.test.tsx +++ b/packages/web/src/features/workspace/views/shared/git-panel.test.tsx @@ -48,6 +48,23 @@ describe("GitPanel", () => { }, ]; + const compactListWorktrees = [ + { + name: "develop", + path: "/home/spencer/workspace/coder-studio", + branch: "refs/heads/develop", + commit: "abc1234", + status: "dirty" as const, + }, + { + name: "performance-monitoring", + path: "/home/spencer/workspace/coder-studio-performance-monitoring", + branch: "refs/heads/feat/performance-monitoring", + commit: "def5678", + status: "dirty" as const, + }, + ]; + const historyEntries = [ { sha: "98db173000000000000000000000000000000000", @@ -426,14 +443,208 @@ describe("GitPanel", () => { const worktreeToggle = (await screen.findByText("Worktrees")).closest("button"); const newWorktreeButton = screen.getByRole("button", { name: "New" }); + const manageWorktreeButton = screen.getByRole("button", { name: "Manage" }); expect(worktreeToggle).toHaveAttribute("aria-expanded", "false"); expect(screen.queryByText("pr/123-fix-auth")).toBeNull(); + expect(manageWorktreeButton).toBeInTheDocument(); expect( newWorktreeButton.querySelector('[data-icon-semantic="worktree.action.new"]') ).toBeTruthy(); }); + it("keeps the compact worktree list to a single row without branch refs", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return status; + } + + if (op === "git.branches") { + return { current: "develop", branches: [] }; + } + + if (op === "worktree.list") { + return { + worktrees: compactListWorktrees, + }; + } + + if (op === "git.log") { + return { entries: [] }; + } + + return {}; + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspaceStore(store); + + render( + + + + ); + + fireEvent.click(await screen.findByRole("button", { name: "Worktrees2" })); + + const removableRow = screen + .getByRole("button", { name: "Remove performance-monitoring" }) + .closest(".git-worktree-row"); + + expect(removableRow).not.toBeNull(); + expect(removableRow?.querySelector(".git-worktree-row__name")).toHaveTextContent( + "performance-monitoring" + ); + expect(removableRow?.querySelector(".git-worktree-row__status")).toHaveTextContent( + "Has changes" + ); + expect(screen.queryByText("refs/heads/develop")).toBeNull(); + expect(screen.queryByText("refs/heads/feat/performance-monitoring")).toBeNull(); + expect(screen.queryByText("feat/performance-monitoring")).toBeNull(); + }); + + it("opens the full worktree manager list view from the Git panel manage action", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return status; + } + + if (op === "git.branches") { + return { current: "feature/ai-agent", branches: [] }; + } + + if (op === "worktree.list") { + return { worktrees }; + } + + if (op === "git.log") { + return { entries: [] }; + } + + return {}; + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspaceStore(store); + + render( + + + + ); + + fireEvent.click(await screen.findByRole("button", { name: "Manage" })); + + expect(await screen.findByRole("dialog", { name: "Worktrees" })).toBeInTheDocument(); + expect(screen.getByText("pr/123-fix-auth")).toBeInTheDocument(); + }); + + it("shows inline delete only for removable worktrees in the compact list", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return status; + } + + if (op === "git.branches") { + return { current: "develop", branches: [] }; + } + + if (op === "worktree.list") { + return { + worktrees: compactListWorktrees, + }; + } + + if (op === "git.log") { + return { entries: [] }; + } + + return {}; + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspaceStore(store); + + render( + + + + ); + + fireEvent.click(await screen.findByRole("button", { name: "Worktrees2" })); + + expect(screen.queryByRole("button", { name: "Remove develop" })).toBeNull(); + expect( + screen.getByRole("button", { name: "Remove performance-monitoring" }) + ).toBeInTheDocument(); + }); + + it("removes a dirty compact-list worktree through the existing worktree.remove flow", async () => { + let worktreeListCalls = 0; + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return status; + } + + if (op === "git.branches") { + return { current: "develop", branches: [] }; + } + + if (op === "worktree.list") { + worktreeListCalls += 1; + return { + worktrees: + worktreeListCalls === 1 ? compactListWorktrees : compactListWorktrees.slice(0, 1), + }; + } + + if (op === "worktree.remove") { + return {}; + } + + if (op === "git.log") { + return { entries: [] }; + } + + return {}; + }); + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspaceStore(store); + + render( + + + + ); + + fireEvent.click(await screen.findByRole("button", { name: "Worktrees2" })); + fireEvent.click(screen.getByRole("button", { name: "Remove performance-monitoring" })); + expect(screen.getByRole("dialog", { name: "Delete" })).toBeInTheDocument(); + expect(screen.getByText("Force remove dirty worktree?")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Force Remove" })); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "worktree.remove", + { + workspaceId: "ws-test", + worktreePath: "/home/spencer/workspace/coder-studio-performance-monitoring", + force: true, + }, + undefined + ); + }); + }); + it("does not render the legacy header worktree button", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "git.status") { @@ -546,8 +757,12 @@ describe("GitPanel", () => { const worktreeToggle = await screen.findByRole("button", { name: "Worktrees0" }); fireEvent.click(worktreeToggle); - await screen.findByText("feature/ai-agent"); - fireEvent.click(screen.getByRole("button", { name: /feature\/ai-agent/i })); + const worktreeButtons = await screen.findAllByRole("button", { name: /feature\/ai-agent/i }); + fireEvent.click( + worktreeButtons.find((button) => + button.classList.contains("git-worktree-row__main") + ) as HTMLButtonElement + ); await waitFor(() => { expect(sendCommand).toHaveBeenCalledWith( diff --git a/packages/web/src/features/workspace/views/shared/git-panel.tsx b/packages/web/src/features/workspace/views/shared/git-panel.tsx index d90095d9..1fbccf4f 100644 --- a/packages/web/src/features/workspace/views/shared/git-panel.tsx +++ b/packages/web/src/features/workspace/views/shared/git-panel.tsx @@ -1,7 +1,7 @@ import type { GitCommitSummary, GitFileChange, WorktreeInfo } from "@coder-studio/core"; import { atom, useAtom, useAtomValue } from "jotai"; import { atomFamily } from "jotai-family"; -import { ChevronDown, Minus, Plus, RotateCcw } from "lucide-react"; +import { ChevronDown, Minus, Plus, RotateCcw, Trash2 } from "lucide-react"; import type { FC, MouseEvent, ReactNode } from "react"; import { useEffect, useMemo, useRef } from "react"; import { localeAtom } from "../../../../atoms/app-ui"; @@ -60,6 +60,7 @@ interface GitPanelProps { } interface GitPanelState { + pendingWorktreeDeletePath: string | null; worktreeSurfaceView: "list" | "create" | null; worktreesExpanded: boolean; historyExpanded: boolean; @@ -80,6 +81,7 @@ function createInitialCollapsedGroups(isMobile: boolean): Record = ({ onPreviewOpen, initialHistoryLimit: 20, }); - const { currentWorktree, hasWorkspace, list, loadWorktrees, openWorktree } = + const { currentWorktree, hasWorkspace, list, loadWorktrees, openWorktree, removeWorktreeByPath } = useWorktreeManagementActions(workspaceId); const worktreeAutoLoadAttemptedRef = useRef(false); - const { collapsedGroups, historyExpanded, worktreeSurfaceView, worktreesExpanded } = panelState; + const { + collapsedGroups, + historyExpanded, + pendingWorktreeDeletePath, + worktreeSurfaceView, + worktreesExpanded, + } = panelState; + const pendingWorktreeDelete = useMemo( + () => list.items.find((item) => item.path === pendingWorktreeDeletePath) ?? null, + [list.items, pendingWorktreeDeletePath] + ); useEffect(() => { worktreeAutoLoadAttemptedRef.current = false; @@ -171,6 +183,13 @@ export const GitPanel: FC = ({ await openWorktree(worktree.path); }; + const closePendingWorktreeDelete = () => { + setPanelState((current) => ({ + ...current, + pendingWorktreeDeletePath: null, + })); + }; + return ( <>
@@ -226,19 +245,33 @@ export const GitPanel: FC = ({ - +
+ + +
{worktreesExpanded ? ( @@ -261,33 +294,57 @@ export const GitPanel: FC = ({ ) : list.items.length === 0 ? ( ) : ( - list.items.map((worktree) => ( - - )) + list.items.map((worktree, index) => { + const isCurrent = currentWorktree?.path === worktree.path; + const isPrimary = index === 0; + const isRemovable = !isCurrent && !isPrimary; + + return ( +
+ + {isRemovable ? ( + } + onClick={() => + setPanelState((current) => ({ + ...current, + pendingWorktreeDeletePath: worktree.path, + })) + } + size="sm" + variant="ghost" + /> + ) : null} +
+ ); + }) )}
) : null} @@ -387,11 +444,52 @@ export const GitPanel: FC = ({ onClose={() => setPanelState((current) => ({ ...current, + pendingWorktreeDeletePath: null, worktreeSurfaceView: null, })) } /> + {pendingWorktreeDelete ? ( + +

+ {pendingWorktreeDelete.status === "dirty" + ? t("worktree.remove_force_confirm") + : t("worktree.remove_confirm")} +

+ {pendingWorktreeDelete.path} + + } + cancelText={t("action.cancel")} + closeLabel={t("action.close")} + confirmText={ + pendingWorktreeDelete.status === "dirty" + ? t("worktree.force_remove") + : t("common.delete") + } + onOpenChange={(open) => { + if (!open) { + closePendingWorktreeDelete(); + } + }} + onConfirm={() => { + void removeWorktreeByPath( + pendingWorktreeDelete.path, + pendingWorktreeDelete.status === "dirty" + ).then((result) => { + if (result.ok) { + closePendingWorktreeDelete(); + } + }); + }} + tone="danger" + /> + ) : null} + Date: Sun, 24 May 2026 16:21:27 +0800 Subject: [PATCH 18/36] fix(server): name detached worktrees from path --- .../src/__tests__/worktree-commands.test.ts | 37 +++++++++++++++++++ packages/server/src/git/worktree.ts | 1 + 2 files changed, 38 insertions(+) diff --git a/packages/server/src/__tests__/worktree-commands.test.ts b/packages/server/src/__tests__/worktree-commands.test.ts index 3b3c1979..7ffb70a8 100644 --- a/packages/server/src/__tests__/worktree-commands.test.ts +++ b/packages/server/src/__tests__/worktree-commands.test.ts @@ -106,6 +106,43 @@ describe("Worktree Commands", () => { ); }); + it("falls back to the worktree directory name for detached worktrees", async () => { + const detachedPath = join( + tmpdir(), + `worktree-command-detached-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + tempPaths.push(detachedPath); + + await execFileAsync("git", ["worktree", "add", "--detach", detachedPath, "HEAD"], { + cwd: repoDir, + }); + + const result = await dispatch( + { + kind: "command", + id: "worktree-list-detached-name", + op: "worktree.list", + args: { + workspaceId, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect(result.data).toEqual( + expect.objectContaining({ + worktrees: expect.arrayContaining([ + expect.objectContaining({ + path: detachedPath, + branch: "detached HEAD", + name: detachedPath.split("/").pop(), + }), + ]), + }) + ); + }); + it.each([ "worktree.status", "worktree.diff", diff --git a/packages/server/src/git/worktree.ts b/packages/server/src/git/worktree.ts index 3fe895c6..8c557584 100644 --- a/packages/server/src/git/worktree.ts +++ b/packages/server/src/git/worktree.ts @@ -64,6 +64,7 @@ export async function listWorktrees(repoPath: string): Promise { current.name = branch.split("/").pop() || branch; } else if (line === "detached") { current.branch = "detached HEAD"; + current.name = path.basename(current.path ?? "") || "detached"; } else if (line === "") { // Empty line might indicate end of record if (current.path) { From cf9a34cc359ed1a48894914a53201b1b7dd17c49 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 16:27:11 +0800 Subject: [PATCH 19/36] fix(web): align compact worktree row sizing --- packages/web/src/styles/components.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index 63d14963..56c54534 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -13453,6 +13453,7 @@ textarea.input { flex: 1; align-items: center; gap: 10px; + min-height: 28px; padding: 6px 10px; border: 1px solid transparent; border-radius: 8px; From ee985cae56ecf98064572bc218da2d94d8db49c5 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 16:37:01 +0800 Subject: [PATCH 20/36] fix(web): remove git panel worktree manager entry --- .../workspace/views/shared/git-panel.test.tsx | 40 ---------------- .../workspace/views/shared/git-panel.tsx | 46 ++++++------------- 2 files changed, 14 insertions(+), 72 deletions(-) diff --git a/packages/web/src/features/workspace/views/shared/git-panel.test.tsx b/packages/web/src/features/workspace/views/shared/git-panel.test.tsx index 8ed7ba50..6311a087 100644 --- a/packages/web/src/features/workspace/views/shared/git-panel.test.tsx +++ b/packages/web/src/features/workspace/views/shared/git-panel.test.tsx @@ -443,11 +443,9 @@ describe("GitPanel", () => { const worktreeToggle = (await screen.findByText("Worktrees")).closest("button"); const newWorktreeButton = screen.getByRole("button", { name: "New" }); - const manageWorktreeButton = screen.getByRole("button", { name: "Manage" }); expect(worktreeToggle).toHaveAttribute("aria-expanded", "false"); expect(screen.queryByText("pr/123-fix-auth")).toBeNull(); - expect(manageWorktreeButton).toBeInTheDocument(); expect( newWorktreeButton.querySelector('[data-icon-semantic="worktree.action.new"]') ).toBeTruthy(); @@ -505,44 +503,6 @@ describe("GitPanel", () => { expect(screen.queryByText("feat/performance-monitoring")).toBeNull(); }); - it("opens the full worktree manager list view from the Git panel manage action", async () => { - const sendCommand = vi.fn().mockImplementation(async (op: string) => { - if (op === "git.status") { - return status; - } - - if (op === "git.branches") { - return { current: "feature/ai-agent", branches: [] }; - } - - if (op === "worktree.list") { - return { worktrees }; - } - - if (op === "git.log") { - return { entries: [] }; - } - - return {}; - }); - - const store = createStore(); - store.set(localeAtom, "en"); - store.set(wsClientAtom, { sendCommand } as never); - seedWorkspaceStore(store); - - render( - - - - ); - - fireEvent.click(await screen.findByRole("button", { name: "Manage" })); - - expect(await screen.findByRole("dialog", { name: "Worktrees" })).toBeInTheDocument(); - expect(screen.getByText("pr/123-fix-auth")).toBeInTheDocument(); - }); - it("shows inline delete only for removable worktrees in the compact list", async () => { const sendCommand = vi.fn().mockImplementation(async (op: string) => { if (op === "git.status") { diff --git a/packages/web/src/features/workspace/views/shared/git-panel.tsx b/packages/web/src/features/workspace/views/shared/git-panel.tsx index 1fbccf4f..8d3753ab 100644 --- a/packages/web/src/features/workspace/views/shared/git-panel.tsx +++ b/packages/web/src/features/workspace/views/shared/git-panel.tsx @@ -61,7 +61,7 @@ interface GitPanelProps { interface GitPanelState { pendingWorktreeDeletePath: string | null; - worktreeSurfaceView: "list" | "create" | null; + worktreeSurfaceView: "create" | null; worktreesExpanded: boolean; historyExpanded: boolean; collapsedGroups: Record; @@ -173,10 +173,6 @@ export const GitPanel: FC = ({ const handleWorktreeOpen = async (worktree: WorktreeInfo) => { if (currentWorktree?.path === worktree.path) { - setPanelState((current) => ({ - ...current, - worktreeSurfaceView: "list", - })); return; } @@ -245,33 +241,19 @@ export const GitPanel: FC = ({ -
- - -
+
{worktreesExpanded ? ( From 099756974ae9914c61d2ab609bf3d7685e10b3e0 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 16:43:41 +0800 Subject: [PATCH 21/36] fix(web): close create-only worktree surface --- .../shared/worktree-manager-surface.test.tsx | 21 ++++++-- .../views/shared/worktree-manager-surface.tsx | 54 +++++++++++++++++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/packages/web/src/features/workspace/views/shared/worktree-manager-surface.test.tsx b/packages/web/src/features/workspace/views/shared/worktree-manager-surface.test.tsx index d049bafa..b3e62338 100644 --- a/packages/web/src/features/workspace/views/shared/worktree-manager-surface.test.tsx +++ b/packages/web/src/features/workspace/views/shared/worktree-manager-surface.test.tsx @@ -213,7 +213,8 @@ describe("WorktreeManagerSurface", () => { expect(loadingMessage.closest(".worktree-loading")).toBeTruthy(); }); - it("creates a worktree, reloads the list, and returns to list mode", async () => { + it("creates a worktree and closes the create surface instead of returning to list mode", async () => { + const onClose = vi.fn(); const sendCommand = vi .fn() .mockImplementation(async (op: string, args: Record) => { @@ -249,7 +250,7 @@ describe("WorktreeManagerSurface", () => { render( - + ); @@ -273,7 +274,21 @@ describe("WorktreeManagerSurface", () => { ); }); - expect(await screen.findByText("feature/new-worktree")).toBeInTheDocument(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes the create surface when cancel is pressed", () => { + const onClose = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledTimes(1); }); it("renders the create form fields with shared input compatibility classes", () => { diff --git a/packages/web/src/features/workspace/views/shared/worktree-manager-surface.tsx b/packages/web/src/features/workspace/views/shared/worktree-manager-surface.tsx index 49308232..c1b23053 100644 --- a/packages/web/src/features/workspace/views/shared/worktree-manager-surface.tsx +++ b/packages/web/src/features/workspace/views/shared/worktree-manager-surface.tsx @@ -138,6 +138,7 @@ export function WorktreeManagerSurface({ const canSubmit = branchDraft.trim().length > 0 && /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(pathDraft.trim()); const pathHintId = `worktree-path-hint-${workspaceId}`; + const createOnlyMode = openView === "create"; const closeDeleteConfirm = () => { setRemoveError(null); setDeleteTargetPath(null); @@ -182,7 +183,11 @@ export function WorktreeManagerSurface({ void createWorktree(branchDraft.trim(), pathDraft.trim()).then((result) => { if (result.ok) { resetCreateForm(); - setView("list"); + if (createOnlyMode) { + onClose(); + } else { + setView("list"); + } return; } @@ -229,7 +234,16 @@ export function WorktreeManagerSurface({
- ) : ( - )} @@ -427,7 +461,17 @@ export function WorktreeManagerSurface({ {t("worktree.new")} ) : ( - )} From b30d383d81bb4f566c5a2fb1296332666c308725 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 16:52:31 +0800 Subject: [PATCH 22/36] fix(web): hide back action in create-only worktree flow --- .../views/shared/worktree-manager-surface.test.tsx | 10 ++++++++++ .../views/shared/worktree-manager-surface.tsx | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/web/src/features/workspace/views/shared/worktree-manager-surface.test.tsx b/packages/web/src/features/workspace/views/shared/worktree-manager-surface.test.tsx index b3e62338..616f4618 100644 --- a/packages/web/src/features/workspace/views/shared/worktree-manager-surface.test.tsx +++ b/packages/web/src/features/workspace/views/shared/worktree-manager-surface.test.tsx @@ -291,6 +291,16 @@ describe("WorktreeManagerSurface", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("does not show a back button in create-only mode", () => { + render( + + + + ); + + expect(screen.queryByRole("button", { name: "Back" })).toBeNull(); + }); + it("renders the create form fields with shared input compatibility classes", () => { render( diff --git a/packages/web/src/features/workspace/views/shared/worktree-manager-surface.tsx b/packages/web/src/features/workspace/views/shared/worktree-manager-surface.tsx index c1b23053..50142bea 100644 --- a/packages/web/src/features/workspace/views/shared/worktree-manager-surface.tsx +++ b/packages/web/src/features/workspace/views/shared/worktree-manager-surface.tsx @@ -424,7 +424,7 @@ export function WorktreeManagerSurface({ - ) : ( + ) : createOnlyMode ? null : ( - ) : ( + ) : createOnlyMode ? null : (
- } - initialFocus={() => (view === "create" ? branchInputRef.current : null)} - onOpenChange={(open) => { - if (!open) { - onClose(); - } - }} - open - title={title} - > - {body} - + + {body} + ); } From 8133341139ecda74269381cbc0fa7e902e5c1b89 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 17:27:30 +0800 Subject: [PATCH 26/36] Refine workspace glass surface layering --- packages/web/src/styles/components.css | 355 ++++++++++++++++++ .../web/src/styles/components.theme.test.ts | 115 +++++- 2 files changed, 456 insertions(+), 14 deletions(-) diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index 56c54534..2dd0e028 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -14125,3 +14125,358 @@ textarea.input { .paste-dialog__button--primary:hover { background: color-mix(in srgb, var(--accent-blue) 85%, white); } + +/* ========== Workspace Appearance Personalization ========== */ +.workspace-page { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + background: transparent; +} + +.workspace-main-area { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: transparent; +} + +.workspace-main-stage > .agent-panes { + flex: 1; + min-height: 0; + padding: 0; + background: transparent; +} + +.workspace-git-view { + flex: 1; + min-height: 0; + padding: var(--editor-pane-inset); + background: transparent; +} + +.mobile-terminal-sheet { + padding: 0; + gap: 0; + background: transparent; +} + +.mobile-sheet--terminal, +.mobile-sheet--terminal .mobile-sheet__footer { + background: transparent; +} + +.left-panel { + background: transparent; + border-right: none; +} + +.workspace-sidebar-panel { + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 76%), + transparent + ); + border-right: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.workspace-activity-bar { + display: flex; + width: 52px; + flex-direction: column; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-2); + border-right: 1px solid var(--border); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 62%), + transparent + ); + border-right-color: color-mix(in srgb, var(--border) 72%, transparent); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.workspace-status-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + min-height: var(--desktop-statusbar-height); + min-width: 0; + flex-shrink: 0; + padding: 0 12px; + border-top: 1px solid color-mix(in srgb, var(--border) 62%, transparent); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 68%), + transparent + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.workspace-status-bar--flush { + background: transparent; + backdrop-filter: none; +} + +.workspace-empty-inner, +.workspace-resolving-card { + background: linear-gradient( + 180deg, + color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 82%), + color-mix(in srgb, var(--bg-surface) 52%, transparent) + ), + color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 70%), + color-mix(in srgb, var(--bg-page) 8%, transparent) + ) + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.workspace-git-editor { + background: linear-gradient( + 180deg, + color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 86%), + color-mix(in srgb, var(--bg-surface) 60%, transparent) + ), + color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.92) * 74%), + color-mix(in srgb, var(--bg-terminal) 62%, transparent) + ) + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.code-editor-header { + display: flex; + align-items: center; + gap: var(--gap-default); + padding: var(--gap-default) var(--editor-toolbar-inset); + border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, var(--accent-blue) 28%); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 82%), + color-mix(in srgb, var(--bg-surface) 46%, transparent) + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.session-card { + border: none; + border-radius: 0; + box-shadow: none; + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 44%), + transparent + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.session-card.session-card--active { + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 52%), + color-mix(in srgb, var(--accent-blue) 10%, transparent) + ); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-focus) 84%, transparent); +} + +.session-header { + padding: var(--gap-tight) var(--inset-control-inline); + border-bottom: 1px solid var(--border); + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 66%), + transparent + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.session-card.session-card--active > .panel-header { + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 76%), + color-mix(in srgb, var(--accent-blue) 8%, transparent) + ); + border-bottom-color: color-mix(in srgb, var(--border-focus) 76%, transparent); +} + +.session-card.session-card--active .session-header { + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 76%), + color-mix(in srgb, var(--accent-blue) 8%, transparent) + ); + border-bottom-color: color-mix(in srgb, var(--border-focus) 76%, transparent); +} + +.session-terminal { + background: transparent; +} + +.workspace-sidebar-panel__content, +.workspace-sidebar-view, +.workspace-sidebar-panel__body { + background: transparent; +} + +.agent-draft-launcher { + container-type: inline-size; + background: + radial-gradient(circle at center, rgba(108, 182, 255, 0.06), transparent 32%), transparent; +} + +.agent-draft-content { + width: min(680px, calc(100% - 48px)); + max-width: 100%; + align-items: stretch; + gap: var(--sp-4); + padding: var(--sp-8) var(--sp-6); + border: 1px solid color-mix(in srgb, var(--border) 74%, transparent); + border-radius: calc(var(--radius-xl) + var(--sp-1)); + background: linear-gradient( + 180deg, + color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 78%), + color-mix(in srgb, var(--bg-surface) 34%, transparent) + ), + color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 64%), + color-mix(in srgb, var(--bg-page) 4%, transparent) + ) + ); + box-shadow: var(--shadow-lg); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.agent-provider-card { + height: auto; + min-width: 0; + align-items: flex-start; + justify-content: flex-start; + padding: var(--sp-4); + border-radius: var(--radius-xl); + text-align: left; + white-space: normal; + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 44%), + transparent + ); +} + +.terminal-toolbar { + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 66%), + transparent + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.bottom-terminal-tabs { + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 56%), + transparent + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.workspace-bottom-panel > .bottom-terminal { + border: none; + border-radius: 0; + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 60%), + transparent + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.bottom-terminal-content, +.bottom-terminal-xterm, +.bottom-terminal-empty { + background: transparent; +} + +.mobile-shell__agent-stage .session-card:not(.session-card--active), +.mobile-terminal-sheet .bottom-terminal, +.mobile-sheet--files .file-tree-shell--mobile, +.mobile-sheet--files .git-panel--mobile, +.mobile-sheet--files .workspace-git-editor { + background: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + color-mix(in srgb, var(--bg-panel) 80%, transparent) + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +.mobile-sheet--files .file-tree-shell--mobile, +.mobile-sheet--files .git-panel--mobile, +.mobile-sheet--files .workspace-git-editor, +.mobile-sheet--files .workspace-git-view { + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: var(--radius-md); + box-shadow: none; +} + +.mobile-sheet--files .workspace-git-view { + padding: 0; +} + +.mobile-terminal-sheet .bottom-terminal { + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + border-radius: 0; + box-shadow: none; +} + +.mobile-shell__agent-stage .session-card.session-card--active { + background: color-mix( + in srgb, + var(--bg-active) 76%, + color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 100%), + transparent + ) + ); + backdrop-filter: var(--app-surface-backdrop-filter, none); +} + +@media (max-width: 899px), (pointer: coarse) { + .mobile-shell { + --mobile-safe-top: env(safe-area-inset-top, 0px); + --mobile-safe-right: env(safe-area-inset-right, 0px); + --mobile-safe-bottom: env(safe-area-inset-bottom, 0px); + --mobile-safe-left: env(safe-area-inset-left, 0px); + --mobile-keyboard-inset: 0px; + --mobile-shell-motion-enter-duration: 220ms; + --mobile-shell-motion-press-duration: 100ms; + --mobile-shell-motion-surface-duration: 160ms; + display: flex; + flex-direction: column; + height: 100dvh; + min-height: 100dvh; + overflow: hidden; + background: transparent; + } +} diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index 0705e259..a9b2a4d9 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -894,6 +894,9 @@ describe("components.css theme-sensitive surfaces", () => { const agentPanes = getLastRuleBlock(".workspace-main-stage > .agent-panes"); const bottomPanel = getLastRuleBlock(".workspace-bottom-panel"); const activityBar = getLastRuleBlock(".workspace-activity-bar"); + const workspaceSidebarContent = getLastRuleBlock(".workspace-sidebar-panel__content"); + const workspaceSidebarView = getLastRuleBlock(".workspace-sidebar-view"); + const workspaceSidebarBody = getLastRuleBlock(".workspace-sidebar-panel__body"); const activityBarButton = getLastRuleBlock(".workspace-activity-bar__button"); const activityBarButtonHover = getLastRuleBlock(".workspace-activity-bar__button:hover"); const activityBarButtonActive = getLastRuleBlock(".workspace-activity-bar__button--active"); @@ -924,6 +927,9 @@ describe("components.css theme-sensitive surfaces", () => { ".workspace-bottom-panel > .bottom-terminal" ).join("\n"); const bottomTerminalShell = getLastRuleBlock(".workspace-bottom-panel > .bottom-terminal"); + const bottomTerminalContent = getLastRuleBlock(".bottom-terminal-content"); + const bottomTerminalXterm = getLastRuleBlock(".bottom-terminal-xterm"); + const bottomTerminalEmpty = getLastRuleBlock(".bottom-terminal-empty"); expect(topbar).toContain("var(--surface-overlay-bg)"); expect(topbar).toContain("var(--app-surface-opacity, 0.96)"); @@ -974,18 +980,21 @@ describe("components.css theme-sensitive surfaces", () => { expect(agentPanes).toContain("flex: 1"); expect(agentPanes).toContain("min-height: 0"); expect(agentPanes).toContain("padding: 0"); - expect(sessionTerminal).toContain("var(--bg-terminal)"); - expect(sessionTerminal).not.toContain("rgba(11, 18, 24, 0.98)"); + expect(sessionTerminal).toContain("background: transparent"); + expect(sessionTerminal).not.toContain("var(--bg-terminal)"); expect(sessionCard).toContain("border: none"); expect(sessionCard).not.toContain("border: 1px solid var(--border)"); expect(sessionCard).toContain("var(--surface-overlay-bg)"); expect(sessionCard).toContain("var(--app-surface-opacity, 0.96)"); expect(sessionCard).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); - expect(activeSessionCard).toContain("background: var(--bg-active)"); - expect(activeSessionCard).toContain("box-shadow: inset 0 0 0 1px var(--border-focus)"); - expect(activeSessionHeader).toContain( - "background: color-mix(in srgb, var(--bg-active) 88%, var(--bg-page) 12%)" - ); + expect(activeSessionCard).toContain("var(--surface-overlay-bg)"); + expect(activeSessionCard).toContain("calc(var(--app-surface-opacity, 0.96) * 52%)"); + expect(activeSessionCard).not.toContain("background: var(--bg-active)"); + expect(activeSessionCard).toContain("box-shadow: inset 0 0 0 1px"); + expect(activeSessionCard).toContain("var(--border-focus) 84%"); + expect(activeSessionHeader).toContain("var(--surface-overlay-bg)"); + expect(activeSessionHeader).toContain("calc(var(--app-surface-opacity, 0.96) * 76%)"); + expect(activeSessionHeader).not.toContain("var(--bg-active) 88%"); expect(activeSessionTitle).toContain("color: var(--text-primary)"); expect(resolvingConsoleStatus).toContain("background: var(--state-success-text)"); expect(resolvingConsoleStatus).toContain("border-radius: var(--radius-chip)"); @@ -996,9 +1005,12 @@ describe("components.css theme-sensitive surfaces", () => { expect(resolvingStrongLine).toContain("border: 1px solid var(--state-info-border)"); expect(resolvingStrongLine).toContain("background: var(--state-info-bg)"); expect(activityBar).toContain("border-right: 1px solid var(--border)"); - expect(activityBar).toContain( - "background: color-mix(in srgb, var(--bg-panel) 88%, var(--bg-page))" - ); + expect(activityBar).toContain("var(--surface-overlay-bg)"); + expect(activityBar).toContain("calc(var(--app-surface-opacity, 0.88) * 62%)"); + expect(activityBar).not.toContain("var(--bg-panel) 88%"); + expect(workspaceSidebarContent).toContain("background: transparent"); + expect(workspaceSidebarView).toContain("background: transparent"); + expect(workspaceSidebarBody).toContain("background: transparent"); expect(activityBarButton).toContain("border-radius: var(--radius-lg)"); expect(activityBarButton).toContain("background: transparent"); expect(activityBarButtonHover).toContain("background: var(--bg-hover)"); @@ -1043,6 +1055,9 @@ describe("components.css theme-sensitive surfaces", () => { expect(bottomTerminalShell).toContain("border: none"); expect(bottomTerminalShellRules).toContain("border-radius: 0"); expect(bottomTerminalShellRules).not.toContain("border-radius: 14px"); + expect(bottomTerminalContent).toContain("background: transparent"); + expect(bottomTerminalXterm).toContain("background: transparent"); + expect(bottomTerminalEmpty).toContain("background: transparent"); expect(statusBar).toContain( "border-top: 1px solid color-mix(in srgb, var(--border) 62%, transparent)" ); @@ -1147,8 +1162,26 @@ describe("components.css theme-sensitive surfaces", () => { ); const appTopbar = getLastRuleBlock(".app-topbar"); const workspacePage = getLastRuleBlock(".workspace-page"); + const workspaceMainArea = getLastRuleBlock(".workspace-main-area"); + const agentPanesStage = getLastRuleBlock(".workspace-main-stage > .agent-panes"); + const leftPanel = getLastRuleBlock(".left-panel"); + const workspaceSidebarPanel = getLastRuleBlock(".workspace-sidebar-panel"); + const workspaceActivityBar = getLastRuleBlock(".workspace-activity-bar"); + const workspaceStatusBar = getLastRuleBlock(".workspace-status-bar"); + const workspaceSidebarContent = getLastRuleBlock(".workspace-sidebar-panel__content"); + const workspaceSidebarView = getLastRuleBlock(".workspace-sidebar-view"); + const workspaceSidebarBody = getLastRuleBlock(".workspace-sidebar-panel__body"); const sessionCard = getLastRuleBlock(".session-card"); + const activeSessionCard = getLastRuleBlock(".session-card.session-card--active"); + const sessionTerminal = getLastRuleBlock(".session-terminal"); + const workspaceEmptyInner = getLastRuleBlock(".workspace-empty-inner"); + const workspaceResolvingCard = getLastRuleBlock(".workspace-resolving-card"); const bottomTerminal = getLastRuleBlock(".workspace-bottom-panel > .bottom-terminal"); + const bottomTerminalContent = getLastRuleBlock(".bottom-terminal-content"); + const bottomTerminalXterm = getLastRuleBlock(".bottom-terminal-xterm"); + const bottomTerminalEmpty = getLastRuleBlock(".bottom-terminal-empty"); + const terminalToolbar = getLastRuleBlock(".terminal-toolbar"); + const bottomTerminalTabs = getLastRuleBlock(".bottom-terminal-tabs"); const mobileShell = getLastGroupedRuleBlock(/\.mobile-shell\s*\{([^}]*)\}/g); const mobileTopbar = getLastRuleBlock(".mobile-topbar"); const mobileBottomStack = getLastRuleBlock(".mobile-shell__bottom-stack"); @@ -1162,16 +1195,68 @@ describe("components.css theme-sensitive surfaces", () => { expect(appTopbar).toContain("var(--surface-overlay-bg)"); expect(appTopbar).toContain("var(--app-surface-opacity, 0.96)"); expect(appTopbar).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); - expect(workspacePage).toContain("var(--surface-page-bg)"); - expect(workspacePage).toContain("var(--app-surface-opacity, 0.96)"); + expect(workspacePage).toContain("background: transparent"); + expect(workspaceMainArea).toContain("background: transparent"); + expect(agentPanesStage).toContain("background: transparent"); + expect(leftPanel).toContain("background: transparent"); + expect(workspaceSidebarPanel).toContain("var(--surface-overlay-bg)"); + expect(workspaceSidebarPanel).toContain("var(--app-surface-opacity, 0.96)"); + expect(workspaceSidebarPanel).toContain("calc(var(--app-surface-opacity, 0.96) * 76%)"); + expect(workspaceSidebarPanel).toContain( + "backdrop-filter: var(--app-surface-backdrop-filter, none)" + ); + expect(workspaceSidebarPanel).not.toContain("var(--bg-panel)"); + expect(workspaceActivityBar).toContain("var(--surface-overlay-bg)"); + expect(workspaceActivityBar).toContain("var(--app-surface-opacity, 0.88)"); + expect(workspaceActivityBar).toContain("calc(var(--app-surface-opacity, 0.88) * 62%)"); + expect(workspaceActivityBar).toContain( + "backdrop-filter: var(--app-surface-backdrop-filter, none)" + ); + expect(workspaceActivityBar).not.toContain("var(--bg-panel)"); + expect(workspaceStatusBar).toContain("var(--surface-overlay-bg)"); + expect(workspaceStatusBar).toContain("var(--app-surface-opacity, 0.96)"); + expect(workspaceStatusBar).toContain("calc(var(--app-surface-opacity, 0.96) * 68%)"); + expect(workspaceStatusBar).toContain( + "backdrop-filter: var(--app-surface-backdrop-filter, none)" + ); + expect(workspaceStatusBar).not.toContain("var(--bg-panel)"); + expect(workspaceSidebarContent).toContain("background: transparent"); + expect(workspaceSidebarView).toContain("background: transparent"); + expect(workspaceSidebarBody).toContain("background: transparent"); expect(sessionCard).toContain("var(--surface-overlay-bg)"); expect(sessionCard).toContain("var(--app-surface-opacity, 0.96)"); + expect(sessionCard).toContain("calc(var(--app-surface-opacity, 0.96) * 44%)"); expect(sessionCard).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(activeSessionCard).toContain("calc(var(--app-surface-opacity, 0.96) * 52%)"); + expect(activeSessionCard).not.toContain("background: var(--bg-active)"); + expect(sessionTerminal).toContain("background: transparent"); + expect(workspaceEmptyInner).toContain("var(--surface-overlay-bg)"); + expect(workspaceEmptyInner).toContain("var(--app-surface-opacity, 0.96)"); + expect(workspaceEmptyInner).toContain( + "backdrop-filter: var(--app-surface-backdrop-filter, none)" + ); + expect(workspaceResolvingCard).toContain("var(--surface-overlay-bg)"); + expect(workspaceResolvingCard).toContain("var(--app-surface-opacity, 0.96)"); + expect(workspaceResolvingCard).toContain( + "backdrop-filter: var(--app-surface-backdrop-filter, none)" + ); expect(bottomTerminal).toContain("var(--surface-overlay-bg)"); expect(bottomTerminal).toContain("var(--app-surface-opacity, 0.96)"); + expect(bottomTerminal).toContain("calc(var(--app-surface-opacity, 0.96) * 60%)"); expect(bottomTerminal).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); - expect(mobileShell).toContain("var(--surface-page-bg)"); - expect(mobileShell).toContain("var(--app-surface-opacity, 0.96)"); + expect(bottomTerminalContent).toContain("background: transparent"); + expect(bottomTerminalXterm).toContain("background: transparent"); + expect(bottomTerminalEmpty).toContain("background: transparent"); + expect(terminalToolbar).toContain("var(--surface-overlay-bg)"); + expect(terminalToolbar).toContain("var(--app-surface-opacity, 0.88)"); + expect(terminalToolbar).toContain("calc(var(--app-surface-opacity, 0.88) * 66%)"); + expect(terminalToolbar).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(terminalToolbar).not.toContain("var(--bg-surface)"); + expect(bottomTerminalTabs).toContain("var(--surface-overlay-bg)"); + expect(bottomTerminalTabs).toContain("var(--app-surface-opacity, 0.88)"); + expect(bottomTerminalTabs).toContain("calc(var(--app-surface-opacity, 0.88) * 56%)"); + expect(bottomTerminalTabs).not.toContain("var(--bg-surface)"); + expect(mobileShell).toContain("background: transparent"); expect(mobileTopbar).toContain("var(--surface-overlay-bg)"); expect(mobileTopbar).toContain("var(--app-surface-opacity, 0.96)"); expect(mobileTopbar).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); @@ -1396,6 +1481,8 @@ describe("components.css theme-sensitive surfaces", () => { expect(launcher).toContain("container-type: inline-size"); expect(content).toContain("max-width: 100%"); + expect(content).toContain("var(--surface-overlay-bg)"); + expect(content).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); expect(providerCard).toContain("min-width: 0"); expect(providerBody).toContain("width: 100%"); expect(providerBody).toContain("gap: var(--gap-tight)"); From 1d841200e9ec680e8cbee4082688ab9d6798ed0a Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 17:28:11 +0800 Subject: [PATCH 27/36] docs: add background material settings restyle spec --- ...ground-material-settings-restyle-design.md | 390 ++++++++++++++++++ 1 file changed, 390 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-24-background-material-settings-restyle-design.md diff --git a/docs/superpowers/specs/2026-05-24-background-material-settings-restyle-design.md b/docs/superpowers/specs/2026-05-24-background-material-settings-restyle-design.md new file mode 100644 index 00000000..0ff08f45 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-background-material-settings-restyle-design.md @@ -0,0 +1,390 @@ +# 背景图相关设置样式优化 · 设计文档 + +> **版本:** 1.0 +> **日期:** 2026-05-24 +> **状态:** Draft(待评审) +> **作者:** Codex + +--- + +## 0. 文档说明 + +### 0.1 目标 + +优化设置页 `Appearance` 分区中“背景与材质”相关设置的样式表现,在不改变设置模型和交互语义的前提下,修复当前布局松散、层级失衡、背景图操作区不成组的问题。 + +本轮目标不是重做整页设置风格,也不是扩展新的外观能力,而是让这一个分组回到与当前设置页一致的系统偏好语气,同时适度增强材质感和预览感。 + +### 0.2 用户确认方向 + +本轮已和用户确认以下设计方向: + +- 保持当前设置页整体的桌面工作台 / 系统偏好风格 +- 不做独立素材编辑器,不让该分组脱离整页语气 +- 允许这一个分组比其他设置项多一点材质感和预览感 +- 采用 `A. Material Panel` 方向作为主体 +- 局部借用 `C. Override Blocks` 的层级手法处理桌面端 / 移动端覆盖区 + +### 0.3 本轮范围 + +包含: + +- `Background & Material` 分组的 DOM 结构重排 +- 该分组的局部样式重做 +- 共享设置与设备覆盖设置的层级梳理 +- 桌面与移动端的响应式适配 +- 与该结构调整直接相关的测试更新 + +不包含: + +- `appearance.personalization` 数据结构调整 +- 背景图上传、删除、保存逻辑改写 +- 新增真实缩略图预览器 +- 数值字段改为滑块或新增复杂交互 +- 设置页其他 section 的整体视觉重做 + +--- + +## 1. 现状与问题 + +当前“背景与材质”分组位于: + +- `packages/web/src/features/settings/components/settings-page.tsx` +- `packages/web/src/styles/components.css` + +现有问题主要有四类: + +### 1.1 上传区不成组 + +当前 `背景模式`、背景图资源信息、上传/更换/移除按钮、`背景适配` 分散在多行表单中: + +- 当前背景图资源 ID 像普通说明文一样散落在行内 +- 上传操作和当前资源状态没有形成完整对象 +- 横向空间分配不稳定,容易出现左侧空、右侧控件孤立的问题 + +### 1.2 材质参数区视觉节奏过散 + +`背景压暗`、`背景模糊`、`毛玻璃强度`、`面板不透明度` 当前都以独立行表单展示,导致: + +- 参数之间缺少“同属一组材质控制”的视觉联系 +- 整体更像一串分离输入框,而不是一个可扫描的控制面板 +- 用户很难快速建立“当前材质由哪些参数共同决定”的认知 + +### 1.3 共享设置与覆盖设置层级不够清晰 + +虽然当前逻辑已经区分共享值、桌面端覆盖、移动端覆盖,但视觉层级仍偏平: + +- `桌面端覆盖`、`移动端覆盖` 与共享设置接近同一强度 +- 开启覆盖后的局部字段虽然出现,但缺少明确的次级模块边界 +- 用户能操作,但不容易一眼理解“这里是在继承共享设置后做局部改写” + +### 1.4 分组气质还没有真正体现“背景与材质” + +当前分组仍沿用通用表单行节奏,缺少足够的视觉暗示去表达: + +- 背景图是一种资源对象 +- 背景适配、毛玻璃、压暗、模糊共同组成一套材质设置 +- 这是一个完整的视觉偏好模块,而不只是若干零散字段 + +--- + +## 2. 设计目标与非目标 + +### 2.1 设计目标 + +- 让背景图相关操作形成一个完整、可理解的上层模块 +- 让材质参数形成统一且更紧凑的控制面板节奏 +- 明确共享设置是主层,桌面端 / 移动端覆盖是次层 +- 在不偏离整页设置语气的前提下,为该分组补一点材质感和预览感 +- 保持现有交互、字段语义、保存行为和可访问性标签稳定 + +### 2.2 非目标 + +- 不把该分组做成独立素材面板或图片编辑器 +- 不在本轮加入真实背景图缩略图预览 +- 不引入折叠区、标签页或模式切换器 +- 不把数字输入改成滑块 +- 不重写设置页全局布局或其他 appearance 子分组 + +--- + +## 3. 方案选择 + +### 3.1 方案 A:Material Panel(采用) + +保留系统设置页的表单语言,但把背景图相关操作重组为更完整的材质面板:上层为背景图资产与模式,下层为共享材质参数,覆盖设置作为次级模块收在下方。 + +优点: + +- 与现有设置页最一致,风险最低 +- 能直接修复当前最明显的散乱感 +- 有足够的材质感,但不会破坏整页节奏 + +缺点: + +- 预览感只能通过分组和材质容器暗示,不能像素材编辑器那样强烈 + +### 3.2 方案 B:Preview Rail + +增加明显的背景图展示侧栏或预览卡,再把设置放在旁边。 + +优点: + +- 预览感最强 + +缺点: + +- 很容易在整页中显得这一个分组过重 +- 与当前系统偏好页语言不一致 +- 对移动端和窄宽度适配不友好 + +### 3.3 方案 C:Override Blocks + +重点强化共享值、桌面端覆盖、移动端覆盖的结构层级,让覆盖区更像次级模块。 + +优点: + +- 对覆盖逻辑表达最清楚 + +缺点: + +- 更偏结构治理而不是背景材质感提升 +- 单独采用会让上传区和参数区仍显普通 + +### 3.4 结论 + +本轮采用: + +- `A. Material Panel` 作为主体方案 +- 局部吸收 `C. Override Blocks` 的层级手法处理设备覆盖区 + +不采用 `B`,避免把单个分组做成与整页脱节的独立预览器。 + +--- + +## 4. 最终设计 + +### 4.1 信息结构 + +`Background & Material` 分组重排为三个连续层级: + +1. `资产与模式区` +2. `共享材质参数区` +3. `设备覆盖区` + +层级原则: + +- 背景图资源对象优先于参数 +- 共享设置优先于设备覆盖 +- 覆盖设置只在需要时出现,并保持明显较弱的层级 + +### 4.2 资产与模式区 + +这一层负责承接: + +- `背景模式` +- 当前背景图资源信息 +- `上传 / 更换 / 移除背景图` +- `背景适配` + +设计要求: + +- 使用单独的局部容器包裹,而不是继续拆成几行分散表单 +- 当前资源信息需要被视为“背景图对象状态”的一部分 +- 上传按钮和替换/移除操作与当前资源信息紧邻布局 +- `背景适配` 继续作为共享值,但视觉上与背景图资源区保持连续 + +目标效果: + +- 用户一眼能看出“当前是哪张图”和“我能对它做什么” +- 上传动作不再像散落在右侧的附属按钮 + +### 4.3 共享材质参数区 + +这一层负责承接: + +- `启用毛玻璃` +- `背景压暗` +- `背景模糊` +- `毛玻璃强度` +- `面板不透明度` + +设计要求: + +- 保留精确数值输入,不改为滑块 +- `启用毛玻璃` 继续保留开关行结构 +- 数值参数改为更整齐的双列或自适应网格 +- 每个参数保留清晰标签和说明 + +目标效果: + +- 视觉上明确这几项是同一组材质控制 +- 扫描效率高于当前独立逐行表单 +- 在移动端可自然退化为单列 + +### 4.4 设备覆盖区 + +这一层继续承接: + +- `桌面端覆盖` +- `移动端覆盖` + +以及各自启用后的局部字段: + +- 背景图资源覆盖 +- `启用毛玻璃` +- `面板不透明度` + +设计要求: + +- 覆盖开关仍位于主表单流中 +- 开启覆盖后,显示轻量次级子面板 +- 子面板边界、底色、间距都应弱于共享设置主层 +- 清晰表达“当前是在共享值基础上的局部覆盖” + +目标效果: + +- 开关未启用时,信息密度保持克制 +- 开关启用后,结构比当前更清楚,但不形成第三套平级主设置 + +### 4.5 预览感的边界 + +本轮只做“暗示型预览感”,不做真实预览器。 + +允许的做法: + +- 给背景图资产区更完整的材质容器 +- 通过局部底色、边界和分组节奏表达背景/材质属性 +- 让背景图资源信息从普通说明文升级为状态信息 + +不做的事: + +- 不展示真实缩略图 +- 不模拟实时工作台预览窗 +- 不新增局部舞台式展示组件 + +这样可以保持这块设置仍属于系统偏好页,而不是单独编辑器。 + +--- + +## 5. 实现策略 + +### 5.1 主要改动文件 + +- `packages/web/src/features/settings/components/settings-page.tsx` +- `packages/web/src/styles/components.css` + +按需更新: + +- `packages/web/src/features/settings/components/settings-page.test.tsx` + +### 5.2 DOM 调整原则 + +本轮优先在现有逻辑上增加语义容器,不改业务语义: + +- 给背景图资产和模式区补明确的分组容器类名 +- 给共享材质参数区补网格容器类名 +- 给设备覆盖内容补次级面板容器类名 +- 尽量保留现有表单控件、label、`aria-*` 关系和测试角色 + +不应发生的变化: + +- 字段 key 改名 +- 表单控件角色变化导致测试大面积失效 +- 上传、删除、保存的调用路径被重写 + +### 5.3 样式策略 + +样式处理应遵守以下原则: + +- 只增强 `Background & Material` 这一个分组 +- 复用现有 tokens,不引入脱离体系的新色彩语言 +- 通过轻底色、弱边界、节奏化间距表达层级,而不是叠很多大卡片 +- 保持 light / dark theme 下都具备稳定完成度 + +实现层次固定为: + +1. 分组整体仍留在 `.settings-group` 的页面节奏里 +2. 背景图资产区和参数区使用局部 surface +3. 设备覆盖区使用更弱一级的 inset 子面板 + +### 5.4 响应式规则 + +桌面端: + +- 优先体现两层材质模块与次级覆盖模块的节奏 +- 数值参数优先双列布局 + +移动端: + +- 退化为单列 +- 按钮与资源状态允许纵向堆叠 +- 覆盖子面板保持轻量,不出现横向拥挤 + +--- + +## 6. 测试与验证 + +### 6.1 必须保持稳定的行为 + +- 背景图上传 +- 背景图替换 +- 背景图移除 +- 共享值保存 +- 桌面端覆盖开关 +- 移动端覆盖开关 +- 现有 hydration 行为 + +### 6.2 测试策略 + +- 优先保持现有测试断言可继续使用 +- 如果结构调整导致依赖 DOM 结构的断言失效,仅做最小范围更新 +- 如有现成 settings visual coverage,应补一次该分组的视觉回归验证 + +### 6.3 验收标准 + +- 背景图相关设置从视觉上明显更成组,不再显得散 +- 当前背景图资源信息和上传操作形成完整块 +- 材质参数区具备统一节奏和更强扫描性 +- `桌面端覆盖 / 移动端覆盖` 明确是次级覆盖,而不是平级主设置 +- 桌面端和移动端都能稳定布局 +- 现有交互行为、保存逻辑和可访问性语义保持一致 + +--- + +## 7. 风险与控制 + +### 7.1 风险:样式增强过重,破坏整页一致性 + +控制方式: + +- 不引入明显独立的预览侧栏 +- 不使用强品牌化配色 +- 让材质感只停留在局部分组层面 + +### 7.2 风险:结构改动影响现有测试和交互 + +控制方式: + +- 优先增加容器,不重写控件 +- 保持 label、按钮名称、角色和 `aria-*` 语义稳定 +- 仅在必要时更新测试 + +### 7.3 风险:覆盖区层级过强,导致页面卡片堆叠感 + +控制方式: + +- 次级覆盖面板只使用更弱底色和更轻边界 +- 避免再套一层与主 surface 同强度的外壳 + +--- + +## 8. 最终结论 + +本轮将“背景与材质”分组重构为更完整的系统偏好面板: + +- 上层整合背景图资产与模式 +- 中层组织共享材质参数 +- 下层弱化呈现设备覆盖 + +整体风格仍然属于当前设置页,但该分组会更有材质感、更有预览暗示,也更容易扫描和理解。 From f49996a1ca8d6674193f164ecece255cca2dcb5d Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 18:00:34 +0800 Subject: [PATCH 28/36] Fix workspace glass background rendering --- .../__tests__/xterm-host.test.tsx | 78 ++++++++++++++++++- .../views/shared/xterm-host.tsx | 30 ++++++- packages/web/src/styles/components.css | 18 +++-- .../web/src/styles/components.theme.test.ts | 19 +++++ 4 files changed, 135 insertions(+), 10 deletions(-) diff --git a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx index f1ca9d4d..24622c7a 100644 --- a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +++ b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx @@ -9,7 +9,7 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-librar import userEvent from "@testing-library/user-event"; import { createStore, Provider } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { localeAtom, themeAtom } from "../../../atoms/app-ui"; +import { appearancePersonalizationAtom, localeAtom, themeAtom } from "../../../atoms/app-ui"; import { wsClientAtom } from "../../../atoms/connection"; import { JotaiProvider } from "../../../test-utils/jotai-provider"; import { getThemeById } from "../../../theme"; @@ -2723,6 +2723,41 @@ describe("XtermHost", () => { ); }); + it("uses a transparent xterm background when glass surfaces are enabled", async () => { + const { Terminal } = await import("@xterm/xterm"); + const store = createStore(); + store.set(appearancePersonalizationAtom, { + version: 1, + common: { + backgroundMode: "image", + backgroundAssetId: "asset-glass-terminal", + backgroundFit: "cover", + backgroundDimness: 18, + backgroundBlur: 6, + glassEnabled: true, + glassIntensity: 24, + surfaceOpacity: 56, + }, + desktop: {}, + mobile: {}, + }); + + render( + + + + ); + + expect(Terminal).toHaveBeenCalledWith( + expect.objectContaining({ + theme: expect.objectContaining({ + ...getThemeById("mint-dark").terminalTheme, + background: "transparent", + }), + }) + ); + }); + it("updates the live xterm theme when the ui theme changes to graphite-light", async () => { const store = createStore(); store.set(themeAtom, "mint-dark"); @@ -2746,6 +2781,47 @@ describe("XtermHost", () => { }); }); + it("keeps the live xterm background transparent after a theme switch when glass surfaces are enabled", async () => { + const store = createStore(); + store.set(themeAtom, "mint-dark"); + store.set(appearancePersonalizationAtom, { + version: 1, + common: { + backgroundMode: "image", + backgroundAssetId: "asset-glass-theme-sync", + backgroundFit: "cover", + backgroundDimness: 16, + backgroundBlur: 4, + glassEnabled: true, + glassIntensity: 28, + surfaceOpacity: 52, + }, + desktop: {}, + mobile: {}, + }); + + render( + + + + ); + + await act(async () => { + store.set(themeAtom, "graphite-light"); + }); + + await waitFor(() => { + expect(mockTerminal.options).toEqual( + expect.objectContaining({ + theme: expect.objectContaining({ + ...getThemeById("graphite-light").terminalTheme, + background: "transparent", + }), + }) + ); + }); + }); + it("uses the high-contrast dark terminal palette for hc-dark", async () => { const { Terminal } = await import("@xterm/xterm"); const store = createStore(); diff --git a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx index a20d7284..fd6ffcb5 100644 --- a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx +++ b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx @@ -25,12 +25,14 @@ import { useRef, useState, } from "react"; -import { themeAtom } from "../../../../atoms/app-ui"; +import { resolveAppearancePersonalizationForViewport } from "../../../../appearance"; +import { appearancePersonalizationAtom, themeAtom } from "../../../../atoms/app-ui"; import { dispatchCommandAtom, wsClientAtom } from "../../../../atoms/connection"; import { Button, LocalOverlay, Notice } from "../../../../components/ui"; import { useViewport } from "../../../../hooks/use-viewport"; import { copyTextWithFallback } from "../../../../lib/clipboard"; import { useTranslation } from "../../../../lib/i18n"; +import type { TerminalThemeDefinition } from "../../../../theme"; import { getThemeById } from "../../../../theme"; import type { ConnectionStatus, TerminalBinaryPayload } from "../../../../ws/client"; import { pushToastAtom } from "../../../notifications/atoms"; @@ -324,6 +326,18 @@ export function trimWrittenChunks(buffer: OutputBuffer, writtenChunkCount: numbe }; } +function resolveXtermTheme(themeId: string, glassEnabled: boolean): TerminalThemeDefinition { + const terminalTheme = getThemeById(themeId).terminalTheme; + if (!glassEnabled) { + return terminalTheme; + } + + return { + ...terminalTheme, + background: "transparent", + }; +} + interface XtermHostProps { /** Terminal ID */ terminalId: string; @@ -402,6 +416,7 @@ export function XtermHost({ const t = useTranslation(); const viewport = useViewport(); const uiTheme = useAtomValue(themeAtom); + const appearancePersonalization = useAtomValue(appearancePersonalizationAtom); const terminalPreferences = useAtomValue(terminalPreferencesAtom); const terminalFontSize = getTerminalFontSizeForViewport(terminalPreferences, viewport); const wsClient = useAtomValue(wsClientAtom); @@ -510,6 +525,13 @@ export function XtermHost({ return wsClient.getStatus(); }); + const effectiveAppearance = resolveAppearancePersonalizationForViewport( + appearancePersonalization, + viewport + ); + const glassEnabled = + effectiveAppearance.glassEnabled && uiTheme !== "hc-dark" && uiTheme !== "hc-light"; + const resolvedTerminalTheme = resolveXtermTheme(uiTheme, glassEnabled); // Latest copies of callback identities used inside the mount effect, exposed // via refs so the effect's cleanup/re-creation is not tied to their churn. @@ -617,9 +639,9 @@ export function XtermHost({ useEffect(() => { if (terminalRef.current) { - terminalRef.current.options.theme = getThemeById(uiTheme).terminalTheme; + terminalRef.current.options.theme = resolvedTerminalTheme; } - }, [uiTheme]); + }, [resolvedTerminalTheme]); useEffect(() => { if (replayUiState.kind !== "loading") { @@ -1358,7 +1380,7 @@ export function XtermHost({ // characters used by TUIs (claude, codex) render as a continuous frame // with no gaps between rows. const terminal = new Terminal({ - theme: getThemeById(initialThemeRef.current).terminalTheme, + theme: resolveXtermTheme(initialThemeRef.current, glassEnabled), fontFamily: "JetBrains Mono, Fira Code, SF Mono, monospace", fontSize: terminalFontSize, scrollback: 5000, diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index 2dd0e028..df91c4d0 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -1854,6 +1854,10 @@ background: var(--bg-terminal); } +[data-appearance-glass="on"] .xterm-host .xterm-screen { + background: transparent; +} + /* Third-party widgets render their own scroll containers, so bridge them back * to the shared scrollbar tokens instead of letting their library defaults * drift away from the rest of the app. */ @@ -1950,6 +1954,7 @@ body.focus-mode-active .split-divider-h { display: flex; flex: 1; min-height: 0; + background: transparent; } /* ========== Workspace Page Layout ========== */ @@ -2287,7 +2292,7 @@ body.is-resizing-panels * { flex: 1; display: flex; flex-direction: column; - background: var(--bg); + background: transparent; position: relative; overflow: hidden; min-height: 0; @@ -2308,7 +2313,7 @@ body.is-resizing-panels * { flex: 1; display: flex; flex-direction: column; - background: var(--bg); + background: transparent; position: relative; min-height: 0; } @@ -3014,6 +3019,7 @@ body.is-resizing-panels * { width: 100%; height: 100%; overflow: hidden; + background: transparent; } .pane-layout-horizontal { @@ -3026,6 +3032,7 @@ body.is-resizing-panels * { .pane-layout-child { overflow: hidden; + background: transparent; } .pane-layout-divider { @@ -6219,9 +6226,7 @@ textarea.input { } .agent-panes { - background: - radial-gradient(circle at top center, rgba(108, 182, 255, 0.05), transparent 24%), - linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 20%), var(--bg-page); + background: transparent; } .agent-draft-launcher { @@ -7296,6 +7301,7 @@ textarea.input { min-width: 0; display: flex; flex-direction: column; + background: transparent; } .workspace-main-stage > .agent-panes { @@ -7995,6 +8001,7 @@ textarea.input { .pane-layout, .pane-layout-child { height: 100%; + background: transparent; } .panel-body { @@ -14406,6 +14413,7 @@ textarea.input { var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 60%), transparent ); + box-shadow: none; backdrop-filter: var(--app-surface-backdrop-filter, none); } diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index a9b2a4d9..7fe09bee 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -1162,8 +1162,14 @@ describe("components.css theme-sensitive surfaces", () => { ); const appTopbar = getLastRuleBlock(".app-topbar"); const workspacePage = getLastRuleBlock(".workspace-page"); + const workspaceBody = getLastRuleBlock(".workspace-body"); const workspaceMainArea = getLastRuleBlock(".workspace-main-area"); + const workspaceMainStage = getLastRuleBlock(".workspace-main-stage"); + const agentPanes = getLastRuleBlock(".agent-panes"); + const agentPane = getLastRuleBlock(".agent-pane"); const agentPanesStage = getLastRuleBlock(".workspace-main-stage > .agent-panes"); + const paneLayout = getLastRuleBlock(".pane-layout"); + const paneLayoutChild = getLastRuleBlock(".pane-layout-child"); const leftPanel = getLastRuleBlock(".left-panel"); const workspaceSidebarPanel = getLastRuleBlock(".workspace-sidebar-panel"); const workspaceActivityBar = getLastRuleBlock(".workspace-activity-bar"); @@ -1180,6 +1186,10 @@ describe("components.css theme-sensitive surfaces", () => { const bottomTerminalContent = getLastRuleBlock(".bottom-terminal-content"); const bottomTerminalXterm = getLastRuleBlock(".bottom-terminal-xterm"); const bottomTerminalEmpty = getLastRuleBlock(".bottom-terminal-empty"); + const xtermScreen = getLastRuleBlock(".xterm-host .xterm-screen"); + const glassXtermScreen = getLastRuleBlock( + '[data-appearance-glass="on"] .xterm-host .xterm-screen' + ); const terminalToolbar = getLastRuleBlock(".terminal-toolbar"); const bottomTerminalTabs = getLastRuleBlock(".bottom-terminal-tabs"); const mobileShell = getLastGroupedRuleBlock(/\.mobile-shell\s*\{([^}]*)\}/g); @@ -1196,8 +1206,14 @@ describe("components.css theme-sensitive surfaces", () => { expect(appTopbar).toContain("var(--app-surface-opacity, 0.96)"); expect(appTopbar).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); expect(workspacePage).toContain("background: transparent"); + expect(workspaceBody).toContain("background: transparent"); expect(workspaceMainArea).toContain("background: transparent"); + expect(workspaceMainStage).toContain("background: transparent"); + expect(agentPanes).toContain("background: transparent"); + expect(agentPane).toContain("background: transparent"); expect(agentPanesStage).toContain("background: transparent"); + expect(paneLayout).toContain("background: transparent"); + expect(paneLayoutChild).toContain("background: transparent"); expect(leftPanel).toContain("background: transparent"); expect(workspaceSidebarPanel).toContain("var(--surface-overlay-bg)"); expect(workspaceSidebarPanel).toContain("var(--app-surface-opacity, 0.96)"); @@ -1244,9 +1260,12 @@ describe("components.css theme-sensitive surfaces", () => { expect(bottomTerminal).toContain("var(--app-surface-opacity, 0.96)"); expect(bottomTerminal).toContain("calc(var(--app-surface-opacity, 0.96) * 60%)"); expect(bottomTerminal).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(bottomTerminal).toContain("box-shadow: none"); expect(bottomTerminalContent).toContain("background: transparent"); expect(bottomTerminalXterm).toContain("background: transparent"); expect(bottomTerminalEmpty).toContain("background: transparent"); + expect(xtermScreen).toContain("background: var(--bg-terminal)"); + expect(glassXtermScreen).toContain("background: transparent"); expect(terminalToolbar).toContain("var(--surface-overlay-bg)"); expect(terminalToolbar).toContain("var(--app-surface-opacity, 0.88)"); expect(terminalToolbar).toContain("calc(var(--app-surface-opacity, 0.88) * 66%)"); From aa05516fa717f47cfb2401abba6a8678df783d9c Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 18:15:26 +0800 Subject: [PATCH 29/36] feat(web): restyle background material settings --- .../components/settings-page.test.tsx | 93 ++ .../settings/components/settings-page.tsx | 872 +++++++++--------- packages/web/src/styles/components.css | 132 +++ .../web/src/styles/components.theme.test.ts | 32 + 4 files changed, 702 insertions(+), 427 deletions(-) diff --git a/packages/web/src/features/settings/components/settings-page.test.tsx b/packages/web/src/features/settings/components/settings-page.test.tsx index cf4b9172..b8f8d73c 100644 --- a/packages/web/src/features/settings/components/settings-page.test.tsx +++ b/packages/web/src/features/settings/components/settings-page.test.tsx @@ -1510,6 +1510,99 @@ describe("SettingsPage", () => { expect(document.getElementById("appearance-mobile-surface-opacity")).toBeInTheDocument(); }); + it("groups background material controls into asset and material surfaces", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + "appearance.personalization.common.backgroundFit": "contain", + "appearance.personalization.common.backgroundDimness": 33, + "appearance.personalization.common.backgroundBlur": 8, + "appearance.personalization.common.glassEnabled": true, + "appearance.personalization.common.glassIntensity": 44, + "appearance.personalization.common.surfaceOpacity": 91, + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "外观" })); + + const backgroundMaterialGroup = ( + await screen.findByRole("heading", { name: "背景与材质" }) + ).closest(".settings-group"); + + expect(backgroundMaterialGroup).not.toBeNull(); + + const assetPanel = backgroundMaterialGroup?.querySelector(".settings-appearance-panel--asset"); + const materialPanel = backgroundMaterialGroup?.querySelector( + ".settings-appearance-panel--material" + ); + + expect(assetPanel).not.toBeNull(); + expect(materialPanel).not.toBeNull(); + expect( + document + .getElementById("appearance-background-mode") + ?.closest(".settings-appearance-panel--asset") + ).toBe(assetPanel); + expect( + document + .getElementById("appearance-background-fit") + ?.closest(".settings-appearance-panel--asset") + ).toBe(assetPanel); + expect(screen.getByText("asset-common")).toHaveClass("settings-appearance-asset-id"); + expect(assetPanel?.querySelector(".settings-appearance-actions")).not.toBeNull(); + expect( + screen + .getByRole("spinbutton", { name: "背景压暗" }) + .closest(".settings-appearance-material-grid") + ).toBeTruthy(); + expect( + screen + .getByRole("spinbutton", { name: "面板不透明度" }) + .closest(".settings-appearance-material-grid") + ).toBeTruthy(); + }); + + it("renders desktop and mobile override controls inside nested appearance panels", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "外观" })); + + fireEvent.click(await screen.findByRole("switch", { name: "桌面端覆盖" })); + + const desktopSurfaceOpacity = document.getElementById("appearance-desktop-surface-opacity"); + + expect(desktopSurfaceOpacity).not.toBeNull(); + expect(desktopSurfaceOpacity?.closest(".settings-appearance-override-panel")).toBeTruthy(); + expect( + desktopSurfaceOpacity + ?.closest(".settings-appearance-override-panel") + ?.querySelector(".settings-appearance-actions") + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("switch", { name: "移动端覆盖" })); + + const mobileSurfaceOpacity = document.getElementById("appearance-mobile-surface-opacity"); + + expect(mobileSurfaceOpacity).not.toBeNull(); + expect(mobileSurfaceOpacity?.closest(".settings-appearance-override-panel")).toBeTruthy(); + }); + it("deletes the shared appearance background asset and persists a null background asset id", async () => { appearanceMocks.deleteAppearanceAsset.mockResolvedValue(undefined); const sendCommand = vi.fn().mockImplementation(async (op: string) => { diff --git a/packages/web/src/features/settings/components/settings-page.tsx b/packages/web/src/features/settings/components/settings-page.tsx index 386e4975..281d8f85 100644 --- a/packages/web/src/features/settings/components/settings-page.tsx +++ b/packages/web/src/features/settings/components/settings-page.tsx @@ -2063,6 +2063,23 @@ function AppearanceSettings({ ); + const renderAssetSummary = ( + target: AppearanceAssetScope, + label: string, + assetId: string | null | undefined, + hasAsset: boolean + ) => ( +
+
+ {label} + + {assetId ? assetId : t("settings.appearance_uses_shared_value")} + +
+ {renderAssetButtons(target, hasAsset)} +
+ ); + const commitTerminalFontSize = async ( draft: string, currentValue: number, @@ -2140,473 +2157,474 @@ function AppearanceSettings({

{t("settings.appearance_background_material")}

{t("settings.appearance_background_material_hint")}

-
- -
- { + const nextMode = value as AppearanceBackgroundMode; + const next = { + ...personalization, + common: buildCommonForBackgroundMode(nextMode), + }; + next.common.backgroundMode = nextMode; + void saveNextPersonalization(next); + }} + /> +
- {renderAssetButtons("common", Boolean(personalization.common.backgroundAssetId))} - - ) : null} - -
- -
- { - void commitBoundedCommonField( - backgroundDimnessDraft, - personalization.common.backgroundDimness, - 0, - 100, - setBackgroundDimnessDraft, - setBackgroundDimnessError, - "backgroundDimness" - ); - }} - onChange={(event) => { - setBackgroundDimnessDraft(event.target.value); - setBackgroundDimnessError(null); - }} - /> -
- {backgroundDimnessError ? ( - - {backgroundDimnessError} - - ) : null} -
- -
- -
- { - void commitBoundedCommonField( - backgroundBlurDraft, - personalization.common.backgroundBlur, - 0, - 40, - setBackgroundBlurDraft, - setBackgroundBlurError, - "backgroundBlur" - ); - }} - onChange={(event) => { - setBackgroundBlurDraft(event.target.value); - setBackgroundBlurError(null); - }} - /> -
- {backgroundBlurError ? ( - - {backgroundBlurError} - - ) : null} -
- -
- -
- { - void commitBoundedCommonField( - glassIntensityDraft, - personalization.common.glassIntensity, - 0, - 100, - setGlassIntensityDraft, - setGlassIntensityError, - "glassIntensity" - ); - }} - onChange={(event) => { - setGlassIntensityDraft(event.target.value); - setGlassIntensityError(null); - }} - /> -
- {glassIntensityError ? ( - - {glassIntensityError} - - ) : null} -
-
- -
- { - void commitBoundedCommonField( - surfaceOpacityDraft, - personalization.common.surfaceOpacity, - 0, - 100, - setSurfaceOpacityDraft, - setSurfaceOpacityError, - "surfaceOpacity" - ); - }} - onChange={(event) => { - setSurfaceOpacityDraft(event.target.value); - setSurfaceOpacityError(null); - }} - /> -
- {surfaceOpacityError ? ( - - {surfaceOpacityError} - - ) : null} -
+ {personalization.common.backgroundMode === "image" + ? renderAssetSummary( + "common", + t("settings.appearance_background_upload"), + personalization.common.backgroundAssetId, + Boolean(personalization.common.backgroundAssetId) + ) + : null} -
-
- - {t("settings.appearance_override_desktop")} - - - {isOverrideEnabled("desktop") - ? t("settings.appearance_override_enabled") - : t("settings.appearance_uses_shared_value")} - +
+ +
+ { - void commitBoundedOverrideField( - "desktop", - desktopSurfaceOpacityDraft, - personalization.desktop.surfaceOpacity ?? + +
+
+ +
+ { + void commitBoundedCommonField( + backgroundDimnessDraft, + personalization.common.backgroundDimness, + 0, + 100, + setBackgroundDimnessDraft, + setBackgroundDimnessError, + "backgroundDimness" + ); + }} + onChange={(event) => { + setBackgroundDimnessDraft(event.target.value); + setBackgroundDimnessError(null); + }} + /> +
+ {backgroundDimnessError ? ( + + {backgroundDimnessError} + + ) : null} +
+ +
+ +
+ { + void commitBoundedCommonField( + backgroundBlurDraft, + personalization.common.backgroundBlur, + 0, + 40, + setBackgroundBlurDraft, + setBackgroundBlurError, + "backgroundBlur" + ); + }} + onChange={(event) => { + setBackgroundBlurDraft(event.target.value); + setBackgroundBlurError(null); + }} + /> +
+ {backgroundBlurError ? ( + + {backgroundBlurError} + + ) : null} +
+ +
+ +
+ { + void commitBoundedCommonField( + glassIntensityDraft, + personalization.common.glassIntensity, + 0, + 100, + setGlassIntensityDraft, + setGlassIntensityError, + "glassIntensity" + ); + }} + onChange={(event) => { + setGlassIntensityDraft(event.target.value); + setGlassIntensityError(null); + }} + /> +
+ {glassIntensityError ? ( + + {glassIntensityError} + + ) : null} +
+ +
+ +
+ { + void commitBoundedCommonField( + surfaceOpacityDraft, personalization.common.surfaceOpacity, - 0, - 100, - setDesktopSurfaceOpacityDraft, - setDesktopSurfaceOpacityError, - "surfaceOpacity" - ); - }} - onChange={(event) => { - setDesktopSurfaceOpacityDraft(event.target.value); - setDesktopSurfaceOpacityError(null); - }} - /> + 0, + 100, + setSurfaceOpacityDraft, + setSurfaceOpacityError, + "surfaceOpacity" + ); + }} + onChange={(event) => { + setSurfaceOpacityDraft(event.target.value); + setSurfaceOpacityError(null); + }} + /> +
+ {surfaceOpacityError ? ( + + {surfaceOpacityError} + + ) : null}
- {desktopSurfaceOpacityError ? ( - - {desktopSurfaceOpacityError} - - ) : null}
- ) : null} -
-
- - {t("settings.appearance_override_mobile")} - - - {isOverrideEnabled("mobile") - ? t("settings.appearance_override_enabled") - : t("settings.appearance_uses_shared_value")} - -
- { - void saveNextPersonalization(toggleOverride("mobile", nextValue)); - }} - /> -
+
+
+
+ + {t("settings.appearance_override_desktop")} + + + {isOverrideEnabled("desktop") + ? t("settings.appearance_override_enabled") + : t("settings.appearance_uses_shared_value")} + +
+ { + void saveNextPersonalization(toggleOverride("desktop", nextValue)); + }} + /> +
- {isOverrideEnabled("mobile") ? ( -
- {personalization.common.backgroundMode === "image" ? ( -
-
- - {t("settings.appearance_override_mobile")} - - - {personalization.mobile.backgroundAssetId ?? - t("settings.appearance_uses_shared_value")} - + {isOverrideEnabled("desktop") ? ( +
+ {personalization.common.backgroundMode === "image" + ? renderAssetSummary( + "desktop", + t("settings.appearance_override_desktop"), + personalization.desktop.backgroundAssetId, + Object.prototype.hasOwnProperty.call( + personalization.desktop, + "backgroundAssetId" + ) + ) + : null} +
+
+ + {t("settings.appearance_glass_enabled")} + + + {t("settings.appearance_override_desktop")} + +
+ { + void saveNextPersonalization( + updateOverride("desktop", "glassEnabled", nextValue) + ); + }} + /> +
+
+ +
+ { + void commitBoundedOverrideField( + "desktop", + desktopSurfaceOpacityDraft, + personalization.desktop.surfaceOpacity ?? + personalization.common.surfaceOpacity, + 0, + 100, + setDesktopSurfaceOpacityDraft, + setDesktopSurfaceOpacityError, + "surfaceOpacity" + ); + }} + onChange={(event) => { + setDesktopSurfaceOpacityDraft(event.target.value); + setDesktopSurfaceOpacityError(null); + }} + /> +
+ {desktopSurfaceOpacityError ? ( + + {desktopSurfaceOpacityError} + + ) : null}
- {renderAssetButtons( - "mobile", - Object.prototype.hasOwnProperty.call(personalization.mobile, "backgroundAssetId") - )}
) : null} +
- - {t("settings.appearance_glass_enabled")} - - + {t("settings.appearance_override_mobile")} + + {isOverrideEnabled("mobile") + ? t("settings.appearance_override_enabled") + : t("settings.appearance_uses_shared_value")} +
{ - void saveNextPersonalization(updateOverride("mobile", "glassEnabled", nextValue)); + void saveNextPersonalization(toggleOverride("mobile", nextValue)); }} />
-
- -
- { - void commitBoundedOverrideField( + + {isOverrideEnabled("mobile") ? ( +
+ {personalization.common.backgroundMode === "image" + ? renderAssetSummary( "mobile", - mobileSurfaceOpacityDraft, - personalization.mobile.surfaceOpacity ?? - personalization.common.surfaceOpacity, - 0, - 100, - setMobileSurfaceOpacityDraft, - setMobileSurfaceOpacityError, - "surfaceOpacity" - ); - }} - onChange={(event) => { - setMobileSurfaceOpacityDraft(event.target.value); - setMobileSurfaceOpacityError(null); - }} - /> + t("settings.appearance_override_mobile"), + personalization.mobile.backgroundAssetId, + Object.prototype.hasOwnProperty.call( + personalization.mobile, + "backgroundAssetId" + ) + ) + : null} +
+
+ + {t("settings.appearance_glass_enabled")} + + + {t("settings.appearance_override_mobile")} + +
+ { + void saveNextPersonalization( + updateOverride("mobile", "glassEnabled", nextValue) + ); + }} + /> +
+
+ +
+ { + void commitBoundedOverrideField( + "mobile", + mobileSurfaceOpacityDraft, + personalization.mobile.surfaceOpacity ?? + personalization.common.surfaceOpacity, + 0, + 100, + setMobileSurfaceOpacityDraft, + setMobileSurfaceOpacityError, + "surfaceOpacity" + ); + }} + onChange={(event) => { + setMobileSurfaceOpacityDraft(event.target.value); + setMobileSurfaceOpacityError(null); + }} + /> +
+ {mobileSurfaceOpacityError ? ( + + {mobileSurfaceOpacityError} + + ) : null} +
- {mobileSurfaceOpacityError ? ( - - {mobileSurfaceOpacityError} - - ) : null} -
+ ) : null}
- ) : null} +
{assetActionError ? ( diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index df91c4d0..bcf70fe2 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -1392,6 +1392,122 @@ text-align: right; } +.settings-appearance-file-input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.settings-appearance-panels { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.settings-appearance-panel { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-4); + border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--bg-input) 72%, var(--bg-surface) 28%); +} + +.settings-appearance-panel .settings-config-field:last-child, +.settings-appearance-panel .settings-toggle-row:last-child { + margin-bottom: 0; + border-bottom: none; +} + +.settings-appearance-panel .settings-config-field { + margin-bottom: 0; +} + +.settings-appearance-asset-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: var(--sp-3); + padding: var(--sp-3); + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-surface) 82%, transparent); +} + +.settings-appearance-asset-meta { + min-width: 0; +} + +.settings-appearance-asset-id { + display: block; + overflow-wrap: anywhere; + font-family: var(--font-mono); +} + +.settings-appearance-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--sp-2); +} + +.settings-appearance-material-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sp-3); +} + +.settings-appearance-metric-field { + margin-bottom: 0; + padding: var(--sp-3); + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-surface) 78%, transparent); +} + +.settings-appearance-metric-field .settings-config-control { + justify-content: flex-start; +} + +.settings-appearance-metric-field .settings-input-compact { + width: 100%; + text-align: left; +} + +.settings-appearance-overrides { + display: flex; + flex-direction: column; +} + +.settings-appearance-override-panel { + display: flex; + flex-direction: column; + gap: var(--sp-3); + margin-top: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border: 1px solid color-mix(in srgb, var(--border) 68%, transparent); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--bg-surface) 74%, transparent); +} + +.settings-appearance-override-panel .settings-config-field:last-child, +.settings-appearance-override-panel .settings-toggle-row:last-child { + margin-bottom: 0; + border-bottom: none; +} + +.settings-appearance-override-panel .settings-config-field { + margin-bottom: 0; +} + .settings-provider-args-input { display: block; width: 100%; @@ -12023,6 +12139,22 @@ textarea.input { box-shadow: none; } + .settings-appearance-asset-summary { + grid-template-columns: 1fr; + } + + .settings-appearance-actions { + justify-content: flex-start; + } + + .settings-appearance-material-grid { + grid-template-columns: 1fr; + } + + .settings-appearance-metric-field .settings-input-compact { + text-align: left; + } + .settings-mobile-root { display: flex; flex-direction: column; diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index 7fe09bee..e02ffbc0 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -2503,6 +2503,38 @@ describe("components.css theme-sensitive surfaces", () => { expect(settingsFooter).toContain("padding: var(--sp-3) var(--sp-6)"); }); + it("keeps background-material settings on layered surfaces with a responsive material grid", () => { + const hiddenFileInput = getLastRuleBlock(".settings-appearance-file-input"); + const appearancePanel = getLastRuleBlock(".settings-appearance-panel"); + const assetSummaryBase = getRuleBlocksFrom(stylesheet, ".settings-appearance-asset-summary")[0]; + const assetId = getLastRuleBlock(".settings-appearance-asset-id"); + const actionsBase = getRuleBlocksFrom(stylesheet, ".settings-appearance-actions")[0]; + const materialGridBase = getRuleBlocksFrom(stylesheet, ".settings-appearance-material-grid")[0]; + const metricField = getLastRuleBlock(".settings-appearance-metric-field"); + const overridePanel = getLastRuleBlock(".settings-appearance-override-panel"); + const assetSummaryMobile = getLastRuleBlock(".settings-appearance-asset-summary"); + const actionsMobile = getLastRuleBlock(".settings-appearance-actions"); + const materialGridMobile = getLastRuleBlock(".settings-appearance-material-grid"); + + expect(hiddenFileInput).toContain("position: absolute"); + expect(hiddenFileInput).toContain("clip-path: inset(50%)"); + expect(appearancePanel).toContain("border: 1px solid"); + expect(appearancePanel).toContain("border-radius: var(--radius-lg)"); + expect(appearancePanel).toContain("background: color-mix"); + expect(assetSummaryBase).toContain("grid-template-columns: minmax(0, 1fr) auto"); + expect(assetId).toContain("font-family: var(--font-mono)"); + expect(actionsBase).toContain("justify-content: flex-end"); + expect(materialGridBase).toContain("display: grid"); + expect(materialGridBase).toContain("grid-template-columns: repeat(2, minmax(0, 1fr))"); + expect(metricField).toContain("margin-bottom: 0"); + expect(metricField).toContain("border-radius: var(--radius-md)"); + expect(overridePanel).toContain("background: color-mix"); + expect(overridePanel).toContain("border: 1px solid"); + expect(assetSummaryMobile).toContain("grid-template-columns: 1fr"); + expect(actionsMobile).toContain("justify-content: flex-start"); + expect(materialGridMobile).toContain("grid-template-columns: 1fr"); + }); + it("keeps shared appearance pills aligned with flat editor option toggles instead of rounded app chips", () => { const pill = getLastRuleBlockFrom(pillStylesheet, ".pill"); const pillHover = getLastRuleBlockFrom(pillStylesheet, ".pill:hover:not(:disabled)"); From 40818c2b5fd7da65cd85013206daff6a21e3d933 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 18:19:10 +0800 Subject: [PATCH 30/36] docs: add workspace background material system design spec --- ...space-background-material-system-design.md | 515 ++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-24-workspace-background-material-system-design.md diff --git a/docs/superpowers/specs/2026-05-24-workspace-background-material-system-design.md b/docs/superpowers/specs/2026-05-24-workspace-background-material-system-design.md new file mode 100644 index 00000000..ced87cff --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-workspace-background-material-system-design.md @@ -0,0 +1,515 @@ +# Workspace Background Material System Design + +## Goal + +Define a single workspace-scoped background and material system so background images, transparency, and blur behave consistently across the desktop workspace without per-component exceptions. + +The first rollout is intentionally limited to workspace surfaces: + +- app workspace scene +- sidebar and activity bar +- session cards +- terminal shell and terminal content +- editor shell and editor content +- status bar and footer-style workspace chrome + +## Problem + +The current workspace background behavior is inconsistent because three different systems overlap: + +1. theme foundation background tokens such as `--bg-page`, `--bg-surface`, and `--bg-terminal` +2. surface tokens such as `--surface-overlay-bg` +3. component-local `color-mix(...)` expressions and renderer-specific overrides + +This causes several failure modes: + +- layout wrappers accidentally block the background image +- session, footer, terminal, and editor surfaces do not follow one shared material rule +- xterm and other renderers become special cases with their own background logic +- changing glass opacity or blur requires editing many unrelated components +- tests can verify local selectors but not the consistency of the full workspace material model + +## Product Direction + +For the initial workspace-only rollout, the material behavior follows this rule: + +- layout containers are transparent +- content rendering layers are transparent +- visible shells carry the material treatment +- readability is controlled by shell tint, shell blur, and global appearance settings + +This means the background image should be able to visually pass through the full workspace, including the session and terminal content regions, while shell surfaces still provide readable framing. + +## Scope + +### In scope + +- workspace-only background and material token system +- shared rules for transparent layout containers +- shared rules for shell surfaces +- shared rules for content layers +- xterm and Monaco background policy within the workspace +- tests that enforce the new token and selector usage inside workspace surfaces + +### Out of scope + +- global app-wide conversion of every surface token outside workspace +- hover, active, selection, and state color system redesign +- non-workspace dialogs, sheets, modals, and toasts +- server-side appearance behavior +- introducing a brand new color-space or RGB-channel token architecture across the whole app + +## Constraints + +- Keep the existing theme foundation tokens for now. +- Keep the existing runtime appearance controls for blur and opacity. +- Avoid a repo-wide migration to RGB channel tokens in this phase. +- Do not allow new per-component glass formulas inside workspace CSS after this system lands. + +## Design Principles + +### 1. One source of truth per layer + +Each workspace node should belong to exactly one of these layers: + +- scene/layout layer +- shell/material layer +- content/rendering layer + +No element should mix responsibilities across multiple layers. + +### 2. Components consume semantics, not math + +Workspace components must consume semantic `--ws-*` tokens. + +They must not directly consume: + +- `--app-surface-opacity` +- `--app-surface-backdrop-filter` +- `--surface-overlay-bg` +- raw `color-mix(...)` formulas for shell backgrounds + +### 3. Renderer parity + +Renderer-backed content such as xterm and Monaco must follow the same content-layer rule as normal DOM content: + +- content background is transparent +- shell background provides the visible material + +### 4. Workspace-first rollout + +This system is scoped to workspace first so the migration can be completed and validated before expanding to other app surfaces. + +## Token Architecture + +The system has four layers of tokens and runtime state. + +### 1. Theme Foundation + +Existing theme tokens remain the color foundation and are not directly removed in this phase. + +Examples: + +- `--bg-page` +- `--bg-surface` +- `--bg-panel` +- `--bg-elevated` +- `--bg-terminal` +- `--surface-page-bg` +- `--surface-panel-bg` +- `--surface-elevated-bg` +- `--surface-overlay-bg` + +Responsibility: + +- define theme-family and light/dark specific colors + +Non-responsibility: + +- do not encode workspace material semantics directly + +### 2. Appearance Runtime + +Existing runtime appearance state remains the workspace material input. + +Examples: + +- `data-appearance-glass` +- `--app-surface-opacity` +- `--app-surface-backdrop-filter` + +Responsibility: + +- express current user-selected blur and transparency behavior + +Non-responsibility: + +- components must not directly reference these values + +### 3. Workspace Material System + +Introduce a new workspace-scoped material layer with `--ws-*` tokens. + +#### Level tokens + +- `--ws-level-0` +- `--ws-level-1` +- `--ws-level-2` +- `--ws-level-3` +- `--ws-level-4` + +Responsibility: + +- define the allowed opacity steps for workspace shell surfaces + +#### Behavior tokens + +- `--ws-backdrop-filter` +- `--ws-content-bg` + +Responsibility: + +- define shared blur behavior +- define the default background for workspace content layers + +#### Semantic surface tokens + +- `--ws-sidebar-bg` +- `--ws-activitybar-bg` +- `--ws-statusbar-bg` +- `--ws-session-bg` +- `--ws-session-active-bg` +- `--ws-session-header-bg` +- `--ws-terminal-shell-bg` +- `--ws-terminal-toolbar-bg` +- `--ws-terminal-tabs-bg` +- `--ws-editor-shell-bg` +- `--ws-editor-toolbar-bg` + +Responsibility: + +- provide final component-facing backgrounds + +### 4. Component Usage + +Workspace components may only use: + +- `background: var(--ws-...-bg)` +- `background: var(--ws-content-bg)` +- `background: transparent` +- `backdrop-filter: var(--ws-backdrop-filter)` + +Workspace components must not embed custom material math. + +## Material Behavior States + +The workspace material system resolves into three states. + +### Solid + +Condition: + +- `data-appearance-glass="off"` + +Behavior: + +- `--ws-backdrop-filter: none` +- `--ws-*` shell backgrounds resolve to solid workspace surface colors +- content backgrounds stay transparent where required by structure + +Purpose: + +- stable non-glass workspace appearance without renderer exceptions + +### Glass + +Condition: + +- `data-appearance-glass="on"` +- theme is not high contrast + +Behavior: + +- `--ws-backdrop-filter` resolves from `--app-surface-backdrop-filter` +- `--ws-level-*` tokens are derived once from `--surface-overlay-bg` and `--app-surface-opacity` +- semantic shell tokens resolve from the level scale +- content backgrounds remain transparent + +Purpose: + +- consistent background-image pass-through and shared material response across all workspace surfaces + +### High Contrast + +Condition: + +- `data-theme="hc-dark"` or `data-theme="hc-light"` + +Behavior: + +- `--ws-backdrop-filter: none` +- semantic shell tokens resolve to solid high-contrast surfaces +- no glass transparency or blur is applied + +Purpose: + +- preserve accessibility and predictable contrast + +## Recommended Token Resolution + +The exact percentages can still be tuned during implementation, but the architecture should follow this shape: + +```css +:root { + --ws-backdrop-filter: none; + --ws-content-bg: transparent; + + --ws-sidebar-bg: var(--surface-panel-bg); + --ws-activitybar-bg: var(--surface-panel-bg); + --ws-statusbar-bg: var(--surface-panel-bg); + --ws-session-bg: var(--surface-panel-bg); + --ws-session-active-bg: var(--surface-elevated-bg); + --ws-session-header-bg: var(--surface-elevated-bg); + --ws-terminal-shell-bg: var(--surface-panel-bg); + --ws-terminal-toolbar-bg: var(--surface-elevated-bg); + --ws-terminal-tabs-bg: var(--surface-elevated-bg); + --ws-editor-shell-bg: var(--surface-panel-bg); + --ws-editor-toolbar-bg: var(--surface-elevated-bg); +} + +:root[data-appearance-glass="on"] { + --ws-backdrop-filter: var(--app-surface-backdrop-filter, none); + + --ws-level-0: transparent; + --ws-level-1: color-mix(in srgb, var(--surface-overlay-bg) calc(var(--app-surface-opacity) * 40%), transparent); + --ws-level-2: color-mix(in srgb, var(--surface-overlay-bg) calc(var(--app-surface-opacity) * 56%), transparent); + --ws-level-3: color-mix(in srgb, var(--surface-overlay-bg) calc(var(--app-surface-opacity) * 72%), transparent); + --ws-level-4: color-mix(in srgb, var(--surface-overlay-bg) calc(var(--app-surface-opacity) * 88%), transparent); + + --ws-sidebar-bg: var(--ws-level-3); + --ws-activitybar-bg: var(--ws-level-2); + --ws-statusbar-bg: var(--ws-level-3); + --ws-session-bg: var(--ws-level-2); + --ws-session-active-bg: var(--ws-level-3); + --ws-session-header-bg: var(--ws-level-3); + --ws-terminal-shell-bg: var(--ws-level-3); + --ws-terminal-toolbar-bg: var(--ws-level-2); + --ws-terminal-tabs-bg: var(--ws-level-2); + --ws-editor-shell-bg: var(--ws-level-2); + --ws-editor-toolbar-bg: var(--ws-level-3); +} + +:root[data-theme="hc-dark"], +:root[data-theme="hc-light"] { + --ws-backdrop-filter: none; +} +``` + +## Component Mapping Rules + +### Scene / Layout layer + +These nodes must remain transparent and structural only: + +- `.workspace-page` +- `.workspace-body` +- `.workspace-main-area` +- `.workspace-main-stage` +- `.agent-panes` +- `.agent-pane` +- `.pane-layout` +- `.pane-layout-child` +- `.workspace-sidebar-panel__content` +- `.workspace-sidebar-view` +- `.workspace-sidebar-panel__body` + +Allowed responsibilities: + +- sizing +- layout +- overflow +- clipping +- stacking context when required + +Disallowed responsibilities: + +- visible shell tint +- surface blur +- direct terminal/editor background color + +### Shell / Material layer + +These nodes must consume semantic workspace shell tokens: + +- `.workspace-sidebar-panel` -> `--ws-sidebar-bg` +- `.workspace-activity-bar` -> `--ws-activitybar-bg` +- `.workspace-status-bar` -> `--ws-statusbar-bg` +- `.session-card` -> `--ws-session-bg` +- `.session-card--active` -> `--ws-session-active-bg` +- `.session-header` -> `--ws-session-header-bg` +- `.workspace-bottom-panel > .bottom-terminal` -> `--ws-terminal-shell-bg` +- `.terminal-toolbar` -> `--ws-terminal-toolbar-bg` +- `.bottom-terminal-tabs` -> `--ws-terminal-tabs-bg` +- `.workspace-git-editor` -> `--ws-editor-shell-bg` +- `.code-editor-header` -> `--ws-editor-toolbar-bg` + +These shell nodes also consume: + +- `backdrop-filter: var(--ws-backdrop-filter)` + +### Content / Rendering layer + +These nodes must be transparent: + +- `.session-terminal` +- `.bottom-terminal-content` +- `.bottom-terminal-xterm` +- `.bottom-terminal-empty` +- `.xterm-host` +- `.xterm-screen` +- editor inner rendering surface + +Rule: + +- visible content should sit on the shell material, not create a second competing background + +## Renderer Policy + +### xterm + +xterm must stop acting as a separate background system inside workspace. + +Rules: + +- xterm content background is transparent +- the shell around xterm provides the material +- workspace background behavior must not depend on ad hoc JS branching per terminal + +Desired end state: + +- xterm background policy is expressed as workspace content semantics, not renderer-specific exception logic + +### Monaco + +Monaco should follow the same content-layer behavior. + +Rules: + +- editor rendering background becomes transparent +- selection, cursor, line numbers, and syntax colors still come from the active theme +- editor shell provides the visible material tint + +## Prohibited Patterns + +Inside workspace-related rules, the following patterns should be treated as violations once migration starts: + +- direct `background: var(--bg-page)` +- direct `background: var(--bg-surface)` +- direct `background: var(--bg-terminal)` +- direct `background: var(--surface-overlay-bg)` +- direct use of `--app-surface-opacity` +- direct use of `--app-surface-backdrop-filter` +- new raw `color-mix(...)` shell formulas inside components + +Exceptions: + +- theme foundation and token-definition files +- the centralized workspace material token definition block + +## Migration Plan + +### Phase 1. Transparent container chain + +Normalize the workspace layout chain to transparent: + +- workspace body +- workspace main stage +- agent panes +- agent pane +- pane layout +- pane layout child + +This is the structural prerequisite for background-image pass-through. + +### Phase 2. Shell tokenization + +Replace component-local glass formulas with semantic workspace shell tokens for: + +- sidebar +- activity bar +- status bar +- session card +- terminal shell +- terminal toolbar +- terminal tabs +- editor shell and editor header + +### Phase 3. Transparent content layers + +Normalize all workspace content surfaces to transparent: + +- session terminal +- bottom terminal content +- bottom terminal xterm +- xterm screen +- editor inner content + +This phase is what makes the background image actually visible through the content regions. + +### Phase 4. Renderer adaptation + +Adapt renderer-backed surfaces to follow the workspace content model: + +- xterm theme background +- Monaco editor background +- remove JS-side special casing that exists only to keep old opaque terminal behavior + +## Testing Strategy + +### Theme and token tests + +Extend theme-sensitive stylesheet tests to assert that: + +- workspace containers are transparent +- shell surfaces consume `--ws-*` tokens +- content surfaces are transparent +- workspace components do not carry raw material formulas after migration + +### Renderer tests + +Add focused tests for: + +- xterm background policy inside workspace +- theme switches preserving transparent renderer backgrounds where required +- high-contrast fallback behavior + +### Guardrail tests + +Add assertions or lightweight audits that prevent: + +- new workspace component rules from directly consuming runtime appearance variables +- new workspace component rules from introducing ad hoc shell `color-mix(...)` math + +## Acceptance Criteria + +- background images visually pass through the full workspace scene and content regions +- session, sidebar, terminal shell, footer, and editor surfaces follow one shared workspace material system +- terminal and editor renderers no longer behave as independent background systems +- changing workspace blur or opacity updates all workspace shells consistently +- high-contrast themes bypass glass behavior cleanly +- workspace CSS consumes semantic `--ws-*` tokens instead of local material formulas +- workspace content layers are transparent by default + +## Files Expected To Change During Implementation + +- `packages/web/src/styles/tokens.css` +- `packages/web/src/styles/components.css` +- `packages/web/src/styles/components.theme.test.ts` +- `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` +- Monaco theme integration files used by the workspace editor + +## Open Questions Deferred + +- whether the same material system should later expand beyond workspace into settings and global overlays +- whether the long-term token foundation should migrate to RGB channel tokens +- whether editor shell and terminal shell should share one semantic token or remain separate From 09b96c986c9a7502b485cbe04b67177df226b097 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 19:22:10 +0800 Subject: [PATCH 31/36] feat(web): sync workspace background material system on develop --- .../components/monaco-diff-host.test.tsx | 11 ++ .../components/monaco-diff-host.tsx | 9 +- .../components/monaco-host.test.tsx | 45 +++++- .../code-editor/components/monaco-host.tsx | 13 +- .../__tests__/xterm-host.test.tsx | 25 +++- .../views/shared/xterm-host.tsx | 20 +-- packages/web/src/styles/components.css | 133 ++++-------------- .../web/src/styles/components.theme.test.ts | 126 +++++++---------- packages/web/src/styles/tokens-touch.test.ts | 32 +++++ packages/web/src/styles/tokens.css | 69 +++++++++ packages/web/src/theme/index.ts | 1 + packages/web/src/theme/registry.test.ts | 11 +- packages/web/src/theme/registry.ts | 10 ++ 13 files changed, 292 insertions(+), 213 deletions(-) diff --git a/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx b/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx index 8183295f..c5802e34 100644 --- a/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx +++ b/packages/web/src/features/code-editor/components/monaco-diff-host.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from "@testing-library/react"; import { createStore, Provider } from "jotai"; import { describe, expect, it, vi } from "vitest"; +import { getThemeById } from "../../../theme"; import { MonacoDiffHost } from "./monaco-diff-host"; const { @@ -78,6 +79,16 @@ describe("MonacoDiffHost", () => { ); expect(mockCreateDiffEditor).toHaveBeenCalled(); + expect(mockDefineTheme).toHaveBeenCalledWith( + "coder-studio-workspace-mint-dark", + expect.objectContaining({ + ...getThemeById("mint-dark").monaco, + colors: expect.objectContaining({ + ...getThemeById("mint-dark").monaco.colors, + "editor.background": "#00000000", + }), + }) + ); expect(mockSetModel).toHaveBeenCalledWith({ original: mockOriginalModel, modified: mockModifiedModel, diff --git a/packages/web/src/features/code-editor/components/monaco-diff-host.tsx b/packages/web/src/features/code-editor/components/monaco-diff-host.tsx index 178db8a8..6b791d8e 100644 --- a/packages/web/src/features/code-editor/components/monaco-diff-host.tsx +++ b/packages/web/src/features/code-editor/components/monaco-diff-host.tsx @@ -8,7 +8,7 @@ import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker" import type { FC } from "react"; import { useEffect, useRef } from "react"; import { themeAtom } from "../../../atoms/app-ui"; -import { getThemeById } from "../../../theme"; +import { createWorkspaceMonacoTheme, getThemeById } from "../../../theme"; const monacoGlobal = globalThis as typeof globalThis & { MonacoEnvironment?: monaco.Environment; @@ -43,14 +43,15 @@ export const MonacoDiffHost: FC = ({ const originalModelRef = useRef(null); const modifiedModelRef = useRef(null); const resolvedTheme = getThemeById(uiTheme); - const editorTheme = `coder-studio-${resolvedTheme.id}`; + const editorTheme = `coder-studio-workspace-${resolvedTheme.id}`; + const monacoTheme = createWorkspaceMonacoTheme(resolvedTheme.monaco); useEffect(() => { monaco.editor.defineTheme( editorTheme, - resolvedTheme.monaco as Parameters[1] + monacoTheme as Parameters[1] ); - }, [editorTheme, resolvedTheme]); + }, [editorTheme, monacoTheme]); useEffect(() => { if (!containerRef.current || editorRef.current) { diff --git a/packages/web/src/features/code-editor/components/monaco-host.test.tsx b/packages/web/src/features/code-editor/components/monaco-host.test.tsx index 938ebe24..edb9dd49 100644 --- a/packages/web/src/features/code-editor/components/monaco-host.test.tsx +++ b/packages/web/src/features/code-editor/components/monaco-host.test.tsx @@ -339,11 +339,42 @@ describe("MonacoHost", () => { ); await waitFor(() => { - expect(mockDefineTheme).toHaveBeenCalledWith("coder-studio-mint-light", theme.monaco); + expect(mockDefineTheme).toHaveBeenCalledWith( + "coder-studio-workspace-mint-light", + expect.objectContaining({ + ...theme.monaco, + colors: expect.objectContaining({ + ...theme.monaco.colors, + "editor.background": "#00000000", + }), + }) + ); expect(mockCreateEditor).toHaveBeenCalledWith( expect.any(HTMLDivElement), expect.objectContaining({ readOnly: false, + theme: "coder-studio-workspace-mint-light", + }) + ); + }); + }); + + it("keeps standalone Monaco themes opaque for non-workspace editors", async () => { + const store = createStore(); + store.set(themeAtom, "mint-light"); + const theme = getThemeById("mint-light"); + + render( + + + + ); + + await waitFor(() => { + expect(mockDefineTheme).toHaveBeenCalledWith("coder-studio-mint-light", theme.monaco); + expect(mockCreateEditor).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + expect.objectContaining({ theme: "coder-studio-mint-light", }) ); @@ -432,10 +463,16 @@ describe("MonacoHost", () => { await waitFor(() => { expect(mockDefineTheme).toHaveBeenCalledWith( - "coder-studio-graphite-light", - getThemeById("graphite-light").monaco + "coder-studio-workspace-graphite-light", + expect.objectContaining({ + ...getThemeById("graphite-light").monaco, + colors: expect.objectContaining({ + ...getThemeById("graphite-light").monaco.colors, + "editor.background": "#00000000", + }), + }) ); - expect(mockSetTheme).toHaveBeenCalledWith("coder-studio-graphite-light"); + expect(mockSetTheme).toHaveBeenCalledWith("coder-studio-workspace-graphite-light"); }); }); diff --git a/packages/web/src/features/code-editor/components/monaco-host.tsx b/packages/web/src/features/code-editor/components/monaco-host.tsx index a9507345..3225bbec 100644 --- a/packages/web/src/features/code-editor/components/monaco-host.tsx +++ b/packages/web/src/features/code-editor/components/monaco-host.tsx @@ -18,7 +18,7 @@ import type { FC } from "react"; import { useEffect, useRef, useState } from "react"; import { themeAtom } from "../../../atoms/app-ui"; import { dispatchCommandAtom, wsClientAtom } from "../../../atoms/connection"; -import { getThemeById } from "../../../theme"; +import { createWorkspaceMonacoTheme, getThemeById } from "../../../theme"; import { useOpenLocation } from "../actions/use-open-location"; import { type PendingEditorNavigation, pendingEditorNavigationAtomFamily } from "../atoms"; import { globalLspBridge, type LspBridgeState } from "../lsp/bridge"; @@ -156,17 +156,22 @@ export const MonacoHost: FC = ({ const editorLanguage = detectEditorLanguage(filePath); const lspLanguage = detectLspLanguage(filePath, editorLanguage); const resolvedTheme = getThemeById(uiTheme); - const editorTheme = `coder-studio-${resolvedTheme.id}`; + const editorTheme = isWorkspaceBacked + ? `coder-studio-workspace-${resolvedTheme.id}` + : `coder-studio-${resolvedTheme.id}`; + const monacoTheme = isWorkspaceBacked + ? createWorkspaceMonacoTheme(resolvedTheme.monaco) + : resolvedTheme.monaco; useEffect(() => { if (!registeredMonacoThemeIds.has(editorTheme)) { monaco.editor.defineTheme( editorTheme, - resolvedTheme.monaco as Parameters[1] + monacoTheme as Parameters[1] ); registeredMonacoThemeIds.add(editorTheme); } - }, [editorTheme, resolvedTheme]); + }, [editorTheme, monacoTheme]); useEffect(() => { if (!containerRef.current || editorRef.current) return; diff --git a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx index 24622c7a..c988434f 100644 --- a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +++ b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx @@ -1202,7 +1202,10 @@ describe("XtermHost", () => { const { Terminal } = await import("@xterm/xterm"); expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ - theme: expect.objectContaining(getThemeById("mint-light").terminalTheme), + theme: expect.objectContaining({ + ...getThemeById("mint-light").terminalTheme, + background: "transparent", + }), }) ); }); @@ -2700,7 +2703,10 @@ describe("XtermHost", () => { expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ - theme: expect.objectContaining(getThemeById("mint-dark").terminalTheme), + theme: expect.objectContaining({ + ...getThemeById("mint-dark").terminalTheme, + background: "transparent", + }), }) ); }); @@ -2718,7 +2724,10 @@ describe("XtermHost", () => { expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ - theme: expect.objectContaining(getThemeById("mint-light").terminalTheme), + theme: expect.objectContaining({ + ...getThemeById("mint-light").terminalTheme, + background: "transparent", + }), }) ); }); @@ -2775,7 +2784,10 @@ describe("XtermHost", () => { await waitFor(() => { expect(mockTerminal.options).toEqual( expect.objectContaining({ - theme: expect.objectContaining(getThemeById("graphite-light").terminalTheme), + theme: expect.objectContaining({ + ...getThemeById("graphite-light").terminalTheme, + background: "transparent", + }), }) ); }); @@ -2835,7 +2847,10 @@ describe("XtermHost", () => { expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ - theme: expect.objectContaining(getThemeById("hc-dark").terminalTheme), + theme: expect.objectContaining({ + ...getThemeById("hc-dark").terminalTheme, + background: "transparent", + }), }) ); }); diff --git a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx index fd6ffcb5..1a7ee463 100644 --- a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx +++ b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx @@ -25,8 +25,7 @@ import { useRef, useState, } from "react"; -import { resolveAppearancePersonalizationForViewport } from "../../../../appearance"; -import { appearancePersonalizationAtom, themeAtom } from "../../../../atoms/app-ui"; +import { themeAtom } from "../../../../atoms/app-ui"; import { dispatchCommandAtom, wsClientAtom } from "../../../../atoms/connection"; import { Button, LocalOverlay, Notice } from "../../../../components/ui"; import { useViewport } from "../../../../hooks/use-viewport"; @@ -326,12 +325,8 @@ export function trimWrittenChunks(buffer: OutputBuffer, writtenChunkCount: numbe }; } -function resolveXtermTheme(themeId: string, glassEnabled: boolean): TerminalThemeDefinition { +function resolveXtermTheme(themeId: string): TerminalThemeDefinition { const terminalTheme = getThemeById(themeId).terminalTheme; - if (!glassEnabled) { - return terminalTheme; - } - return { ...terminalTheme, background: "transparent", @@ -416,7 +411,6 @@ export function XtermHost({ const t = useTranslation(); const viewport = useViewport(); const uiTheme = useAtomValue(themeAtom); - const appearancePersonalization = useAtomValue(appearancePersonalizationAtom); const terminalPreferences = useAtomValue(terminalPreferencesAtom); const terminalFontSize = getTerminalFontSizeForViewport(terminalPreferences, viewport); const wsClient = useAtomValue(wsClientAtom); @@ -525,13 +519,7 @@ export function XtermHost({ return wsClient.getStatus(); }); - const effectiveAppearance = resolveAppearancePersonalizationForViewport( - appearancePersonalization, - viewport - ); - const glassEnabled = - effectiveAppearance.glassEnabled && uiTheme !== "hc-dark" && uiTheme !== "hc-light"; - const resolvedTerminalTheme = resolveXtermTheme(uiTheme, glassEnabled); + const resolvedTerminalTheme = resolveXtermTheme(uiTheme); // Latest copies of callback identities used inside the mount effect, exposed // via refs so the effect's cleanup/re-creation is not tied to their churn. @@ -1380,7 +1368,7 @@ export function XtermHost({ // characters used by TUIs (claude, codex) render as a continuous frame // with no gaps between rows. const terminal = new Terminal({ - theme: resolveXtermTheme(initialThemeRef.current, glassEnabled), + theme: resolveXtermTheme(initialThemeRef.current), fontFamily: "JetBrains Mono, Fira Code, SF Mono, monospace", fontSize: terminalFontSize, scrollback: 5000, diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index bcf70fe2..bdbb39aa 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -1967,10 +1967,6 @@ } .xterm-host .xterm-screen { - background: var(--bg-terminal); -} - -[data-appearance-glass="on"] .xterm-host .xterm-screen { background: transparent; } @@ -14314,13 +14310,9 @@ textarea.input { } .workspace-sidebar-panel { - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 76%), - transparent - ); + background: var(--ws-sidebar-bg); border-right: 1px solid color-mix(in srgb, var(--border) 72%, transparent); - backdrop-filter: var(--app-surface-backdrop-filter, none); + backdrop-filter: var(--ws-backdrop-filter); } .workspace-activity-bar { @@ -14331,13 +14323,9 @@ textarea.input { gap: var(--sp-2); padding: var(--sp-3) var(--sp-2); border-right: 1px solid var(--border); - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 62%), - transparent - ); + background: var(--ws-activitybar-bg); border-right-color: color-mix(in srgb, var(--border) 72%, transparent); - backdrop-filter: var(--app-surface-backdrop-filter, none); + backdrop-filter: var(--ws-backdrop-filter); } .workspace-status-bar { @@ -14350,12 +14338,8 @@ textarea.input { flex-shrink: 0; padding: 0 12px; border-top: 1px solid color-mix(in srgb, var(--border) 62%, transparent); - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 68%), - transparent - ); - backdrop-filter: var(--app-surface-backdrop-filter, none); + background: var(--ws-statusbar-bg); + backdrop-filter: var(--ws-backdrop-filter); } .workspace-status-bar--flush { @@ -14365,37 +14349,13 @@ textarea.input { .workspace-empty-inner, .workspace-resolving-card { - background: linear-gradient( - 180deg, - color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 82%), - color-mix(in srgb, var(--bg-surface) 52%, transparent) - ), - color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 70%), - color-mix(in srgb, var(--bg-page) 8%, transparent) - ) - ); - backdrop-filter: var(--app-surface-backdrop-filter, none); + background: linear-gradient(180deg, var(--ws-editor-toolbar-bg), var(--ws-editor-shell-bg)); + backdrop-filter: var(--ws-backdrop-filter); } .workspace-git-editor { - background: linear-gradient( - 180deg, - color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 86%), - color-mix(in srgb, var(--bg-surface) 60%, transparent) - ), - color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.92) * 74%), - color-mix(in srgb, var(--bg-terminal) 62%, transparent) - ) - ); - backdrop-filter: var(--app-surface-backdrop-filter, none); + background: var(--ws-editor-shell-bg); + backdrop-filter: var(--ws-backdrop-filter); } .code-editor-header { @@ -14404,61 +14364,34 @@ textarea.input { gap: var(--gap-default); padding: var(--gap-default) var(--editor-toolbar-inset); border-bottom: 1px solid color-mix(in srgb, var(--border) 72%, var(--accent-blue) 28%); - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 82%), - color-mix(in srgb, var(--bg-surface) 46%, transparent) - ); - backdrop-filter: var(--app-surface-backdrop-filter, none); + background: var(--ws-editor-toolbar-bg); + backdrop-filter: var(--ws-backdrop-filter); } .session-card { border: none; border-radius: 0; box-shadow: none; - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 44%), - transparent - ); - backdrop-filter: var(--app-surface-backdrop-filter, none); + background: var(--ws-session-bg); + backdrop-filter: var(--ws-backdrop-filter); } .session-card.session-card--active { - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 52%), - color-mix(in srgb, var(--accent-blue) 10%, transparent) - ); + background: var(--ws-session-active-bg); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border-focus) 84%, transparent); } -.session-header { +.session-header, +.session-card.session-card--active > .panel-header, +.session-card.session-card--active .session-header { padding: var(--gap-tight) var(--inset-control-inline); border-bottom: 1px solid var(--border); - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 66%), - transparent - ); - backdrop-filter: var(--app-surface-backdrop-filter, none); -} - -.session-card.session-card--active > .panel-header { - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 76%), - color-mix(in srgb, var(--accent-blue) 8%, transparent) - ); - border-bottom-color: color-mix(in srgb, var(--border-focus) 76%, transparent); + background: var(--ws-session-header-bg); + backdrop-filter: var(--ws-backdrop-filter); } +.session-card.session-card--active > .panel-header, .session-card.session-card--active .session-header { - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 76%), - color-mix(in srgb, var(--accent-blue) 8%, transparent) - ); border-bottom-color: color-mix(in srgb, var(--border-focus) 76%, transparent); } @@ -14520,33 +14453,21 @@ textarea.input { } .terminal-toolbar { - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 66%), - transparent - ); - backdrop-filter: var(--app-surface-backdrop-filter, none); + background: var(--ws-terminal-toolbar-bg); + backdrop-filter: var(--ws-backdrop-filter); } .bottom-terminal-tabs { - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.88) * 56%), - transparent - ); - backdrop-filter: var(--app-surface-backdrop-filter, none); + background: var(--ws-terminal-tabs-bg); + backdrop-filter: var(--ws-backdrop-filter); } .workspace-bottom-panel > .bottom-terminal { border: none; border-radius: 0; - background: color-mix( - in srgb, - var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 60%), - transparent - ); + background: var(--ws-terminal-shell-bg); box-shadow: none; - backdrop-filter: var(--app-surface-backdrop-filter, none); + backdrop-filter: var(--ws-backdrop-filter); } .bottom-terminal-content, diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index e02ffbc0..518ff0fc 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -884,9 +884,12 @@ describe("components.css theme-sensitive surfaces", () => { const sessionTerminal = getLastRuleBlock(".session-terminal"); const sessionCard = getLastRuleBlock(".session-card"); const activeSessionCard = getLastRuleBlock(".session-card.session-card--active"); - const activeSessionHeader = getLastRuleBlock( + const activeSessionHeader = getRuleBlocksFrom( + stylesheet, ".session-card.session-card--active > .panel-header" - ); + ) + .slice(-2) + .join("\n"); const activeSessionTitle = getLastRuleBlock( ".session-card.session-card--active > .panel-header .panel-header__title" ); @@ -969,9 +972,12 @@ describe("components.css theme-sensitive surfaces", () => { expect(tokensStylesheet).toContain("--workspace-session-map-idle: color-mix("); expect(tokensStylesheet).toContain("--workspace-session-map-empty: color-mix("); expect(workspaceResizer).toContain("z-index: var(--z-inline)"); - expect(emptyCard).toContain("var(--bg-surface)"); - expect(resolvingCard).toContain("var(--bg-surface)"); - expect(workspaceGitEditor).toContain("var(--bg-terminal)"); + expect(emptyCard).toContain("var(--ws-editor-toolbar-bg)"); + expect(emptyCard).toContain("var(--ws-editor-shell-bg)"); + expect(resolvingCard).toContain("var(--ws-editor-toolbar-bg)"); + expect(resolvingCard).toContain("var(--ws-editor-shell-bg)"); + expect(workspaceGitEditor).toContain("background: var(--ws-editor-shell-bg)"); + expect(workspaceGitEditor).toContain("backdrop-filter: var(--ws-backdrop-filter)"); expect(mainStage).toContain("flex: 1"); expect(mainStage).toContain("min-height: 0"); expect(mainStage).toContain("min-width: 0"); @@ -984,16 +990,14 @@ describe("components.css theme-sensitive surfaces", () => { expect(sessionTerminal).not.toContain("var(--bg-terminal)"); expect(sessionCard).toContain("border: none"); expect(sessionCard).not.toContain("border: 1px solid var(--border)"); - expect(sessionCard).toContain("var(--surface-overlay-bg)"); - expect(sessionCard).toContain("var(--app-surface-opacity, 0.96)"); - expect(sessionCard).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); - expect(activeSessionCard).toContain("var(--surface-overlay-bg)"); - expect(activeSessionCard).toContain("calc(var(--app-surface-opacity, 0.96) * 52%)"); + expect(sessionCard).toContain("background: var(--ws-session-bg)"); + expect(sessionCard).toContain("backdrop-filter: var(--ws-backdrop-filter)"); + expect(activeSessionCard).toContain("background: var(--ws-session-active-bg)"); expect(activeSessionCard).not.toContain("background: var(--bg-active)"); expect(activeSessionCard).toContain("box-shadow: inset 0 0 0 1px"); expect(activeSessionCard).toContain("var(--border-focus) 84%"); - expect(activeSessionHeader).toContain("var(--surface-overlay-bg)"); - expect(activeSessionHeader).toContain("calc(var(--app-surface-opacity, 0.96) * 76%)"); + expect(activeSessionHeader).toContain("background: var(--ws-session-header-bg)"); + expect(activeSessionHeader).toContain("backdrop-filter: var(--ws-backdrop-filter)"); expect(activeSessionHeader).not.toContain("var(--bg-active) 88%"); expect(activeSessionTitle).toContain("color: var(--text-primary)"); expect(resolvingConsoleStatus).toContain("background: var(--state-success-text)"); @@ -1005,8 +1009,8 @@ describe("components.css theme-sensitive surfaces", () => { expect(resolvingStrongLine).toContain("border: 1px solid var(--state-info-border)"); expect(resolvingStrongLine).toContain("background: var(--state-info-bg)"); expect(activityBar).toContain("border-right: 1px solid var(--border)"); - expect(activityBar).toContain("var(--surface-overlay-bg)"); - expect(activityBar).toContain("calc(var(--app-surface-opacity, 0.88) * 62%)"); + expect(activityBar).toContain("background: var(--ws-activitybar-bg)"); + expect(activityBar).toContain("backdrop-filter: var(--ws-backdrop-filter)"); expect(activityBar).not.toContain("var(--bg-panel) 88%"); expect(workspaceSidebarContent).toContain("background: transparent"); expect(workspaceSidebarView).toContain("background: transparent"); @@ -1047,11 +1051,8 @@ describe("components.css theme-sensitive surfaces", () => { expect(bottomPanel).toContain("padding: 0"); expect(bottomPanel).not.toContain("padding: 0 0 14px"); expect(bottomPanel).not.toContain("padding: 0 14px 14px"); - expect(bottomTerminalShellRules).toContain("var(--surface-overlay-bg)"); - expect(bottomTerminalShellRules).toContain("var(--app-surface-opacity, 0.96)"); - expect(bottomTerminalShellRules).toContain( - "backdrop-filter: var(--app-surface-backdrop-filter, none)" - ); + expect(bottomTerminalShellRules).toContain("background: var(--ws-terminal-shell-bg)"); + expect(bottomTerminalShellRules).toContain("backdrop-filter: var(--ws-backdrop-filter)"); expect(bottomTerminalShell).toContain("border: none"); expect(bottomTerminalShellRules).toContain("border-radius: 0"); expect(bottomTerminalShellRules).not.toContain("border-radius: 14px"); @@ -1179,6 +1180,12 @@ describe("components.css theme-sensitive surfaces", () => { const workspaceSidebarBody = getLastRuleBlock(".workspace-sidebar-panel__body"); const sessionCard = getLastRuleBlock(".session-card"); const activeSessionCard = getLastRuleBlock(".session-card.session-card--active"); + const activeSessionHeader = getRuleBlocksFrom( + stylesheet, + ".session-card.session-card--active > .panel-header" + ) + .slice(-2) + .join("\n"); const sessionTerminal = getLastRuleBlock(".session-terminal"); const workspaceEmptyInner = getLastRuleBlock(".workspace-empty-inner"); const workspaceResolvingCard = getLastRuleBlock(".workspace-resolving-card"); @@ -1187,9 +1194,6 @@ describe("components.css theme-sensitive surfaces", () => { const bottomTerminalXterm = getLastRuleBlock(".bottom-terminal-xterm"); const bottomTerminalEmpty = getLastRuleBlock(".bottom-terminal-empty"); const xtermScreen = getLastRuleBlock(".xterm-host .xterm-screen"); - const glassXtermScreen = getLastRuleBlock( - '[data-appearance-glass="on"] .xterm-host .xterm-screen' - ); const terminalToolbar = getLastRuleBlock(".terminal-toolbar"); const bottomTerminalTabs = getLastRuleBlock(".bottom-terminal-tabs"); const mobileShell = getLastGroupedRuleBlock(/\.mobile-shell\s*\{([^}]*)\}/g); @@ -1215,65 +1219,39 @@ describe("components.css theme-sensitive surfaces", () => { expect(paneLayout).toContain("background: transparent"); expect(paneLayoutChild).toContain("background: transparent"); expect(leftPanel).toContain("background: transparent"); - expect(workspaceSidebarPanel).toContain("var(--surface-overlay-bg)"); - expect(workspaceSidebarPanel).toContain("var(--app-surface-opacity, 0.96)"); - expect(workspaceSidebarPanel).toContain("calc(var(--app-surface-opacity, 0.96) * 76%)"); - expect(workspaceSidebarPanel).toContain( - "backdrop-filter: var(--app-surface-backdrop-filter, none)" - ); - expect(workspaceSidebarPanel).not.toContain("var(--bg-panel)"); - expect(workspaceActivityBar).toContain("var(--surface-overlay-bg)"); - expect(workspaceActivityBar).toContain("var(--app-surface-opacity, 0.88)"); - expect(workspaceActivityBar).toContain("calc(var(--app-surface-opacity, 0.88) * 62%)"); - expect(workspaceActivityBar).toContain( - "backdrop-filter: var(--app-surface-backdrop-filter, none)" - ); - expect(workspaceActivityBar).not.toContain("var(--bg-panel)"); - expect(workspaceStatusBar).toContain("var(--surface-overlay-bg)"); - expect(workspaceStatusBar).toContain("var(--app-surface-opacity, 0.96)"); - expect(workspaceStatusBar).toContain("calc(var(--app-surface-opacity, 0.96) * 68%)"); - expect(workspaceStatusBar).toContain( - "backdrop-filter: var(--app-surface-backdrop-filter, none)" - ); - expect(workspaceStatusBar).not.toContain("var(--bg-panel)"); + expect(workspaceSidebarPanel).toContain("background: var(--ws-sidebar-bg)"); + expect(workspaceSidebarPanel).toContain("backdrop-filter: var(--ws-backdrop-filter)"); + expect(workspaceSidebarPanel).not.toContain("var(--surface-overlay-bg)"); + expect(workspaceActivityBar).toContain("background: var(--ws-activitybar-bg)"); + expect(workspaceStatusBar).toContain("background: var(--ws-statusbar-bg)"); expect(workspaceSidebarContent).toContain("background: transparent"); expect(workspaceSidebarView).toContain("background: transparent"); expect(workspaceSidebarBody).toContain("background: transparent"); - expect(sessionCard).toContain("var(--surface-overlay-bg)"); - expect(sessionCard).toContain("var(--app-surface-opacity, 0.96)"); - expect(sessionCard).toContain("calc(var(--app-surface-opacity, 0.96) * 44%)"); - expect(sessionCard).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); - expect(activeSessionCard).toContain("calc(var(--app-surface-opacity, 0.96) * 52%)"); + expect(sessionCard).toContain("background: var(--ws-session-bg)"); + expect(sessionCard).toContain("backdrop-filter: var(--ws-backdrop-filter)"); + expect(activeSessionCard).toContain("background: var(--ws-session-active-bg)"); + expect(activeSessionHeader).toContain("background: var(--ws-session-header-bg)"); + expect(activeSessionHeader).toContain("backdrop-filter: var(--ws-backdrop-filter)"); expect(activeSessionCard).not.toContain("background: var(--bg-active)"); expect(sessionTerminal).toContain("background: transparent"); - expect(workspaceEmptyInner).toContain("var(--surface-overlay-bg)"); - expect(workspaceEmptyInner).toContain("var(--app-surface-opacity, 0.96)"); - expect(workspaceEmptyInner).toContain( - "backdrop-filter: var(--app-surface-backdrop-filter, none)" - ); - expect(workspaceResolvingCard).toContain("var(--surface-overlay-bg)"); - expect(workspaceResolvingCard).toContain("var(--app-surface-opacity, 0.96)"); - expect(workspaceResolvingCard).toContain( - "backdrop-filter: var(--app-surface-backdrop-filter, none)" - ); - expect(bottomTerminal).toContain("var(--surface-overlay-bg)"); - expect(bottomTerminal).toContain("var(--app-surface-opacity, 0.96)"); - expect(bottomTerminal).toContain("calc(var(--app-surface-opacity, 0.96) * 60%)"); - expect(bottomTerminal).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(workspaceEmptyInner).toContain("var(--ws-editor-toolbar-bg)"); + expect(workspaceEmptyInner).toContain("var(--ws-editor-shell-bg)"); + expect(workspaceEmptyInner).toContain("backdrop-filter: var(--ws-backdrop-filter)"); + expect(workspaceResolvingCard).toContain("var(--ws-editor-toolbar-bg)"); + expect(workspaceResolvingCard).toContain("var(--ws-editor-shell-bg)"); + expect(workspaceResolvingCard).toContain("backdrop-filter: var(--ws-backdrop-filter)"); + expect(bottomTerminal).toContain("background: var(--ws-terminal-shell-bg)"); + expect(bottomTerminal).toContain("backdrop-filter: var(--ws-backdrop-filter)"); expect(bottomTerminal).toContain("box-shadow: none"); expect(bottomTerminalContent).toContain("background: transparent"); expect(bottomTerminalXterm).toContain("background: transparent"); expect(bottomTerminalEmpty).toContain("background: transparent"); - expect(xtermScreen).toContain("background: var(--bg-terminal)"); - expect(glassXtermScreen).toContain("background: transparent"); - expect(terminalToolbar).toContain("var(--surface-overlay-bg)"); - expect(terminalToolbar).toContain("var(--app-surface-opacity, 0.88)"); - expect(terminalToolbar).toContain("calc(var(--app-surface-opacity, 0.88) * 66%)"); - expect(terminalToolbar).toContain("backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(xtermScreen).toContain("background: transparent"); + expect(xtermScreen).not.toContain("var(--bg-terminal)"); + expect(terminalToolbar).toContain("background: var(--ws-terminal-toolbar-bg)"); + expect(terminalToolbar).toContain("backdrop-filter: var(--ws-backdrop-filter)"); expect(terminalToolbar).not.toContain("var(--bg-surface)"); - expect(bottomTerminalTabs).toContain("var(--surface-overlay-bg)"); - expect(bottomTerminalTabs).toContain("var(--app-surface-opacity, 0.88)"); - expect(bottomTerminalTabs).toContain("calc(var(--app-surface-opacity, 0.88) * 56%)"); + expect(bottomTerminalTabs).toContain("background: var(--ws-terminal-tabs-bg)"); expect(bottomTerminalTabs).not.toContain("var(--bg-surface)"); expect(mobileShell).toContain("background: transparent"); expect(mobileTopbar).toContain("var(--surface-overlay-bg)"); @@ -1323,9 +1301,11 @@ describe("components.css theme-sensitive surfaces", () => { const addedLine = getLastRuleBlock(".git-diff-line-added"); const removedLine = getLastRuleBlock(".git-diff-line-removed"); - expect(editorShell).toContain("var(--bg-surface)"); + expect(editorShell).toContain("background: var(--ws-editor-shell-bg)"); + expect(editorShell).toContain("backdrop-filter: var(--ws-backdrop-filter)"); expect(editorShell).not.toContain("rgba(11, 18, 24, 0.92)"); - expect(editorHeader).toContain("var(--bg-surface)"); + expect(editorHeader).toContain("background: var(--ws-editor-toolbar-bg)"); + expect(editorHeader).toContain("backdrop-filter: var(--ws-backdrop-filter)"); expect(editorHeader).not.toContain("rgba(18, 26, 34, 0.96)"); expect(editorError).toContain("gap: var(--gap-tight)"); expect(editorError).toContain("padding: var(--gap-tight) var(--inset-control-inline)"); diff --git a/packages/web/src/styles/tokens-touch.test.ts b/packages/web/src/styles/tokens-touch.test.ts index c4d99343..4c88a99a 100644 --- a/packages/web/src/styles/tokens-touch.test.ts +++ b/packages/web/src/styles/tokens-touch.test.ts @@ -211,6 +211,38 @@ describe("tokens.css touch tokens", () => { expect(getCustomProperty(root, "--terminal-line-height")).toBe("1.6"); }); + it("defines workspace material tokens for solid and glass workspace surfaces", () => { + const root = getRuleBlock(":root"); + + expect(root).toContain("--ws-backdrop-filter: none"); + expect(root).toContain("--ws-content-bg: transparent"); + expect(root).toContain("--ws-sidebar-bg: var(--surface-panel-bg)"); + expect(root).toContain("--ws-terminal-shell-bg: var(--surface-panel-bg)"); + expect(root).toContain("--ws-editor-toolbar-bg: var(--surface-elevated-bg)"); + expect(root).toContain("--ws-level-0: transparent"); + expect(root).toContain("--ws-level-1: color-mix("); + expect(root).toContain("--ws-level-4: color-mix("); + }); + + it("overrides workspace material tokens for glass and high-contrast runtime states", () => { + const glassRoot = getRuleBlock(':root[data-appearance-glass="on"]'); + const highContrastDark = getRuleBlock(':root[data-theme="hc-dark"]'); + const highContrastLight = getRuleBlock(':root[data-theme="hc-light"]'); + + expect(glassRoot).toContain("--ws-backdrop-filter: var(--app-surface-backdrop-filter, none)"); + expect(glassRoot).toContain("--ws-sidebar-bg: var(--ws-level-3)"); + expect(glassRoot).toContain("--ws-terminal-shell-bg: var(--ws-level-3)"); + expect(glassRoot).toContain("--ws-editor-shell-bg: var(--ws-level-2)"); + + expect(highContrastDark).toContain("--ws-backdrop-filter: none"); + expect(highContrastDark).toContain("--ws-sidebar-bg: var(--surface-panel-bg)"); + expect(highContrastDark).toContain("--ws-editor-toolbar-bg: var(--surface-elevated-bg)"); + + expect(highContrastLight).toContain("--ws-backdrop-filter: none"); + expect(highContrastLight).toContain("--ws-sidebar-bg: var(--surface-panel-bg)"); + expect(highContrastLight).toContain("--ws-editor-toolbar-bg: var(--surface-elevated-bg)"); + }); + it("overrides touch tokens on narrow viewport only", () => { const mediaMatch = /@media\s*\(max-width:\s*899px\)\s*\{([\s\S]*?)\}\s*\}/m.exec(stylesheet); diff --git a/packages/web/src/styles/tokens.css b/packages/web/src/styles/tokens.css index 48328577..40d64f15 100644 --- a/packages/web/src/styles/tokens.css +++ b/packages/web/src/styles/tokens.css @@ -119,6 +119,44 @@ --overlay-width-lg: 680px; --overlay-backdrop-opacity: 0.48; + /* ========== Workspace Material System ========== */ + --ws-backdrop-filter: none; + --ws-content-bg: transparent; + + --ws-level-0: transparent; + --ws-level-1: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 40%), + transparent + ); + --ws-level-2: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 56%), + transparent + ); + --ws-level-3: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 72%), + transparent + ); + --ws-level-4: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 88%), + transparent + ); + + --ws-sidebar-bg: var(--surface-panel-bg); + --ws-activitybar-bg: var(--surface-panel-bg); + --ws-statusbar-bg: var(--surface-panel-bg); + --ws-session-bg: var(--surface-panel-bg); + --ws-session-active-bg: var(--surface-elevated-bg); + --ws-session-header-bg: var(--surface-elevated-bg); + --ws-terminal-shell-bg: var(--surface-panel-bg); + --ws-terminal-toolbar-bg: var(--surface-elevated-bg); + --ws-terminal-tabs-bg: var(--surface-elevated-bg); + --ws-editor-shell-bg: var(--surface-panel-bg); + --ws-editor-toolbar-bg: var(--surface-elevated-bg); + /* ========== Foundation: Code Surface Sub-Specs ========== */ --terminal-panel-inset: 2px; --terminal-toolbar-gap: var(--gap-default); @@ -1385,6 +1423,37 @@ --surface-overlay-bg: color-mix(in srgb, #ffffff 96%, transparent); } +:root[data-appearance-glass="on"] { + --ws-backdrop-filter: var(--app-surface-backdrop-filter, none); + --ws-sidebar-bg: var(--ws-level-3); + --ws-activitybar-bg: var(--ws-level-2); + --ws-statusbar-bg: var(--ws-level-3); + --ws-session-bg: var(--ws-level-2); + --ws-session-active-bg: var(--ws-level-3); + --ws-session-header-bg: var(--ws-level-3); + --ws-terminal-shell-bg: var(--ws-level-3); + --ws-terminal-toolbar-bg: var(--ws-level-2); + --ws-terminal-tabs-bg: var(--ws-level-2); + --ws-editor-shell-bg: var(--ws-level-2); + --ws-editor-toolbar-bg: var(--ws-level-3); +} + +:root[data-theme="hc-dark"], +:root[data-theme="hc-light"] { + --ws-backdrop-filter: none; + --ws-sidebar-bg: var(--surface-panel-bg); + --ws-activitybar-bg: var(--surface-panel-bg); + --ws-statusbar-bg: var(--surface-panel-bg); + --ws-session-bg: var(--surface-panel-bg); + --ws-session-active-bg: var(--surface-elevated-bg); + --ws-session-header-bg: var(--surface-elevated-bg); + --ws-terminal-shell-bg: var(--surface-panel-bg); + --ws-terminal-toolbar-bg: var(--surface-elevated-bg); + --ws-terminal-tabs-bg: var(--surface-elevated-bg); + --ws-editor-shell-bg: var(--surface-panel-bg); + --ws-editor-toolbar-bg: var(--surface-elevated-bg); +} + /* ========== Mobile / Touch Override (Phase 0) ========== */ @media (max-width: 899px) { :root { diff --git a/packages/web/src/theme/index.ts b/packages/web/src/theme/index.ts index 96226cd6..d0ffdf2a 100644 --- a/packages/web/src/theme/index.ts +++ b/packages/web/src/theme/index.ts @@ -13,6 +13,7 @@ export { } from "./icon-theme"; export { type AppThemeDefinition, + createWorkspaceMonacoTheme, type MonacoThemeDefinition, type TerminalThemeDefinition, THEME_IDS, diff --git a/packages/web/src/theme/registry.test.ts b/packages/web/src/theme/registry.test.ts index d50c4b98..3eee1978 100644 --- a/packages/web/src/theme/registry.test.ts +++ b/packages/web/src/theme/registry.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import en from "../locales/en.json"; import zh from "../locales/zh.json"; -import { THEME_IDS, THEMES } from "./index"; +import { createWorkspaceMonacoTheme, THEME_IDS, THEMES } from "./index"; function getTranslationValue(messages: Record, key: string): unknown { return key.split(".").reduce((current, segment) => { @@ -179,4 +179,13 @@ describe("theme registry", () => { ]) ); }); + + it("creates workspace monaco themes with transparent editor backgrounds", () => { + for (const theme of THEMES) { + expect(createWorkspaceMonacoTheme(theme.monaco).colors["editor.background"]).toBe( + "#00000000" + ); + expect(theme.monaco.colors["editor.background"]).not.toBe("#00000000"); + } + }); }); diff --git a/packages/web/src/theme/registry.ts b/packages/web/src/theme/registry.ts index f46a368f..c6989e55 100644 --- a/packages/web/src/theme/registry.ts +++ b/packages/web/src/theme/registry.ts @@ -783,5 +783,15 @@ const THEMES_REGISTRY: ReadonlyArray = [ registerIconThemes(THEMES_REGISTRY); +export function createWorkspaceMonacoTheme(theme: MonacoThemeDefinition): MonacoThemeDefinition { + return { + ...theme, + colors: { + ...theme.colors, + "editor.background": "#00000000", + }, + }; +} + export const THEMES = THEMES_REGISTRY; export const THEME_IDS = THEMES_REGISTRY.map((theme) => theme.id) as readonly string[]; From fe4d3717060c958f00b9978b4cde9bfe0a612406 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 22:16:56 +0800 Subject: [PATCH 32/36] fix(server): keep sessions idle until submit --- .../src/__tests__/session-integration.test.ts | 8 +++---- .../src/__tests__/session-manager-api.test.ts | 24 +++++++++---------- packages/server/src/session/manager.ts | 6 ----- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/server/src/__tests__/session-integration.test.ts b/packages/server/src/__tests__/session-integration.test.ts index e73c9d58..7e80fd25 100644 --- a/packages/server/src/__tests__/session-integration.test.ts +++ b/packages/server/src/__tests__/session-integration.test.ts @@ -883,7 +883,7 @@ describe("Session Integration", () => { expect(sessionMgr.get(sessionId)?.state).toBe("running"); }); - it("ignores typing echo but restores running when real PTY output follows", async () => { + it("ignores typing echo and keeps the session idle when PTY output follows without a submit", async () => { vi.advanceTimersByTime(3050); expect(sessionMgr.get(sessionId)?.state).toBe("idle"); @@ -907,10 +907,10 @@ describe("Session Integration", () => { expect(sessionMgr.get(sessionId)?.state).toBe("idle"); triggerDataForProcessIndex(0, "assistant working\n"); - expect(sessionMgr.get(sessionId)?.state).toBe("running"); + expect(sessionMgr.get(sessionId)?.state).toBe("idle"); }); - it("restores running when a recovered PTY stream mixes typing echo with real output", async () => { + it("keeps the session idle when a recovered PTY stream mixes typing echo with output", async () => { vi.advanceTimersByTime(3050); expect(sessionMgr.get(sessionId)?.state).toBe("idle"); @@ -931,7 +931,7 @@ describe("Session Integration", () => { expect(typingResult.ok).toBe(true); triggerDataForProcessIndex(0, "gassistant working\n"); - expect(sessionMgr.get(sessionId)?.state).toBe("running"); + expect(sessionMgr.get(sessionId)?.state).toBe("idle"); }); }); diff --git a/packages/server/src/__tests__/session-manager-api.test.ts b/packages/server/src/__tests__/session-manager-api.test.ts index 2c6bad0b..2081322f 100644 --- a/packages/server/src/__tests__/session-manager-api.test.ts +++ b/packages/server/src/__tests__/session-manager-api.test.ts @@ -250,7 +250,7 @@ describe("SessionManager session-level API", () => { expect(lifecycleEvents).toEqual([]); }); - it("promotes an idle session back to running when PTY emits real output", async () => { + it("keeps an idle session idle when PTY emits output without a submit", async () => { vi.useFakeTimers(); provider = { ...provider, @@ -283,13 +283,13 @@ describe("SessionManager session-level API", () => { expect(sessionMgr.get(session.id)?.state).toBe("idle"); onData?.("assistant working\n"); - expect(sessionMgr.get(session.id)?.state).toBe("running"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); vi.advanceTimersByTime(3000); expect(sessionMgr.get(session.id)?.state).toBe("idle"); }); - it("ignores recent input echo while promoting real PTY output back to running", async () => { + it("keeps an idle session idle even when recent typing echo is followed by PTY output", async () => { vi.useFakeTimers(); provider = { ...provider, @@ -328,13 +328,13 @@ describe("SessionManager session-level API", () => { expect(sessionMgr.get(session.id)?.state).toBe("idle"); onData?.("assistant working\n"); - expect(sessionMgr.get(session.id)?.state).toBe("running"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); vi.advanceTimersByTime(3000); expect(sessionMgr.get(session.id)?.state).toBe("idle"); }); - it("restores running when PTY output contains remaining text beyond a recent typing echo", async () => { + it("keeps an idle session idle when PTY output extends beyond a recent typing echo", async () => { vi.useFakeTimers(); provider = { ...provider, @@ -373,10 +373,10 @@ describe("SessionManager session-level API", () => { expect(sessionMgr.get(session.id)?.state).toBe("idle"); onData?.("enerating...\n"); - expect(sessionMgr.get(session.id)?.state).toBe("running"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); }); - it("restores running when a single PTY chunk mixes typing echo with real output", async () => { + it("keeps an idle session idle when a PTY chunk mixes typing echo with output", async () => { vi.useFakeTimers(); provider = { ...provider, @@ -412,7 +412,7 @@ describe("SessionManager session-level API", () => { expect(sessionMgr.get(session.id)?.state).toBe("idle"); onData?.("gassistant working\n"); - expect(sessionMgr.get(session.id)?.state).toBe("running"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); vi.advanceTimersByTime(3000); expect(sessionMgr.get(session.id)?.state).toBe("idle"); @@ -463,7 +463,7 @@ describe("SessionManager session-level API", () => { expect(sessionMgr.get(session.id)?.state).toBe("idle"); }); - it("restores running when real output follows a typing repaint sequence within the aggregation window", async () => { + it("keeps an idle session idle when PTY output follows a typing repaint sequence", async () => { vi.useFakeTimers(); provider = { ...provider, @@ -505,7 +505,7 @@ describe("SessionManager session-level API", () => { expect(sessionMgr.get(session.id)?.state).toBe("idle"); onData?.("\nassistant working\n"); - expect(sessionMgr.get(session.id)?.state).toBe("running"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); }); it("does not restore running for a pure control-triggered line repaint", async () => { @@ -547,7 +547,7 @@ describe("SessionManager session-level API", () => { expect(sessionMgr.get(session.id)?.state).toBe("idle"); }); - it("restores running when real output arrives after recent control input has aged out", async () => { + it("keeps an idle session idle when PTY output arrives after recent control input has aged out", async () => { vi.useFakeTimers(); provider = { ...provider, @@ -584,7 +584,7 @@ describe("SessionManager session-level API", () => { vi.advanceTimersByTime(250); onData?.("assistant working\n"); - expect(sessionMgr.get(session.id)?.state).toBe("running"); + expect(sessionMgr.get(session.id)?.state).toBe("idle"); }); it("emits turn_completed only after an armed submit returns to idle", async () => { diff --git a/packages/server/src/session/manager.ts b/packages/server/src/session/manager.ts index 8c8de8b3..f758e2fe 100644 --- a/packages/server/src/session/manager.ts +++ b/packages/server/src/session/manager.ts @@ -938,9 +938,6 @@ export class SessionManager { if (outputAssessment.countsAsTurnOutput) { session.sawOutputSinceTurnStart = true; } - if (outputAssessment.shouldResumeRunning && session.state === "idle") { - this.transitionSessionToRunning(session); - } } private schedulePendingResumeAggregation(session: ActiveSession, chunk: Buffer): void { @@ -1084,9 +1081,6 @@ export class SessionManager { if (outputAssessment.countsAsTurnOutput) { activeSession.sawOutputSinceTurnStart = true; } - if (outputAssessment.shouldResumeRunning && activeSession.state === "idle") { - this.transitionSessionToRunning(activeSession); - } detector.feed(event.chunk); }); From 8fdf4dfcd8cf8065183a95342d99a6218cf2c379 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 22:18:28 +0800 Subject: [PATCH 33/36] fix(web): align terminal material backgrounds with glass surfaces --- .../__tests__/xterm-host.test.tsx | 143 ++++++++++- .../views/shared/xterm-host.tsx | 229 +++++++++++++++++- packages/web/src/styles/components.css | 3 +- .../web/src/styles/components.theme.test.ts | 3 + 4 files changed, 364 insertions(+), 14 deletions(-) diff --git a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx index c988434f..201b08f4 100644 --- a/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx +++ b/packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx @@ -278,10 +278,12 @@ const mockTerminal = { open: vi.fn(), onData: vi.fn(() => vi.fn()), // Return dispose function onResize: vi.fn(() => vi.fn()), + onRender: vi.fn(() => vi.fn()), onSelectionChange: vi.fn(() => vi.fn()), attachCustomKeyEventHandler: vi.fn(), hasSelection: vi.fn(() => false), getSelection: vi.fn(() => ""), + input: vi.fn(), write: vi.fn(), writeln: vi.fn(), scrollLines: vi.fn(), @@ -297,6 +299,9 @@ const mockTerminal = { getLine: vi.fn((row: number) => mockBufferLines.get(row)), }, }, + parser: { + registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })), + }, options: {}, }; @@ -356,6 +361,8 @@ describe("XtermHost", () => { mockTerminal.write.mockImplementation((_data: Uint8Array | string, callback?: () => void) => { callback?.(); }); + mockTerminal.onRender.mockImplementation(() => vi.fn()); + mockTerminal.input.mockImplementation(() => {}); mockTerminal.writeln.mockImplementation(() => {}); mockTerminal.reset.mockImplementation(() => {}); mockTerminal.scrollLines.mockImplementation((amount: number) => { @@ -1204,7 +1211,7 @@ describe("XtermHost", () => { expect.objectContaining({ theme: expect.objectContaining({ ...getThemeById("mint-light").terminalTheme, - background: "transparent", + background: "#00000000", }), }) ); @@ -2703,9 +2710,10 @@ describe("XtermHost", () => { expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ + allowTransparency: true, theme: expect.objectContaining({ ...getThemeById("mint-dark").terminalTheme, - background: "transparent", + background: "#00000000", }), }) ); @@ -2724,9 +2732,10 @@ describe("XtermHost", () => { expect(Terminal).toHaveBeenCalledWith( expect.objectContaining({ + allowTransparency: true, theme: expect.objectContaining({ ...getThemeById("mint-light").terminalTheme, - background: "transparent", + background: "#00000000", }), }) ); @@ -2761,7 +2770,7 @@ describe("XtermHost", () => { expect.objectContaining({ theme: expect.objectContaining({ ...getThemeById("mint-dark").terminalTheme, - background: "transparent", + background: "#00000000", }), }) ); @@ -2786,7 +2795,7 @@ describe("XtermHost", () => { expect.objectContaining({ theme: expect.objectContaining({ ...getThemeById("graphite-light").terminalTheme, - background: "transparent", + background: "#00000000", }), }) ); @@ -2827,13 +2836,133 @@ describe("XtermHost", () => { expect.objectContaining({ theme: expect.objectContaining({ ...getThemeById("graphite-light").terminalTheme, - background: "transparent", + background: "#00000000", }), }) ); }); }); + it("reports semantic terminal colors for OSC 10/11 queries while keeping the rendered background transparent", async () => { + const store = createStore(); + const sendTerminalInput = vi.fn().mockResolvedValue(undefined); + store.set(themeAtom, "mint-light"); + store.set(wsClientAtom, { + sendTerminalInput, + sendCommand: vi.fn().mockResolvedValue({ status: "ok" }), + subscribe: vi.fn(() => () => {}), + getStatus: vi.fn(() => "connected"), + onStatus: vi.fn(() => () => {}), + } as never); + + render( + + + + ); + + const fgHandlerCall = mockTerminal.parser.registerOscHandler.mock.calls.find( + ([ident]) => ident === 10 + ); + const bgHandlerCall = mockTerminal.parser.registerOscHandler.mock.calls.find( + ([ident]) => ident === 11 + ); + + expect(fgHandlerCall).toBeTruthy(); + expect(bgHandlerCall).toBeTruthy(); + + const fgHandler = fgHandlerCall?.[1] as + | ((data: string) => boolean | Promise) + | undefined; + const bgHandler = bgHandlerCall?.[1] as + | ((data: string) => boolean | Promise) + | undefined; + + await act(async () => { + expect(await fgHandler?.("?")).toBe(true); + expect(await bgHandler?.("?")).toBe(true); + }); + + await waitFor(() => { + expect(sendTerminalInput).toHaveBeenNthCalledWith( + 1, + "osc-query-terminal", + textEncoder.encode("\u001b]10;rgb:1f1f/2323/2828\u001b\\"), + "system", + undefined + ); + expect(sendTerminalInput).toHaveBeenNthCalledWith( + 2, + "osc-query-terminal", + textEncoder.encode("\u001b]11;rgb:fcfc/ffff/fdfd\u001b\\"), + "system", + undefined + ); + }); + + expect(mockTerminal.options).toEqual( + expect.objectContaining({ + theme: expect.objectContaining({ + ...getThemeById("mint-light").terminalTheme, + background: "#00000000", + }), + }) + ); + }); + + it("normalizes rendered ANSI cell backgrounds to the active surface opacity", async () => { + const store = createStore(); + store.set(themeAtom, "mint-light"); + store.set(appearancePersonalizationAtom, { + version: 1, + common: { + backgroundMode: "image", + backgroundAssetId: "asset-terminal-material-alpha", + backgroundFit: "cover", + backgroundDimness: 18, + backgroundBlur: 4, + glassEnabled: true, + glassIntensity: 28, + surfaceOpacity: 52, + }, + desktop: {}, + mobile: {}, + }); + + let renderListener: ((viewport: { start: number; end: number }) => void) | undefined; + mockTerminal.onRender.mockImplementationOnce((listener) => { + renderListener = listener; + return vi.fn(); + }); + + const { container } = render( + + + + ); + + const host = container.querySelector(".xterm-host") as HTMLDivElement | null; + expect(host).toBeTruthy(); + expect(renderListener).toBeTypeOf("function"); + + const rowsElement = document.createElement("div"); + rowsElement.className = "xterm-rows"; + const row = document.createElement("div"); + const cell = document.createElement("span"); + cell.textContent = "你好"; + cell.style.backgroundColor = "rgb(55, 55, 55)"; + cell.style.color = "rgb(255, 255, 255)"; + row.appendChild(cell); + rowsElement.appendChild(row); + host!.appendChild(rowsElement); + + await act(async () => { + renderListener?.({ start: 0, end: 0 }); + }); + + expect(cell.style.backgroundColor).toBe("rgba(55, 55, 55, 0.52)"); + }); + it("uses the high-contrast dark terminal palette for hc-dark", async () => { const { Terminal } = await import("@xterm/xterm"); const store = createStore(); @@ -2849,7 +2978,7 @@ describe("XtermHost", () => { expect.objectContaining({ theme: expect.objectContaining({ ...getThemeById("hc-dark").terminalTheme, - background: "transparent", + background: "#00000000", }), }) ); diff --git a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx index 1a7ee463..a889b41f 100644 --- a/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx +++ b/packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx @@ -25,7 +25,8 @@ import { useRef, useState, } from "react"; -import { themeAtom } from "../../../../atoms/app-ui"; +import { resolveAppearancePersonalizationForViewport } from "../../../../appearance/personalization"; +import { appearancePersonalizationAtom, themeAtom } from "../../../../atoms/app-ui"; import { dispatchCommandAtom, wsClientAtom } from "../../../../atoms/connection"; import { Button, LocalOverlay, Notice } from "../../../../components/ui"; import { useViewport } from "../../../../hooks/use-viewport"; @@ -329,10 +330,164 @@ function resolveXtermTheme(themeId: string): TerminalThemeDefinition { const terminalTheme = getThemeById(themeId).terminalTheme; return { ...terminalTheme, - background: "transparent", + // xterm theme parsing rejects the `transparent` keyword and falls back to + // opaque black, so use an explicit transparent RGBA hex color. + background: "#00000000", }; } +function resolveReportedXtermTheme(themeId: string): TerminalThemeDefinition { + return getThemeById(themeId).terminalTheme; +} + +function parseTerminalThemeRgb(color: string): [number, number, number] | null { + const trimmed = color.trim(); + if (!trimmed.startsWith("#")) { + return null; + } + + const hex = trimmed.slice(1); + let normalized: string; + + if (hex.length === 3 || hex.length === 4) { + normalized = hex + .slice(0, 3) + .split("") + .map((channel) => `${channel}${channel}`) + .join(""); + } else if (hex.length === 6 || hex.length === 8) { + normalized = hex.slice(0, 6); + } else { + return null; + } + + if (!/^[\da-f]{6}$/iu.test(normalized)) { + return null; + } + + return [ + Number.parseInt(normalized.slice(0, 2), 16), + Number.parseInt(normalized.slice(2, 4), 16), + Number.parseInt(normalized.slice(4, 6), 16), + ]; +} + +function formatTerminalThemeQueryResponse(ident: "10" | "11", color: string): string | null { + const rgb = parseTerminalThemeRgb(color); + if (!rgb) { + return null; + } + + const encodeChannel = (channel: number) => channel.toString(16).padStart(2, "0").repeat(2); + return `\x1b]${ident};rgb:${encodeChannel(rgb[0])}/${encodeChannel(rgb[1])}/${encodeChannel( + rgb[2] + )}\x1b\\`; +} + +function parseCssColorRgb(color: string): [number, number, number] | null { + const trimmed = color.trim(); + if (!trimmed) { + return null; + } + + const rgbMatch = trimmed.match( + /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(?:\d*\.?\d+))?\s*\)$/iu + ); + if (rgbMatch) { + return [ + Math.min(255, Number.parseInt(rgbMatch[1] ?? "0", 10)), + Math.min(255, Number.parseInt(rgbMatch[2] ?? "0", 10)), + Math.min(255, Number.parseInt(rgbMatch[3] ?? "0", 10)), + ]; + } + + return parseTerminalThemeRgb(trimmed); +} + +function formatCssRgbColor(rgb: [number, number, number], alpha: number): string { + const clampedAlpha = Math.min(Math.max(alpha, 0), 1); + if (clampedAlpha >= 0.999) { + return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; + } + + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${Math.round(clampedAlpha * 1000) / 1000})`; +} + +function shouldNormalizeTerminalCellBackground(element: HTMLSpanElement): boolean { + return !Array.from(element.classList).some( + (className) => className === "xterm-cursor" || className.startsWith("xterm-cursor-") + ); +} + +function resolveTerminalCellBackgroundSource(element: HTMLSpanElement): string | null { + const inlineBackground = element.style.backgroundColor.trim(); + if (inlineBackground) { + return inlineBackground; + } + + const hasPaletteBackgroundClass = Array.from(element.classList).some((className) => + className.startsWith("xterm-bg-") + ); + if (!hasPaletteBackgroundClass || typeof window === "undefined") { + return null; + } + + const computedBackground = window.getComputedStyle(element).backgroundColor.trim(); + return computedBackground ? computedBackground : null; +} + +function applyTerminalMaterialToRenderedRows( + container: HTMLElement | null, + alpha: number, + range?: { start: number; end: number } +): void { + if (!container) { + return; + } + + const rowsElement = container.querySelector(".xterm-rows"); + if (!(rowsElement instanceof HTMLElement)) { + return; + } + + const rowElements = Array.from(rowsElement.children); + if (rowElements.length === 0) { + return; + } + + const start = Math.max(0, range?.start ?? 0); + const end = Math.min(rowElements.length - 1, range?.end ?? rowElements.length - 1); + + for (let rowIndex = start; rowIndex <= end; rowIndex += 1) { + const rowElement = rowElements[rowIndex]; + if (!(rowElement instanceof HTMLElement)) { + continue; + } + + const spanElements = rowElement.querySelectorAll("span"); + for (const spanElement of spanElements) { + if ( + !(spanElement instanceof HTMLSpanElement) || + !shouldNormalizeTerminalCellBackground(spanElement) + ) { + continue; + } + + const sourceBackground = resolveTerminalCellBackgroundSource(spanElement); + if (!sourceBackground) { + continue; + } + + const rgb = parseCssColorRgb(sourceBackground); + if (!rgb) { + continue; + } + + spanElement.style.backgroundColor = formatCssRgbColor(rgb, alpha); + } + } +} + interface XtermHostProps { /** Terminal ID */ terminalId: string; @@ -411,6 +566,7 @@ export function XtermHost({ const t = useTranslation(); const viewport = useViewport(); const uiTheme = useAtomValue(themeAtom); + const appearancePersonalization = useAtomValue(appearancePersonalizationAtom); const terminalPreferences = useAtomValue(terminalPreferencesAtom); const terminalFontSize = getTerminalFontSizeForViewport(terminalPreferences, viewport); const wsClient = useAtomValue(wsClientAtom); @@ -520,6 +676,15 @@ export function XtermHost({ return wsClient.getStatus(); }); const resolvedTerminalTheme = resolveXtermTheme(uiTheme); + const resolvedAppearancePersonalization = resolveAppearancePersonalizationForViewport( + appearancePersonalization, + viewport + ); + const terminalMaterialBackgroundAlpha = + uiTheme === "hc-dark" || uiTheme === "hc-light" + ? 1 + : Math.min(Math.max(resolvedAppearancePersonalization.surfaceOpacity, 0), 100) / 100; + const terminalMaterialBackgroundAlphaRef = useRef(terminalMaterialBackgroundAlpha); // Latest copies of callback identities used inside the mount effect, exposed // via refs so the effect's cleanup/re-creation is not tied to their churn. @@ -631,6 +796,11 @@ export function XtermHost({ } }, [resolvedTerminalTheme]); + useEffect(() => { + terminalMaterialBackgroundAlphaRef.current = terminalMaterialBackgroundAlpha; + applyTerminalMaterialToRenderedRows(containerRef.current, terminalMaterialBackgroundAlpha); + }, [terminalMaterialBackgroundAlpha]); + useEffect(() => { if (replayUiState.kind !== "loading") { setLoadingOverlayVisible(false); @@ -1369,6 +1539,7 @@ export function XtermHost({ // with no gaps between rows. const terminal = new Terminal({ theme: resolveXtermTheme(initialThemeRef.current), + allowTransparency: true, fontFamily: "JetBrains Mono, Fira Code, SF Mono, monospace", fontSize: terminalFontSize, scrollback: 5000, @@ -1378,6 +1549,38 @@ export function XtermHost({ allowProposedApi: true, }); + const reportTerminalThemeColor = (ident: "10" | "11", color: string) => { + const response = formatTerminalThemeQueryResponse(ident, color); + if (!response) { + return false; + } + + void handleInputRef.current(response, "system"); + return true; + }; + + const foregroundColorQueryDisposable = terminal.parser.registerOscHandler(10, (data) => { + if (data !== "?") { + return false; + } + + return reportTerminalThemeColor( + "10", + resolveReportedXtermTheme(initialThemeRef.current).foreground + ); + }); + + const backgroundColorQueryDisposable = terminal.parser.registerOscHandler(11, (data) => { + if (data !== "?") { + return false; + } + + return reportTerminalThemeColor( + "11", + resolveReportedXtermTheme(initialThemeRef.current).background + ); + }); + const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); @@ -1387,6 +1590,16 @@ export function XtermHost({ terminal.onData((data) => { void handleInputRef.current(data); }); + const renderDisposable = + typeof terminal.onRender === "function" + ? terminal.onRender(({ start, end }) => { + applyTerminalMaterialToRenderedRows( + containerRef.current, + terminalMaterialBackgroundAlphaRef.current, + { start, end } + ); + }) + : undefined; const selectionChangeDisposable = typeof terminal.onSelectionChange === "function" ? terminal.onSelectionChange(() => { @@ -1610,8 +1823,7 @@ export function XtermHost({ resetTerminalBeforeWrite?: boolean; }) => { let coveredSeq = options?.coveredSeq ?? replayedSeqRef.current; - let nextWrites: HistoricalWrite[] = []; - let firstBatch = true; + const nextWrites: HistoricalWrite[] = []; if (options?.bytes && typeof options.coveredSeq === "number") { nextWrites.push({ @@ -1626,7 +1838,6 @@ export function XtermHost({ if (nextWrites.length > 0) { await writeHistoricalBatch(nextWrites); - firstBatch = false; } replayedSeqRef.current = coveredSeq; @@ -1652,7 +1863,6 @@ export function XtermHost({ })); await writeHistoricalBatch(pendingWrites); - firstBatch = false; } finalizeHistoricalRecovery(activeHistoricalRecoveryModeRef.current); @@ -2153,6 +2363,13 @@ export function XtermHost({ } else { selectionChangeDisposable?.dispose?.(); } + if (typeof renderDisposable === "function") { + renderDisposable(); + } else { + renderDisposable?.dispose?.(); + } + foregroundColorQueryDisposable.dispose(); + backgroundColorQueryDisposable.dispose(); }; }, [ hydrationState.kind, diff --git a/packages/web/src/styles/components.css b/packages/web/src/styles/components.css index bdbb39aa..56152748 100644 --- a/packages/web/src/styles/components.css +++ b/packages/web/src/styles/components.css @@ -3821,7 +3821,8 @@ body.is-resizing-panels * { flex-direction: column; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); - background: color-mix(in srgb, var(--bg-surface) 70%, var(--bg-page) 30%); + background: var(--ws-session-header-bg); + backdrop-filter: var(--ws-backdrop-filter); border-bottom: 1px solid var(--border); } diff --git a/packages/web/src/styles/components.theme.test.ts b/packages/web/src/styles/components.theme.test.ts index 518ff0fc..6e1be8b6 100644 --- a/packages/web/src/styles/components.theme.test.ts +++ b/packages/web/src/styles/components.theme.test.ts @@ -1338,6 +1338,7 @@ describe("components.css theme-sensitive surfaces", () => { const xtermReplayCard = getLastRuleBlock(".xterm-replay-overlay__card"); const sessionProgress = getLastRuleBlock(".session-progress"); const sessionHeader = getLastRuleBlock(".session-header"); + const supervisorCard = getLastRuleBlock(".supervisor-card"); const sessionHeaderLeft = getLastRuleBlock(".session-header-left"); const sessionHeaderCopyBlocks = getRuleBlocksFrom(stylesheet, ".session-header-copy"); const sessionTitleRow = getLastRuleBlock(".session-title-row"); @@ -1362,6 +1363,8 @@ describe("components.css theme-sensitive surfaces", () => { expect(xtermReplayCard).toContain("border-radius: var(--terminal-local-overlay-radius)"); expect(sessionProgress).toContain("background: var(--state-info-bg)"); expect(sessionHeader).toContain("padding: var(--gap-tight) var(--inset-control-inline)"); + expect(supervisorCard).toContain("background: var(--ws-session-header-bg)"); + expect(supervisorCard).toContain("backdrop-filter: var(--ws-backdrop-filter)"); expect(sessionHeaderLeft).toContain("gap: var(--gap-default)"); expect(sessionHeaderCopyBlocks.some((block) => block.includes("gap: var(--gap-compact)"))).toBe( true From 2788974828c6402ab5e7614f4ba92591c7775e22 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 22:24:26 +0800 Subject: [PATCH 34/36] docs: add workspace planning and spec notes --- .../plans/2026-05-22-footer-update-rail.md | 1171 ++++++++ ...3-mobile-workspace-three-view-alignment.md | 1179 ++++++++ ...2026-05-23-supervisor-refresh-hydration.md | 443 +++ ...kspace-search-quick-open-visual-refresh.md | 860 ++++++ ...-23-workspace-sidebar-search-quick-open.md | 2674 +++++++++++++++++ ...-05-23-workspace-tab-instance-isolation.md | 73 + ...24-background-material-settings-restyle.md | 898 ++++++ .../2026-05-24-git-panel-worktree-list.md | 268 ++ .../2026-05-24-system-dependency-installer.md | 1998 ++++++++++++ ...24-workspace-background-material-system.md | 662 ++++ ...workspace-tab-instance-isolation-design.md | 107 + ...26-05-24-git-panel-worktree-list-design.md | 131 + 12 files changed, 10464 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-22-footer-update-rail.md create mode 100644 docs/superpowers/plans/2026-05-23-mobile-workspace-three-view-alignment.md create mode 100644 docs/superpowers/plans/2026-05-23-supervisor-refresh-hydration.md create mode 100644 docs/superpowers/plans/2026-05-23-workspace-search-quick-open-visual-refresh.md create mode 100644 docs/superpowers/plans/2026-05-23-workspace-sidebar-search-quick-open.md create mode 100644 docs/superpowers/plans/2026-05-23-workspace-tab-instance-isolation.md create mode 100644 docs/superpowers/plans/2026-05-24-background-material-settings-restyle.md create mode 100644 docs/superpowers/plans/2026-05-24-git-panel-worktree-list.md create mode 100644 docs/superpowers/plans/2026-05-24-system-dependency-installer.md create mode 100644 docs/superpowers/plans/2026-05-24-workspace-background-material-system.md create mode 100644 docs/superpowers/specs/2026-05-23-workspace-tab-instance-isolation-design.md create mode 100644 docs/superpowers/specs/2026-05-24-git-panel-worktree-list-design.md diff --git a/docs/superpowers/plans/2026-05-22-footer-update-rail.md b/docs/superpowers/plans/2026-05-22-footer-update-rail.md new file mode 100644 index 00000000..c25b22b8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-footer-update-rail.md @@ -0,0 +1,1171 @@ +# Footer Update Rail Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move update discovery and in-progress update visibility into the shared workspace footer on desktop and mobile, while reusing the existing update backend flow. + +**Architecture:** Keep the current update state source (`updateStateAtom` plus `updates.prepareInstall` / `updates.startInstall`) and add a focused footer-side UI component that renders compact update status and actions. Restructure the shared workspace status bar into left and right zones, remove the toast and topbar badge discovery signals, and keep `Settings > About` as the details surface for failure and manual-action states. + +**Tech Stack:** React 19, Jotai, React Router, Testing Library, Vitest, CSS compatibility styles in `packages/web/src/styles/components.css`. + +**Spec reference:** `docs/superpowers/specs/2026-05-22-footer-update-rail-design.md` + +**Git hygiene:** The worktree already contains unrelated user changes in `packages/web/src/features/topbar/components/tab.tsx`, `packages/web/src/features/topbar/components/tab.test.tsx`, `packages/web/src/features/topbar/components/workspace-session-mini-map.tsx`, `packages/web/src/features/topbar/components/workspace-session-mini-map.test.tsx`, and `packages/web/src/styles/components.css`. Do not revert those unrelated edits. Read carefully before patching `components.css`. + +--- + +## File Structure + +**New files:** +- `packages/web/src/features/workspace/views/shared/footer-update-rail.tsx` — compact right-side footer update UI, update action entry point, success auto-hide logic, and `Settings > About` navigation +- `packages/web/src/features/workspace/views/shared/footer-update-rail.test.tsx` — focused tests for footer update states, update commands, confirmation flow, timeout-based success hiding, and details navigation + +**Modified files:** +- `packages/web/src/features/workspace/views/shared/workspace-status-bar.tsx` — reshape the shared footer into explicit left/right zones and host `FooterUpdateRail` +- `packages/web/src/features/workspace/views/shared/git-panel-status-strip.tsx` — lock Git content into the left zone without owning footer-wide alignment +- `packages/web/src/features/workspace/views/shared/git-panel-status-strip.test.tsx` — update layout assertions to the new left-zone contract +- `packages/web/src/features/workspace/index.test.tsx` — verify desktop shared footer still shows branch info and now exposes a right-side update region when state is active +- `packages/web/src/shells/mobile-shell/index.test.tsx` — verify mobile shared footer keeps Git on the left and can render update UI on the right +- `packages/web/src/styles/components.css` — add shared footer two-zone layout, update rail styling, truncation rules, and mobile-safe compact spacing +- `packages/web/src/features/updates/atoms.ts` — remove the derived topbar badge selector and keep only base update state atoms if no longer needed +- `packages/web/src/app/providers.tsx` — remove the update-available toast subscription and keep only state hydration/event routing +- `packages/web/src/app/providers.lifecycle.test.tsx` — replace the toast assertion with a no-toast assertion after `update.state.changed` +- `packages/web/src/features/topbar/index.tsx` — remove the update badge from the settings button +- `packages/web/src/features/topbar/index.test.tsx` — delete badge expectations and assert the settings entry remains plain +- `packages/web/src/features/workspace/views/shared/git-status-bar.tsx` — export a focused update-confirmation helper or shared action callback only if needed to avoid duplicating the existing confirmation behavior +- `packages/web/src/features/settings/components/settings-page.tsx` — accept `?section=about` so footer “查看详情” can open `Settings > About` directly +- `packages/web/src/features/settings/components/settings-page.test.tsx` — verify deep-linking or route-state opening of the About section +- `packages/web/src/locales/zh.json` — add compact footer update strings +- `packages/web/src/locales/en.json` — add compact footer update strings + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/footer-update-rail.test.tsx` +- `pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/git-panel-status-strip.test.tsx src/features/workspace/index.test.tsx src/shells/mobile-shell/index.test.tsx` +- `pnpm --filter @coder-studio/web test -- src/app/providers.lifecycle.test.tsx src/features/topbar/index.test.tsx src/features/settings/components/settings-page.test.tsx` + +--- + +### Task 1: Add Footer Update Rail Component With TDD + +**Files:** +- Create: `packages/web/src/features/workspace/views/shared/footer-update-rail.tsx` +- Create: `packages/web/src/features/workspace/views/shared/footer-update-rail.test.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/locales/en.json` + +- [ ] **Step 1: Write the failing footer update rail tests** + +Create `packages/web/src/features/workspace/views/shared/footer-update-rail.test.tsx`: + +```tsx +// @vitest-environment jsdom + +import type { UpdatePrepareInstallResponse, UpdateStateView } from "@coder-studio/core"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { serverInfoAtom, wsClientAtom } from "../../../../atoms/connection"; +import { updatePrepareInstallAtom, updateStateAtom } from "../../../updates/atoms"; +import { FooterUpdateRail } from "./footer-update-rail"; + +function baseUpdateState(overrides: Partial = {}): UpdateStateView { + return { + version: 1, + currentVersion: "0.4.0", + latestVersion: "0.5.0", + availability: "update_available", + updateStatus: "idle", + lastCheckedAt: 1, + targetVersion: null, + startedAt: null, + finishedAt: null, + requiresManualStep: false, + manualCommand: null, + errorSummary: null, + supported: true, + installKind: "global_npm", + unsupportedReason: null, + ...overrides, + }; +} + +function basePrepareResponse( + overrides: Partial = {} +): UpdatePrepareInstallResponse { + return { + ...baseUpdateState(), + canStartInstall: true, + activity: { + runningTerminalCount: 0, + runningSessionCount: 0, + runningSupervisorCount: 0, + hasActiveWork: false, + }, + ...overrides, + }; +} + +function renderRail({ + dispatch = vi.fn(), + updateState = baseUpdateState(), +}: { + dispatch?: ReturnType; + updateState?: UpdateStateView | null; +} = {}) { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: dispatch } as never); + store.set(serverInfoAtom, { + version: "0.4.0", + serverInstanceId: "server-123", + }); + store.set(updateStateAtom, updateState); + + render( + + + + } /> + settings
} /> + + + + ); + + return { store, dispatch }; +} + +describe("FooterUpdateRail", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("renders update discovery with an immediate action when a newer version is available", () => { + renderRail(); + + expect(screen.getByText("检测到新版本 v0.5.0")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "立即更新" })).toBeInTheDocument(); + }); + + it("starts the existing update flow from the footer when no active work exists", async () => { + const dispatch = vi.fn().mockImplementation(async (op: string) => { + if (op === "updates.prepareInstall") { + return basePrepareResponse(); + } + if (op === "updates.startInstall") { + return baseUpdateState({ + availability: "update_available", + updateStatus: "installing", + targetVersion: "0.5.0", + startedAt: 10, + }); + } + throw new Error(`unexpected op: ${op}`); + }); + const { store } = renderRail({ dispatch }); + + fireEvent.click(screen.getByRole("button", { name: "立即更新" })); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith("updates.prepareInstall", {}, undefined); + }); + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith( + "updates.startInstall", + { targetVersion: "0.5.0", force: false }, + undefined + ); + }); + expect(store.get(updateStateAtom)?.updateStatus).toBe("installing"); + }); + + it("opens the existing confirmation dialog before install when active work exists", async () => { + const dispatch = vi.fn().mockResolvedValue( + basePrepareResponse({ + activity: { + runningTerminalCount: 1, + runningSessionCount: 1, + runningSupervisorCount: 0, + hasActiveWork: true, + }, + }) + ); + + renderRail({ dispatch }); + + fireEvent.click(screen.getByRole("button", { name: "立即更新" })); + + await waitFor(() => { + expect(screen.getByText("确认更新")).toBeInTheDocument(); + }); + }); + + it("renders failure and manual-required states with a details action", () => { + const { rerender } = render( + { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set(updateStateAtom, baseUpdateState({ updateStatus: "failed", availability: "check_failed" })); + return store; + })()} + > + + + } /> + settings
} /> + + + + ); + + expect(screen.getByText("更新失败")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "查看详情" })).toBeInTheDocument(); + + rerender( + { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set(updateStateAtom, baseUpdateState({ updateStatus: "manual_required" })); + return store; + })()} + > + + + } /> + settings
} /> + + + + ); + + expect(screen.getByText("需要手动处理")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "查看详情" })).toBeInTheDocument(); + }); + + it("hides the success state three seconds after it appears", async () => { + const { store } = renderRail({ + updateState: baseUpdateState({ + availability: "up_to_date", + updateStatus: "succeeded", + latestVersion: "0.5.0", + targetVersion: "0.5.0", + finishedAt: 42, + }), + }); + + expect(screen.getByText("已更新到 v0.5.0")).toBeInTheDocument(); + + act(() => { + vi.advanceTimersByTime(3000); + }); + + await waitFor(() => { + expect(screen.queryByText("已更新到 v0.5.0")).toBeNull(); + }); + + expect(store.get(updateStateAtom)?.updateStatus).toBe("succeeded"); + }); +}); +``` + +Modify `renderSettingsPage` in `packages/web/src/features/settings/components/settings-page.test.tsx`: + +```tsx +function renderSettingsPage( + store = createConnectedStore(vi.fn().mockResolvedValue({})), + initialEntry = "/settings" +) { + return render( + + + + + + ); +} +``` + +Add to `packages/web/src/features/settings/components/settings-page.test.tsx`: + +```tsx +it("opens the About section when the route asks for it explicitly", async () => { + renderSettingsPage(undefined, "/settings?section=about"); + + await screen.findByTestId("about-settings"); + expect(screen.getByRole("button", { name: "关于" })).toHaveClass("settings-nav-item-active"); +}); +``` + +- [ ] **Step 2: Run the focused tests and confirm RED** + +Run: + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/footer-update-rail.test.tsx src/features/settings/components/settings-page.test.tsx +``` + +Expected: FAIL because `FooterUpdateRail` does not exist and SettingsPage does not honor the explicit About-section route hint. + +- [ ] **Step 3: Implement the minimal footer update rail, About deep-linking, and strings** + +Create `packages/web/src/features/workspace/views/shared/footer-update-rail.tsx`: + +```tsx +import type { UpdatePrepareInstallResponse, UpdateStateView } from "@coder-studio/core"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { dispatchCommandAtom } from "../../../../atoms/connection"; +import { Button, ConfirmDialog } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { updatePrepareInstallAtom, updateStateAtom } from "../../../updates/atoms"; + +type RailView = + | { kind: "hidden" } + | { kind: "available"; text: string; buttonLabel: string } + | { kind: "installing"; text: string } + | { kind: "restarting"; text: string } + | { kind: "failed"; text: string; buttonLabel: string } + | { kind: "manual_required"; text: string; buttonLabel: string } + | { kind: "succeeded"; text: string }; + +function resolveTargetVersion(state: UpdateStateView | null): string | null { + return state?.latestVersion ?? state?.targetVersion ?? null; +} + +function buildView( + state: UpdateStateView | null, + showSucceeded: boolean, + t: ReturnType +): RailView { + if (!state) return { kind: "hidden" }; + + if (state.updateStatus === "installing") { + return { kind: "installing", text: t("settings.about.footer_installing") }; + } + + if (state.updateStatus === "restarting") { + return { kind: "restarting", text: t("settings.about.footer_restarting") }; + } + + if (state.updateStatus === "failed") { + return { + kind: "failed", + text: t("settings.about.footer_failed"), + buttonLabel: t("settings.about.footer_view_details"), + }; + } + + if (state.updateStatus === "manual_required") { + return { + kind: "manual_required", + text: t("settings.about.footer_manual_required"), + buttonLabel: t("settings.about.footer_view_details"), + }; + } + + if (state.updateStatus === "succeeded" && showSucceeded) { + const version = resolveTargetVersion(state) ?? state.currentVersion; + return { + kind: "succeeded", + text: t("settings.about.footer_succeeded", { version }), + }; + } + + if (state.availability === "update_available" && state.updateStatus === "idle" && state.latestVersion) { + return { + kind: "available", + text: t("settings.about.footer_available", { version: state.latestVersion }), + buttonLabel: t("settings.about.update_now"), + }; + } + + return { kind: "hidden" }; +} + +export function FooterUpdateRail() { + const t = useTranslation(); + const navigate = useNavigate(); + const dispatch = useAtomValue(dispatchCommandAtom); + const updateState = useAtomValue(updateStateAtom); + const setUpdateState = useSetAtom(updateStateAtom); + const setUpdatePrepareInstall = useSetAtom(updatePrepareInstallAtom); + const [confirmState, setConfirmState] = useState(null); + const [busy, setBusy] = useState<"prepare" | "install" | null>(null); + const [showSucceeded, setShowSucceeded] = useState(updateState?.updateStatus === "succeeded"); + + useEffect(() => { + if (updateState?.updateStatus !== "succeeded") { + setShowSucceeded(false); + return; + } + + setShowSucceeded(true); + const timer = window.setTimeout(() => { + setShowSucceeded(false); + }, 3000); + + return () => { + window.clearTimeout(timer); + }; + }, [updateState?.finishedAt, updateState?.targetVersion, updateState?.updateStatus]); + + const view = buildView(updateState, showSucceeded, t); + + const openAbout = () => { + navigate("/settings?section=about"); + }; + + const startInstallCommand = async ( + prepared: UpdatePrepareInstallResponse, + force: boolean + ): Promise => { + const result = await dispatch("updates.startInstall", { + targetVersion: prepared.latestVersion ?? prepared.targetVersion ?? undefined, + force, + }); + if (!result.ok || !result.data) { + return null; + } + return result.data; + }; + + const handleStartInstall = async ( + prepared: UpdatePrepareInstallResponse, + force: boolean + ) => { + setBusy("install"); + const nextState = await startInstallCommand(prepared, force); + setBusy(null); + setConfirmState(null); + if (!nextState) { + return; + } + setUpdateState(nextState); + }; + + const handleUpdateNow = async () => { + setBusy("prepare"); + const result = await dispatch("updates.prepareInstall", {}); + setBusy(null); + if (!result.ok || !result.data) { + return; + } + setUpdatePrepareInstall(result.data); + if (result.data.activity.hasActiveWork) { + setConfirmState(result.data); + return; + } + await handleStartInstall(result.data, false); + }; + + if (view.kind === "hidden") { + return null; + } + + return ( + <> +
+ {view.text} + {"buttonLabel" in view ? ( + + ) : null} +
+ + {confirmState ? ( + { + if (!open) { + setConfirmState(null); + } + }} + title={t("settings.about.confirm_update_title")} + description={ +
+

{t("settings.about.confirm_update_message")}

+

+ {t("settings.about.confirm_update_activity", { + terminals: confirmState.activity.runningTerminalCount, + sessions: confirmState.activity.runningSessionCount, + supervisors: confirmState.activity.runningSupervisorCount, + })} +

+
+ } + cancelText={t("action.cancel")} + confirmText={t("settings.about.update_now")} + tone="danger" + onConfirm={() => { + void handleStartInstall(confirmState, true); + }} + /> + ) : null} + + ); +} + +export default FooterUpdateRail; +``` + +Modify `packages/web/src/features/settings/components/settings-page.tsx` near `navigationState` initialization: + +```tsx + const initialSearch = + typeof window === "undefined" ? new URLSearchParams() : new URLSearchParams(window.location.search); + const initialSection = initialSearch.get("section"); + const requestedSection = initialSection === "about" ? "about" : null; + + const [navigationState, setNavigationState] = useState(() => + isMobile + ? { kind: "detail", section: requestedSection ?? DEFAULT_SETTINGS_SECTION } + : { kind: "detail", section: requestedSection ?? DEFAULT_SETTINGS_SECTION } + ); +``` + +Add locale strings: + +```json +"footer_available": "检测到新版本 v{version}", +"footer_installing": "更新中...", +"footer_restarting": "正在重启服务...", +"footer_failed": "更新失败", +"footer_manual_required": "需要手动处理", +"footer_succeeded": "已更新到 v{version}", +"footer_view_details": "查看详情" +``` + +```json +"footer_available": "Update available v{version}", +"footer_installing": "Installing update...", +"footer_restarting": "Restarting service...", +"footer_failed": "Update failed", +"footer_manual_required": "Manual action required", +"footer_succeeded": "Updated to v{version}", +"footer_view_details": "View details" +``` + +- [ ] **Step 4: Run the focused tests and confirm GREEN** + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/footer-update-rail.test.tsx src/features/settings/components/settings-page.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/workspace/views/shared/footer-update-rail.tsx packages/web/src/features/workspace/views/shared/footer-update-rail.test.tsx packages/web/src/features/settings/components/settings-page.tsx packages/web/src/features/settings/components/settings-page.test.tsx packages/web/src/locales/zh.json packages/web/src/locales/en.json +git commit -m "feat: add footer update rail" +``` + +--- + +### Task 2: Reshape Shared Desktop and Mobile Footer Layout Around Left/Right Zones + +**Files:** +- Modify: `packages/web/src/features/workspace/views/shared/workspace-status-bar.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/git-panel-status-strip.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/git-panel-status-strip.test.tsx` +- Modify: `packages/web/src/features/workspace/index.test.tsx` +- Modify: `packages/web/src/shells/mobile-shell/index.test.tsx` +- Modify: `packages/web/src/styles/components.css` + +- [ ] **Step 1: Write the failing layout tests** + +Add to `packages/web/src/features/workspace/views/shared/git-panel-status-strip.test.tsx`: + +```tsx +it("renders the branch and git meta controls inside separate left-side strip slots", () => { + const store = createStore(); + store.set(localeAtom, "en"); + + const { container } = render( + + + + ); + + expect(container.querySelector(".git-panel-status-strip__left")).toBeTruthy(); + expect(container.querySelector(".git-panel-status-strip__right")).toBeNull(); + expect(container.querySelector(".git-panel-status-strip__left .git-panel-status-strip__branch")).toBeTruthy(); + expect(container.querySelector(".git-panel-status-strip__left .git-panel-status-strip__meta")).toBeTruthy(); +}); +``` + +Add to `packages/web/src/features/workspace/index.test.tsx`: + +```tsx +it("renders a dedicated right-side update region in the desktop shared footer", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "feature/footer-update", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + store.set(updateStateAtom, { + version: 1, + currentVersion: "0.4.0", + latestVersion: "0.5.0", + availability: "update_available", + updateStatus: "idle", + lastCheckedAt: 1, + targetVersion: null, + startedAt: null, + finishedAt: null, + requiresManualStep: false, + manualCommand: null, + errorSummary: null, + supported: true, + installKind: "global_npm", + unsupportedReason: null, + }); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + + render( + + + + } /> + + + + ); + + await screen.findByText("feature/footer-update"); + + expect(document.querySelector(".workspace-status-bar__left .git-panel-status-strip")).toBeTruthy(); + expect(document.querySelector(".workspace-status-bar__right")).toHaveTextContent("检测到新版本"); +}); +``` + +Add to `packages/web/src/shells/mobile-shell/index.test.tsx`: + +```tsx +it("renders git on the left and footer update UI on the right in the shared mobile footer", async () => { + const { store } = renderMobileShell({ initialEntry: "/workspace" }); + + act(() => { + store.set(gitStateAtomFamily("ws-1"), { + branch: "feature/mobile-footer-update", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }); + store.set(updateStateAtom, { + version: 1, + currentVersion: "0.4.0", + latestVersion: "0.5.0", + availability: "update_available", + updateStatus: "idle", + lastCheckedAt: 1, + targetVersion: null, + startedAt: null, + finishedAt: null, + requiresManualStep: false, + manualCommand: null, + errorSummary: null, + supported: true, + installKind: "global_npm", + unsupportedReason: null, + }); + }); + + expect(document.querySelector(".mobile-shell__bottom-stack .workspace-status-bar__left")).toHaveTextContent( + "feature/mobile-footer-update" + ); + expect(document.querySelector(".mobile-shell__bottom-stack .workspace-status-bar__right")).toHaveTextContent( + "检测到新版本" + ); +}); +``` + +- [ ] **Step 2: Run the layout tests and confirm RED** + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/git-panel-status-strip.test.tsx src/features/workspace/index.test.tsx src/shells/mobile-shell/index.test.tsx +``` + +Expected: FAIL because the shared footer does not yet expose left/right slots or a right-side update region. + +- [ ] **Step 3: Implement the minimal shared footer layout and styling** + +Modify `packages/web/src/features/workspace/views/shared/workspace-status-bar.tsx`: + +```tsx +import type { GitStatus } from "@coder-studio/core"; +import { FooterUpdateRail } from "./footer-update-rail"; +import { GitPanelStatusStrip } from "./git-panel-status-strip"; + +interface WorkspaceStatusBarProps { + workspaceId: string; + gitState: GitStatus | null | undefined; + onOpenBranchSwitcher?: () => void; + flush?: boolean; +} + +export function WorkspaceStatusBar({ + workspaceId, + gitState, + onOpenBranchSwitcher, + flush = false, +}: WorkspaceStatusBarProps) { + return ( +
+
+ +
+
+ +
+
+ ); +} +``` + +Modify `packages/web/src/features/workspace/views/shared/git-panel-status-strip.tsx`: + +```tsx + return ( +
+
+ {viewport === "desktop" && onOpenBranchSwitcher ? ( + + {branchTrigger} + + ) : ( + branchTrigger + )} +
+ +
+
+
+ ); +``` + +If duplicating the install-start command inside `FooterUpdateRail` feels too brittle after Step 3, extract the shared command launcher into `packages/web/src/features/workspace/views/shared/git-status-bar.tsx` as: + +```tsx +export async function dispatchStartInstall( + dispatch: ReturnType>, + prepared: UpdatePrepareInstallResponse, + force: boolean +): Promise { + const result = await dispatch("updates.startInstall", { + targetVersion: prepared.latestVersion ?? prepared.targetVersion ?? undefined, + force, + }); + + if (!result.ok || !result.data) { + return null; + } + + return result.data; +} +``` + +Use it from both the footer rail and About settings only if necessary. Otherwise leave `git-status-bar.tsx` untouched. + +Modify `packages/web/src/styles/components.css`: + +```css +.workspace-status-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: var(--desktop-statusbar-height); + flex-shrink: 0; + border-top: 1px solid color-mix(in srgb, var(--border) 62%, transparent); + background: var(--bg-panel); +} + +.workspace-status-bar__left, +.workspace-status-bar__right { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.workspace-status-bar__left { + flex: 1 1 auto; +} + +.workspace-status-bar__right { + flex: 0 1 auto; + justify-content: flex-end; +} + +.git-panel-status-strip { + display: flex; + align-items: center; + min-height: 24px; + width: 100%; + padding: 0 12px; + color: var(--text-tertiary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); +} + +.git-panel-status-strip__left { + display: inline-flex; + align-items: center; + min-width: 0; + gap: 10px; +} + +.footer-update-rail { + display: inline-flex; + align-items: center; + min-width: 0; + gap: 8px; + color: var(--text-secondary); + font-size: var(--type-body-6-size); + line-height: var(--type-body-6-line-height); + font-weight: var(--type-body-6-weight); +} + +.footer-update-rail__text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.footer-update-rail__action { + flex-shrink: 0; +} + +.mobile-shell__bottom-stack .workspace-status-bar { + gap: 8px; +} + +.mobile-shell__bottom-stack .workspace-status-bar__right, +.mobile-sheet__footer .workspace-status-bar__right { + min-width: 0; + max-width: 48%; +} +``` + +- [ ] **Step 4: Run the layout tests and confirm GREEN** + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/git-panel-status-strip.test.tsx src/features/workspace/index.test.tsx src/shells/mobile-shell/index.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/features/workspace/views/shared/workspace-status-bar.tsx packages/web/src/features/workspace/views/shared/git-panel-status-strip.tsx packages/web/src/features/workspace/views/shared/git-panel-status-strip.test.tsx packages/web/src/features/workspace/index.test.tsx packages/web/src/shells/mobile-shell/index.test.tsx packages/web/src/styles/components.css +git commit -m "feat: move update status into shared footer" +``` + +--- + +### Task 3: Remove Toast and Topbar Badge Discovery Signals + +**Files:** +- Modify: `packages/web/src/app/providers.tsx` +- Modify: `packages/web/src/app/providers.lifecycle.test.tsx` +- Modify: `packages/web/src/features/updates/atoms.ts` +- Modify: `packages/web/src/features/topbar/index.tsx` +- Modify: `packages/web/src/features/topbar/index.test.tsx` + +- [ ] **Step 1: Write the failing tests for removing the old discovery signals** + +Modify `packages/web/src/app/providers.lifecycle.test.tsx` by replacing the toast test with: + +```tsx +it("does not show a toast when an update becomes available after connect", async () => { + const updateState: UpdateStateView = { + version: 1, + currentVersion: "0.4.0", + latestVersion: "0.5.0", + availability: "update_available", + updateStatus: "idle", + lastCheckedAt: 123, + targetVersion: null, + startedAt: null, + finishedAt: null, + requiresManualStep: false, + manualCommand: null, + errorSummary: null, + supported: true, + installKind: "global_npm", + unsupportedReason: null, + }; + wsState.client!.sendCommand = createWsSendCommandMock(async (op) => { + if (op === "updates.getState") { + return { + ...updateState, + latestVersion: null, + availability: "unknown" as const, + lastCheckedAt: null, + }; + } + return undefined; + }); + const store = createStore(); + setVisibilityState("visible"); + + renderProviders(store); + + await vi.waitFor(() => { + expect(wsState.client?.connect).toHaveBeenCalled(); + }); + + act(() => { + wsState.client?.statusHandler?.("connected"); + }); + + await vi.waitFor(() => { + expect(wsState.client?.sendCommand).toHaveBeenCalledWith("updates.getState", {}, undefined); + }); + + act(() => { + wsState.client?.eventHandler?.("update.state.changed", updateState, 1); + }); + + await vi.waitFor(() => { + expect(store.get(updateStateAtom)?.latestVersion).toBe("0.5.0"); + }); + + expect(store.get(toastsAtom)).toHaveLength(0); +}); +``` + +Modify `packages/web/src/features/topbar/index.test.tsx`: + +```tsx +it("keeps the settings entry plain when an update is available", () => { + const store = createStore(); + store.set(localeAtom, "en"); + store.set(workspacesLoadStateAtom, "ready"); + store.set(updateStateAtom, { + version: 1, + currentVersion: "0.4.0", + latestVersion: "0.5.0", + availability: "update_available", + updateStatus: "idle", + lastCheckedAt: 1, + targetVersion: null, + startedAt: null, + finishedAt: null, + requiresManualStep: false, + manualCommand: null, + errorSummary: null, + supported: true, + installKind: "global_npm", + unsupportedReason: null, + }); + + render( + + + + ); + + const settingsEntry = screen.getByTestId("settings-open"); + expect(settingsEntry.querySelector(".topbar-unread")).toBeNull(); +}); +``` + +- [ ] **Step 2: Run the old-discovery tests and confirm RED** + +```bash +pnpm --filter @coder-studio/web test -- src/app/providers.lifecycle.test.tsx src/features/topbar/index.test.tsx +``` + +Expected: FAIL because the toast subscription and topbar badge are still present. + +- [ ] **Step 3: Remove the toast and badge implementation** + +Modify `packages/web/src/app/providers.tsx` by deleting the `announcedUpdateVersionRef` bookkeeping and the `store.sub(updateStateAtom, ...)` effect that calls `pushToast`. + +Modify `packages/web/src/features/updates/atoms.ts`: + +```ts +import type { UpdatePrepareInstallResponse, UpdateStateView } from "@coder-studio/core"; +import { atom } from "jotai"; + +export const updateStateAtom = atom(null); +export const updatePrepareInstallAtom = atom(null); +``` + +Modify `packages/web/src/features/topbar/index.tsx`: + +```tsx +import { useAtom } from "jotai"; +// remove updateMarkerVisibleAtom import + +// remove updateMarkerVisible lookup + +} + onClick={() => navigate("/settings")} +/> +``` + +- [ ] **Step 4: Run the old-discovery tests and confirm GREEN** + +```bash +pnpm --filter @coder-studio/web test -- src/app/providers.lifecycle.test.tsx src/features/topbar/index.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/app/providers.tsx packages/web/src/app/providers.lifecycle.test.tsx packages/web/src/features/updates/atoms.ts packages/web/src/features/topbar/index.tsx packages/web/src/features/topbar/index.test.tsx +git commit -m "refactor: remove legacy update discovery signals" +``` + +--- + +### Task 4: Run Focused Regression Verification + +**Files:** +- No code changes required unless a regression appears + +- [ ] **Step 1: Run the full focused web regression set** + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/footer-update-rail.test.tsx src/features/workspace/views/shared/git-panel-status-strip.test.tsx src/features/workspace/index.test.tsx src/shells/mobile-shell/index.test.tsx src/app/providers.lifecycle.test.tsx src/features/topbar/index.test.tsx src/features/settings/components/settings-page.test.tsx +``` + +Expected: PASS with all targeted footer-update, settings, mobile-shell, topbar, and provider lifecycle tests green. + +- [ ] **Step 2: Run web type-adjacent smoke coverage via the package test suite slice** + +```bash +pnpm --filter @coder-studio/web test -- src/features/workspace/views/shared/git-status-bar.test.tsx src/features/settings/components/about-settings.test.tsx +``` + +Expected: PASS, confirming the reused update flow and existing Git footer actions remain intact. + +- [ ] **Step 3: Inspect the diff before branch completion** + +Run: + +```bash +git diff --stat +``` + +Expected: Only the planned footer-update-related files appear, plus no accidental staging of unrelated dirty files. + +- [ ] **Step 4: Commit any regression-fix follow-up if needed** + +If Step 1 or Step 2 required additional fixes: + +```bash +git add packages/web/src/features/workspace/views/shared/footer-update-rail.tsx packages/web/src/features/workspace/views/shared/footer-update-rail.test.tsx packages/web/src/features/workspace/views/shared/workspace-status-bar.tsx packages/web/src/features/workspace/views/shared/git-panel-status-strip.tsx packages/web/src/features/workspace/views/shared/git-panel-status-strip.test.tsx packages/web/src/features/workspace/index.test.tsx packages/web/src/shells/mobile-shell/index.test.tsx packages/web/src/app/providers.tsx packages/web/src/app/providers.lifecycle.test.tsx packages/web/src/features/updates/atoms.ts packages/web/src/features/topbar/index.tsx packages/web/src/features/topbar/index.test.tsx packages/web/src/features/settings/components/settings-page.tsx packages/web/src/features/settings/components/settings-page.test.tsx packages/web/src/locales/zh.json packages/web/src/locales/en.json packages/web/src/styles/components.css +git commit -m "test: finalize footer update rail coverage" +``` + +If no extra fixes were needed, skip this step. + +--- + +## Self-Review + +**Spec coverage:** This plan covers the footer right-side update entry, desktop/mobile shared footer normalization, reuse of existing update commands and confirmation dialog, success auto-hide after 3 seconds, removal of the update toast, removal of the topbar settings badge, and direct navigation to `Settings > About` for details states. + +**Placeholder scan:** No TODO/TBD placeholders remain. Each task includes concrete files, tests, commands, and minimal implementation snippets. + +**Type consistency:** The plan uses existing names from the current codebase: `updateStateAtom`, `updatePrepareInstallAtom`, `updates.prepareInstall`, `updates.startInstall`, `WorkspaceStatusBar`, `GitPanelStatusStrip`, and `SettingsPage`. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-22-footer-update-rail.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/plans/2026-05-23-mobile-workspace-three-view-alignment.md b/docs/superpowers/plans/2026-05-23-mobile-workspace-three-view-alignment.md new file mode 100644 index 00000000..f0497530 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-mobile-workspace-three-view-alignment.md @@ -0,0 +1,1179 @@ +# Mobile Workspace Three-View Alignment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Upgrade the mobile workspace resources sheet from `Files / Git` into `Explorer / Search / Source Control`, add `Open Editors` plus `Quick Jump` inside Explorer, and keep the existing segmented-tab interaction while aligning the tab icon semantics with desktop. + +**Architecture:** Keep the mobile sheet shell and detail-route model intact, but split the root resources surface into three views. Build a dedicated mobile Explorer composition layer from shared `Open Editors` and `Quick Jump` sections, reuse the existing `SearchPanel` logic with a mobile variant, and route all mobile file-opening entry points through `useOpenLocation` plus detail-route callbacks so root-sheet navigation and editor loading stay consistent. + +**Tech Stack:** React 19, Jotai, React Router, Vitest, Testing Library, `lucide-react`, existing websocket command dispatch, and shared styles in `packages/web/src/styles/components.css`. + +**Spec reference:** `docs/superpowers/specs/2026-05-23-mobile-workspace-three-view-alignment-design.md` + +**Git hygiene:** The worktree already contains unrelated user changes. Read files before patching them, stage only the files listed in each task, and never revert unrelated edits. + +--- + +## File Structure + +**New files:** +- `packages/web/src/features/workspace/views/shared/open-editors-section.tsx` — shared `Open Editors` section used by desktop Explorer and the new mobile Explorer +- `packages/web/src/features/workspace/views/shared/quick-jump-section.tsx` — filename/path jump section for mobile Explorer using `file.search` +- `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.tsx` — mobile Explorer composition layer that stacks `Open Editors`, `Quick Jump`, and the file tree +- `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` — coverage for mobile Explorer composition and `Quick Jump` + +**Modified files:** +- `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts` — export the shared mobile sidebar-view type +- `packages/web/src/features/workspace/views/shared/explorer-panel.tsx` — switch desktop Explorer to the shared `Open Editors` section +- `packages/web/src/features/workspace/views/shared/search-panel.tsx` — add a mobile variant and a callback for route-aware file opening +- `packages/web/src/features/workspace/views/shared/search-panel.test.tsx` — verify mobile variant behavior +- `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx` — replace two text tabs with three icon tabs and render `Explorer / Search / Git` +- `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` — cover the three-view mobile shell +- `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx` — hold the new mobile root-view state and title mapping +- `packages/web/src/styles/components.css` — mobile Explorer section styling, quick-jump rows, mobile Search variant, and icon-tab alignment +- `packages/web/src/styles/components.theme.test.ts` — lock the new icon-tab and shared surface selectors +- `packages/web/src/locales/en.json` — add `Quick Jump` copy +- `packages/web/src/locales/zh.json` — add `快速跳转` copy +- `packages/web/src/ui-preview/scenes/showcase-scenes.tsx` — keep the mobile resources preview scene aligned with the new props and default tab + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/search-panel.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/mobile/mobile-files-sheet.test.tsx src/features/workspace/index.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/ui-preview/catalog.test.tsx` + +--- + +### Task 1: Build Shared Explorer Sections And Mobile Explorer Composition + +**Files:** +- Create: `packages/web/src/features/workspace/views/shared/open-editors-section.tsx` +- Create: `packages/web/src/features/workspace/views/shared/quick-jump-section.tsx` +- Create: `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.tsx` +- Create: `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/explorer-panel.tsx` + +- [ ] **Step 1: Write the failing mobile Explorer composition test** + +Create `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx` with this coverage: + +```tsx +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { wsClientAtom } from "../../../../atoms/connection"; +import { + activeFilePathAtomFamily, + fileTreeAtomFamily, + openFilesAtomFamily, +} from "../../atoms"; +import { MobileExplorerPanel } from "./mobile-explorer-panel"; + +const fileTreePanelSpy = vi.fn(); + +vi.mock("../shared/file-tree-panel", () => ({ + FileTreePanel: (props: unknown) => { + fileTreePanelSpy(props); + return
; + }, +})); + +describe("MobileExplorerPanel", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + fileTreePanelSpy.mockReset(); + }); + + it("renders open editors, quick jump, and a file tree without the embedded tree search", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string, args: { query?: string }) => { + if (op === "file.search") { + return { + files: [ + { path: "README.md", name: "README.md", kind: "file" }, + { path: "src/mobile-files-sheet.tsx", name: "mobile-files-sheet.tsx", kind: "file" }, + ].filter((file) => + file.path.toLowerCase().includes((args.query ?? "").toLowerCase()) + ), + }; + } + + return { ok: true }; + }); + + const onSelectFile = vi.fn(); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + store.set(activeFilePathAtomFamily("ws-test"), "src/mobile-files-sheet.tsx"); + store.set(openFilesAtomFamily("ws-test"), { + "README.md": { + kind: "text", + path: "README.md", + content: "# docs", + savedContent: "# docs", + baseHash: "base-readme", + isDirty: false, + }, + "src/mobile-files-sheet.tsx": { + kind: "text", + path: "src/mobile-files-sheet.tsx", + content: "export function MobileFilesSheet() {}\n", + savedContent: "export function MobileFilesSheet() {}\n", + baseHash: "base-mobile-files-sheet", + isDirty: false, + }, + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "README.md" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "src/mobile-files-sheet.tsx" })).toHaveClass( + "workspace-open-editors__item--active" + ); + expect(screen.getByRole("searchbox", { name: /Quick Jump|快速跳转/i })).toBeInTheDocument(); + expect(screen.queryByRole("searchbox", { name: /Search Files|搜索文件/i })).toBeNull(); + expect(fileTreePanelSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "mobile", + showSearch: false, + }) + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Quick Jump|快速跳转/i }), { + target: { value: "read" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + fireEvent.click(await screen.findByRole("button", { name: /README\.md/i })); + + expect(onSelectFile).toHaveBeenCalledWith("README.md"); + }); +}); +``` + +- [ ] **Step 2: Run the focused test to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx +``` + +Expected: +- FAIL because `MobileExplorerPanel` does not exist yet +- FAIL because there is no `Quick Jump` section or shared `Open Editors` component + +- [ ] **Step 3: Implement the shared sections and mobile Explorer panel** + +Create `packages/web/src/features/workspace/views/shared/open-editors-section.tsx`: + +```tsx +import { useAtomValue, useSetAtom } from "jotai"; +import type { FC } from "react"; +import { useTranslation } from "../../../../lib/i18n"; +import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; +import { + activeFilePathAtomFamily, + deriveEditorModeForPath, + editorModeAtomFamily, + openFilesAtomFamily, +} from "../../atoms"; + +interface OpenEditorsSectionProps { + workspaceId: string; + onSelectFile?: (path: string) => void; + title?: string; +} + +export const OpenEditorsSection: FC = ({ + workspaceId, + onSelectFile, + title, +}) => { + const t = useTranslation(); + const openFiles = useAtomValue(openFilesAtomFamily(workspaceId)); + const activeFilePath = useAtomValue(activeFilePathAtomFamily(workspaceId)); + const setEditorMode = useSetAtom(editorModeAtomFamily(workspaceId)); + const { openLocation } = useOpenLocation(workspaceId); + const openEditorPaths = Object.keys(openFiles).sort((left, right) => left.localeCompare(right)); + + return ( +
+

+ {title ?? t("workspace.sidebar.open_editors")} +

+
+ {openEditorPaths.map((path) => ( + + ))} +
+
+ ); +}; +``` + +Create `packages/web/src/features/workspace/views/shared/quick-jump-section.tsx`: + +```tsx +import type { FileNode } from "@coder-studio/core"; +import { useAtomValue, useSetAtom } from "jotai"; +import { ThemedIcon } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { useEffect, useRef, useState } from "react"; +import { dispatchCommandAtom } from "../../../../atoms/connection"; +import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; +import { + deriveEditorModeForPath, + editorModeAtomFamily, +} from "../../atoms"; + +interface SearchFilesResult { + files: FileNode[]; +} + +interface QuickJumpSectionProps { + workspaceId: string; + onSelectFile?: (path: string) => void; +} + +export function QuickJumpSection({ workspaceId, onSelectFile }: QuickJumpSectionProps) { + const t = useTranslation(); + const dispatch = useAtomValue(dispatchCommandAtom); + const setEditorMode = useSetAtom(editorModeAtomFamily(workspaceId)); + const { openLocation } = useOpenLocation(workspaceId); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(false); + const requestIdRef = useRef(0); + const hasQuery = query.trim().length > 0; + + useEffect(() => { + const trimmed = query.trim(); + if (!trimmed) { + setResults([]); + setLoading(false); + setFailed(false); + return; + } + + let cancelled = false; + setLoading(true); + setFailed(false); + const requestId = ++requestIdRef.current; + + const timeout = window.setTimeout(() => { + void dispatch("file.search", { + workspaceId, + query: trimmed, + limit: 10, + }) + .then((result) => { + if (cancelled || requestId !== requestIdRef.current) { + return; + } + + if (!result.ok || !result.data) { + setResults([]); + setFailed(true); + return; + } + + setResults(result.data.files); + }) + .catch(() => { + if (!cancelled) { + setResults([]); + setFailed(true); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + }, 150); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [dispatch, query, workspaceId]); + + return ( +
+

{t("workspace.quick_jump.title")}

+ + + {hasQuery ? ( +
+ {loading ? ( +

{t("common.loading")}

+ ) : failed ? ( +

{t("workspace.quick_jump.failed")}

+ ) : results.length === 0 ? ( +

{t("workspace.quick_jump.no_results")}

+ ) : ( + results.map((file) => ( + + )) + )} +
+ ) : null} +
+ ); +} +``` + +Create `packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.tsx`: + +```tsx +import { useTranslation } from "../../../../lib/i18n"; +import type { CreateRequest } from "../../actions/use-file-actions"; +import { FileTreePanel } from "../shared/file-tree-panel"; +import { OpenEditorsSection } from "../shared/open-editors-section"; +import { QuickJumpSection } from "../shared/quick-jump-section"; + +interface MobileExplorerPanelProps { + workspaceId: string; + createRequest?: CreateRequest | null; + onCreateRequestConsumed?: () => void; + collapseVersion?: number; + routeToDetail: (path: string) => void; +} + +export function MobileExplorerPanel({ + workspaceId, + createRequest = null, + onCreateRequestConsumed, + collapseVersion = 0, + routeToDetail, +}: MobileExplorerPanelProps) { + const t = useTranslation(); + + return ( +
+ + +
+

{t("workspace.sidebar.workspace")}

+ +
+
+ ); +} +``` + +Update `packages/web/src/features/workspace/views/shared/explorer-panel.tsx` to use `OpenEditorsSection`: + +```diff +-import { useAtomValue } from "jotai"; + import { ChevronsUp } from "lucide-react"; + import type { FC } from "react"; + import { useState } from "react"; + import { IconButton, ThemedIcon, Tooltip } from "../../../../components/ui"; + import { useTranslation } from "../../../../lib/i18n"; +-import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; + import { PanelHeader } from "../../../shared/components/panel-header"; + import type { WorkspaceCreateRequest } from "../../actions/use-workspace-screen-model"; +-import { activeFilePathAtomFamily, openFilesAtomFamily } from "../../atoms"; + import { FileTreePanel } from "./file-tree-panel"; ++import { OpenEditorsSection } from "./open-editors-section"; + + ... + +-
+-

+- {t("workspace.sidebar.open_editors")} +-

+-
+- {openEditorPaths.map((path) => ( +- +- ))} +-
+-
++ +``` + +- [ ] **Step 4: Run the Explorer tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx +``` + +Expected: +- PASS +- `MobileExplorerPanel` renders `Open Editors`, `Quick Jump`, and the tree with `showSearch={false}` + +- [ ] **Step 5: Commit the Explorer section work** + +```bash +git add \ + packages/web/src/features/workspace/views/shared/open-editors-section.tsx \ + packages/web/src/features/workspace/views/shared/quick-jump-section.tsx \ + packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.tsx \ + packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx \ + packages/web/src/features/workspace/views/shared/explorer-panel.tsx +git commit -m "feat: add mobile explorer sections" +``` + +--- + +### Task 2: Reuse SearchPanel In Mobile Sheet Mode + +**Files:** +- Modify: `packages/web/src/features/workspace/views/shared/search-panel.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/search-panel.test.tsx` + +- [ ] **Step 1: Write the failing mobile SearchPanel variant test** + +Add this test to `packages/web/src/features/workspace/views/shared/search-panel.test.tsx`: + +```tsx + it("renders a mobile variant without the desktop header and still opens the selected match", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + const onSelectFile = vi.fn(); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + expect(screen.queryByRole("heading", { name: /Search|搜索/i })).toBeNull(); + + await searchFor("needle"); + fireEvent.click(screen.getByRole("button", { name: /12.*needle/i })); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + expect(onSelectFile).toHaveBeenCalledWith("src/app.tsx"); + }); +``` + +- [ ] **Step 2: Run the focused test to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/search-panel.test.tsx +``` + +Expected: +- FAIL because `SearchPanel` does not accept `variant` or `onSelectFile` +- FAIL because the desktop `PanelHeader` is still always rendered + +- [ ] **Step 3: Implement the mobile SearchPanel variant** + +Update `packages/web/src/features/workspace/views/shared/search-panel.tsx`: + +```diff +-import { useAtomValue } from "jotai"; ++import { useAtomValue, useSetAtom } from "jotai"; + ... + import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; ++import { deriveEditorModeForPath, editorModeAtomFamily } from "../../atoms"; + import { PanelHeader } from "../../../shared/components/panel-header"; + + interface SearchPanelProps { + workspaceId: string; ++ variant?: "desktop" | "mobile"; ++ onSelectFile?: (path: string) => void; + } + +-export const SearchPanel: FC = ({ workspaceId }) => { ++export const SearchPanel: FC = ({ ++ workspaceId, ++ variant = "desktop", ++ onSelectFile, ++}) => { + const t = useTranslation(); + const dispatch = useAtomValue(dispatchCommandAtom); + const { openLocation } = useOpenLocation(workspaceId); ++ const setEditorMode = useSetAtom(editorModeAtomFamily(workspaceId)); + ... + ++ const openMatch = (path: string, line: number, column: number, endColumn: number) => { ++ setEditorMode(deriveEditorModeForPath(path)); ++ void openLocation({ ++ workspaceId, ++ path, ++ line, ++ column, ++ endColumn, ++ source: "search", ++ }); ++ onSelectFile?.(path); ++ }; ++ + return ( +-
+- ++
++ {variant === "desktop" ? : null} + +
+ +- void openLocation({ +- workspaceId, +- path: file.path, +- line: match.line, +- column: match.column, +- endColumn: match.endColumn, +- source: "search", +- }) +- } ++ onClick={() => ++ openMatch(file.path, match.line, match.column, match.endColumn) ++ } + > +``` + +- [ ] **Step 4: Run the SearchPanel tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/search-panel.test.tsx +``` + +Expected: +- PASS +- Desktop behavior unchanged +- Mobile variant omits the desktop heading but still updates `activeFilePath` and calls `onSelectFile` + +- [ ] **Step 5: Commit the SearchPanel work** + +```bash +git add \ + packages/web/src/features/workspace/views/shared/search-panel.tsx \ + packages/web/src/features/workspace/views/shared/search-panel.test.tsx +git commit -m "feat: add mobile search panel variant" +``` + +--- + +### Task 3: Wire Mobile Files Sheet To Explorer, Search, And Source Control + +**Files:** +- Modify: `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts` +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx` +- Modify: `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` +- Modify: `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx` +- Modify: `packages/web/src/ui-preview/scenes/showcase-scenes.tsx` + +- [ ] **Step 1: Write the failing three-view mobile sheet tests** + +Update `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx` to use `Explorer / Search / Git` and mobile stubs: + +```tsx +vi.mock("./mobile-explorer-panel", () => ({ + MobileExplorerPanel: () =>
, +})); + +vi.mock("../shared/search-panel", () => ({ + SearchPanel: ({ variant }: { variant?: string }) => ( +
+ ), +})); + +it("renders three icon tabs and keeps explorer actions scoped to the explorer view", async () => { + render( + + + + ); + + expect(screen.getByRole("tab", { name: /Explorer|资源管理器/i })).toHaveClass( + "mobile-files-sheet__segment", + "active" + ); + expect(screen.getByRole("tab", { name: /Search|搜索/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Source Control|源代码管理/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "New File" })).toBeInTheDocument(); + expect(screen.getByTestId("mobile-explorer-panel")).toBeInTheDocument(); +}); + +it("renders the mobile search panel without explorer actions when Search is active", () => { + render( + + + + ); + + expect(screen.queryByRole("button", { name: "New File" })).toBeNull(); + expect(screen.getByTestId("search-panel")).toHaveAttribute("data-variant", "mobile"); +}); + +it("keeps Git preview routing on the third tab", () => { + const handleRouteChange = vi.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "git-panel" })); + + expect(handleRouteChange).toHaveBeenCalledWith({ + kind: "detail", + path: "abc123", + title: "abc123 · commit subject", + }); +}); +``` + +- [ ] **Step 2: Run the mobile sheet tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/mobile/mobile-files-sheet.test.tsx +``` + +Expected: +- FAIL because `MobileFilesSheet` still accepts `"files" | "git"` +- FAIL because Search is not a first-class root view +- FAIL because tabs still render text labels instead of icon-only controls with aria labels + +- [ ] **Step 3: Implement the three-view mobile resources shell** + +In `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts`, export the shared view union: + +```diff +export type MobileWorkspaceSidebarView = "explorer" | "search" | "source-control"; + + export type WorkspaceMainAreaMode = "agent" | "editor"; + export type MobileWorkspaceSheetKind = "files" | "terminal" | "supervisor" | null; + export type MobileFilesRoute = +``` + +Update `packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx`: + +```diff +-import { ChevronsUp } from "lucide-react"; ++import { ChevronsUp, FolderTree, GitBranch, Search } from "lucide-react"; + import { IconButton, Tab, TabList, Tabs, ThemedIcon, Tooltip } from "../../../../components/ui"; + import { useTranslation } from "../../../../lib/i18n"; + ... +-import type { MobileFilesRoute } from "../../actions/use-workspace-screen-model"; ++import type { ++ MobileFilesRoute, ++ MobileWorkspaceSidebarView, ++} from "../../actions/use-workspace-screen-model"; + import type { GitDiffPreview } from "../../atoms"; +-import { FileTreePanel } from "../shared/file-tree-panel"; + import { GitPanel } from "../shared/git-panel"; ++import { SearchPanel } from "../shared/search-panel"; ++import { MobileExplorerPanel } from "./mobile-explorer-panel"; + + interface MobileFilesSheetProps { + workspaceId: string; + route: MobileFilesRoute; +- activeTab: "files" | "git"; ++ activeView: MobileWorkspaceSidebarView; + ... +- onTabChange?: (tab: "files" | "git") => void; ++ onTabChange?: (tab: MobileWorkspaceSidebarView) => void; + } + ++const mobileSheetTabs = [ ++ { value: "explorer" as const, labelKey: "workspace.sidebar.explorer", icon: FolderTree }, ++ { value: "search" as const, labelKey: "workspace.sidebar.search", icon: Search }, ++ { ++ value: "source-control" as const, ++ labelKey: "workspace.sidebar.source_control", ++ icon: GitBranch, ++ }, ++]; ++ + export function MobileFilesSheet({ + workspaceId, + route, +- activeTab, ++ activeView, + ... +
+ onTabChange?.(tab as "files" | "git")} +- value={activeTab} ++ onValueChange={(tab) => onTabChange?.(tab as MobileWorkspaceSidebarView)} ++ value={activeView} + > + +- +- {t("file.title")} +- +- +- {t("label.git")} +- ++ {mobileSheetTabs.map(({ value, labelKey, icon: Icon }) => ( ++ ++ ++ ))} + + + +- {activeTab === "files" ? ( ++ {activeView === "explorer" ? ( +
+ ... +
+- {activeTab === "files" ? ( +- onRouteChange?.({ kind: "detail", path })} ++ routeToDetail={(path) => onRouteChange?.({ kind: "detail", path })} + collapseVersion={collapseVersion} +- variant="mobile" + /> ++ ) : activeView === "search" ? ( ++ onRouteChange?.({ kind: "detail", path })} ++ /> + ) : ( + + )} +``` + +Update `packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx`: + +```diff ++import type { MobileWorkspaceSidebarView } from "../../actions/use-workspace-screen-model"; + ... +- const [mobileFilesTab, setMobileFilesTab] = useState<"files" | "git">("files"); ++ const [mobileFilesView, setMobileFilesView] = useState("explorer"); + ... + title: + mobileFilesRoute.kind === "detail" + ? (mobileFilesRoute.title ?? + mobileFilesRoute.path?.split("/").pop() ?? + t("mobile.files.editor_fallback")) +- : mobileFilesTab === "files" +- ? t("file.title") +- : t("label.git"), ++ : mobileFilesView === "explorer" ++ ? t("workspace.sidebar.explorer") ++ : mobileFilesView === "search" ++ ? t("workspace.sidebar.search") ++ : t("workspace.sidebar.source_control"), + ... +- activeTab={mobileFilesTab} ++ activeView={mobileFilesView} + ... +- onTabChange={setMobileFilesTab} ++ onTabChange={setMobileFilesView} +``` + +Update the mobile preview scene in `packages/web/src/ui-preview/scenes/showcase-scenes.tsx`: + +```diff +- title="Files" ++ title="Explorer" + ... + +``` + +- [ ] **Step 4: Run the mobile sheet regression tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/mobile/mobile-files-sheet.test.tsx \ + src/features/workspace/index.test.tsx +``` + +Expected: +- PASS +- Mobile root sheet now exposes `Explorer / Search / Source Control` +- Explorer actions render only on the Explorer tab + +- [ ] **Step 5: Commit the mobile shell wiring** + +```bash +git add \ + packages/web/src/features/workspace/actions/use-workspace-screen-model.ts \ + packages/web/src/features/workspace/views/mobile/mobile-files-sheet.tsx \ + packages/web/src/features/workspace/views/mobile/mobile-files-sheet.test.tsx \ + packages/web/src/features/workspace/views/mobile/workspace-mobile-view.tsx \ + packages/web/src/ui-preview/scenes/showcase-scenes.tsx +git commit -m "feat: align mobile workspace views with desktop" +``` + +--- + +### Task 4: Add Copy, Styling, And Theme Regression Coverage + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing style and copy assertions** + +Add these assertions to `packages/web/src/styles/components.theme.test.ts` near the existing mobile files checks: + +```tsx + const mobileFilesSegmentIcon = getLastRuleBlock(".mobile-files-sheet__segment-icon"); + const mobileExplorerPanel = getLastRuleBlock(".mobile-explorer-panel"); + const mobileQuickJumpSearch = getLastRuleBlock(".workspace-quick-jump__search"); + const mobileQuickJumpItem = getLastRuleBlock(".workspace-quick-jump__item"); + const mobileSearchPanel = getLastRuleBlock(".workspace-search-panel--mobile"); + + expect(mobileFilesSegment).toContain("justify-content: center"); + expect(mobileFilesSegment).toContain("min-width: 32px"); + expect(mobileFilesSegmentIcon).toContain("display: block"); + expect(mobileExplorerPanel).toContain("display: flex"); + expect(mobileExplorerPanel).toContain("flex-direction: column"); + expect(mobileQuickJumpSearch).toContain("border: 1px solid"); + expect(mobileQuickJumpItem).toContain("grid-template-columns: minmax(0, 1fr)"); + expect(mobileSearchPanel).toContain("background: transparent"); +``` + +Add a new `quick_jump` object under the existing `workspace` section in both locale files. + +- [ ] **Step 2: Run the style test to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/styles/components.theme.test.ts +``` + +Expected: +- FAIL because the new selectors do not exist yet + +- [ ] **Step 3: Implement the styling and localized copy** + +Update `packages/web/src/styles/components.css` with the new mobile resources selectors: + +```css +.mobile-files-sheet__segment { + justify-content: center; + min-width: 32px; +} + +.mobile-files-sheet__segment-icon { + display: block; + flex-shrink: 0; +} + +.mobile-explorer-panel { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.workspace-quick-jump { + padding-bottom: var(--sp-3); +} + +.workspace-quick-jump__search { + display: flex; + align-items: center; + gap: var(--gap-default); + min-height: 34px; + padding: 0 10px; + border: 1px solid color-mix(in srgb, var(--border) 84%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--bg-surface) 92%, var(--bg-page)); +} + +.workspace-quick-jump__input { + min-width: 0; + flex: 1; + border: none; + background: transparent; + color: var(--text-primary); +} + +.workspace-quick-jump__results { + display: flex; + flex-direction: column; + padding-top: var(--sp-2); +} + +.workspace-quick-jump__item { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 2px; + width: 100%; + min-height: 40px; + padding: 8px 0; + border: none; + background: transparent; + color: inherit; + text-align: left; +} + +.workspace-quick-jump__primary { + color: var(--text-primary); + font-size: var(--type-body-3-size); +} + +.workspace-quick-jump__secondary { + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: var(--type-body-6-size); +} + +.workspace-quick-jump__state { + margin: 0; + padding-top: var(--sp-2); + color: var(--text-tertiary); + font-size: var(--type-body-5-size); +} + +.workspace-search-panel--mobile { + background: transparent; +} + +.workspace-search-panel--mobile .workspace-search-panel__controls { + padding-top: 0; +} +``` + +Add the `quick_jump` object under the existing `workspace` section in both locale files: + +```diff + "sidebar": { + "label": "Workspace activity bar", + "explorer": "Explorer", + "search": "Search", + "source_control": "Source Control", + "open_editors": "Open Editors", + "workspace": "Workspace" + }, ++ "quick_jump": { ++ "title": "Quick Jump", ++ "placeholder": "Type a filename or path", ++ "no_results": "No matching files found.", ++ "failed": "File search failed. Try again." ++ }, + "search": { +``` + +and: + +```diff + "sidebar": { + "label": "工作区活动栏", + "explorer": "资源管理器", + "search": "搜索", + "source_control": "源代码管理", + "open_editors": "打开的编辑器", + "workspace": "工作区" + }, ++ "quick_jump": { ++ "title": "快速跳转", ++ "placeholder": "输入文件名或路径", ++ "no_results": "未找到匹配文件。", ++ "failed": "文件搜索失败,请重试。" ++ }, + "search": { +``` + +- [ ] **Step 4: Run the style and preview regressions** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/styles/components.theme.test.ts \ + src/ui-preview/catalog.test.tsx +``` + +Expected: +- PASS +- Theme checks lock the icon-tab and quick-jump styling hooks +- UI preview scene still renders with the new prop shape + +- [ ] **Step 5: Commit the styling and copy updates** + +```bash +git add \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "style: polish mobile workspace three-view sheet" +``` + +--- + +## Self-Review + +### Spec coverage + +- `Explorer / Search / Git` three-view mobile root model: Task 3 +- keep segmented-tab interaction: Task 3 + Task 4 +- icon-only tabs aligned with desktop semantics: Task 3 + Task 4 +- `Open Editors` inside mobile Explorer: Task 1 +- rename file-name search to `Quick Jump`: Task 1 + Task 4 +- `Search` as file-content search aligned with desktop: Task 2 + Task 3 +- keep Git as a separate third view: Task 3 +- keep detail route model intact: Task 2 + Task 3 + +No spec gaps found. + +### Placeholder scan + +- Searched for `TODO`, `TBD`, and vague hand-offs while writing this file. +- No placeholder implementation steps remain. + +### Type consistency + +- Root mobile view union is consistently `MobileWorkspaceSidebarView = "explorer" | "search" | "source-control"`. +- Mobile sheet prop uses `activeView`, not the old `"files" | "git"` union. +- Route callback naming is consistently `onSelectFile` / `routeToDetail`. + +No type-name drift remains. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-23-mobile-workspace-three-view-alignment.md`. + +Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/plans/2026-05-23-supervisor-refresh-hydration.md b/docs/superpowers/plans/2026-05-23-supervisor-refresh-hydration.md new file mode 100644 index 00000000..bb9314d2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-supervisor-refresh-hydration.md @@ -0,0 +1,443 @@ +# Supervisor Refresh Hydration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ensure an existing in-memory supervisor reappears after a web refresh or websocket reconnect without changing the current design that server restarts do not restore supervisor runtime. + +**Architecture:** Reintroduce client-side supervisor hydration as an explicit read of the server's current runtime snapshot via `supervisor.get`. Trigger hydration per mounted full-capability session when the client is connected, dedupe it with a per-session hydration marker, and clear that marker when the websocket reconnects or the server instance changes so the next connected state re-fetches authoritative runtime state. + +**Tech Stack:** React, Jotai, Vitest, existing websocket command/event routing + +--- + +## File Structure + +- Modify: `packages/web/src/features/supervisor/actions/use-supervisor.ts` + - Expand the hook from dialog-only behavior into dialog + runtime hydration behavior. +- Modify: `packages/web/src/features/supervisor/atoms.ts` + - Keep the existing per-session hydration marker and add any minimal reset helper atom only if needed. +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` + - Replace the current "does not hydrate" expectation with refresh-hydration expectations. +- Modify: `packages/web/src/app/providers.lifecycle.test.tsx` + - Add a reconnect/server-instance regression test that proves hydration markers reset after reconnect. +- Modify: `packages/web/src/app/providers.tsx` + - Reset supervisor hydration markers when connection state/server identity indicates the client must re-fetch runtime snapshot. +- Optional modify: `packages/web/src/app/providers.test.tsx` + - Add atom-routing level coverage if a helper/reset path is implemented there. + +## Semantics To Preserve + +- `supervisor.get` is for client refresh/reconnect hydration against the current live server runtime. +- `supervisor.get` is **not** the mechanism for server restart runtime recovery. +- A server restart may still legitimately result in no active supervisor runtime; the UI should then reflect that after hydration returns `null`. +- Existing push-driven `supervisor.state` updates remain the primary live update path; hydration only fills the cold-start / reconnect gap. + +### Task 1: Lock Down Expected Behavior In SessionCard Tests + +**Files:** +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` + +- [ ] **Step 1: Write the failing test for initial hydration** + +Add a test near the existing supervisor coverage that renders a running full-capability session and expects a `supervisor.get` command on mount: + +```tsx + it("hydrates supervisor state via supervisor.get when a full session card mounts", async () => { + const { store, sendCommand } = createSessionStore({ + state: "running", + capability: "full", + endedAt: undefined, + terminalId: "term-live", + }); + + sendCommand.mockImplementation(async (op: string) => { + if (op === "supervisor.get") { + return { supervisor: null }; + } + + return undefined; + }); + + render( + + + + ); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "supervisor.get", + { sessionId: "sess_123456" }, + undefined + ); + }); + }); +``` + +- [ ] **Step 2: Write the failing test for deduped hydration** + +Add a second test proving a rerender does not spam `supervisor.get` once the session is marked hydrated: + +```tsx + it("hydrates a mounted session only once per hydration cycle", async () => { + const { store, sendCommand } = createSessionStore({ + state: "running", + capability: "full", + endedAt: undefined, + terminalId: "term-live", + }); + + sendCommand.mockImplementation(async (op: string) => { + if (op === "supervisor.get") { + return { supervisor: null }; + } + + return undefined; + }); + + const view = render( + + + + ); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledTimes(1); + }); + + view.rerender( + + + + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(sendCommand).toHaveBeenCalledTimes(1); + }); +``` + +- [ ] **Step 3: Run the focused test file to verify failure** + +Run: `pnpm --filter web test -- packages/web/src/features/agent-panes/components/session-card.test.tsx` + +Expected: FAIL because the current implementation explicitly does not call `supervisor.get`. + +- [ ] **Step 4: Commit the failing-test checkpoint** + +```bash +git add packages/web/src/features/agent-panes/components/session-card.test.tsx +git commit -m "test: capture supervisor refresh hydration regression" +``` + +### Task 2: Implement Client-Side Supervisor Hydration + +**Files:** +- Modify: `packages/web/src/features/supervisor/actions/use-supervisor.ts` +- Modify: `packages/web/src/features/supervisor/atoms.ts` + +- [ ] **Step 1: Implement hydration-aware useSupervisor hook** + +Update `useSupervisor.ts` so the hook: +- reads `connectionStatusAtom`, `dispatchCommandAtom`, `supervisorsAtom`, and `supervisorHydratedAtomFamily(sessionId)` +- only hydrates when the session exists, is `capability === "full"`, is not `draft`/`ended`, and connection is `connected` +- marks the session as hydrated before/around the request to prevent duplicate in-flight requests +- writes the returned supervisor into `supervisorsAtom` when non-null +- removes any stale entry for that session when the response returns `null` +- keeps the existing dialog API unchanged + +Target shape: + +```ts +import type { Session, Supervisor } from "@coder-studio/core"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect } from "react"; +import { connectionStatusAtom, dispatchCommandAtom } from "../../../atoms/connection"; +import { supervisorDialogAtom, supervisorHydratedAtomFamily, supervisorsAtom } from "../atoms"; +import { formatScheduledAtInput } from "./use-objective-dialog-state"; + +const EMPTY_SESSION_ID = "__supervisor-empty__"; + +export function useSupervisor(session: Session | null | undefined) { + const sessionId = session?.id ?? EMPTY_SESSION_ID; + const connectionStatus = useAtomValue(connectionStatusAtom); + const dispatch = useAtomValue(dispatchCommandAtom); + const hydrated = useAtomValue(supervisorHydratedAtomFamily(sessionId)); + const setHydrated = useSetAtom(supervisorHydratedAtomFamily(sessionId)); + const setSupervisors = useSetAtom(supervisorsAtom); + const setDialog = useSetAtom(supervisorDialogAtom); + + useEffect(() => { + if (!session) { + return; + } + if (session.capability !== "full") { + return; + } + if (session.state === "draft" || session.state === "ended") { + return; + } + if (connectionStatus !== "connected" || hydrated) { + return; + } + + let cancelled = false; + setHydrated(true); + + void dispatch<{ supervisor: Supervisor | null }>("supervisor.get", { sessionId: session.id }) + .then((result) => { + if (cancelled || !result.ok) { + return; + } + + setSupervisors((prev) => { + const next = new Map(prev); + if (result.data?.supervisor) { + next.set(session.id, result.data.supervisor); + } else { + next.delete(session.id); + } + return next; + }); + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [connectionStatus, dispatch, hydrated, session, setHydrated, setSupervisors]); + + // existing openDialog callback remains +} +``` + +- [ ] **Step 2: Keep atom changes minimal** + +If `supervisorHydratedAtomFamily` is sufficient as-is, do not introduce new atom structures. Only adjust `packages/web/src/features/supervisor/atoms.ts` if you need a tiny helper such as a resettable atom family export or a documented comment update: + +```ts +// Tracks whether the current client connection has already fetched supervisor.get +// for this session. Reset when reconnecting to the server. +export const supervisorHydratedAtomFamily = atomFamily((_sessionId: string) => atom(false)); +``` + +- [ ] **Step 3: Run the focused SessionCard tests to verify green** + +Run: `pnpm --filter web test -- packages/web/src/features/agent-panes/components/session-card.test.tsx` + +Expected: PASS with the new hydration expectations. + +- [ ] **Step 4: Commit the implementation checkpoint** + +```bash +git add packages/web/src/features/supervisor/actions/use-supervisor.ts packages/web/src/features/supervisor/atoms.ts packages/web/src/features/agent-panes/components/session-card.test.tsx +git commit -m "fix: hydrate supervisor state after refresh" +``` + +### Task 3: Reset Hydration On Reconnect / Server Identity Change + +**Files:** +- Modify: `packages/web/src/app/providers.tsx` +- Modify: `packages/web/src/app/providers.lifecycle.test.tsx` + +- [ ] **Step 1: Write the failing lifecycle regression test** + +Add a lifecycle test proving that after one successful hydration cycle, a reconnect or server instance replacement resets the hydration marker so a subsequent mounted session can fetch again. + +Suggested test pattern: + +```tsx + it("clears supervisor hydration markers after reconnect so sessions can rehydrate", async () => { + const store = createStore(); + const sendCommand = createWsSendCommandMock(async (op) => { + if (op === "supervisor.get") { + return { supervisor: null }; + } + return undefined; + }); + + wsState.client = { + ...wsState.client!, + sendCommand, + }; + + renderProviders(store); + + act(() => { + store.set(connectionStatusAtom, "connected"); + store.set(sessionsAtom, { + "sess-1": { + id: "sess-1", + workspaceId: "ws-1", + terminalId: "term-1", + providerId: "codex", + state: "running", + capability: "full", + startedAt: 1, + lastActiveAt: 1, + }, + }); + }); + + // mount path that calls useSupervisor, or directly render SessionCard under Provider + // assert first hydration call + + act(() => { + wsState.client?.statusHandler?.("reconnecting"); + wsState.client?.eventHandler?.( + "connection.status", + { + status: "connected", + version: "0.4.0", + serverInstanceId: "server-2", + authEnabled: false, + }, + 1 + ); + wsState.client?.statusHandler?.("connected"); + }); + + // remount or trigger effect again and assert supervisor.get is called a second time + }); +``` + +If the existing lifecycle harness is awkward for `SessionCard`, render a minimal `` inside this file after the provider setup. The core assertion is that reconnect resets the per-session hydration gate. + +- [ ] **Step 2: Run the lifecycle test to confirm it fails** + +Run: `pnpm --filter web test -- packages/web/src/app/providers.lifecycle.test.tsx` + +Expected: FAIL because hydration markers currently never reset. + +- [ ] **Step 3: Implement hydration reset in AppProviders** + +In `packages/web/src/app/providers.tsx`, add a small effect that observes connection lifecycle/server metadata and clears the per-session hydration marker when the client must distrust previous hydration: + +- track the last connected `serverInstanceId` +- when connection transitions away from `connected`, or when a new `connected` metadata event carries a different `serverInstanceId`, set every currently known session's `supervisorHydratedAtomFamily(sessionId)` back to `false` +- do **not** clear `supervisorsAtom` eagerly; let `supervisor.get` refresh authoritative state and remove stale entries if the runtime no longer exists + +One acceptable implementation shape: + +```ts +import { supervisorHydratedAtomFamily } from "../features/supervisor/atoms"; + +const lastSupervisorHydrationServerIdRef = useRef(null); + +useEffect(() => { + if (connectionStatus !== "connected") { + for (const session of Object.values(store.get(sessionsAtom))) { + store.set(supervisorHydratedAtomFamily(session.id), false); + } + lastSupervisorHydrationServerIdRef.current = null; + return; + } + + const currentServerId = store.get(serverInfoAtom)?.serverInstanceId ?? null; + const previousServerId = lastSupervisorHydrationServerIdRef.current; + if (previousServerId && currentServerId && previousServerId !== currentServerId) { + for (const session of Object.values(store.get(sessionsAtom))) { + store.set(supervisorHydratedAtomFamily(session.id), false); + } + } + + if (currentServerId) { + lastSupervisorHydrationServerIdRef.current = currentServerId; + } +}, [connectionStatus, store, sessions]); +``` + +Refine the exact dependency shape to avoid re-running on every session mutation; the important part is deterministic reset on disconnect/reconnect/server replacement, not per-render churn. + +- [ ] **Step 4: Run lifecycle tests to verify green** + +Run: `pnpm --filter web test -- packages/web/src/app/providers.lifecycle.test.tsx` + +Expected: PASS, proving reconnect/server replacement opens a new hydration cycle. + +- [ ] **Step 5: Commit the reconnect-reset checkpoint** + +```bash +git add packages/web/src/app/providers.tsx packages/web/src/app/providers.lifecycle.test.tsx +git commit -m "fix: rehydrate supervisor state after reconnect" +``` + +### Task 4: Regression Verification + +**Files:** +- Verify only + +- [ ] **Step 1: Run the targeted supervisor-related unit suite** + +Run: + +```bash +pnpm --filter web test -- \ + packages/web/src/features/agent-panes/components/session-card.test.tsx \ + packages/web/src/app/providers.lifecycle.test.tsx \ + packages/web/src/features/supervisor/components/supervisor-card.test.tsx \ + packages/web/src/features/supervisor/views/mobile/mobile-supervisor-sheet.test.tsx +``` + +Expected: PASS + +- [ ] **Step 2: Run the websocket resync/server tests to confirm no accidental contract change** + +Run: + +```bash +pnpm --filter server test -- packages/server/src/__tests__/ws-hub.test.ts +``` + +Expected: PASS, with no requirement to emit `supervisor.state` during resync. + +- [ ] **Step 3: Optional higher-confidence browser regression** + +Run if time permits: + +```bash +pnpm --filter e2e test -- e2e/specs/supervisor/lifecycle.spec.ts +``` + +Expected: PASS. If no dedicated refresh scenario exists yet, capture that gap in follow-up notes rather than expanding scope in this fix. + +- [ ] **Step 4: Commit the verification checkpoint** + +```bash +git add . +git commit -m "test: verify supervisor refresh hydration fix" +``` + +## Recommended Approach + +Prefer the client-side `supervisor.get` hydration path over changing websocket resync: + +- Smallest behavioral change. +- Aligns with the already documented issue and existing `supervisorHydratedAtomFamily`. +- Preserves the current server contract that resync only replays workspace/session state. +- Avoids broadening websocket replay semantics for every reconnecting client. + +## Explicit Non-Goals + +- Do not restore supervisor runtime after a server restart. +- Do not change `supervisor.get` into a persisted-history lookup. +- Do not add speculative localStorage persistence for supervisor UI state. +- Do not bundle unrelated supervisor dialog or mobile UI cleanup into this fix. + +## Self-Review + +- Spec coverage: covers initial refresh hydration, reconnect/server-instance reset, and regression verification. +- Placeholder scan: all tasks include concrete files, commands, and expected outcomes. +- Type consistency: uses existing `Session`, `Supervisor`, `dispatchCommandAtom`, `supervisorsAtom`, and `supervisorHydratedAtomFamily` names from the current codebase. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-23-supervisor-refresh-hydration.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/plans/2026-05-23-workspace-search-quick-open-visual-refresh.md b/docs/superpowers/plans/2026-05-23-workspace-search-quick-open-visual-refresh.md new file mode 100644 index 00000000..59ec4d2d --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-workspace-search-quick-open-visual-refresh.md @@ -0,0 +1,860 @@ +# Workspace Search And Quick Open Visual Refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refresh the desktop `Search` sidebar and `Quick Open` overlay so they behave and read closer to VS Code, including collapsible file-group search results and a denser two-line file-only quick-open list. + +**Architecture:** Keep the existing fetch and navigation flows intact. Limit functional changes to client-side presentation state: `SearchPanel` gains per-query expanded group state keyed by file path, and `QuickOpen` keeps file-only results while switching to a clearer two-line row hierarchy. CSS work lives in `components.css`, with focused theme assertions added to `components.theme.test.ts`. + +**Tech Stack:** React 19, Jotai, Vitest, Testing Library, Lucide React, existing `useOpenLocation` navigation, and shared theme assertions in `packages/web/src/styles/components.theme.test.ts`. + +**Spec reference:** `docs/superpowers/specs/2026-05-23-workspace-search-quick-open-visual-refresh-design.md` + +**Git hygiene:** The current worktree already contains unrelated user changes. Stage only the files listed in each task, and never revert unrelated edits. + +--- + +## File Structure + +**Modified files:** +- `packages/web/src/features/workspace/views/shared/search-panel.tsx` — add per-query expand state, header buttons, and compact match-list markup +- `packages/web/src/features/workspace/views/shared/search-panel.test.tsx` — cover default-expanded groups, collapse/re-expand behavior, reset-on-new-query behavior, and preserved navigation +- `packages/web/src/features/quick-open/components/quick-open.tsx` — change file result rows to a VS Code-like two-line hierarchy without changing data sources +- `packages/web/src/features/quick-open/components/quick-open.test.tsx` — cover two-line result structure and active-row keyboard behavior +- `packages/web/src/styles/components.css` — add the missing `workspace-search-panel*` and `quick-open*` selector rules for compact editor-like chrome +- `packages/web/src/styles/components.theme.test.ts` — assert the new selectors keep the intended compact hierarchy + +**No backend changes:** +- `file.searchContent` payload shape already groups matches by file +- `file.search` already returns file-only results for `Quick Open` + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/search-panel.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/features/quick-open/components/quick-open.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/search-panel.test.tsx src/features/quick-open/components/quick-open.test.tsx src/styles/components.theme.test.ts` + +--- + +### Task 1: Add Collapsible Search Result Groups + +**Files:** +- Modify: `packages/web/src/features/workspace/views/shared/search-panel.test.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/search-panel.tsx` + +- [ ] **Step 1: Write the failing search-panel interaction tests** + +Extend `packages/web/src/features/workspace/views/shared/search-panel.test.tsx` with these cases: + +```tsx + it("expands file groups by default and lets users collapse or re-expand them", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 2, + hasMoreMatches: false, + matches: [ + { + line: 3, + column: 7, + endColumn: 18, + preview: "const needleValue = searchState;", + previewColumnStart: 7, + previewColumnEnd: 18, + }, + { + line: 8, + column: 8, + endColumn: 19, + preview: "return needleValue;", + previewColumnStart: 8, + previewColumnEnd: 19, + }, + ], + }, + ], + totalMatchCount: 2, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: "needle" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + const groupToggle = screen.getByRole("button", { + name: /app\.tsx.*src\/app\.tsx.*2/i, + }); + + expect(groupToggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /3.*needle/i })).toBeInTheDocument(); + + fireEvent.click(groupToggle); + + expect(groupToggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("button", { name: /3.*needle/i })).toBeNull(); + + fireEvent.click(groupToggle); + + expect(groupToggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /3.*needle/i })).toBeInTheDocument(); + }); + + it("resets returned file groups to expanded after a new successful query", async () => { + const sendCommand = vi + .fn() + .mockResolvedValueOnce({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 3, + column: 7, + endColumn: 18, + preview: "const needleValue = searchState;", + previewColumnStart: 7, + previewColumnEnd: 18, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult) + .mockResolvedValueOnce({ + files: [ + { + path: "src/view-state.ts", + name: "view-state.ts", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 4, + endColumn: 8, + preview: "export const view = createViewState();", + previewColumnStart: 14, + previewColumnEnd: 18, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: "needle" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + fireEvent.click( + screen.getByRole("button", { + name: /app\.tsx.*src\/app\.tsx.*1/i, + }) + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: "view" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + + const nextToggle = screen.getByRole("button", { + name: /view-state\.ts.*src\/view-state\.ts.*1/i, + }); + + expect(nextToggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("button", { name: /12.*view/i })).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run the search-panel tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/search-panel.test.tsx +``` + +Expected: +- FAIL because file headers are not interactive buttons +- FAIL because there is no `aria-expanded` state or collapse behavior + +- [ ] **Step 3: Implement per-query expand state and grouped header buttons** + +Update `packages/web/src/features/workspace/views/shared/search-panel.tsx` so successful results initialize all returned paths as expanded, and group headers toggle match visibility. + +```tsx +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +function buildExpandedPaths(result: SearchContentResult): Record { + return Object.fromEntries(result.files.map((file) => [file.path, true])); +} + +export const SearchPanel: FC = ({ workspaceId }) => { + // existing state... + const [expandedPaths, setExpandedPaths] = useState>({}); + + useEffect(() => { + const trimmed = query.trim(); + if (!trimmed) { + setResults(null); + setExpandedPaths({}); + setLoading(false); + setError(false); + return; + } + + // existing debounce setup... + const timeout = window.setTimeout(() => { + void dispatchRef + .current("file.searchContent", { + workspaceId, + query: trimmed, + maxFiles: 50, + maxMatchesPerFile: 20, + }) + .then((result) => { + if (cancelled) { + return; + } + + if (!result.ok || !result.data) { + setResults(null); + setExpandedPaths({}); + setError(true); + return; + } + + setResults(result.data); + setExpandedPaths(buildExpandedPaths(result.data)); + }) + .catch(() => { + if (!cancelled) { + setResults(null); + setExpandedPaths({}); + setError(true); + } + }); + }, 250); + }, [query, retryNonce, workspaceId]); + + const toggleFileGroup = (path: string) => { + setExpandedPaths((current) => ({ + ...current, + [path]: !current[path], + })); + }; + + return ( +
+ + {/* existing controls */} +
+ {results?.files.map((file) => { + const expanded = expandedPaths[file.path] ?? true; + const groupId = `workspace-search-group-${file.path.replace(/[^a-z0-9_-]+/gi, "-")}`; + + return ( +
+ + + {expanded ? ( +
+ {file.matches.map((match) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+
+ ); +}; +``` + +- [ ] **Step 4: Run the search-panel tests to verify pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/search-panel.test.tsx +``` + +Expected: +- PASS +- Existing open-location assertions still pass + +- [ ] **Step 5: Commit the interaction change** + +```bash +git add \ + packages/web/src/features/workspace/views/shared/search-panel.tsx \ + packages/web/src/features/workspace/views/shared/search-panel.test.tsx +git commit -m "feat(workspace): add collapsible search result groups" +``` + +--- + +### Task 2: Add Compact Search Sidebar Chrome + +**Files:** +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/styles/components.css` + +- [ ] **Step 1: Write the failing theme assertions for search chrome** + +Add this case to `packages/web/src/styles/components.theme.test.ts` near other desktop tool-surface checks: + +```ts + it("keeps the workspace search panel on compact editor-like chrome", () => { + const controls = getLastRuleBlock(".workspace-search-panel__controls"); + const input = getLastRuleBlock(".workspace-search-panel__input"); + const summary = getLastRuleBlock(".workspace-search-panel__summary"); + const groupToggle = getLastRuleBlock(".workspace-search-panel__group-toggle"); + const groupCopy = getLastRuleBlock(".workspace-search-panel__group-copy"); + const groupPath = getLastRuleBlock(".workspace-search-panel__group-path"); + const matches = getLastRuleBlock(".workspace-search-panel__matches"); + const match = getLastRuleBlock(".workspace-search-panel__match"); + const line = getLastRuleBlock(".workspace-search-panel__line"); + + expect(controls).toContain("gap: 4px"); + expect(input).toContain("min-height: 28px"); + expect(input).toContain("border-radius: 2px"); + expect(groupToggle).toContain("grid-template-columns: 16px minmax(0, 1fr) auto"); + expect(groupCopy).toContain("gap: 2px"); + expect(groupPath).toContain("color: var(--text-tertiary)"); + expect(matches).toContain("gap: 0"); + expect(match).toContain("grid-template-columns: 34px minmax(0, 1fr)"); + expect(match).toContain("border-radius: 0"); + expect(line).toContain("text-align: right"); + }); +``` + +- [ ] **Step 2: Run the theme test to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/styles/components.theme.test.ts +``` + +Expected: +- FAIL because the `workspace-search-panel*` selectors do not yet exist in `components.css` + +- [ ] **Step 3: Add the search-panel selectors and compact rules** + +Add a dedicated search-panel block to `packages/web/src/styles/components.css` near the workspace sidebar rules: + +```css +.workspace-search-panel { + min-height: 0; +} + +.workspace-search-panel__controls { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px 10px; +} + +.workspace-search-panel__input { + min-height: 28px; + padding: 0 8px; + border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); + border-radius: 2px; + background: color-mix(in srgb, var(--bg-page) 88%, var(--bg-surface) 12%); + color: var(--text-primary); +} + +.workspace-search-panel__summary, +.workspace-search-panel__truncate-note, +.workspace-search-panel__state { + color: var(--text-tertiary); + font-size: var(--type-body-5-size); + line-height: var(--type-body-5-line-height); + font-weight: var(--type-body-5-weight); +} + +.workspace-search-panel__results { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + overflow: auto; + padding: 0 8px 8px; +} + +.workspace-search-panel__group { + border-top: 1px solid color-mix(in srgb, var(--border) 54%, transparent); +} + +.workspace-search-panel__group-toggle { + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + align-items: center; + width: 100%; + gap: 8px; + padding: 8px 4px 6px; + border: none; + background: transparent; + color: inherit; + text-align: left; +} + +.workspace-search-panel__group-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.workspace-search-panel__group-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); +} + +.workspace-search-panel__group-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: var(--type-body-5-size); +} + +.workspace-search-panel__matches { + display: flex; + flex-direction: column; + gap: 0; +} + +.workspace-search-panel__match { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 8px; + align-items: start; + width: 100%; + padding: 4px 4px 4px 0; + border: none; + border-radius: 0; + background: transparent; + color: inherit; + text-align: left; +} + +.workspace-search-panel__line { + color: var(--text-quaternary); + text-align: right; + font-variant-numeric: tabular-nums; +} +``` + +- [ ] **Step 4: Run the search and theme tests** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/search-panel.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- PASS +- Theme test now finds the new selectors and compact grid rules + +- [ ] **Step 5: Commit the search chrome** + +```bash +git add \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "style(workspace): tighten search sidebar chrome" +``` + +--- + +### Task 3: Convert Quick Open To A Two-Line File List + +**Files:** +- Modify: `packages/web/src/features/quick-open/components/quick-open.test.tsx` +- Modify: `packages/web/src/features/quick-open/components/quick-open.tsx` + +- [ ] **Step 1: Write the failing quick-open structure tests** + +Extend `packages/web/src/features/quick-open/components/quick-open.test.tsx` with these cases: + +```tsx + it("renders each result as a two-line file row", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }], + }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspace(store); + store.set(quickOpenOpenAtom, true); + + render( + + + + ); + + fireEvent.change(screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }), { + target: { value: "app" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + const row = screen.getByRole("button", { name: /app\.tsx.*src\/app\.tsx/i }); + + expect(row.querySelector(".quick-open__item-copy")).toBeTruthy(); + expect(row.querySelector(".quick-open__item-title")).toHaveTextContent("app.tsx"); + expect(row.querySelector(".quick-open__item-path")).toHaveTextContent("src/app.tsx"); + }); + + it("moves the active quick-open row with arrow keys", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { path: "src/app.tsx", name: "app.tsx", kind: "file" }, + { path: "src/view-state.ts", name: "view-state.ts", kind: "file" }, + ], + }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspace(store); + store.set(quickOpenOpenAtom, true); + + render( + + + + ); + + const input = screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }); + + fireEvent.change(input, { + target: { value: "t" }, + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + const firstRow = screen.getByRole("button", { name: /app\.tsx.*src\/app\.tsx/i }); + const secondRow = screen.getByRole("button", { + name: /view-state\.ts.*src\/view-state\.ts/i, + }); + + expect(firstRow).toHaveClass("quick-open__item--active"); + expect(secondRow).not.toHaveClass("quick-open__item--active"); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + + expect(secondRow).toHaveClass("quick-open__item--active"); + }); +``` + +- [ ] **Step 2: Run the quick-open tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/quick-open/components/quick-open.test.tsx +``` + +Expected: +- FAIL because the row still renders flat `quick-open__name` and `quick-open__path` spans +- FAIL because the new structural selectors do not exist yet + +- [ ] **Step 3: Replace the flat row markup with a two-line hierarchy** + +Update `packages/web/src/features/quick-open/components/quick-open.tsx` so rows keep the same click and keyboard behavior but expose clearer hierarchy hooks: + +```tsx +{results.map((file, index) => ( + +))} +``` + +Remove the old flat selectors: + +```diff +-{file.name} +-{file.path} +``` + +- [ ] **Step 4: Run the quick-open tests to verify pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/quick-open/components/quick-open.test.tsx +``` + +Expected: +- PASS +- existing Enter-to-open behavior still passes + +- [ ] **Step 5: Commit the quick-open row structure** + +```bash +git add \ + packages/web/src/features/quick-open/components/quick-open.tsx \ + packages/web/src/features/quick-open/components/quick-open.test.tsx +git commit -m "feat(quick-open): add dual-line file rows" +``` + +--- + +### Task 4: Add Quick Open Chrome And Final Verification + +**Files:** +- Modify: `packages/web/src/styles/components.theme.test.ts` +- Modify: `packages/web/src/styles/components.css` + +- [ ] **Step 1: Write the failing quick-open theme assertions** + +Add this case to `packages/web/src/styles/components.theme.test.ts` near the existing command-palette desktop chrome assertions: + +```ts + it("keeps quick open on compact file-switcher chrome", () => { + const quickOpen = getLastRuleBlock(".quick-open"); + const quickOpenSearch = getLastRuleBlock(".quick-open__search"); + const quickOpenItem = getLastRuleBlock(".quick-open__item"); + const quickOpenItemCopy = getLastRuleBlock(".quick-open__item-copy"); + const quickOpenItemTitle = getLastRuleBlock(".quick-open__item-title"); + const quickOpenItemPath = getLastRuleBlock(".quick-open__item-path"); + + expect(quickOpen).toContain("max-width: var(--desktop-modal-max-width-md)"); + expect(quickOpenSearch).toContain("min-height: 38px"); + expect(quickOpenItem).toContain("padding: 6px 12px"); + expect(quickOpenItemCopy).toContain("gap: 2px"); + expect(quickOpenItemTitle).toContain("color: var(--text-primary)"); + expect(quickOpenItemPath).toContain("color: var(--text-tertiary)"); + }); +``` + +- [ ] **Step 2: Run the quick-open and theme tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/quick-open/components/quick-open.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- FAIL because the new `quick-open__item-copy`, `__item-title`, and `__item-path` rules are not in CSS yet + +- [ ] **Step 3: Add the quick-open selectors and compact overlay rules** + +Add the `quick-open*` rules to `packages/web/src/styles/components.css` near other desktop overlay rules: + +```css +.quick-open { + width: min(100%, var(--desktop-modal-max-width-md)); + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--border) 76%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--bg-panel) 98%, var(--bg-page) 2%); + box-shadow: var(--shadow-lg); +} + +.quick-open__search { + display: flex; + align-items: center; + gap: 8px; + min-height: 38px; + padding: 0 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 68%, transparent); +} + +.quick-open__input { + flex: 1; + min-width: 0; + border: none; + background: transparent; + color: var(--text-primary); +} + +.quick-open__list { + display: flex; + flex-direction: column; + padding: 6px 0; +} + +.quick-open__item { + display: flex; + width: 100%; + padding: 6px 12px; + border: none; + background: transparent; + color: inherit; + text-align: left; +} + +.quick-open__item-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.quick-open__item-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-primary); + font-size: var(--type-body-3-size); +} + +.quick-open__item-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-tertiary); + font-family: var(--font-mono); + font-size: var(--type-body-5-size); +} +``` + +Keep the existing `quick-open__item--active` class, but ensure it uses a single background fill rather than card-like decoration. + +- [ ] **Step 4: Run the final focused verification** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/search-panel.test.tsx \ + src/features/quick-open/components/quick-open.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- PASS +- search groups stay collapsible and reset to expanded per query +- quick-open rows expose the two-line hierarchy selectors +- theme assertions confirm both surfaces use compact editor-like chrome + +- [ ] **Step 5: Commit the quick-open chrome** + +```bash +git add \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "style(quick-open): match vscode file switcher chrome" +``` diff --git a/docs/superpowers/plans/2026-05-23-workspace-sidebar-search-quick-open.md b/docs/superpowers/plans/2026-05-23-workspace-sidebar-search-quick-open.md new file mode 100644 index 00000000..4c19655e --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-workspace-sidebar-search-quick-open.md @@ -0,0 +1,2674 @@ +# Workspace Sidebar, Search, and Quick Open Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the desktop `文件 / Git` sidebar tabs with a VS Code style workbench sidebar, add true workspace content search, and add a Quick Open file-jump overlay plus a desktop quick-action entry point. + +**Architecture:** Split desktop workspace navigation into an Activity Bar and three independent sidebar views: `Explorer`, `Search`, and `Source Control`. Keep filename/path search on the existing `file.search` command for Quick Open, add a new `file.searchContent` command for real content search, and route both Search results and Quick Open through the existing `useOpenLocation` flow so file activation and navigation remain workspace-scoped and consistent. + +**Tech Stack:** React 19, Jotai, React Router, Vitest, Testing Library, Node 24, existing websocket command dispatch, `rg` with a Node fallback for content search, and compatibility styles in `packages/web/src/styles/components.css`. + +**Spec reference:** `docs/superpowers/specs/2026-05-23-workspace-sidebar-search-quick-open-design.md` + +**Git hygiene:** The worktree may already contain unrelated user changes. Read files before patching them, stage only the files listed in each task, and never revert unrelated edits. + +--- + +## File Structure + +**New files:** +- `packages/server/src/fs/content-search.ts` — `rg`-first content-search helper with Node fallback, preview shaping, and truncation metadata +- `packages/server/src/__tests__/fs/content-search.test.ts` — helper tests for grouping, ignore behavior, fallback mode, binary skipping, and truncation +- `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx` — left-hand desktop Activity Bar for `Explorer`, `Search`, and `Source Control` +- `packages/web/src/features/workspace/views/shared/explorer-panel.tsx` — desktop Explorer wrapper with header, `Open Editors`, and file tree +- `packages/web/src/features/workspace/views/shared/search-panel.tsx` — desktop content-search view with debounce, highlight rendering, truncation messaging, retry, and open-location behavior +- `packages/web/src/features/workspace/views/shared/search-panel.test.tsx` — Search panel tests for debounce, highlight output, retry, and open-location +- `packages/web/src/features/quick-open/components/quick-open.tsx` — desktop Quick Open overlay, global shortcut handler, filename/path search loop, and keyboard navigation +- `packages/web/src/features/quick-open/components/quick-open.test.tsx` — Quick Open tests for keyboard trigger, query behavior, desktop-only scope, and file open +- `packages/web/src/features/quick-open/index.tsx` — Quick Open feature barrel + +**Modified files:** +- `packages/core/src/domain/types.ts` — shared content-search result interfaces +- `packages/server/src/commands/file.ts` — register `file.searchContent` +- `packages/server/src/__tests__/file-commands.test.ts` — command-level coverage for `file.searchContent` +- `packages/web/src/atoms/app-ui.ts` — add `quickOpenOpenAtom` +- `packages/web/src/features/workspace/atoms/layout.ts` — add persisted desktop sidebar view state +- `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts` — replace desktop `files / git` tab state with `explorer / search / source-control` +- `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` — swap tabbed sidebar for Activity Bar + view shell +- `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` — keep mobile filename search, allow desktop Explorer to hide it +- `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` — lock desktop/mobile search split +- `packages/web/src/features/workspace/index.test.tsx` — workspace-level regression tests for new desktop sidebar structure +- `packages/web/src/features/command-palette/components/command-palette.tsx` — add a desktop-only “Go to File...” action that opens Quick Open +- `packages/web/src/features/command-palette/components/command-palette.test.tsx` — verify “Go to File...” opens Quick Open and stays hidden on mobile +- `packages/web/src/shells/desktop-shell.tsx` — mount `QuickOpen` next to `CommandPalette` +- `packages/web/src/shells/desktop-shell.test.tsx` — verify desktop shell mounts the new overlay +- `packages/web/src/styles/components.css` — sidebar shell, Activity Bar, Explorer/Search sections, Search results, and Quick Open styling +- `packages/web/src/locales/en.json` — add sidebar, Search, and Quick Open copy +- `packages/web/src/locales/zh.json` — add Chinese copy for the same states + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/index.test.tsx src/features/workspace/views/shared/file-tree-panel.test.tsx` +- `pnpm --filter @coder-studio/server exec vitest run src/__tests__/fs/content-search.test.ts src/__tests__/file-commands.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/search-panel.test.tsx src/features/workspace/index.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/features/quick-open/components/quick-open.test.tsx src/features/command-palette/components/command-palette.test.tsx src/shells/desktop-shell.test.tsx` + +--- + +### Task 1: Replace Desktop Sidebar Tabs With Activity Bar + Explorer Shell + +**Files:** +- Create: `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx` +- Create: `packages/web/src/features/workspace/views/shared/explorer-panel.tsx` +- Modify: `packages/web/src/features/workspace/atoms/layout.ts` +- Modify: `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts` +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx` +- Modify: `packages/web/src/features/workspace/index.test.tsx` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing desktop sidebar tests** + +Add these tests to `packages/web/src/features/workspace/index.test.tsx` near the current desktop sidebar coverage: + +```tsx + it("renders an explorer-first activity bar and removes the desktop tree search box", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(openFilesAtomFamily("ws-test"), { + "README.md": { + kind: "text", + path: "README.md", + content: "# docs", + savedContent: "# docs", + baseHash: "base-readme", + isDirty: false, + }, + "src/app.tsx": { + kind: "text", + path: "src/app.tsx", + content: "export const App = () => null;\n", + savedContent: "export const App = () => null;\n", + baseHash: "base-app", + isDirty: false, + }, + }); + store.set(activeFilePathAtomFamily("ws-test"), "src/app.tsx"); + + render( + + + + } /> + + + + ); + + await screen.findByText(/Open Editors|已打开的编辑器/i); + + expect(screen.getByRole("button", { name: /Explorer|资源管理器/i })).toHaveAttribute( + "aria-pressed", + "true" + ); + expect(screen.getByText("README.md")).toBeInTheDocument(); + expect(screen.getByText("src/app.tsx")).toBeInTheDocument(); + expect(screen.queryByLabelText(/Search Files|搜索文件/i)).toBeNull(); + expect(document.querySelector(".workspace-activity-bar")).toBeTruthy(); + }); + + it("switches desktop sidebar views from the activity bar", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + + render( + + + + } /> + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /Search|搜索/i })); + expect(screen.getByRole("heading", { name: /Search|搜索/i })).toBeInTheDocument(); + expect(screen.getByText(/Type to search across file contents|输入关键词以搜索文件内容/i)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Source Control|源代码管理/i })); + expect(screen.getByTestId("git-panel")).toBeInTheDocument(); + }); +``` + +Add this focused regression to `packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx`: + +```tsx + it("omits the desktop filename search input when showSearch is false", () => { + const store = createStore(); + store.set(wsClientAtom, { sendCommand: vi.fn() } as never); + store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); + + render( + + + + ); + + expect(screen.queryByLabelText("action.search_files")).toBeNull(); + }); +``` + +- [ ] **Step 2: Run the focused tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/index.test.tsx \ + src/features/workspace/views/shared/file-tree-panel.test.tsx +``` + +Expected: +- FAIL because `WorkspaceDesktopView` still renders tab chrome instead of an Activity Bar +- FAIL because `FileTreePanel` always renders the desktop search input + +- [ ] **Step 3: Implement the sidebar foundation and Explorer shell** + +In `packages/web/src/features/workspace/atoms/layout.ts`, add persisted desktop sidebar state: + +```diff ++export type DesktopSidebarView = "explorer" | "search" | "source-control"; ++ ++export const desktopSidebarViewAtom = atomWithStorage( ++ "ui.desktopSidebarView", ++ "explorer" ++); +``` + +In `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts`, replace desktop tab state with the persisted sidebar view: + +```diff +-import { useAtomValue, useSetAtom, useStore } from "jotai"; ++import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; ++import { desktopSidebarViewAtom } from "../atoms/layout"; + +-export type WorkspaceSidebarTab = "files" | "git"; + export type WorkspaceMainAreaMode = "agent" | "editor"; + +- const [sidebarTab, setSidebarTab] = useState("files"); ++ const [desktopSidebarView, setDesktopSidebarView] = useAtom(desktopSidebarViewAtom); + + const handleOpenBranchSwitcher = useCallback(() => { + if (!workspace) { + return; + } + +- setSidebarTab("git"); ++ setDesktopSidebarView("source-control"); + setBranchQuickPick({ + visible: true, + workspaceId: workspace.id, + inputValue: "", + }); +- }, [setBranchQuickPick, workspace]); ++ }, [setBranchQuickPick, setDesktopSidebarView, workspace]); + + return { + createRequest, ++ desktopSidebarView, + handleConsumeCreateRequest, + handleOpenBranchSwitcher, + handleOpenFileCreate, + handleOpenFolderCreate, + mainAreaMode, +- setSidebarTab, ++ setDesktopSidebarView, + sidebarCollapsed, +- sidebarTab, + terminalPanelVisible, + workspace, +``` + +Create `packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx`: + +```tsx +import { FolderTree, GitBranch, Search } from "lucide-react"; +import type { FC } from "react"; +import { useTranslation } from "../../../../lib/i18n"; +import type { DesktopSidebarView } from "../../atoms/layout"; + +interface WorkspaceActivityBarProps { + activeView: DesktopSidebarView; + onChange: (view: DesktopSidebarView) => void; +} + +export const WorkspaceActivityBar: FC = ({ + activeView, + onChange, +}) => { + const t = useTranslation(); + const items = [ + { value: "explorer" as const, icon: FolderTree, label: t("workspace.sidebar.explorer") }, + { value: "search" as const, icon: Search, label: t("workspace.sidebar.search") }, + { + value: "source-control" as const, + icon: GitBranch, + label: t("workspace.sidebar.source_control"), + }, + ]; + + return ( +
+ {items.map(({ value, icon: Icon, label }) => ( + + ))} +
+ ); +}; +``` + +Create `packages/web/src/features/workspace/views/shared/explorer-panel.tsx`: + +```tsx +import { useAtomValue } from "jotai"; +import { ChevronsUp } from "lucide-react"; +import type { FC } from "react"; +import { IconButton, ThemedIcon, Tooltip } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; +import { PanelHeader } from "../../../shared/components/panel-header"; +import { activeFilePathAtomFamily, openFilesAtomFamily } from "../../atoms/files"; +import type { CreateRequest } from "../../actions/use-file-actions"; +import { FileTreePanel } from "./file-tree-panel"; + +interface ExplorerPanelProps { + workspaceId: string; + createRequest?: CreateRequest | null; + onCreateRequestConsumed?: () => void; + onOpenFileCreate: () => void; + onOpenFolderCreate: () => void; + onCollapseAll: () => void; + collapseVersion: number; +} + +export const ExplorerPanel: FC = ({ + workspaceId, + createRequest = null, + onCreateRequestConsumed, + onOpenFileCreate, + onOpenFolderCreate, + onCollapseAll, + collapseVersion, +}) => { + const t = useTranslation(); + const openFiles = useAtomValue(openFilesAtomFamily(workspaceId)); + const activeFilePath = useAtomValue(activeFilePathAtomFamily(workspaceId)); + const { openLocation } = useOpenLocation(workspaceId); + const openEditors = Object.values(openFiles).sort((a, b) => a.path.localeCompare(b.path)); + + return ( +
+ + + } + onClick={onOpenFileCreate} + size="sm" + /> + + + } + onClick={onOpenFolderCreate} + size="sm" + /> + + + } + onClick={onCollapseAll} + size="sm" + /> + +
+ } + /> + +
+
+
{t("workspace.sidebar.open_editors")}
+
+ {openEditors.map((file) => ( + + ))} +
+
+ +
+
{t("workspace.sidebar.workspace")}
+ +
+
+
+ ); +}; +``` + +Update `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` so desktop Explorer can hide the filename search box without affecting mobile: + +```diff + interface FileTreePanelProps { + workspaceId: string; + refreshToken?: number; + createRequest?: CreateRequest | null; + onCreateRequestConsumed?: () => void; + onSelectFile?: (path: string) => void; + onVisibleCountChange?: (count: number, loading: boolean) => void; + collapseVersion?: number; + variant?: "desktop" | "mobile"; ++ showSearch?: boolean; + } + + export const FileTreePanel: FC = ({ + workspaceId, + refreshToken = 0, + createRequest = null, + onCreateRequestConsumed, + onSelectFile, + onVisibleCountChange, + collapseVersion = 0, + variant = "desktop", ++ showSearch = true, + }) => { + +- ++ {showSearch ? ( ++ ++ ) : null} +``` + +Update `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` so desktop Search uses a temporary placeholder in this task and Task 3 replaces it with the real `SearchPanel`: + +```diff +-import { +- EmptyState, +- IconButton, +- Tab, +- TabList, +- Tabs, +- ThemedIcon, +- Tooltip, +-} from "../../../../components/ui"; ++import { EmptyState } from "../../../../components/ui"; + import { useTranslation } from "../../../../lib/i18n"; + import { AgentPanes } from "../../../agent-panes"; + import { CodeEditorHost } from "../../../code-editor/views/shared/code-editor-host"; + import { PanelHeader } from "../../../shared/components/panel-header"; + import { TerminalPanel } from "../../../terminal-panel"; + import { TopBar } from "../../../topbar"; + import { useWorkspaceFullscreen } from "../../actions/use-workspace-fullscreen"; + import { useWorkspaceScreenModel } from "../../actions/use-workspace-screen-model"; + import { sidebarCollapsedAtom } from "../../atoms"; +-import { FileTreePanel } from "../shared/file-tree-panel"; + import { GitPanel } from "../shared/git-panel"; ++import { ExplorerPanel } from "../shared/explorer-panel"; ++import { WorkspaceActivityBar } from "../shared/workspace-activity-bar"; + + const { + createRequest, ++ desktopSidebarView, + focusMode, + gitState, + handleBottomMouseDown, + handleConsumeCreateRequest, + handleLeftMouseDown, + handleOpenBranchSwitcher, + handleOpenFileCreate, + handleOpenFolderCreate, + leftPanelWidth, + leftPanelRef, + mainAreaMode, +- setSidebarTab, ++ setDesktopSidebarView, + sidebarCollapsed, +- sidebarTab, + terminalPanelVisible, + workspace, + bottomPanelHeight, + bottomPanelRef, + } = useWorkspaceScreenModel(); + + if (event.key === "1") { + event.preventDefault(); +- setSidebarTab("files"); ++ setDesktopSidebarView("explorer"); + return; + } + + if (event.key === "2") { + event.preventDefault(); +- setSidebarTab("git"); ++ setDesktopSidebarView("search"); ++ return; ++} ++ ++if (event.key === "3") { ++ event.preventDefault(); ++ setDesktopSidebarView("source-control"); + } + +-}, [setSidebarCollapsed, setSidebarTab]); ++}, [setDesktopSidebarView, setSidebarCollapsed]); + +
+ +
+ {desktopSidebarView === "explorer" ? ( + setFileTreeCollapseVersion((value) => value + 1)} + /> + ) : desktopSidebarView === "search" ? ( +
+ +
+ {t("workspace.search.empty")}

} + /> +
+
+ ) : ( +
+ +
+ +
+
+ )} +
+
+``` + +Add the Activity Bar, section, and open-editor styles in `packages/web/src/styles/components.css`: + +```css +.workspace-sidebar-panel { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + min-height: 0; + height: 100%; + background: var(--bg-panel); +} + +.workspace-activity-bar { + display: grid; + align-content: start; + gap: 6px; + padding: 10px 6px; + border-right: 1px solid color-mix(in srgb, var(--border) 82%, transparent); + background: color-mix(in srgb, var(--bg-sidebar) 92%, var(--bg-panel)); +} + +.workspace-activity-bar__button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-tertiary); +} + +.workspace-activity-bar__button--active { + color: var(--text-primary); + background: color-mix(in srgb, var(--bg-hover) 94%, transparent); +} + +.workspace-activity-bar__button--active::before { + content: ""; + position: absolute; + left: -6px; + top: 5px; + bottom: 5px; + width: 2px; + border-radius: 999px; + background: var(--accent-blue); +} + +.workspace-sidebar-panel__content, +.workspace-sidebar-view { + display: flex; + min-width: 0; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.workspace-sidebar-panel__body--stacked { + display: grid; + gap: 10px; + padding: 8px 0 0; +} + +.workspace-sidebar-section { + display: flex; + min-width: 0; + flex-direction: column; +} + +.workspace-sidebar-section--fill { + min-height: 0; + flex: 1; +} + +.workspace-sidebar-section__title { + padding: 0 12px 6px; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.workspace-open-editors { + display: grid; + gap: 2px; + padding: 0 8px; +} + +.workspace-open-editors__item { + min-height: 24px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + text-align: left; + padding: 0 8px; +} + +.workspace-open-editors__item--active { + background: var(--state-selected-bg); + color: var(--text-primary); +} +``` + +Add sidebar strings to `packages/web/src/locales/en.json` and `packages/web/src/locales/zh.json`: + +```json +"workspace": { + "sidebar": { + "label": "Workspace sidebar", + "explorer": "Explorer", + "search": "Search", + "source_control": "Source Control", + "open_editors": "Open Editors", + "workspace": "Workspace" + }, + "search": { + "empty": "Type to search across file contents" + } +} +``` + +```json +"workspace": { + "sidebar": { + "label": "工作区侧边栏", + "explorer": "资源管理器", + "search": "搜索", + "source_control": "源代码管理", + "open_editors": "已打开的编辑器", + "workspace": "工作区" + }, + "search": { + "empty": "输入关键词以搜索文件内容" + } +} +``` + +- [ ] **Step 4: Re-run the focused tests to verify the desktop shell is green** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/index.test.tsx \ + src/features/workspace/views/shared/file-tree-panel.test.tsx +``` + +Expected: +- PASS +- the desktop sidebar now uses an Activity Bar and Explorer sections +- mobile filename search coverage stays intact because `showSearch` defaults to `true` +- the Search branch still uses a placeholder, which is intentional until Task 3 + +- [ ] **Step 5: Commit the sidebar foundation slice** + +```bash +git add \ + packages/web/src/features/workspace/atoms/layout.ts \ + packages/web/src/features/workspace/actions/use-workspace-screen-model.ts \ + packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx \ + packages/web/src/features/workspace/views/shared/workspace-activity-bar.tsx \ + packages/web/src/features/workspace/views/shared/explorer-panel.tsx \ + packages/web/src/features/workspace/views/shared/file-tree-panel.tsx \ + packages/web/src/features/workspace/views/shared/file-tree-panel.test.tsx \ + packages/web/src/features/workspace/index.test.tsx \ + packages/web/src/styles/components.css \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "feat: add desktop workbench sidebar shell" +``` + +--- + +### Task 2: Add `file.searchContent` With `rg` + Node Fallback + +**Files:** +- Create: `packages/server/src/fs/content-search.ts` +- Create: `packages/server/src/__tests__/fs/content-search.test.ts` +- Modify: `packages/core/src/domain/types.ts` +- Modify: `packages/server/src/commands/file.ts` +- Modify: `packages/server/src/__tests__/file-commands.test.ts` + +- [ ] **Step 1: Write the failing backend tests** + +Create `packages/server/src/__tests__/fs/content-search.test.ts`: + +```ts +import { mkdir, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { searchFileContents } from "../../fs/content-search.js"; + +describe("searchFileContents", () => { + let rootDir: string; + + beforeEach(async () => { + rootDir = join(tmpdir(), `content-search-${Date.now()}`); + await mkdir(rootDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(rootDir, { recursive: true, force: true }); + }); + + it("groups matches by file and returns preview highlight metadata", async () => { + await writeFile( + join(rootDir, "src.ts"), + "const alpha = 1;\nconst needleValue = alpha;\nexport { needleValue };\n" + ); + + const result = await searchFileContents(rootDir, "needle", { + maxFiles: 50, + maxMatchesPerFile: 20, + }); + + expect(result.files).toEqual([ + { + path: "src.ts", + name: "src.ts", + matchCount: 2, + hasMoreMatches: false, + matches: [ + expect.objectContaining({ + line: 2, + column: 7, + preview: expect.stringContaining("needleValue"), + previewColumnStart: 7, + }), + expect.objectContaining({ + line: 3, + preview: expect.stringContaining("needleValue"), + }), + ], + }, + ]); + expect(result.hasMoreFiles).toBe(false); + }); + + it("falls back to the Node scanner when rg is unavailable", async () => { + await writeFile(join(rootDir, "notes.txt"), "first line\nneedle on second line\n"); + + const result = await searchFileContents( + rootDir, + "needle", + { maxFiles: 50, maxMatchesPerFile: 20 }, + { + runRg: vi.fn(async () => { + const error = Object.assign(new Error("rg missing"), { code: "ENOENT" }); + throw error; + }), + } + ); + + expect(result.files).toEqual([ + { + path: "notes.txt", + name: "notes.txt", + matchCount: 1, + hasMoreMatches: false, + matches: [expect.objectContaining({ line: 2, preview: expect.stringContaining("needle") })], + }, + ]); + }); + + it("respects .gitignore, skips binary files, and reports truncation", async () => { + await writeFile(join(rootDir, ".gitignore"), "ignored.txt\n"); + await writeFile(join(rootDir, "ignored.txt"), "needle\n"); + await writeFile(join(rootDir, "keep.txt"), "needle\nneedle\nneedle\n"); + await writeFile(join(rootDir, "binary.bin"), "\u0000needle"); + + const result = await searchFileContents( + rootDir, + "needle", + { maxFiles: 50, maxMatchesPerFile: 2 }, + { + runRg: vi.fn(async () => { + const error = Object.assign(new Error("rg missing"), { code: "ENOENT" }); + throw error; + }), + } + ); + + expect(result.files).toHaveLength(1); + expect(result.files[0]).toMatchObject({ + path: "keep.txt", + matchCount: 3, + hasMoreMatches: true, + }); + expect(result.files[0].matches).toHaveLength(2); + expect(result.truncatedMatchFileCount).toBe(1); + }); +}); +``` + +Add this command-level test to `packages/server/src/__tests__/file-commands.test.ts`: + +```ts + it("searches file contents through file.searchContent", async () => { + await writeFile( + join(testDir, "src.ts"), + "export const alpha = true;\nexport const needle = alpha;\n" + ); + + const result = await dispatch( + { + kind: "command", + id: "file-search-content-1", + op: "file.searchContent", + args: { + workspaceId, + query: "needle", + maxFiles: 50, + maxMatchesPerFile: 20, + }, + }, + ctx + ); + + expect(result.ok).toBe(true); + expect((result.data as { files: Array<{ path: string; matchCount: number }> }).files).toEqual([ + expect.objectContaining({ + path: "src.ts", + matchCount: 1, + }), + ]); + }); +``` + +- [ ] **Step 2: Run the backend tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/fs/content-search.test.ts \ + src/__tests__/file-commands.test.ts +``` + +Expected: +- FAIL because `searchFileContents` does not exist +- FAIL because `file.searchContent` is not registered + +- [ ] **Step 3: Implement shared result types and the server-side content-search helper** + +In `packages/core/src/domain/types.ts`, add shared content-search types below `FileNode`: + +```ts +export interface SearchContentMatch { + line: number; + column: number; + endColumn: number; + preview: string; + previewColumnStart: number; + previewColumnEnd: number; +} + +export interface SearchContentFileResult { + path: string; + name: string; + matchCount: number; + hasMoreMatches: boolean; + matches: SearchContentMatch[]; +} + +export interface SearchContentResult { + files: SearchContentFileResult[]; + totalMatchCount: number; + hasMoreFiles: boolean; + truncatedMatchFileCount: number; +} +``` + +Create `packages/server/src/fs/content-search.ts`: + +```ts +import type { + SearchContentFileResult, + SearchContentMatch, + SearchContentResult, +} from "@coder-studio/core"; +import { execFile } from "child_process"; +import { readdir, readFile, stat } from "fs/promises"; +import { basename, join, relative } from "path"; +import { promisify } from "util"; +import { createGitignoreFilter } from "./gitignore.js"; + +const execFileAsync = promisify(execFile); +const FALLBACK_MAX_FILE_BYTES = 1_000_000; +const PREVIEW_CONTEXT_CHARS = 40; + +export interface SearchContentOptions { + maxFiles: number; + maxMatchesPerFile: number; +} + +interface SearchContentDeps { + runRg?: typeof execFileAsync; +} + +export async function searchFileContents( + rootPath: string, + query: string, + options: SearchContentOptions, + deps: SearchContentDeps = {} +): Promise { + const normalizedQuery = query.trim(); + if (!normalizedQuery) { + return { + files: [], + totalMatchCount: 0, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + }; + } + + const runRg = deps.runRg ?? execFileAsync; + + try { + const { stdout } = await runRg( + "rg", + [ + "--json", + "--line-number", + "--column", + "--smart-case", + "--hidden", + "--glob", + "!.git", + normalizedQuery, + rootPath, + ], + { cwd: rootPath, maxBuffer: 10 * 1024 * 1024 } + ); + + return parseRgJson(stdout, rootPath, options); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + + return scanFileContentsFallback(rootPath, normalizedQuery, options); + } +} + +function parseRgJson( + stdout: string, + rootPath: string, + options: SearchContentOptions +): SearchContentResult { + const files = new Map(); + let totalMatchCount = 0; + let hasMoreFiles = false; + let truncatedMatchFileCount = 0; + + for (const line of stdout.split("\n")) { + if (!line) continue; + const event = JSON.parse(line) as { + type: string; + data?: { + path?: { text: string }; + line_number?: number; + lines?: { text: string }; + submatches?: Array<{ start: number; end: number }>; + }; + }; + + if (event.type !== "match" || !event.data?.path?.text || !event.data.line_number) { + continue; + } + + const relPath = relative(rootPath, event.data.path.text); + const current = + files.get(relPath) ?? + { + path: relPath, + name: basename(relPath), + matchCount: 0, + hasMoreMatches: false, + matches: [], + }; + + if (!files.has(relPath) && files.size >= options.maxFiles) { + hasMoreFiles = true; + continue; + } + + const submatch = event.data.submatches?.[0]; + if (!submatch) { + continue; + } + + totalMatchCount += 1; + current.matchCount += 1; + + if (current.matches.length >= options.maxMatchesPerFile) { + if (!current.hasMoreMatches) { + current.hasMoreMatches = true; + truncatedMatchFileCount += 1; + } + files.set(relPath, current); + continue; + } + + current.matches.push(buildPreviewMatch(event.data.lines?.text ?? "", event.data.line_number, submatch.start, submatch.end)); + files.set(relPath, current); + } + + return { + files: [...files.values()], + totalMatchCount, + hasMoreFiles, + truncatedMatchFileCount, + }; +} + +function buildPreviewMatch( + lineText: string, + line: number, + matchStart: number, + matchEnd: number +): SearchContentMatch { + const trimmedLine = lineText.replace(/\r?\n$/, ""); + const start = Math.max(0, matchStart - PREVIEW_CONTEXT_CHARS); + const end = Math.min(trimmedLine.length, matchEnd + PREVIEW_CONTEXT_CHARS); + const prefix = start > 0 ? "…" : ""; + const suffix = end < trimmedLine.length ? "…" : ""; + const preview = `${prefix}${trimmedLine.slice(start, end)}${suffix}`; + const previewColumnStart = prefix.length + (matchStart - start) + 1; + const previewColumnEnd = previewColumnStart + (matchEnd - matchStart); + + return { + line, + column: matchStart + 1, + endColumn: matchEnd + 1, + preview, + previewColumnStart, + previewColumnEnd, + }; +} + +async function scanFileContentsFallback( + rootPath: string, + query: string, + options: SearchContentOptions +): Promise { + const loweredQuery = query.toLowerCase(); + const files: SearchContentFileResult[] = []; + let totalMatchCount = 0; + let hasMoreFiles = false; + let truncatedMatchFileCount = 0; + + async function walk(dirPath: string): Promise { + if (files.length >= options.maxFiles) { + hasMoreFiles = true; + return; + } + + const filter = createGitignoreFilter(rootPath, dirPath); + const entries = await readdir(dirPath, { withFileTypes: true }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + if (!filter(entry.name) || entry.name === ".git") { + continue; + } + + const fullPath = join(dirPath, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + if (files.length >= options.maxFiles) { + return; + } + continue; + } + + if (!entry.isFile()) { + continue; + } + + const fileStat = await stat(fullPath); + if (fileStat.size > FALLBACK_MAX_FILE_BYTES) { + continue; + } + + const content = await readFile(fullPath, "utf-8").catch(() => null); + if (!content || content.includes("\u0000")) { + continue; + } + + const matches: SearchContentMatch[] = []; + let matchCount = 0; + + for (const [index, line] of content.split(/\r?\n/).entries()) { + let searchStart = 0; + const loweredLine = line.toLowerCase(); + + while (searchStart < loweredLine.length) { + const foundIndex = loweredLine.indexOf(loweredQuery, searchStart); + if (foundIndex === -1) { + break; + } + + matchCount += 1; + totalMatchCount += 1; + + if (matches.length < options.maxMatchesPerFile) { + matches.push(buildPreviewMatch(line, index + 1, foundIndex, foundIndex + query.length)); + } + + searchStart = foundIndex + Math.max(query.length, 1); + } + } + + if (matchCount === 0) { + continue; + } + + const hasMoreMatches = matchCount > matches.length; + if (hasMoreMatches) { + truncatedMatchFileCount += 1; + } + + files.push({ + path: relative(rootPath, fullPath), + name: entry.name, + matchCount, + hasMoreMatches, + matches, + }); + } + } + + await walk(rootPath); + + return { + files, + totalMatchCount, + hasMoreFiles, + truncatedMatchFileCount, + }; +} +``` + +Register the command in `packages/server/src/commands/file.ts`: + +```diff +-import { readTree, searchFiles } from "../fs/tree.js"; ++import { searchFileContents } from "../fs/content-search.js"; ++import { readTree, searchFiles } from "../fs/tree.js"; + + registerCommand( ++ "file.searchContent", ++ z.object({ ++ workspaceId: z.string(), ++ query: z.string(), ++ maxFiles: z.number().int().positive().max(200), ++ maxMatchesPerFile: z.number().int().positive().max(200), ++ }), ++ async (args, ctx) => { ++ const workspace = ctx.workspaceMgr.get(args.workspaceId); ++ if (!workspace) { ++ throw { code: "workspace_not_found", message: `Workspace not found: ${args.workspaceId}` }; ++ } ++ ++ return searchFileContents(workspace.path, args.query, { ++ maxFiles: args.maxFiles, ++ maxMatchesPerFile: args.maxMatchesPerFile, ++ }); ++ } ++); +``` + +- [ ] **Step 4: Re-run the backend tests and make them pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/fs/content-search.test.ts \ + src/__tests__/file-commands.test.ts +``` + +Expected: +- PASS +- `file.searchContent` returns grouped file matches with preview highlight metadata +- fallback tests pass even if `rg` is unavailable in CI +- truncation and ignore handling are covered + +- [ ] **Step 5: Commit the content-search backend slice** + +```bash +git add \ + packages/core/src/domain/types.ts \ + packages/server/src/fs/content-search.ts \ + packages/server/src/__tests__/fs/content-search.test.ts \ + packages/server/src/commands/file.ts \ + packages/server/src/__tests__/file-commands.test.ts +git commit -m "feat: add workspace content search command" +``` + +--- + +### Task 3: Build the Desktop Search View and Wire It to `file.searchContent` + +**Files:** +- Create: `packages/web/src/features/workspace/views/shared/search-panel.tsx` +- Create: `packages/web/src/features/workspace/views/shared/search-panel.test.tsx` +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` +- Modify: `packages/web/src/features/workspace/index.test.tsx` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing Search panel tests** + +Create `packages/web/src/features/workspace/views/shared/search-panel.test.tsx`: + +```tsx +// @vitest-environment jsdom + +import type { SearchContentResult } from "@coder-studio/core"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { wsClientAtom } from "../../../../atoms/connection"; +import { activeFilePathAtomFamily } from "../../atoms/files"; +import { pendingEditorNavigationAtomFamily } from "../../../code-editor/atoms"; +import { SearchPanel } from "./search-panel"; + +describe("SearchPanel", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("debounces content queries, renders grouped results, and highlights matches", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 2, + hasMoreMatches: true, + matches: [ + { + line: 3, + column: 7, + endColumn: 13, + preview: "const needleValue = searchState;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + { + line: 8, + column: 8, + endColumn: 14, + preview: "return needleValue;", + previewColumnStart: 8, + previewColumnEnd: 14, + }, + ], + }, + ], + totalMatchCount: 2, + hasMoreFiles: true, + truncatedMatchFileCount: 1, + } satisfies SearchContentResult); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: "needle" }, + }); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.searchContent", + { + workspaceId: "ws-test", + query: "needle", + maxFiles: 50, + maxMatchesPerFile: 20, + }, + undefined + ); + }); + + expect(await screen.findByText("app.tsx")).toBeInTheDocument(); + expect(screen.getByText("src/app.tsx")).toBeInTheDocument(); + expect(screen.getAllByText("needleValue")[0]?.tagName).toBe("MARK"); + expect(screen.getByText(/Results limited|结果已截断/i)).toBeInTheDocument(); + }); + + it("opens the file at the selected match location", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [ + { + path: "src/app.tsx", + name: "app.tsx", + matchCount: 1, + hasMoreMatches: false, + matches: [ + { + line: 12, + column: 5, + endColumn: 11, + preview: "const needle = true;", + previewColumnStart: 7, + previewColumnEnd: 13, + }, + ], + }, + ], + totalMatchCount: 1, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + } satisfies SearchContentResult); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: "needle" }, + }); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + fireEvent.click(await screen.findByRole("button", { name: /12/ })); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + expect(store.get(pendingEditorNavigationAtomFamily("ws-test"))).toMatchObject({ + workspaceId: "ws-test", + path: "src/app.tsx", + line: 12, + column: 5, + source: "search", + }); + }); + + it("shows retry when the search command fails", async () => { + const sendCommand = vi.fn().mockRejectedValue(new Error("boom")); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + + render( + + + + ); + + fireEvent.change(screen.getByRole("searchbox", { name: /Search|搜索/i }), { + target: { value: "needle" }, + }); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + + expect(await screen.findByRole("button", { name: /Retry|重试/i })).toBeInTheDocument(); + }); +}); +``` + +Add this integration test to `packages/web/src/features/workspace/index.test.tsx`: + +```tsx + it("renders the content search input when the Search activity item is active", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "git.status") { + return { + branch: "main", + ahead: 0, + behind: 0, + staged: [], + modified: [], + deleted: [], + untracked: [], + }; + } + + if (op === "file.searchContent") { + return { + files: [], + totalMatchCount: 0, + hasMoreFiles: false, + truncatedMatchFileCount: 0, + }; + } + + return []; + }); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(wsClientAtom, { sendCommand } as never); + seedReadyWorkspaceState(store, { + "ws-test": { + id: "ws-test", + path: "/home/spencer/workspace/coder-studio", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + + render( + + + + } /> + + + + ); + + fireEvent.click(screen.getByRole("button", { name: /Search|搜索/i })); + expect(screen.getByRole("searchbox", { name: /Search|搜索/i })).toBeInTheDocument(); + }); +``` + +- [ ] **Step 2: Run the Search panel tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/search-panel.test.tsx \ + src/features/workspace/index.test.tsx +``` + +Expected: +- FAIL because `SearchPanel` does not exist +- FAIL because the desktop Search view still only renders the placeholder state + +- [ ] **Step 3: Implement `SearchPanel`, highlight rendering, retry, and truncation messaging** + +Create `packages/web/src/features/workspace/views/shared/search-panel.tsx`: + +```tsx +import type { SearchContentMatch, SearchContentResult } from "@coder-studio/core"; +import { useAtomValue } from "jotai"; +import type { FC, ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { dispatchCommandAtom } from "../../../../atoms/connection"; +import { Button } from "../../../../components/ui"; +import { useTranslation } from "../../../../lib/i18n"; +import { useOpenLocation } from "../../../code-editor/actions/use-open-location"; +import { PanelHeader } from "../../../shared/components/panel-header"; + +interface SearchPanelProps { + workspaceId: string; +} + +function renderPreview(match: SearchContentMatch): ReactNode { + const start = Math.max(0, match.previewColumnStart - 1); + const end = Math.max(start, match.previewColumnEnd - 1); + + return ( + <> + {match.preview.slice(0, start)} + {match.preview.slice(start, end)} + {match.preview.slice(end)} + + ); +} + +export const SearchPanel: FC = ({ workspaceId }) => { + const t = useTranslation(); + const dispatch = useAtomValue(dispatchCommandAtom); + const { openLocation } = useOpenLocation(workspaceId); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [retryNonce, setRetryNonce] = useState(0); + const [results, setResults] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + const trimmed = query.trim(); + if (!trimmed) { + setResults(null); + setLoading(false); + setError(false); + return; + } + + let cancelled = false; + setLoading(true); + setError(false); + + const timeout = window.setTimeout(() => { + void dispatch("file.searchContent", { + workspaceId, + query: trimmed, + maxFiles: 50, + maxMatchesPerFile: 20, + }) + .then((result) => { + if (cancelled) { + return; + } + if (!result.ok || !result.data) { + setResults(null); + setError(true); + return; + } + setResults(result.data); + }) + .catch(() => { + if (!cancelled) { + setResults(null); + setError(true); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + }, 250); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [dispatch, query, retryNonce, workspaceId]); + + const renderedMatchCount = useMemo( + () => results?.files.reduce((sum, file) => sum + file.matchCount, 0) ?? 0, + [results] + ); + + return ( +
+ + +
+ setQuery(event.target.value)} + placeholder={t("workspace.search.placeholder")} + /> + +
+ {loading + ? t("common.loading") + : query.trim() + ? t("workspace.search.results_count", { + count: renderedMatchCount, + files: results?.files.length ?? 0, + }) + : t("workspace.search.empty")} +
+ + {results && (results.hasMoreFiles || results.truncatedMatchFileCount > 0) ? ( +
+ {t("workspace.search.truncated")} +
+ ) : null} +
+ +
+ {error ? ( +
+

{t("workspace.search.failed")}

+ +
+ ) : !query.trim() ? ( +

{t("workspace.search.empty")}

+ ) : loading ? ( +

{t("common.loading")}

+ ) : !results || results.files.length === 0 ? ( +

{t("workspace.search.no_results")}

+ ) : ( + results.files.map((file) => ( +
+
+ {file.name} + {file.path} + + {t("workspace.search.file_match_count", { + count: file.matchCount, + suffix: file.hasMoreMatches ? "+" : "", + })} + +
+ + {file.matches.map((match) => ( + + ))} +
+ )) + )} +
+
+ ); +}; +``` + +Update `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` to replace the placeholder Search view: + +```diff ++import { SearchPanel } from "../shared/search-panel"; + + {desktopSidebarView === "explorer" ? ( + setFileTreeCollapseVersion((value) => value + 1)} + /> + ) : desktopSidebarView === "search" ? ( +-
+- +-
+- {t("workspace.search.empty")}

} +- /> +-
+-
++ + ) : ( +``` + +Add Search view styles to `packages/web/src/styles/components.css`: + +```css +.workspace-search-panel__controls { + display: grid; + gap: 8px; + padding: 8px 12px 10px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 82%, transparent); +} + +.workspace-search-panel__input { + min-height: 32px; + border: 1px solid color-mix(in srgb, var(--border) 88%, transparent); + border-radius: 7px; + background: color-mix(in srgb, var(--bg-surface) 92%, var(--bg-panel)); + color: var(--text-primary); + padding: 0 10px; +} + +.workspace-search-panel__summary, +.workspace-search-panel__state, +.workspace-search-panel__group-header span, +.workspace-search-panel__line, +.workspace-search-panel__truncate-note { + color: var(--text-tertiary); + font-size: 12px; +} + +.workspace-search-panel__truncate-note { + padding: 6px 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--bg-hover) 90%, transparent); +} + +.workspace-search-panel__results { + display: grid; + gap: 10px; + min-height: 0; + overflow: auto; + padding: 10px 12px 12px; +} + +.workspace-search-panel__group { + display: grid; + gap: 4px; +} + +.workspace-search-panel__group-header { + display: grid; + gap: 2px; +} + +.workspace-search-panel__match { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 8px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--text-secondary); + padding: 6px 8px; + text-align: left; +} + +.workspace-search-panel__match:hover { + background: color-mix(in srgb, var(--bg-hover) 92%, transparent); +} + +.workspace-search-panel__preview mark { + background: color-mix(in srgb, var(--accent-blue) 30%, transparent); + color: var(--text-primary); +} + +.workspace-search-panel__state-block { + display: grid; + justify-items: start; + gap: 8px; +} +``` + +Add Search strings to `packages/web/src/locales/en.json` and `packages/web/src/locales/zh.json`: + +```json +"workspace": { + "search": { + "placeholder": "Search workspace contents", + "empty": "Type to search across file contents", + "no_results": "No content matches found", + "failed": "Search failed. Try again.", + "retry": "Retry", + "results_count": "{count} matches across {files} files", + "file_match_count": "{count}{suffix} matches", + "truncated": "Results limited to 50 files and 20 visible matches per file." + } +} +``` + +```json +"workspace": { + "search": { + "placeholder": "搜索当前工作区内容", + "empty": "输入关键词以搜索文件内容", + "no_results": "没有找到内容匹配项", + "failed": "搜索失败,请重试。", + "retry": "重试", + "results_count": "{files} 个文件中共 {count} 个匹配", + "file_match_count": "{count}{suffix} 个匹配", + "truncated": "结果已截断:最多显示 50 个文件,每个文件最多显示 20 条匹配。" + } +} +``` + +- [ ] **Step 4: Re-run the Search panel tests and make them pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/search-panel.test.tsx \ + src/features/workspace/index.test.tsx +``` + +Expected: +- PASS +- Search debounces requests at `250ms` +- Search result clicks update `activeFilePathAtomFamily` and `pendingEditorNavigationAtomFamily` +- highlight rendering uses backend preview columns +- truncation and retry states are covered + +- [ ] **Step 5: Commit the desktop Search slice** + +```bash +git add \ + packages/web/src/features/workspace/views/shared/search-panel.tsx \ + packages/web/src/features/workspace/views/shared/search-panel.test.tsx \ + packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx \ + packages/web/src/features/workspace/index.test.tsx \ + packages/web/src/styles/components.css \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "feat: add desktop workspace search panel" +``` + +--- + +### Task 4: Add Quick Open and Expose It From Desktop Quick Actions + +**Files:** +- Create: `packages/web/src/features/quick-open/components/quick-open.tsx` +- Create: `packages/web/src/features/quick-open/components/quick-open.test.tsx` +- Create: `packages/web/src/features/quick-open/index.tsx` +- Modify: `packages/web/src/atoms/app-ui.ts` +- Modify: `packages/web/src/features/command-palette/components/command-palette.tsx` +- Modify: `packages/web/src/features/command-palette/components/command-palette.test.tsx` +- Modify: `packages/web/src/shells/desktop-shell.tsx` +- Modify: `packages/web/src/shells/desktop-shell.test.tsx` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` + +- [ ] **Step 1: Write the failing Quick Open and quick-action tests** + +Create `packages/web/src/features/quick-open/components/quick-open.test.tsx`: + +```tsx +// @vitest-environment jsdom + +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { activeWorkspaceIdAtom, workspaceOrderAtom, workspacesAtom } from "../../../atoms/workspaces"; +import { quickOpenOpenAtom } from "../../../atoms/app-ui"; +import { wsClientAtom } from "../../../atoms/connection"; +import { activeFilePathAtomFamily } from "../../workspace/atoms/files"; +import { QuickOpen } from "./quick-open"; + +function seedWorkspace(store: ReturnType) { + store.set(workspacesAtom, { + "ws-test": { + id: "ws-test", + path: "/workspace", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + } as never); + store.set(workspaceOrderAtom, ["ws-test"]); + store.set(activeWorkspaceIdAtom, "ws-test"); +} + +describe("QuickOpen", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("opens on Ctrl/Cmd+P and queries file.search for the active workspace", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }], + }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspace(store); + + render( + + + + ); + + fireEvent.keyDown(window, { key: "p", ctrlKey: true }); + fireEvent.change(screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }), { + target: { value: "app" }, + }); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "file.search", + { + workspaceId: "ws-test", + query: "app", + limit: 25, + }, + undefined + ); + }); + + expect(await screen.findByText("app.tsx")).toBeInTheDocument(); + }); + + it("opens the selected file and closes after Enter", async () => { + const sendCommand = vi.fn().mockResolvedValue({ + files: [{ path: "src/app.tsx", name: "app.tsx", kind: "file" }], + }); + const store = createStore(); + store.set(wsClientAtom, { sendCommand } as never); + seedWorkspace(store); + store.set(quickOpenOpenAtom, true); + + render( + + + + ); + + fireEvent.change(screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }), { + target: { value: "app" }, + }); + + await screen.findByText("app.tsx"); + fireEvent.keyDown(screen.getByRole("textbox", { name: /Go to File|跳转到文件/i }), { + key: "Enter", + }); + + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/app.tsx"); + expect(store.get(quickOpenOpenAtom)).toBe(false); + }); +}); +``` + +Add these tests to `packages/web/src/features/command-palette/components/command-palette.test.tsx`: + +```tsx + it("opens Quick Open from the quick actions list on desktop", () => { + const store = createStore(); + store.set(localeAtom, "en"); + store.set(commandPaletteOpenAtom, true); + store.set(workspacesAtom, { + "ws-1": createWorkspace("ws-1", "/tmp/one"), + }); + store.set(workspaceOrderAtom, ["ws-1"]); + store.set(workspacesLoadStateAtom, "ready"); + + render( + + + + ); + + fireEvent.click(screen.getByText("Go to File...")); + + expect(store.get(quickOpenOpenAtom)).toBe(true); + expect(store.get(commandPaletteOpenAtom)).toBe(false); + }); + + it("hides Go to File on mobile", () => { + viewportMocks.viewport = "mobile"; + + const store = createStore(); + store.set(localeAtom, "en"); + store.set(commandPaletteOpenAtom, true); + store.set(workspacesAtom, { + "ws-1": createWorkspace("ws-1", "/tmp/one"), + }); + store.set(workspaceOrderAtom, ["ws-1"]); + store.set(workspacesLoadStateAtom, "ready"); + + render( + + + + ); + + expect(screen.queryByText("Go to File...")).toBeNull(); + }); +``` + +Update `packages/web/src/shells/desktop-shell.test.tsx` mocks and add one coverage check: + +```tsx +vi.mock("../features/quick-open", () => ({ + QuickOpen: () =>
QuickOpen
, +})); + +it("mounts QuickOpen beside CommandPalette on desktop", () => { + window.history.replaceState({}, "", "/workspace"); + + const store = createStore(); + store.set(connectionStatusAtom, "connected"); + store.set(authEnabledAtom, false); + store.set(authenticatedAtom, true); + store.set(workspacesAtom, { + "ws-1": { + id: "ws-1", + path: "/tmp/ws-1", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: { + leftPanelWidth: 280, + bottomPanelHeight: 200, + focusMode: false, + }, + }, + }); + store.set(workspaceOrderAtom, ["ws-1"]); + store.set(activeWorkspaceIdAtom, "ws-1"); + store.set(workspacesLoadStateAtom, "ready"); + + renderShell(store); + + expect(screen.getByText("QuickOpen")).toBeInTheDocument(); +}); +``` + +- [ ] **Step 2: Run the Quick Open tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/quick-open/components/quick-open.test.tsx \ + src/features/command-palette/components/command-palette.test.tsx \ + src/shells/desktop-shell.test.tsx +``` + +Expected: +- FAIL because `quickOpenOpenAtom` and `QuickOpen` do not exist +- FAIL because the command palette has no “Go to File...” action +- FAIL because desktop shell does not mount the overlay + +- [ ] **Step 3: Implement Quick Open, its global state, and the desktop quick-action bridge** + +In `packages/web/src/atoms/app-ui.ts`, add the new overlay state: + +```diff + export const commandPaletteOpenAtom = atom(false); ++export const quickOpenOpenAtom = atom(false); +``` + +Create `packages/web/src/features/quick-open/components/quick-open.tsx`: + +```tsx +import type { FileNode } from "@coder-studio/core"; +import { useAtom, useAtomValue } from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { quickOpenOpenAtom } from "../../../atoms/app-ui"; +import { dispatchCommandAtom } from "../../../atoms/connection"; +import { resolvedActiveWorkspaceIdAtom } from "../../../atoms/workspaces"; +import { EmptyState, ThemedIcon, WorkbenchLayer } from "../../../components/ui"; +import { useTranslation } from "../../../lib/i18n"; +import { useOpenLocation } from "../../code-editor/actions/use-open-location"; + +interface SearchFilesResult { + files: FileNode[]; +} + +export function QuickOpen() { + const t = useTranslation(); + const [open, setOpen] = useAtom(quickOpenOpenAtom); + const workspaceId = useAtomValue(resolvedActiveWorkspaceIdAtom); + const dispatch = useAtomValue(dispatchCommandAtom); + const { openLocation } = useOpenLocation(workspaceId ?? "__workspace_placeholder__"); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "p") { + event.preventDefault(); + setOpen(true); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [setOpen]); + + useEffect(() => { + if (!open) { + return; + } + + inputRef.current?.focus(); + setQuery(""); + setSelectedIndex(0); + setResults([]); + setError(null); + }, [open]); + + useEffect(() => { + if (!open || !workspaceId) { + return; + } + + const trimmed = query.trim(); + if (!trimmed) { + setResults([]); + setLoading(false); + setError(null); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + + const timeout = window.setTimeout(() => { + void dispatch("file.search", { + workspaceId, + query: trimmed, + limit: 25, + }) + .then((result) => { + if (cancelled) return; + if (!result.ok || !result.data) { + setError(t("quick_open.failed")); + setResults([]); + return; + } + setResults(result.data.files); + setSelectedIndex(0); + }) + .catch(() => { + if (!cancelled) { + setError(t("quick_open.failed")); + setResults([]); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + }, 150); + + return () => { + cancelled = true; + window.clearTimeout(timeout); + }; + }, [dispatch, open, query, t, workspaceId]); + + if (!open) { + return null; + } + + const activeResult = results[selectedIndex] ?? null; + + return ( + inputRef.current} + onOpenChange={setOpen} + open + > +
{ + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, Math.max(results.length - 1, 0))); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + return; + } + if (event.key === "Enter" && activeResult && workspaceId) { + event.preventDefault(); + void openLocation({ + workspaceId, + path: activeResult.path, + source: "manual", + }); + setOpen(false); + } + }} + > +
+ + setQuery(event.target.value)} + /> +
+ +
+ {!workspaceId ? ( + {t("workspace.no_workspace")}

} /> + ) : error ? ( +

{error}

+ ) : !query.trim() ? ( +

{t("quick_open.empty")}

+ ) : loading ? ( +

{t("common.loading")}

+ ) : results.length === 0 ? ( +

{t("quick_open.no_results")}

+ ) : ( + results.map((file, index) => ( + + )) + )} +
+
+
+ ); +} +``` + +Create the barrel `packages/web/src/features/quick-open/index.tsx`: + +```tsx +export { QuickOpen } from "./components/quick-open"; +``` + +Mount the overlay in `packages/web/src/shells/desktop-shell.tsx`: + +```diff ++import { QuickOpen } from "../features/quick-open"; + import { CommandPalette } from "../features/command-palette"; + + + ++ + + +``` + +Add the desktop-only quick-action bridge to `packages/web/src/features/command-palette/components/command-palette.tsx`: + +```diff +-import { commandPaletteOpenAtom } from "../../../atoms/app-ui"; ++import { commandPaletteOpenAtom, quickOpenOpenAtom } from "../../../atoms/app-ui"; + + const [isOpen, setIsOpen] = useAtom(commandPaletteOpenAtom); ++ const setQuickOpenOpen = useSetAtom(quickOpenOpenAtom); + + const commands = buildCommands({ + shellKind: isMobile ? "mobile" : "desktop", + focusMode, + setFocusMode, + sidebarCollapsed, + setSidebarCollapsed, + terminalPanelVisible, + setTerminalPanelVisible, + bottomPanelHeight, + setBottomPanelHeight, + activeWorkspaceId, + setActiveWorkspaceId, + selectWorkspaceTarget, + workspaces, + locationPathname: location.pathname, + navigate, + t, ++ setQuickOpenOpen, + setShowWorkspaceLaunch: (nextValue) => { + if (nextValue) { + setIsOpen(false); + } + setShowWorkspaceLaunch(nextValue); + }, + }); + + function buildCommands(context: { + shellKind: ShellKind; + focusMode: boolean; + setFocusMode: (v: boolean) => void; + sidebarCollapsed: boolean; + setSidebarCollapsed: (v: boolean) => void; + terminalPanelVisible: boolean; + setTerminalPanelVisible: (v: boolean) => void; + bottomPanelHeight: number; + setBottomPanelHeight: (v: number) => void; + activeWorkspaceId: string | null; + setActiveWorkspaceId: (v: string | null) => void; + selectWorkspaceTarget: (workspaceId: string) => Promise; + workspaces: Workspace[]; + locationPathname: string; + navigate: (path: string) => void; + t: (key: string) => string; ++ setQuickOpenOpen: (v: boolean) => void; + setShowWorkspaceLaunch: (v: boolean) => void; + }): Command[] { + const { + shellKind, + focusMode, + setFocusMode, + sidebarCollapsed, + setSidebarCollapsed, + terminalPanelVisible, + setTerminalPanelVisible, + bottomPanelHeight, + setBottomPanelHeight, + activeWorkspaceId, + setActiveWorkspaceId, + selectWorkspaceTarget, + workspaces, + locationPathname, + navigate, + t, ++ setQuickOpenOpen, + setShowWorkspaceLaunch, + } = context; + + const commands: Command[] = [ + { + id: "new-workspace", + label: t("workspace.open"), + description: t("workspace.open_hint"), + shortcut: "Ctrl+N", + action: () => { + setShowWorkspaceLaunch(true); + }, + }, + ]; + + if (shellKind === "desktop" && activeWorkspaceId) { + commands.push({ + id: "go-to-file", + label: t("quick_open.command_label"), + description: t("quick_open.command_description"), + shortcut: "Ctrl+P", + action: () => { + setQuickOpenOpen(true); + }, + }); + } +``` + +Add Quick Open styles to `packages/web/src/styles/components.css`: + +```css +.quick-open { + width: min(720px, calc(100vw - 32px)); + border-radius: 14px; + background: var(--bg-panel); + box-shadow: var(--shadow-xl); + overflow: hidden; +} + +.quick-open__search { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 84%, transparent); +} + +.quick-open__input { + width: 100%; + border: none; + background: transparent; + color: var(--text-primary); +} + +.quick-open__list { + display: grid; + gap: 2px; + max-height: 420px; + overflow: auto; + padding: 8px; +} + +.quick-open__item { + display: grid; + gap: 2px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + padding: 10px 12px; + text-align: left; +} + +.quick-open__item--active, +.quick-open__item:hover { + background: color-mix(in srgb, var(--bg-hover) 92%, transparent); +} + +.quick-open__name { + color: var(--text-primary); + font-size: var(--type-body-3-size); +} + +.quick-open__path, +.quick-open__state { + color: var(--text-tertiary); + font-size: var(--type-body-6-size); +} +``` + +Add Quick Open strings to `packages/web/src/locales/en.json` and `packages/web/src/locales/zh.json`: + +```json +"quick_open": { + "title": "Go to File", + "placeholder": "Type a file name or path", + "empty": "Type a file name or path to jump", + "no_results": "No files found", + "failed": "Unable to search files right now", + "command_label": "Go to File...", + "command_description": "Jump to a file in the current workspace" +} +``` + +```json +"quick_open": { + "title": "跳转到文件", + "placeholder": "输入文件名或路径", + "empty": "输入文件名或路径以跳转", + "no_results": "没有找到文件", + "failed": "暂时无法搜索文件", + "command_label": "跳转到文件...", + "command_description": "在当前工作区中快速打开文件" +} +``` + +- [ ] **Step 4: Re-run the Quick Open tests and make them pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/quick-open/components/quick-open.test.tsx \ + src/features/command-palette/components/command-palette.test.tsx \ + src/shells/desktop-shell.test.tsx +``` + +Expected: +- PASS +- `Ctrl/Cmd+P` opens Quick Open +- clicking “Go to File...” from the command palette opens Quick Open and closes the palette +- “Go to File...” does not appear in the mobile command palette +- desktop shell mounts both global overlays + +- [ ] **Step 5: Commit the Quick Open slice** + +```bash +git add \ + packages/web/src/atoms/app-ui.ts \ + packages/web/src/features/quick-open/components/quick-open.tsx \ + packages/web/src/features/quick-open/components/quick-open.test.tsx \ + packages/web/src/features/quick-open/index.tsx \ + packages/web/src/features/command-palette/components/command-palette.tsx \ + packages/web/src/features/command-palette/components/command-palette.test.tsx \ + packages/web/src/shells/desktop-shell.tsx \ + packages/web/src/shells/desktop-shell.test.tsx \ + packages/web/src/styles/components.css \ + packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json +git commit -m "feat: add quick open file jump overlay" +``` + +--- + +## Self-Review Checklist + +1. **Spec coverage** + - Task 1 covers the Activity Bar, independent desktop sidebar views, Explorer ownership, and desktop filename-search removal. + - Task 2 covers `file.searchContent`, grouped content-search results, `rg`, fallback scanning, ignore handling, binary skipping, preview truncation, and bounded result limits. + - Task 3 covers the Search sidebar view, debounce, grouped matches, highlighted previews from backend columns, retry, and match-click open-location behavior. + - Task 4 covers `Ctrl/Cmd+P`, Quick Open overlay behavior, desktop-only quick-action entry, and workspace-only file jump scope. + +2. **Placeholder scan** + - Task 1 intentionally uses a temporary Search placeholder so the sidebar shell can land before the real Search panel exists. + - Task 3 explicitly removes that placeholder and swaps in `SearchPanel`. + - No step relies on `TODO`, `TBD`, or unnamed helpers. + +3. **Type consistency** + - `DesktopSidebarView` values are always `explorer`, `search`, and `source-control`. + - The backend content-search command is always named `file.searchContent`. + - Search result open-location uses `source: "search"`. + - Quick Open open-location uses `source: "manual"`. + - Quick Open command-palette entry is desktop-only and requires an active workspace. diff --git a/docs/superpowers/plans/2026-05-23-workspace-tab-instance-isolation.md b/docs/superpowers/plans/2026-05-23-workspace-tab-instance-isolation.md new file mode 100644 index 00000000..f12e7e13 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-workspace-tab-instance-isolation.md @@ -0,0 +1,73 @@ +# Workspace Tab Instance Isolation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make each workspace behave like its own tab instance with isolated layout state and isolated panel/view state. + +**Architecture:** Introduce workspace-scoped layout buckets for persistent state, keep adapter atoms for the active workspace, and move panel-local state that must restore across workspace switches into workspace-scoped in-memory buckets. Key the workspace root by `workspace.id` so React never reuses one instance across different workspaces. + +**Tech Stack:** React 19, Jotai, Vitest, Testing Library + +--- + +### Task 1: Lock the bug with integration tests + +**Files:** +- Modify: `packages/web/src/features/workspace/index.test.tsx` +- Test: `packages/web/src/features/workspace/index.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Add one test that proves the sidebar tab and terminal/sidebar layout state are isolated between `ws-a` and `ws-b`, and another test that proves content search query/results restore when switching back to the original workspace. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/index.test.tsx -t "workspace-scoped"` +Expected: FAIL because the current implementation reuses shared workspace UI state. + +### Task 2: Implement workspace-scoped layout state + +**Files:** +- Modify: `packages/web/src/features/workspace/atoms/layout.ts` +- Modify: `packages/web/src/features/workspace/actions/use-workspace-layout-actions.ts` +- Modify: `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts` +- Modify: `packages/web/src/features/topbar/index.tsx` +- Modify: `packages/web/src/features/command-palette/components/command-palette.tsx` +- Modify: `packages/web/src/features/focus-mode/components/focus-mode.tsx` + +- [ ] **Step 1: Add workspaceId-keyed layout state families and active-workspace adapter atoms** +- [ ] **Step 2: Update layout actions and screen model to read/write the correct workspace bucket** +- [ ] **Step 3: Update topbar, command palette, and focus mode to operate on the active workspace bucket** +- [ ] **Step 4: Run the targeted workspace integration test and fix any regressions** + +### Task 3: Implement workspace-scoped panel instance state + +**Files:** +- Modify: `packages/web/src/features/workspace/atoms/layout.ts` +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/search-panel.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/git-panel.tsx` +- Modify: `packages/web/src/features/workspace/views/shared/file-tree-panel.tsx` +- Modify: `packages/web/src/features/workspace/actions/use-workspace-screen-model.ts` + +- [ ] **Step 1: Add workspace-scoped state buckets for panel/session state that must restore on tab switch** +- [ ] **Step 2: Key the workspace root by `workspace.id`** +- [ ] **Step 3: Move search, git panel, file tree search, and screen model local state into workspace buckets** +- [ ] **Step 4: Run the targeted search restoration test and keep both tests green** + +### Task 4: Verify affected focused tests + +**Files:** +- Test: `packages/web/src/features/workspace/index.test.tsx` +- Test: `packages/web/src/features/workspace/views/shared/search-panel.test.tsx` +- Test: `packages/web/src/features/topbar/index.test.tsx` + +- [ ] **Step 1: Run the targeted suite** + +Run: `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/index.test.tsx src/features/workspace/views/shared/search-panel.test.tsx src/features/topbar/index.test.tsx` +Expected: PASS + +- [ ] **Step 2: Review diff for unintended cross-workspace behavior changes** + +Run: `git diff -- packages/web/src/features/workspace packages/web/src/features/topbar` +Expected: only workspace instance isolation changes diff --git a/docs/superpowers/plans/2026-05-24-background-material-settings-restyle.md b/docs/superpowers/plans/2026-05-24-background-material-settings-restyle.md new file mode 100644 index 00000000..9f461e7f --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-background-material-settings-restyle.md @@ -0,0 +1,898 @@ +# Background Material Settings Restyle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restyle the `Background & Material` settings group into layered asset, material, and override surfaces without changing any appearance-personalization behavior. + +**Architecture:** Keep the existing `appearance.personalization` state, field ids, `aria-*` labels, and `settings.update` dispatch flow intact. Only reorganize the `SettingsPage` markup around the current controls, then add token-driven CSS in `components.css` and lock the new structure down with component and theme assertions, including the mobile single-column fallback. + +**Tech Stack:** React 19, Jotai, Vitest, Testing Library, Vite, and the shared token-driven stylesheet in `packages/web/src/styles/components.css`. + +--- + +**Spec reference:** `docs/superpowers/specs/2026-05-24-background-material-settings-restyle-design.md` + +**Git hygiene:** The current worktree already contains unrelated user changes and untracked docs files. Stage only the files listed in each task, and never revert or sweep unrelated edits. + +## File Structure + +**Modified files:** +- `packages/web/src/features/settings/components/settings-page.tsx` — regroup the background/material controls into dedicated asset, material, and override surfaces while preserving the existing state/update logic. +- `packages/web/src/features/settings/components/settings-page.test.tsx` — add structure-level regressions for the new grouped surfaces and nested override panels while keeping the existing persistence coverage intact. +- `packages/web/src/styles/components.css` — add token-driven surface, grid, action-row, nested override, and mobile collapse rules for the appearance section, including hiding the raw file input. +- `packages/web/src/styles/components.theme.test.ts` — assert the new appearance selectors stay on tokens, use a two-column desktop grid, and collapse to a single column on mobile. + +**No backend changes:** +- `appearance.personalization` payload shape stays unchanged. +- `uploadAppearanceAsset` and `deleteAppearanceAsset` call sites stay unchanged. +- `settings.get` / `settings.update` commands stay unchanged. + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/settings-page.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/settings/components/settings-page.test.tsx src/styles/components.theme.test.ts` + +--- + +### Task 1: Regroup The Background-Material Markup + +**Files:** +- Modify: `packages/web/src/features/settings/components/settings-page.test.tsx:1374-1563` +- Modify: `packages/web/src/features/settings/components/settings-page.tsx:2037-2605` + +- [ ] **Step 1: Write the failing structure tests for grouped asset/material surfaces** + +Add these two tests to `packages/web/src/features/settings/components/settings-page.test.tsx` immediately after the existing hydration and override coverage: + +```tsx + it("groups background material controls into asset and material surfaces", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + "appearance.personalization.common.backgroundFit": "contain", + "appearance.personalization.common.backgroundDimness": 33, + "appearance.personalization.common.backgroundBlur": 8, + "appearance.personalization.common.glassEnabled": true, + "appearance.personalization.common.glassIntensity": 44, + "appearance.personalization.common.surfaceOpacity": 91, + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "外观" })); + + const backgroundMaterialGroup = ( + await screen.findByRole("heading", { name: "背景与材质" }) + ).closest(".settings-group"); + + expect(backgroundMaterialGroup).not.toBeNull(); + + const assetPanel = backgroundMaterialGroup?.querySelector( + ".settings-appearance-panel--asset" + ); + const materialPanel = backgroundMaterialGroup?.querySelector( + ".settings-appearance-panel--material" + ); + + expect(assetPanel).not.toBeNull(); + expect(materialPanel).not.toBeNull(); + expect(document.getElementById("appearance-background-mode")?.closest( + ".settings-appearance-panel--asset" + )).toBe(assetPanel); + expect(document.getElementById("appearance-background-fit")?.closest( + ".settings-appearance-panel--asset" + )).toBe(assetPanel); + expect(screen.getByText("asset-common")).toHaveClass("settings-appearance-asset-id"); + expect(assetPanel?.querySelector(".settings-appearance-actions")).not.toBeNull(); + expect( + screen + .getByRole("spinbutton", { name: "背景压暗" }) + .closest(".settings-appearance-material-grid") + ).toBeTruthy(); + expect( + screen + .getByRole("spinbutton", { name: "面板不透明度" }) + .closest(".settings-appearance-material-grid") + ).toBeTruthy(); + }); + + it("renders desktop and mobile override controls inside nested appearance panels", async () => { + const sendCommand = vi.fn().mockImplementation(async (op: string) => { + if (op === "settings.get") { + return { + "appearance.personalization.common.backgroundMode": "image", + "appearance.personalization.common.backgroundAssetId": "asset-common", + }; + } + return {}; + }); + const store = createConnectedStore(sendCommand); + + renderSettingsPage(store); + fireEvent.click(screen.getByRole("button", { name: "外观" })); + + fireEvent.click(await screen.findByRole("switch", { name: "桌面端覆盖" })); + + const desktopSurfaceOpacity = document.getElementById("appearance-desktop-surface-opacity"); + + expect(desktopSurfaceOpacity).not.toBeNull(); + expect(desktopSurfaceOpacity?.closest(".settings-appearance-override-panel")).toBeTruthy(); + expect( + desktopSurfaceOpacity + ?.closest(".settings-appearance-override-panel") + ?.querySelector(".settings-appearance-actions") + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("switch", { name: "移动端覆盖" })); + + const mobileSurfaceOpacity = document.getElementById("appearance-mobile-surface-opacity"); + + expect(mobileSurfaceOpacity).not.toBeNull(); + expect(mobileSurfaceOpacity?.closest(".settings-appearance-override-panel")).toBeTruthy(); + }); +``` + +- [ ] **Step 2: Run the settings-page tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx +``` + +Expected: +- FAIL because `.settings-appearance-panel--asset` and `.settings-appearance-panel--material` do not exist yet +- FAIL because the override inputs are not wrapped in `.settings-appearance-override-panel` + +- [ ] **Step 3: Implement the grouped asset/material/override markup in `settings-page.tsx`** + +In `packages/web/src/features/settings/components/settings-page.tsx`, keep all existing update handlers and field ids, but add a small summary helper and regroup the JSX like this: + +```tsx + const renderAssetSummary = ( + target: AppearanceAssetScope, + label: string, + assetId: string | null | undefined, + hasAsset: boolean + ) => ( +
+
+ {label} + + {assetId ? assetId : t("settings.appearance_uses_shared_value")} + +
+ {renderAssetButtons(target, hasAsset)} +
+ ); +``` + +Then replace the current loose rows inside the `背景与材质` group with this structure: + +```tsx +
+
+
+ +
+ { + void saveNextPersonalization( + updateCommon("backgroundFit", value as AppearanceBackgroundFit) + ); + }} + /> +
+
+
+ +
+
+
+ + {t("settings.appearance_glass_enabled")} + + + {t("settings.appearance_uses_shared_value")} + +
+ { + void saveNextPersonalization(updateCommon("glassEnabled", nextValue)); + }} + /> +
+ +
+
+ +
+ { + void commitBoundedCommonField( + backgroundDimnessDraft, + personalization.common.backgroundDimness, + 0, + 100, + setBackgroundDimnessDraft, + setBackgroundDimnessError, + "backgroundDimness" + ); + }} + onChange={(event) => { + setBackgroundDimnessDraft(event.target.value); + setBackgroundDimnessError(null); + }} + /> +
+ {backgroundDimnessError ? ( + + {backgroundDimnessError} + + ) : null} +
+
+ +
+ { + void commitBoundedCommonField( + backgroundBlurDraft, + personalization.common.backgroundBlur, + 0, + 40, + setBackgroundBlurDraft, + setBackgroundBlurError, + "backgroundBlur" + ); + }} + onChange={(event) => { + setBackgroundBlurDraft(event.target.value); + setBackgroundBlurError(null); + }} + /> +
+ {backgroundBlurError ? ( + + {backgroundBlurError} + + ) : null} +
+
+ +
+ { + void commitBoundedCommonField( + glassIntensityDraft, + personalization.common.glassIntensity, + 0, + 100, + setGlassIntensityDraft, + setGlassIntensityError, + "glassIntensity" + ); + }} + onChange={(event) => { + setGlassIntensityDraft(event.target.value); + setGlassIntensityError(null); + }} + /> +
+ {glassIntensityError ? ( + + {glassIntensityError} + + ) : null} +
+
+ +
+ { + void commitBoundedCommonField( + surfaceOpacityDraft, + personalization.common.surfaceOpacity, + 0, + 100, + setSurfaceOpacityDraft, + setSurfaceOpacityError, + "surfaceOpacity" + ); + }} + onChange={(event) => { + setSurfaceOpacityDraft(event.target.value); + setSurfaceOpacityError(null); + }} + /> +
+ {surfaceOpacityError ? ( + + {surfaceOpacityError} + + ) : null} +
+
+
+ +
+
+
+ + {t("settings.appearance_override_desktop")} + + + {isOverrideEnabled("desktop") + ? t("settings.appearance_override_enabled") + : t("settings.appearance_uses_shared_value")} + +
+ { + void saveNextPersonalization(toggleOverride("desktop", nextValue)); + }} + /> +
+ + {isOverrideEnabled("desktop") ? ( +
+ {personalization.common.backgroundMode === "image" + ? renderAssetSummary( + "desktop", + t("settings.appearance_override_desktop"), + personalization.desktop.backgroundAssetId, + Object.prototype.hasOwnProperty.call( + personalization.desktop, + "backgroundAssetId" + ) + ) + : null} +
+
+ + {t("settings.appearance_glass_enabled")} + + + {t("settings.appearance_override_desktop")} + +
+ { + void saveNextPersonalization( + updateOverride("desktop", "glassEnabled", nextValue) + ); + }} + /> +
+ +
+ +
+ { + void commitBoundedOverrideField( + "desktop", + desktopSurfaceOpacityDraft, + personalization.desktop.surfaceOpacity ?? + personalization.common.surfaceOpacity, + 0, + 100, + setDesktopSurfaceOpacityDraft, + setDesktopSurfaceOpacityError, + "surfaceOpacity" + ); + }} + onChange={(event) => { + setDesktopSurfaceOpacityDraft(event.target.value); + setDesktopSurfaceOpacityError(null); + }} + /> +
+ {desktopSurfaceOpacityError ? ( + + {desktopSurfaceOpacityError} + + ) : null} +
+
+ ) : null} + +
+
+ + {t("settings.appearance_override_mobile")} + + + {isOverrideEnabled("mobile") + ? t("settings.appearance_override_enabled") + : t("settings.appearance_uses_shared_value")} + +
+ { + void saveNextPersonalization(toggleOverride("mobile", nextValue)); + }} + /> +
+ + {isOverrideEnabled("mobile") ? ( +
+ {personalization.common.backgroundMode === "image" + ? renderAssetSummary( + "mobile", + t("settings.appearance_override_mobile"), + personalization.mobile.backgroundAssetId, + Object.prototype.hasOwnProperty.call( + personalization.mobile, + "backgroundAssetId" + ) + ) + : null} +
+
+ + {t("settings.appearance_glass_enabled")} + + + {t("settings.appearance_override_mobile")} + +
+ { + void saveNextPersonalization( + updateOverride("mobile", "glassEnabled", nextValue) + ); + }} + /> +
+ +
+ +
+ { + void commitBoundedOverrideField( + "mobile", + mobileSurfaceOpacityDraft, + personalization.mobile.surfaceOpacity ?? + personalization.common.surfaceOpacity, + 0, + 100, + setMobileSurfaceOpacityDraft, + setMobileSurfaceOpacityError, + "surfaceOpacity" + ); + }} + onChange={(event) => { + setMobileSurfaceOpacityDraft(event.target.value); + setMobileSurfaceOpacityError(null); + }} + /> +
+ {mobileSurfaceOpacityError ? ( + + {mobileSurfaceOpacityError} + + ) : null} +
+
+ ) : null} +
+
+ + {assetActionError ? ( + + {assetActionError} + + ) : null} +``` + +Important guardrails while applying that snippet: +- Keep every existing `id`, `aria-label`, and `aria-describedby` value unchanged. +- Keep every existing `settings.update` payload unchanged. +- Keep `renderAssetButtons()` behavior unchanged; only move where it renders. +- Do not rename the existing numeric draft states or validation state setters. + +- [ ] **Step 4: Run the settings-page tests to verify the regrouped markup passes** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx +``` + +Expected: +- PASS for the two new structure tests +- PASS for the existing hydration, override, upload, and delete tests + +- [ ] **Step 5: Commit the markup regrouping** + +```bash +git add \ + packages/web/src/features/settings/components/settings-page.tsx \ + packages/web/src/features/settings/components/settings-page.test.tsx +git commit -m "fix(web): regroup background material settings" +``` + +--- + +### Task 2: Add Layered CSS And Theme Assertions + +**Files:** +- Modify: `packages/web/src/styles/components.theme.test.ts:2407-2485` +- Modify: `packages/web/src/styles/components.css:996-1405` +- Modify: `packages/web/src/styles/components.css:11960-12017` + +- [ ] **Step 1: Write the failing theme assertions for the new appearance surfaces** + +Add this test to `packages/web/src/styles/components.theme.test.ts` immediately after `keeps settings content groups and provider controls aligned with editor configuration panels`: + +```tsx + it("keeps background-material settings on layered surfaces with a responsive material grid", () => { + const hiddenFileInput = getLastRuleBlock(".settings-appearance-file-input"); + const appearancePanel = getLastRuleBlock(".settings-appearance-panel"); + const assetSummaryBase = getRuleBlocksFrom(stylesheet, ".settings-appearance-asset-summary")[0]; + const assetId = getLastRuleBlock(".settings-appearance-asset-id"); + const actionsBase = getRuleBlocksFrom(stylesheet, ".settings-appearance-actions")[0]; + const materialGridBase = getRuleBlocksFrom(stylesheet, ".settings-appearance-material-grid")[0]; + const metricField = getLastRuleBlock(".settings-appearance-metric-field"); + const overridePanel = getLastRuleBlock(".settings-appearance-override-panel"); + const assetSummaryMobile = getLastRuleBlock(".settings-appearance-asset-summary"); + const actionsMobile = getLastRuleBlock(".settings-appearance-actions"); + const materialGridMobile = getLastRuleBlock(".settings-appearance-material-grid"); + + expect(hiddenFileInput).toContain("position: absolute"); + expect(hiddenFileInput).toContain("clip-path: inset(50%)"); + expect(appearancePanel).toContain("border: 1px solid"); + expect(appearancePanel).toContain("border-radius: var(--radius-lg)"); + expect(appearancePanel).toContain("background: color-mix"); + expect(assetSummaryBase).toContain("grid-template-columns: minmax(0, 1fr) auto"); + expect(assetId).toContain("font-family: var(--font-mono)"); + expect(actionsBase).toContain("justify-content: flex-end"); + expect(materialGridBase).toContain("display: grid"); + expect(materialGridBase).toContain("grid-template-columns: repeat(2, minmax(0, 1fr))"); + expect(metricField).toContain("margin-bottom: 0"); + expect(metricField).toContain("border-radius: var(--radius-md)"); + expect(overridePanel).toContain("background: color-mix"); + expect(overridePanel).toContain("border: 1px solid"); + expect(assetSummaryMobile).toContain("grid-template-columns: 1fr"); + expect(actionsMobile).toContain("justify-content: flex-start"); + expect(materialGridMobile).toContain("grid-template-columns: 1fr"); + }); +``` + +- [ ] **Step 2: Run the theme test to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/styles/components.theme.test.ts +``` + +Expected: +- FAIL because `.settings-appearance-file-input`, `.settings-appearance-panel`, `.settings-appearance-asset-summary`, `.settings-appearance-material-grid`, and `.settings-appearance-override-panel` rules do not exist yet + +- [ ] **Step 3: Add the appearance surface and mobile-collapse rules in `components.css`** + +Insert the desktop/base rules near the existing settings layout rules in `packages/web/src/styles/components.css`, directly after `.settings-input-compact`: + +```css +.settings-appearance-file-input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.settings-appearance-panels { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} + +.settings-appearance-panel { + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-4); + border: 1px solid color-mix(in srgb, var(--border) 78%, transparent); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--bg-input) 72%, var(--bg-surface) 28%); +} + +.settings-appearance-panel .settings-config-field:last-child, +.settings-appearance-panel .settings-toggle-row:last-child { + margin-bottom: 0; + border-bottom: none; +} + +.settings-appearance-panel .settings-config-field { + margin-bottom: 0; +} + +.settings-appearance-asset-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: var(--sp-3); + padding: var(--sp-3); + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-surface) 82%, transparent); +} + +.settings-appearance-asset-meta { + min-width: 0; +} + +.settings-appearance-asset-id { + display: block; + overflow-wrap: anywhere; + font-family: var(--font-mono); +} + +.settings-appearance-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--sp-2); +} + +.settings-appearance-material-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sp-3); +} + +.settings-appearance-metric-field { + margin-bottom: 0; + padding: var(--sp-3); + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--bg-surface) 78%, transparent); +} + +.settings-appearance-metric-field .settings-config-control { + justify-content: flex-start; +} + +.settings-appearance-metric-field .settings-input-compact { + width: 100%; + text-align: left; +} + +.settings-appearance-overrides { + display: flex; + flex-direction: column; +} + +.settings-appearance-override-panel { + display: flex; + flex-direction: column; + gap: var(--sp-3); + margin-top: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border: 1px solid color-mix(in srgb, var(--border) 68%, transparent); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--bg-surface) 74%, transparent); +} + +.settings-appearance-override-panel .settings-config-field:last-child, +.settings-appearance-override-panel .settings-toggle-row:last-child { + margin-bottom: 0; + border-bottom: none; +} + +.settings-appearance-override-panel .settings-config-field { + margin-bottom: 0; +} +``` + +Then add the mobile fallback rules inside the existing mobile settings block near `.settings-content--mobile .settings-content-surface`: + +```css + .settings-appearance-asset-summary { + grid-template-columns: 1fr; + } + + .settings-appearance-actions { + justify-content: flex-start; + } + + .settings-appearance-material-grid { + grid-template-columns: 1fr; + } + + .settings-appearance-metric-field .settings-input-compact { + text-align: left; + } +``` + +Important guardrails while applying those rules: +- Use existing tokens only; do not introduce hard-coded light-only fills. +- Keep the override panel visually weaker than the main appearance surfaces. +- Do not change unrelated settings-group spacing or global form rules. + +- [ ] **Step 4: Run the combined settings and theme suites** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/settings/components/settings-page.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- PASS for the grouped settings-page tests +- PASS for the new theme assertions +- PASS for the pre-existing settings/theme regressions + +- [ ] **Step 5: Commit the CSS and theme coverage** + +```bash +git add \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "fix(web): polish background material settings styling" +``` diff --git a/docs/superpowers/plans/2026-05-24-git-panel-worktree-list.md b/docs/superpowers/plans/2026-05-24-git-panel-worktree-list.md new file mode 100644 index 00000000..7b9bc19b --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-git-panel-worktree-list.md @@ -0,0 +1,268 @@ +# Git Panel Worktree List Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Clean up the Git panel compact worktree list by shortening branch refs, adding inline delete for removable worktrees, and exposing the full manager list view from the panel. + +**Architecture:** Keep the existing worktree commands and action hook intact. Update the Git panel to derive a compact branch label, split each row into a main open/switch button plus an optional delete button, and reuse the already-mounted `WorktreeManagerSurface` for a new `Manage` entry point. + +**Tech Stack:** React 19, Jotai, Testing Library, Vitest, shared Button/IconButton/ConfirmDialog primitives, existing worktree action hooks, and shared CSS assertions in `packages/web/src/styles/components.theme.test.ts`. + +**Spec reference:** `docs/superpowers/specs/2026-05-24-git-panel-worktree-list-design.md` + +--- + +## File Structure + +**Modify:** +- `packages/web/src/features/workspace/views/shared/git-panel.tsx` — compact worktree row rendering, manage entry point, delete confirm state, branch label formatting +- `packages/web/src/features/workspace/views/shared/git-panel.test.tsx` — focused behavior coverage for compact list management and delete actions +- `packages/web/src/styles/components.css` — compact row/action layout updates for split controls +- `packages/web/src/styles/components.theme.test.ts` — keep compact row size assertions aligned with the new structure + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/git-panel.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/git-panel.test.tsx src/styles/components.theme.test.ts` + +--- + +### Task 1: Add Failing Git Panel Tests + +**Files:** +- Modify: `packages/web/src/features/workspace/views/shared/git-panel.test.tsx` + +- [ ] **Step 1: Write the failing test for compact branch display** + +Add a test that seeds a worktree branch as `refs/heads/develop`, expands the worktree section, and expects: + +```ts +expect(screen.getByText("develop")).toBeInTheDocument(); +expect(screen.queryByText("refs/heads/develop")).toBeNull(); +``` + +- [ ] **Step 2: Write the failing test for the manage entry point** + +Add a test that clicks the Git panel `Manage` action and expects the full manager surface to appear: + +```ts +fireEvent.click(screen.getByRole("button", { name: "Manage" })); +expect(await screen.findByRole("dialog", { name: "Worktrees" })).toBeInTheDocument(); +``` + +- [ ] **Step 3: Write the failing test for inline delete on removable worktrees** + +Add a test that expands the worktree list and asserts: + +```ts +expect(screen.queryByRole("button", { name: "Remove feature/ai-agent" })).toBeNull(); +expect(screen.queryByRole("button", { name: "Remove develop" })).toBeNull(); +expect(screen.getByRole("button", { name: "Remove performance-monitoring" })).toBeInTheDocument(); +``` + +- [ ] **Step 4: Write the failing test for dirty delete dispatch** + +Add a test that clicks the removable dirty worktree delete button, confirms the destructive action, and expects: + +```ts +expect(sendCommand).toHaveBeenCalledWith( + "worktree.remove", + { + workspaceId: "ws-test", + worktreePath: "/home/spencer/workspace/coder-studio-performance-monitoring", + force: true, + }, + undefined +); +``` + +- [ ] **Step 5: Run the Git panel test file to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/git-panel.test.tsx +``` + +Expected: +- FAIL because the current compact list still renders full refs, has no `Manage` action, and has no inline delete button + +--- + +### Task 2: Implement Compact Row Management and Delete + +**Files:** +- Modify: `packages/web/src/features/workspace/views/shared/git-panel.tsx` + +- [ ] **Step 1: Add compact branch formatting and removable-row rules** + +Add a small helper in `git-panel.tsx`: + +```ts +function formatCompactWorktreeBranch(branch: string) { + return branch.startsWith("refs/heads/") ? branch.slice("refs/heads/".length) : branch; +} +``` + +Use the row index to keep the first worktree non-removable, and treat the current worktree as non-removable. + +- [ ] **Step 2: Add manage entry point and row-level delete state** + +Extend local Git panel state with: + +```ts +pendingWorktreeDeletePath: string | null; +``` + +Expose a `Manage` section-link action that sets: + +```ts +worktreeSurfaceView: "list" +``` + +Also add helpers to open/close compact delete confirmation. + +- [ ] **Step 3: Split each compact row into main/open and delete controls** + +Replace the single row button with a row container: + +```tsx +
+ + {isRemovable ? ( + } + onClick={...} + size="sm" + type="button" + variant="ghost" + /> + ) : null} +
+``` + +Use `formatCompactWorktreeBranch(worktree.branch)` in the meta line. + +- [ ] **Step 4: Reuse existing delete command behavior** + +Wire confirm handling to: + +```ts +await removeWorktreeByPath(target.path, target.status === "dirty"); +``` + +On success, close the compact delete dialog. +On failure, keep the dialog open and show the returned error message. + +- [ ] **Step 5: Run the Git panel tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/workspace/views/shared/git-panel.test.tsx +``` + +Expected: +- PASS + +--- + +### Task 3: Update Compact Row Styling and Verify + +**Files:** +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Update compact row CSS for split controls** + +Adjust `.git-worktree-row` to remain the outer container and add: + +```css +.git-worktree-row__main { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 10px; + padding: 6px 10px; + border: 0; + background: transparent; + color: inherit; + text-align: left; +} + +.git-worktree-row__delete { + flex-shrink: 0; +} +``` + +Keep compact density consistent with the existing tool-surface row height expectations. + +- [ ] **Step 2: Keep theme assertions aligned with the compact row density** + +Update `components.theme.test.ts` only if the selector coverage needs to assert the new structure while preserving: + +```ts +expect(gitWorktreeRow).toContain("min-height: 28px"); +``` + +- [ ] **Step 3: Run style/theme verification** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: +- PASS + +--- + +### Task 4: Run Final Focused Verification + +**Files:** +- No file changes + +- [ ] **Step 1: Run the focused verification bundle** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/workspace/views/shared/git-panel.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- PASS + +- [ ] **Step 2: Review output for regressions** + +Confirm the run shows: + +- Git panel tests passing +- theme/style tests passing +- no new failing worktree-manager behavior in the touched surface + +- [ ] **Step 3: Commit** + +Run: + +```bash +git add \ + docs/superpowers/specs/2026-05-24-git-panel-worktree-list-design.md \ + docs/superpowers/plans/2026-05-24-git-panel-worktree-list.md \ + packages/web/src/features/workspace/views/shared/git-panel.tsx \ + packages/web/src/features/workspace/views/shared/git-panel.test.tsx \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "feat: refine git panel worktree list actions" +``` + +Expected: +- commit created with only the intended files staged diff --git a/docs/superpowers/plans/2026-05-24-system-dependency-installer.md b/docs/superpowers/plans/2026-05-24-system-dependency-installer.md new file mode 100644 index 00000000..57d56b25 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-system-dependency-installer.md @@ -0,0 +1,1998 @@ +# System Dependency Installer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a first-class system dependency installer for `git` and `node` that supports Linux/macOS package-manager installs, web-based interactive privilege escalation, and diagnostics-page recovery flows. + +**Architecture:** Add a new `systemDeps` domain shared between `@coder-studio/core`, `@coder-studio/server`, and `@coder-studio/web`. Server-side runtime status and install jobs stay separate from provider installation, but reuse the same structured `job/step/failure` model. Interactive installs run in dedicated PTY-backed sessions owned by the system dependency install manager, and the diagnostics page drives them through `systemDeps.install.*` commands plus a dedicated output topic. + +**Tech Stack:** TypeScript, React 19, Jotai, Vitest, Testing Library, Zod, existing websocket command dispatch, existing `node-pty` host, and diagnostics styles in `packages/web/src/styles/components.css`. + +**Spec reference:** `docs/superpowers/specs/2026-05-24-system-dependency-installer-design.md` + +**Git hygiene:** The worktree already contains unrelated user changes and an in-progress merge. Read files before patching them, stage only the files named in each task, and never revert unrelated edits. + +--- + +## File Structure + +**New files:** +- `packages/core/src/domain/system-dependency-install.ts` — shared runtime/install types, dependency ids, and output payload contract +- `packages/core/src/domain/system-dependency-install.test.ts` — runtime helpers and exported constant coverage +- `packages/server/src/system-deps/definitions.ts` — installable dependency definitions, package-manager command templates, docs, and manual guide keys +- `packages/server/src/system-deps/runtime-status.ts` — package-manager detection, version probing, and `autoInstallSupported` calculation +- `packages/server/src/system-deps/interaction-detector.ts` — PTY output parsing for `sudo` password and confirmation prompts +- `packages/server/src/system-deps/install-manager.ts` — job lifecycle, PTY session ownership, output broadcasting, input handling, cancelation, and final verification +- `packages/server/src/commands/system-deps.ts` — websocket command handlers for runtime status and install lifecycle +- `packages/server/src/__tests__/system-deps/runtime-status.test.ts` — runtime-status and package-manager detection tests +- `packages/server/src/__tests__/system-deps/interaction-detector.test.ts` — prompt-detection tests +- `packages/server/src/__tests__/system-deps/install-manager.test.ts` — install job lifecycle tests with a fake PTY host +- `packages/server/src/__tests__/system-deps/commands.test.ts` — command wiring tests for `systemDeps.install.*` +- `packages/web/src/features/diagnostics/actions/use-system-dependency-installer.ts` — hook for install start/get/input/cancel, output subscription, and automatic recheck +- `packages/web/src/features/diagnostics/components/system-dependency-install-panel.tsx` — embedded install log panel, password input state, and cancel action + +**Modified files:** +- `packages/core/src/domain/diagnostics.ts` — enrich checks with `dependencyId` and install metadata +- `packages/core/src/index.ts` — export the new system dependency domain types +- `packages/core/src/protocol/topics.ts` — add install-output topic helper +- `packages/server/src/commands/diagnostics.ts` — consume runtime status and include base dependency checks in the right contexts +- `packages/server/src/commands/index.ts` — register system dependency commands +- `packages/server/src/server.ts` — construct the system dependency install manager and inject it into command context +- `packages/server/src/ws/dispatch.ts` — extend `CommandContext` with `systemDependencyInstallMgr` +- `packages/server/src/__tests__/diagnostics-commands.test.ts` — cover base runtime diagnostics wiring +- `packages/web/src/features/diagnostics/page.tsx` — render install actions, mount the installer panel, and gate session continuation on missing base dependencies +- `packages/web/src/features/diagnostics/index.test.tsx` — diagnostics page end-to-end behavior for install, password input, success recheck, and non-blocking workspace open +- `packages/web/src/locales/en.json` — install CTA, status, prompt, cancel, and fallback copy +- `packages/web/src/locales/zh.json` — Chinese translations for the same copy +- `packages/web/src/styles/components.css` — diagnostics install panel, log surface, password form, and mobile behavior +- `packages/web/src/styles/components.theme.test.ts` — lock diagnostic install panel onto theme tokens + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/core exec vitest run src/domain/system-dependency-install.test.ts` +- `pnpm --filter @coder-studio/server exec vitest run src/__tests__/system-deps/runtime-status.test.ts src/__tests__/diagnostics-commands.test.ts` +- `pnpm --filter @coder-studio/server exec vitest run src/__tests__/system-deps/interaction-detector.test.ts src/__tests__/system-deps/install-manager.test.ts src/__tests__/system-deps/commands.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/diagnostics/index.test.tsx src/styles/components.theme.test.ts` + +--- + +### Task 1: Add The Shared System Dependency Contract + +**Files:** +- Create: `packages/core/src/domain/system-dependency-install.ts` +- Create: `packages/core/src/domain/system-dependency-install.test.ts` +- Modify: `packages/core/src/domain/diagnostics.ts` +- Modify: `packages/core/src/index.ts` + +- [ ] **Step 1: Write the failing shared-domain test** + +Add this test to `packages/core/src/domain/system-dependency-install.test.ts`: + +```ts +import { + SYSTEM_DEPENDENCY_IDS, + SYSTEM_DEPENDENCY_INSTALL_OUTPUT_TOPIC_SCOPE, + isSystemDependencyId, +} from "./system-dependency-install.js"; + +describe("system dependency install domain", () => { + it("exports the supported dependency ids in a stable order", () => { + expect(SYSTEM_DEPENDENCY_IDS).toEqual(["git", "node"]); + }); + + it("recognizes supported dependency ids only", () => { + expect(isSystemDependencyId("git")).toBe(true); + expect(isSystemDependencyId("node")).toBe(true); + expect(isSystemDependencyId("python")).toBe(false); + }); + + it("keeps the output topic scope stable for websocket subscribers", () => { + expect(SYSTEM_DEPENDENCY_INSTALL_OUTPUT_TOPIC_SCOPE).toBe("systemDeps.install"); + }); +}); +``` + +- [ ] **Step 2: Run the core test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/core exec vitest run src/domain/system-dependency-install.test.ts +``` + +Expected: FAIL because `system-dependency-install.ts` does not exist yet. + +- [ ] **Step 3: Add the shared system dependency types and exports** + +Create `packages/core/src/domain/system-dependency-install.ts` with: + +```ts +export const SYSTEM_DEPENDENCY_IDS = ["git", "node"] as const; +export const SYSTEM_DEPENDENCY_INSTALL_OUTPUT_TOPIC_SCOPE = "systemDeps.install" as const; + +export type SystemDependencyId = (typeof SYSTEM_DEPENDENCY_IDS)[number]; +export type SystemDependencyPackageManager = + | "brew" + | "apt-get" + | "dnf" + | "yum" + | "pacman" + | "zypper"; + +export function isSystemDependencyId(value: string): value is SystemDependencyId { + return (SYSTEM_DEPENDENCY_IDS as readonly string[]).includes(value); +} + +export interface SystemDependencyRuntimeEntry { + dependencyId: SystemDependencyId; + available: boolean; + version?: string; + autoInstallSupported: boolean; + installReadiness: "ready" | "unsupported_platform" | "unsupported_package_manager"; + packageManager?: SystemDependencyPackageManager; + manualGuideKeys: string[]; + docUrl?: string; +} + +export interface SystemDependencyRuntimeStatusResponse { + dependencies: Record; +} + +export interface SystemDependencyInstallInteraction { + kind: "none" | "sudo_password" | "confirm"; + promptExcerpt?: string; + echo: boolean; +} + +export interface SystemDependencyInstallStepSnapshot { + id: string; + titleKey: string; + kind: "check" | "install" | "verify"; + command: string; + args: string[]; + status: "pending" | "running" | "succeeded" | "failed"; + startedAt?: number; + finishedAt?: number; + exitCode?: number; + stdoutExcerpt?: string; + stderrExcerpt?: string; +} + +export interface SystemDependencyInstallFailure { + code: + | "unsupported_platform" + | "unsupported_package_manager" + | "permission_denied" + | "user_cancelled" + | "pty_disconnected" + | "command_not_found" + | "command_failed" + | "verification_failed" + | "unknown_failure"; + dependencyId: SystemDependencyId; + failedStepId: string; + message: string; + command: string; + args: string[]; + exitCode?: number; + stdoutExcerpt?: string; + stderrExcerpt?: string; + packageManager?: SystemDependencyPackageManager; + manualGuideKeys: string[]; + docUrl?: string; +} + +export interface SystemDependencyInstallJobSnapshot { + jobId: string; + dependencyId: SystemDependencyId; + status: "queued" | "running" | "waiting_input" | "succeeded" | "failed" | "cancelled"; + packageManager?: SystemDependencyPackageManager; + currentStepId?: string; + steps: SystemDependencyInstallStepSnapshot[]; + interaction: SystemDependencyInstallInteraction; + failure?: SystemDependencyInstallFailure; +} + +export interface SystemDependencyInstallOutputChunk { + jobId: string; + chunk: string; + seq: number; +} +``` + +Update `packages/core/src/domain/diagnostics.ts` to import `SystemDependencyId` and extend `DiagnosticsCheck`: + +```ts +import type { SystemDependencyId } from "./system-dependency-install"; + +export interface DiagnosticsCheck { + id: string; + code: DiagnosticsCheckCode; + status: DiagnosticsCheckStatus; + workspaceId?: string; + workspacePath?: string; + providerId?: string; + dependencyId?: SystemDependencyId; + autoInstallSupported?: boolean; + installReadiness?: + | "ready" + | "missing_prerequisite" + | "unsupported_platform" + | "unsupported_package_manager"; + missingCommands?: string[]; + missingPrerequisites?: string[]; + manualGuideKeys?: string[]; + docUrl?: string; + version?: string; +} +``` + +Update `packages/core/src/index.ts` to export: + +```ts +export * from "./domain/system-dependency-install"; +``` + +- [ ] **Step 4: Run the core test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/core exec vitest run src/domain/system-dependency-install.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the shared contract** + +```bash +git add packages/core/src/domain/system-dependency-install.ts \ + packages/core/src/domain/system-dependency-install.test.ts \ + packages/core/src/domain/diagnostics.ts \ + packages/core/src/index.ts +git commit -m "feat(core): add system dependency install contracts" +``` + +### Task 2: Build Runtime Status Detection And Diagnostics Wiring + +**Files:** +- Create: `packages/server/src/system-deps/definitions.ts` +- Create: `packages/server/src/system-deps/runtime-status.ts` +- Create: `packages/server/src/__tests__/system-deps/runtime-status.test.ts` +- Modify: `packages/server/src/commands/diagnostics.ts` +- Modify: `packages/server/src/__tests__/diagnostics-commands.test.ts` + +- [ ] **Step 1: Write the failing server runtime-status and diagnostics tests** + +Add this to `packages/server/src/__tests__/system-deps/runtime-status.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import { buildSystemDependencyRuntimeStatus } from "../../system-deps/runtime-status.js"; + +describe("buildSystemDependencyRuntimeStatus", () => { + it("marks git installable on macOS when brew exists but git is missing", async () => { + const runCommand = vi.fn(async (file: string) => { + if (file === "git") { + throw Object.assign(new Error("missing git"), { exitCode: 127, stdout: "", stderr: "" }); + } + if (file === "node") { + return { stdout: "v24.1.0\n", stderr: "" }; + } + throw new Error(`unexpected command: ${file}`); + }); + + const status = await buildSystemDependencyRuntimeStatus({ + platform: "darwin", + commandExists: vi.fn(async (command: string) => command === "brew"), + runCommand, + }); + + expect(status.dependencies.git).toMatchObject({ + dependencyId: "git", + available: false, + autoInstallSupported: true, + installReadiness: "ready", + packageManager: "brew", + }); + expect(status.dependencies.node).toMatchObject({ + available: true, + version: "v24.1.0", + }); + }); + + it("reports unsupported_package_manager when Linux has neither apt nor brew", async () => { + const status = await buildSystemDependencyRuntimeStatus({ + platform: "linux", + commandExists: vi.fn(async () => false), + runCommand: vi.fn(async () => { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + expect(status.dependencies.git.installReadiness).toBe("unsupported_package_manager"); + expect(status.dependencies.node.autoInstallSupported).toBe(false); + }); +}); +``` + +Add this to `packages/server/src/__tests__/diagnostics-commands.test.ts`: + +```ts + it("blocks session start when node is missing but keeps workspace-open non-blocking", async () => { + const nodeMissingContext = createContext({ + providerRuntimeDeps: { + commandExists: async (command: string) => command === "brew" || command === "claude", + runCommand: async (file: string) => { + if (file === "git") return { stdout: "git version 2.49.0\n", stderr: "" }; + if (file === "node") throw Object.assign(new Error("missing node"), { exitCode: 127 }); + return { stdout: "", stderr: "" }; + }, + platform: "darwin", + }, + }); + + const sessionResult = await dispatch( + { + kind: "command", + id: "diag-session-node-missing", + op: "diagnostics.get", + args: { context: "session_start", workspaceId: "ws-1", providerId: "claude" }, + }, + nodeMissingContext + ); + + expect(sessionResult.ok).toBe(true); + expect(sessionResult.data).toMatchObject({ context: "session_start", canContinue: false }); + expect((sessionResult.data as { checks: Array<{ code: string }> }).checks).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "nodejs_missing" })]) + ); + + const workspaceResult = await dispatch( + { + kind: "command", + id: "diag-workspace-node-missing", + op: "diagnostics.get", + args: { context: "workspace_open", workspacePath: "/tmp/project" }, + }, + nodeMissingContext + ); + + expect(workspaceResult.ok).toBe(true); + expect(workspaceResult.data).toMatchObject({ context: "workspace_open", canContinue: true }); + }); +``` + +- [ ] **Step 2: Run the server diagnostics tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/system-deps/runtime-status.test.ts \ + src/__tests__/diagnostics-commands.test.ts +``` + +Expected: FAIL because `system-deps/runtime-status.ts` and the new diagnostics behavior do not exist yet. + +- [ ] **Step 3: Implement runtime status detection and reuse it from diagnostics** + +Create `packages/server/src/system-deps/definitions.ts`: + +```ts +import type { SystemDependencyId, SystemDependencyPackageManager } from "@coder-studio/core"; + +export interface SystemDependencyDefinition { + dependencyId: SystemDependencyId; + versionCommand: { file: string; args: string[] }; + docsUrl: string; + manualGuideKeys: string[]; +} + +export const SYSTEM_DEPENDENCY_DEFINITIONS: Record = + { + git: { + dependencyId: "git", + versionCommand: { file: "git", args: ["--version"] }, + docsUrl: "https://git-scm.com/downloads", + manualGuideKeys: ["system_deps.install.git.manual"], + }, + node: { + dependencyId: "node", + versionCommand: { file: "node", args: ["--version"] }, + docsUrl: "https://nodejs.org/en/download", + manualGuideKeys: ["system_deps.install.node.manual"], + }, + }; + +export const PACKAGE_MANAGER_ORDER: Partial> = + { + darwin: ["brew"], + linux: ["apt-get", "dnf", "yum", "pacman", "zypper"], + }; +``` + +Create `packages/server/src/system-deps/runtime-status.ts`: + +```ts +import type { + SystemDependencyId, + SystemDependencyRuntimeEntry, + SystemDependencyRuntimeStatusResponse, +} from "@coder-studio/core"; +import type { RuntimeStatusDeps } from "../provider-runtime/runtime-status.js"; +import { runCommandAsString } from "../provider-runtime/command-runner.js"; +import { SYSTEM_DEPENDENCY_DEFINITIONS, PACKAGE_MANAGER_ORDER } from "./definitions.js"; + +async function readVersion( + dependencyId: SystemDependencyId, + deps: RuntimeStatusDeps +): Promise { + const definition = SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId]; + const runner = deps.runCommand ?? runCommandAsString; + + try { + const { stdout } = await runner(definition.versionCommand.file, definition.versionCommand.args, { + windowsHide: true, + }); + const version = stdout.trim(); + return version.length > 0 ? version : undefined; + } catch { + return undefined; + } +} + +async function detectPackageManager(deps: RuntimeStatusDeps) { + const platform = deps.platform ?? process.platform; + const candidates = PACKAGE_MANAGER_ORDER[platform] ?? []; + const commandExists = deps.commandExists ?? (async () => false); + + for (const candidate of candidates) { + if (await commandExists(candidate)) { + return candidate; + } + } + + return undefined; +} + +export async function buildSystemDependencyRuntimeStatus( + deps: RuntimeStatusDeps = {} +): Promise { + const platform = deps.platform ?? process.platform; + const packageManager = await detectPackageManager(deps); + const dependencies = {} as Record; + + for (const dependencyId of ["git", "node"] as const) { + const definition = SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId]; + const version = await readVersion(dependencyId, deps); + const available = Boolean(version); + + dependencies[dependencyId] = { + dependencyId, + available, + version, + autoInstallSupported: !available && Boolean(packageManager), + installReadiness: available + ? "ready" + : packageManager + ? "ready" + : platform === "darwin" || platform === "linux" + ? "unsupported_package_manager" + : "unsupported_platform", + packageManager, + manualGuideKeys: definition.manualGuideKeys, + docUrl: definition.docsUrl, + }; + } + + return { dependencies }; +} +``` + +Update `packages/server/src/commands/diagnostics.ts` so `buildBaseRuntimeChecks()` maps from the new runtime status and the contexts wire it like this: + +```ts +async function buildBaseRuntimeChecks( + ctx: CommandContext +): Promise<{ canContinue: boolean; checks: DiagnosticsCheck[] }> { + const runtime = await buildSystemDependencyRuntimeStatus(ctx.providerRuntimeDeps); + const git = runtime.dependencies.git; + const node = runtime.dependencies.node; + + return { + canContinue: git.available && node.available, + checks: [ + { + id: "runtime:git", + code: git.available ? "git_ready" : "git_missing", + status: git.available ? "ready" : "needs_attention", + dependencyId: "git", + autoInstallSupported: git.autoInstallSupported, + installReadiness: git.installReadiness, + manualGuideKeys: git.manualGuideKeys, + docUrl: git.docUrl, + version: git.version, + }, + { + id: "runtime:nodejs", + code: node.available ? "nodejs_ready" : "nodejs_missing", + status: node.available ? "ready" : "needs_attention", + dependencyId: "node", + autoInstallSupported: node.autoInstallSupported, + installReadiness: node.installReadiness, + manualGuideKeys: node.manualGuideKeys, + docUrl: node.docUrl, + version: node.version, + }, + ], + }; +} +``` + +Then update the context builders: + +```ts +// session_start +const baseRuntime = await buildBaseRuntimeChecks(ctx); +const checks = [ + ...workspaceSelection.checks, + ...baseRuntime.checks, + ...providerChecks.checks, + buildServerAuthCheck(ctx), + mobileHost.check, +]; +const canContinue = + workspaceSelection.canContinue && + baseRuntime.canContinue && + providerChecks.canContinueForPreferredProvider; + +// workspace_open +const baseRuntime = await buildBaseRuntimeChecks(ctx); +checks: [ + ...workspaceSelection.checks, + ...baseRuntime.checks, + ...providerChecks.checks, + buildServerAuthCheck(ctx), + mobileHost.check, +] + +// mobile_continue +// no baseRuntime injection +``` + +- [ ] **Step 4: Run the server diagnostics tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/system-deps/runtime-status.test.ts \ + src/__tests__/diagnostics-commands.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit runtime status and diagnostics wiring** + +```bash +git add packages/server/src/system-deps/definitions.ts \ + packages/server/src/system-deps/runtime-status.ts \ + packages/server/src/__tests__/system-deps/runtime-status.test.ts \ + packages/server/src/commands/diagnostics.ts \ + packages/server/src/__tests__/diagnostics-commands.test.ts +git commit -m "feat(server): add system dependency runtime diagnostics" +``` + +### Task 3: Implement PTY-Backed Install Jobs And Prompt Detection + +**Files:** +- Create: `packages/server/src/system-deps/interaction-detector.ts` +- Create: `packages/server/src/system-deps/install-manager.ts` +- Create: `packages/server/src/__tests__/system-deps/interaction-detector.test.ts` +- Create: `packages/server/src/__tests__/system-deps/install-manager.test.ts` + +- [ ] **Step 1: Write the failing interaction detector and install manager tests** + +Add this to `packages/server/src/__tests__/system-deps/interaction-detector.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { detectSystemDependencyInteraction } from "../../system-deps/interaction-detector.js"; + +describe("detectSystemDependencyInteraction", () => { + it("detects sudo password prompts without enabling echo", () => { + expect(detectSystemDependencyInteraction("[sudo] password for spencer:")).toEqual({ + kind: "sudo_password", + promptExcerpt: "[sudo] password for spencer:", + echo: false, + }); + }); + + it("detects confirmation prompts", () => { + expect(detectSystemDependencyInteraction("Proceed? [Y/n]")).toEqual({ + kind: "confirm", + promptExcerpt: "Proceed? [Y/n]", + echo: true, + }); + }); + + it("returns none when output is not interactive", () => { + expect(detectSystemDependencyInteraction("installed git")).toEqual({ + kind: "none", + echo: false, + }); + }); +}); +``` + +Add this to `packages/server/src/__tests__/system-deps/install-manager.test.ts`: + +```ts +import { Topics } from "@coder-studio/core"; +import { describe, expect, it, vi } from "vitest"; +import { SystemDependencyInstallManager } from "../../system-deps/install-manager.js"; + +function createFakePtyHost() { + let onData: ((data: string) => void) | undefined; + let onExit: ((event: { exitCode: number }) => void) | undefined; + const writes: string[] = []; + + return { + writes, + host: { + spawn: vi.fn(() => ({ + onData: (cb: (data: string) => void) => { + onData = cb; + }, + onExit: (cb: (event: { exitCode: number }) => void) => { + onExit = cb; + }, + write: (data: string | Buffer) => { + writes.push(Buffer.isBuffer(data) ? data.toString("utf8") : data); + }, + resize: () => {}, + kill: async () => { + onExit?.({ exitCode: 130 }); + }, + })), + }, + emitData: (data: string) => onData?.(data), + emitExit: (exitCode = 0) => onExit?.({ exitCode }), + }; +} + +describe("SystemDependencyInstallManager", () => { + it("reuses the active job, broadcasts output, waits for password input, and verifies success", async () => { + const pty = createFakePtyHost(); + const broadcast = vi.fn(); + let gitInstalled = false; + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: pty.host, + broadcaster: { broadcast } as never, + commandExists: vi.fn(async (command: string) => command === "apt-get"), + runCommand: vi.fn(async (file: string) => { + if (file === "git") { + if (!gitInstalled) { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + } + return { stdout: "git version 2.49.0\n", stderr: "" }; + } + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const first = await manager.start("git"); + const second = await manager.start("git"); + + expect(second.jobId).toBe(first.jobId); + + pty.emitData("[sudo] password for spencer:"); + + await vi.waitFor(() => { + expect(manager.get(first.jobId)?.status).toBe("waiting_input"); + }); + + await manager.submitInput(first.jobId, "hunter2\n"); + expect(pty.writes.at(-1)).toBe("hunter2\n"); + + gitInstalled = true; + pty.emitData("installed git\n"); + pty.emitExit(0); + + await vi.waitFor(() => { + expect(manager.get(first.jobId)?.status).toBe("succeeded"); + }); + + expect(broadcast).toHaveBeenCalledWith( + Topics.systemDependencyInstallOutput(first.jobId), + expect.objectContaining({ jobId: first.jobId, chunk: "installed git\n" }) + ); + }); + + it("marks a cancelled job when the user aborts the install", async () => { + const pty = createFakePtyHost(); + const manager = new SystemDependencyInstallManager({ + platform: "linux", + ptyHost: pty.host, + broadcaster: { broadcast: vi.fn() } as never, + commandExists: vi.fn(async (command: string) => command === "apt-get"), + runCommand: vi.fn(async () => { + throw Object.assign(new Error("missing"), { exitCode: 127, stdout: "", stderr: "" }); + }), + }); + + const job = await manager.start("git"); + await manager.cancel(job.jobId); + + expect(manager.get(job.jobId)).toMatchObject({ + status: "cancelled", + failure: { code: "user_cancelled" }, + }); + }); +}); +``` + +- [ ] **Step 2: Run the install-manager tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/system-deps/interaction-detector.test.ts \ + src/__tests__/system-deps/install-manager.test.ts +``` + +Expected: FAIL because the detector and install manager do not exist yet. + +- [ ] **Step 3: Implement prompt detection and the PTY-backed install manager** + +Create `packages/server/src/system-deps/interaction-detector.ts`: + +```ts +import type { SystemDependencyInstallInteraction } from "@coder-studio/core"; + +const SUDO_PASSWORD_PATTERNS = [/\[sudo\] password for .*:$/i, /^password:$/i]; +const CONFIRM_PATTERNS = [/proceed\?\s*\[y\/n\]/i, /continue\?\s*\[y\/n\]/i]; + +export function detectSystemDependencyInteraction( + chunk: string +): SystemDependencyInstallInteraction { + const trimmed = chunk.trim(); + + if (SUDO_PASSWORD_PATTERNS.some((pattern) => pattern.test(trimmed))) { + return { + kind: "sudo_password", + promptExcerpt: trimmed, + echo: false, + }; + } + + if (CONFIRM_PATTERNS.some((pattern) => pattern.test(trimmed))) { + return { + kind: "confirm", + promptExcerpt: trimmed, + echo: true, + }; + } + + return { + kind: "none", + echo: false, + }; +} +``` + +Create `packages/server/src/system-deps/install-manager.ts` with the core pieces below: + +```ts +import { randomUUID } from "node:crypto"; +import { Topics, type SystemDependencyId, type SystemDependencyInstallJobSnapshot } from "@coder-studio/core"; +import type { Broadcaster } from "../ws/hub.js"; +import type { PtyHost, PtyProcess } from "../terminal/types.js"; +import type { RuntimeStatusDeps } from "../provider-runtime/runtime-status.js"; +import { SYSTEM_DEPENDENCY_DEFINITIONS } from "./definitions.js"; +import { buildSystemDependencyRuntimeStatus } from "./runtime-status.js"; +import { detectSystemDependencyInteraction } from "./interaction-detector.js"; + +interface InstallSession { + process: PtyProcess; + seq: number; +} + +export interface SystemDependencyInstallManagerDeps extends RuntimeStatusDeps { + ptyHost: PtyHost; + broadcaster: Pick; +} + +export class SystemDependencyInstallManager { + private readonly jobs = new Map(); + private readonly activeJobIdsByDependencyId = new Map(); + private readonly sessions = new Map(); + + constructor(private readonly deps: SystemDependencyInstallManagerDeps) {} + + async start(dependencyId: SystemDependencyId): Promise { + const activeJobId = this.activeJobIdsByDependencyId.get(dependencyId); + if (activeJobId) { + return structuredClone(this.jobs.get(activeJobId)!); + } + + const runtime = await buildSystemDependencyRuntimeStatus(this.deps); + const entry = runtime.dependencies[dependencyId]; + if (entry.available) { + const readyJob: SystemDependencyInstallJobSnapshot = { + jobId: randomUUID(), + dependencyId, + status: "succeeded", + packageManager: entry.packageManager, + currentStepId: undefined, + steps: [], + interaction: { kind: "none", echo: false }, + }; + this.jobs.set(readyJob.jobId, readyJob); + return structuredClone(readyJob); + } + + if (!entry.autoInstallSupported || !entry.packageManager) { + const failedJob: SystemDependencyInstallJobSnapshot = { + jobId: randomUUID(), + dependencyId, + status: "failed", + packageManager: entry.packageManager, + currentStepId: `install-${dependencyId}`, + steps: [ + { + id: `install-${dependencyId}`, + titleKey: `system_deps.install.step.install.${dependencyId}`, + kind: "install", + command: entry.packageManager ?? dependencyId, + args: [], + status: "failed", + }, + ], + interaction: { kind: "none", echo: false }, + failure: { + code: + entry.installReadiness === "unsupported_platform" + ? "unsupported_platform" + : "unsupported_package_manager", + dependencyId, + failedStepId: `install-${dependencyId}`, + message: `Cannot auto-install ${dependencyId}`, + command: entry.packageManager ?? dependencyId, + args: [], + packageManager: entry.packageManager, + manualGuideKeys: entry.manualGuideKeys, + docUrl: entry.docUrl, + }, + }; + this.jobs.set(failedJob.jobId, failedJob); + return structuredClone(failedJob); + } + + return this.spawnInstallJob(dependencyId, entry.packageManager); + } + + async submitInput(jobId: string, text: string): Promise { + const job = this.jobs.get(jobId); + const session = this.sessions.get(jobId); + if (!job || !session) { + throw { code: "system_dependency_install_job_not_found", message: `Install job not found: ${jobId}` }; + } + + job.status = "running"; + job.interaction = { kind: "none", echo: false }; + session.process.write(text); + return structuredClone(job); + } + + async cancel(jobId: string): Promise { + const job = this.jobs.get(jobId); + const session = this.sessions.get(jobId); + if (!job) { + throw { code: "system_dependency_install_job_not_found", message: `Install job not found: ${jobId}` }; + } + + if (session) { + await session.process.kill("SIGTERM"); + this.sessions.delete(jobId); + } + + job.status = "cancelled"; + job.interaction = { kind: "none", echo: false }; + job.failure = { + code: "user_cancelled", + dependencyId: job.dependencyId, + failedStepId: job.currentStepId ?? `install-${job.dependencyId}`, + message: `Install cancelled for ${job.dependencyId}`, + command: job.steps.find((step) => step.id === job.currentStepId)?.command ?? job.dependencyId, + args: job.steps.find((step) => step.id === job.currentStepId)?.args ?? [], + packageManager: job.packageManager, + manualGuideKeys: SYSTEM_DEPENDENCY_DEFINITIONS[job.dependencyId].manualGuideKeys, + docUrl: SYSTEM_DEPENDENCY_DEFINITIONS[job.dependencyId].docsUrl, + }; + this.activeJobIdsByDependencyId.delete(job.dependencyId); + return structuredClone(job); + } +} +``` + +Inside the same file, add a private `spawnInstallJob()` that: + +```ts + private async spawnInstallJob( + dependencyId: SystemDependencyId, + packageManager: NonNullable + ): Promise { + const command = + packageManager === "brew" + ? `brew install ${dependencyId === "git" ? "git" : "node"}` + : packageManager === "apt-get" + ? dependencyId === "git" + ? "sudo apt-get update && sudo apt-get install -y git" + : "sudo apt-get update && sudo apt-get install -y nodejs npm" + : packageManager === "dnf" + ? `sudo dnf install -y ${dependencyId === "git" ? "git" : "nodejs"}` + : packageManager === "yum" + ? `sudo yum install -y ${dependencyId === "git" ? "git" : "nodejs"}` + : packageManager === "pacman" + ? `sudo pacman -Sy --noconfirm ${dependencyId === "git" ? "git" : "nodejs npm"}` + : `sudo zypper --non-interactive install ${dependencyId === "git" ? "git" : "nodejs"}`; + + const process = this.deps.ptyHost.spawn(["/bin/sh", "-lc", command], { + cwd: process.cwd(), + env: { + ...Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] != null) + ), + TERM: "xterm-256color", + COLORTERM: "truecolor", + FORCE_COLOR: "3", + }, + cols: 120, + rows: 30, + }); + + const job: SystemDependencyInstallJobSnapshot = { + jobId: randomUUID(), + dependencyId, + status: "running", + packageManager, + currentStepId: `install-${dependencyId}`, + steps: [ + { + id: `install-${dependencyId}`, + titleKey: `system_deps.install.step.install.${dependencyId}`, + kind: "install", + command: "/bin/sh", + args: ["-lc", command], + status: "running", + startedAt: Date.now(), + }, + { + id: `verify-${dependencyId}`, + titleKey: `system_deps.install.step.verify.${dependencyId}`, + kind: "verify", + command: SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId].versionCommand.file, + args: SYSTEM_DEPENDENCY_DEFINITIONS[dependencyId].versionCommand.args, + status: "pending", + }, + ], + interaction: { kind: "none", echo: false }, + }; + + this.jobs.set(job.jobId, job); + this.activeJobIdsByDependencyId.set(dependencyId, job.jobId); + this.sessions.set(job.jobId, { process, seq: 0 }); + + process.onData((chunk) => this.handleOutput(job.jobId, chunk)); + process.onExit(({ exitCode }) => { + void this.handleExit(job.jobId, exitCode); + }); + + return structuredClone(job); + } +``` + +Also add `handleOutput()` and `handleExit()`: + +```ts + private handleOutput(jobId: string, chunk: string): void { + const job = this.jobs.get(jobId); + const session = this.sessions.get(jobId); + if (!job || !session) return; + + session.seq += 1; + this.deps.broadcaster.broadcast(Topics.systemDependencyInstallOutput(jobId), { + jobId, + chunk, + seq: session.seq, + }); + + const interaction = detectSystemDependencyInteraction(chunk); + if (interaction.kind !== "none") { + job.status = "waiting_input"; + job.interaction = interaction; + } + + const installStep = job.steps[0]; + if (installStep) { + installStep.stdoutExcerpt = chunk.slice(-400); + } + } + + private async handleExit(jobId: string, exitCode: number): Promise { + const job = this.jobs.get(jobId); + if (!job) return; + + const installStep = job.steps[0]; + if (installStep) { + installStep.finishedAt = Date.now(); + installStep.exitCode = exitCode; + installStep.status = exitCode === 0 ? "succeeded" : "failed"; + } + + this.sessions.delete(jobId); + + if (job.status === "cancelled") { + this.activeJobIdsByDependencyId.delete(job.dependencyId); + return; + } + + if (exitCode !== 0) { + job.status = "failed"; + job.interaction = { kind: "none", echo: false }; + job.failure = { + code: "command_failed", + dependencyId: job.dependencyId, + failedStepId: installStep?.id ?? `install-${job.dependencyId}`, + message: `Install failed for ${job.dependencyId}`, + command: installStep?.command ?? "/bin/sh", + args: installStep?.args ?? [], + exitCode, + packageManager: job.packageManager, + manualGuideKeys: SYSTEM_DEPENDENCY_DEFINITIONS[job.dependencyId].manualGuideKeys, + docUrl: SYSTEM_DEPENDENCY_DEFINITIONS[job.dependencyId].docsUrl, + }; + this.activeJobIdsByDependencyId.delete(job.dependencyId); + return; + } + + const runtime = await buildSystemDependencyRuntimeStatus(this.deps); + const entry = runtime.dependencies[job.dependencyId]; + const verifyStep = job.steps[1]; + if (verifyStep) { + verifyStep.status = entry.available ? "succeeded" : "failed"; + verifyStep.startedAt = Date.now(); + verifyStep.finishedAt = Date.now(); + verifyStep.stdoutExcerpt = entry.version; + } + + if (!entry.available) { + job.status = "failed"; + job.failure = { + code: "verification_failed", + dependencyId: job.dependencyId, + failedStepId: verifyStep?.id ?? `verify-${job.dependencyId}`, + message: `Verification failed for ${job.dependencyId}`, + command: verifyStep?.command ?? job.dependencyId, + args: verifyStep?.args ?? [], + packageManager: job.packageManager, + manualGuideKeys: entry.manualGuideKeys, + docUrl: entry.docUrl, + }; + this.activeJobIdsByDependencyId.delete(job.dependencyId); + return; + } + + job.status = "succeeded"; + job.interaction = { kind: "none", echo: false }; + this.activeJobIdsByDependencyId.delete(job.dependencyId); + } +``` + +- [ ] **Step 4: Run the install-manager tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/system-deps/interaction-detector.test.ts \ + src/__tests__/system-deps/install-manager.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the install manager** + +```bash +git add packages/server/src/system-deps/interaction-detector.ts \ + packages/server/src/system-deps/install-manager.ts \ + packages/server/src/__tests__/system-deps/interaction-detector.test.ts \ + packages/server/src/__tests__/system-deps/install-manager.test.ts +git commit -m "feat(server): add interactive system dependency installer" +``` + +### Task 4: Wire Commands, Topics, And Server Bootstrap + +**Files:** +- Create: `packages/server/src/commands/system-deps.ts` +- Create: `packages/server/src/__tests__/system-deps/commands.test.ts` +- Modify: `packages/core/src/protocol/topics.ts` +- Modify: `packages/server/src/commands/index.ts` +- Modify: `packages/server/src/server.ts` +- Modify: `packages/server/src/ws/dispatch.ts` + +- [ ] **Step 1: Write the failing command and topic tests** + +Add this to `packages/server/src/__tests__/system-deps/commands.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest"; +import type { CommandContext } from "../../ws/dispatch.js"; +import { dispatch } from "../../ws/dispatch.js"; + +import "../../commands/system-deps.js"; + +function createContext(overrides: Partial = {}): CommandContext { + return { + workspaceMgr: {} as never, + sessionMgr: {} as never, + terminalMgr: {} as never, + eventBus: {} as never, + broadcaster: { broadcast: vi.fn(), sendToClient: () => true, sendBinaryToClient: () => true } as never, + settingsRepo: {} as never, + providerConfigRepo: {} as never, + providerRegistry: [], + fencingMgr: {} as never, + supervisorMgr: {} as never, + autoFetch: {} as never, + activationMgr: {} as never, + lspMgr: {} as never, + providerRuntimeDeps: { + platform: "darwin", + commandExists: vi.fn(async (command: string) => command === "brew"), + runCommand: vi.fn(async (file: string) => { + if (file === "git") return { stdout: "git version 2.49.0\n", stderr: "" }; + if (file === "node") throw Object.assign(new Error("missing node"), { exitCode: 127, stdout: "", stderr: "" }); + return { stdout: "", stderr: "" }; + }), + }, + ...overrides, + }; +} + +describe("system deps commands", () => { + it("returns runtime status through systemDeps.runtimeStatus", async () => { + const result = await dispatch( + { kind: "command", id: "sysdeps-status", op: "systemDeps.runtimeStatus", args: {} }, + createContext() + ); + + expect(result.ok).toBe(true); + expect(result.data).toMatchObject({ + dependencies: { + git: { available: true }, + node: { available: false, autoInstallSupported: true }, + }, + }); + }); + + it("returns install lifecycle errors when the manager is missing or the job id is unknown", async () => { + const unavailable = await dispatch( + { + kind: "command", + id: "sysdeps-start-missing", + op: "systemDeps.install.start", + args: { dependencyId: "git" }, + }, + createContext() + ); + expect(unavailable.ok).toBe(false); + expect(unavailable.error?.code).toBe("system_dependency_install_unavailable"); + + const contextWithManager = createContext({ + systemDependencyInstallMgr: { + start: vi.fn(async () => ({ jobId: "job-1", dependencyId: "git", status: "queued", steps: [], interaction: { kind: "none", echo: false } })), + get: vi.fn(() => undefined), + submitInput: vi.fn(), + cancel: vi.fn(), + } as never, + }); + + const missingJob = await dispatch( + { + kind: "command", + id: "sysdeps-get-missing", + op: "systemDeps.install.get", + args: { jobId: "missing-job" }, + }, + contextWithManager + ); + expect(missingJob.ok).toBe(false); + expect(missingJob.error?.code).toBe("system_dependency_install_job_not_found"); + }); +}); +``` + +Add this to `packages/core/src/protocol/topics.ts`: + +```ts + systemDependencyInstallOutput: (jobId: string) => `systemDeps.install.${jobId}.output`, +``` + +- [ ] **Step 2: Run the command tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run src/__tests__/system-deps/commands.test.ts +``` + +Expected: FAIL because the command module, context field, and new topic do not exist yet. + +- [ ] **Step 3: Add command handlers and bootstrap wiring** + +Update `packages/server/src/ws/dispatch.ts`: + +```ts +import type { SystemDependencyInstallManager } from "../system-deps/install-manager.js"; + +export interface CommandContext { + // existing fields... + systemDependencyInstallMgr?: SystemDependencyInstallManager; +} +``` + +Create `packages/server/src/commands/system-deps.ts`: + +```ts +import type { + SystemDependencyInstallJobSnapshot, + SystemDependencyRuntimeStatusResponse, +} from "@coder-studio/core"; +import { z } from "zod"; +import { buildSystemDependencyRuntimeStatus } from "../system-deps/runtime-status.js"; +import { registerCommand } from "../ws/dispatch.js"; + +registerCommand("systemDeps.runtimeStatus", z.object({}), async (_args, ctx) => { + return buildSystemDependencyRuntimeStatus(ctx.providerRuntimeDeps); +}); + +registerCommand( + "systemDeps.install.start", + z.object({ dependencyId: z.enum(["git", "node"]) }), + async (args, ctx) => { + if (!ctx.systemDependencyInstallMgr) { + throw { + code: "system_dependency_install_unavailable", + message: "System dependency install manager not configured", + }; + } + return ctx.systemDependencyInstallMgr.start(args.dependencyId); + } +); + +registerCommand( + "systemDeps.install.get", + z.object({ jobId: z.string() }), + async (args, ctx): Promise => { + if (!ctx.systemDependencyInstallMgr) { + throw { + code: "system_dependency_install_unavailable", + message: "System dependency install manager not configured", + }; + } + const job = ctx.systemDependencyInstallMgr.get(args.jobId); + if (!job) { + throw { + code: "system_dependency_install_job_not_found", + message: `Install job not found: ${args.jobId}`, + }; + } + return job; + } +); + +registerCommand( + "systemDeps.install.input", + z.object({ jobId: z.string(), text: z.string() }), + async (args, ctx) => { + if (!ctx.systemDependencyInstallMgr) { + throw { + code: "system_dependency_install_unavailable", + message: "System dependency install manager not configured", + }; + } + return ctx.systemDependencyInstallMgr.submitInput(args.jobId, args.text); + } +); + +registerCommand( + "systemDeps.install.cancel", + z.object({ jobId: z.string() }), + async (args, ctx) => { + if (!ctx.systemDependencyInstallMgr) { + throw { + code: "system_dependency_install_unavailable", + message: "System dependency install manager not configured", + }; + } + return ctx.systemDependencyInstallMgr.cancel(args.jobId); + } +); +``` + +Update `packages/server/src/commands/index.ts`: + +```ts +import "./system-deps.js"; +``` + +Update `packages/server/src/server.ts` to construct and inject the new manager: + +```ts +import { SystemDependencyInstallManager } from "./system-deps/install-manager.js"; + +const systemDependencyInstallMgr = new SystemDependencyInstallManager({ + ...providerRuntimeDeps, + runCommand: providerMockOverrides?.runCommand ?? runCommandAsString, + ptyHost: createPtyHost(), + broadcaster: wsHub, +}); + +commandContext = { + // existing fields... + systemDependencyInstallMgr, +}; +``` + +Add `get()` to the install manager if it is not already present: + +```ts + get(jobId: string): SystemDependencyInstallJobSnapshot | undefined { + const job = this.jobs.get(jobId); + return job ? structuredClone(job) : undefined; + } +``` + +- [ ] **Step 4: Run the command tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/system-deps/commands.test.ts \ + src/__tests__/system-deps/install-manager.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Commit command and server wiring** + +```bash +git add packages/server/src/commands/system-deps.ts \ + packages/server/src/__tests__/system-deps/commands.test.ts \ + packages/core/src/protocol/topics.ts \ + packages/server/src/commands/index.ts \ + packages/server/src/server.ts \ + packages/server/src/ws/dispatch.ts \ + packages/server/src/system-deps/install-manager.ts +git commit -m "feat(server): wire system dependency install commands" +``` + +### Task 5: Add Diagnostics Installer Hook And Embedded Install Panel + +**Files:** +- Create: `packages/web/src/features/diagnostics/actions/use-system-dependency-installer.ts` +- Create: `packages/web/src/features/diagnostics/components/system-dependency-install-panel.tsx` +- Modify: `packages/web/src/features/diagnostics/page.tsx` +- Modify: `packages/web/src/features/diagnostics/index.test.tsx` + +- [ ] **Step 1: Write the failing diagnostics page behavior tests** + +Add these tests to `packages/web/src/features/diagnostics/index.test.tsx`: + +```tsx + it("installs a missing git dependency inline, accepts a sudo password, and rechecks on success", async () => { + let diagnosticsCallCount = 0; + let subscriptionHandler: ((topic: string, payload: unknown) => void) | undefined; + const sendCommand = vi.fn(async (op: string, args?: Record) => { + if (op === "diagnostics.get") { + diagnosticsCallCount += 1; + if (diagnosticsCallCount === 1) { + return createResponse( + { context: "manual_check", canContinue: false }, + [ + { + id: "git-missing", + code: "git_missing", + status: "needs_attention", + dependencyId: "git", + autoInstallSupported: true, + installReadiness: "ready", + manualGuideKeys: ["system_deps.install.git.manual"], + docUrl: "https://git-scm.com/downloads", + }, + ] as DiagnosticsCheck[] + ); + } + + return createResponse( + { context: "manual_check", canContinue: true }, + [ + { + id: "git-ready", + code: "git_ready", + status: "ready", + dependencyId: "git", + version: "git version 2.49.0", + }, + ] as DiagnosticsCheck[] + ); + } + + if (op === "systemDeps.install.start") { + expect(args).toEqual({ dependencyId: "git" }); + return { + jobId: "job-1", + dependencyId: "git", + status: "waiting_input", + packageManager: "apt-get", + currentStepId: "install-git", + steps: [], + interaction: { + kind: "sudo_password", + promptExcerpt: "[sudo] password for spencer:", + echo: false, + }, + }; + } + + if (op === "systemDeps.install.input") { + expect(args).toEqual({ jobId: "job-1", text: "hunter2\n" }); + return { + jobId: "job-1", + dependencyId: "git", + status: "running", + packageManager: "apt-get", + currentStepId: "install-git", + steps: [], + interaction: { kind: "none", echo: false }, + }; + } + + if (op === "systemDeps.install.get") { + return { + jobId: "job-1", + dependencyId: "git", + status: "succeeded", + packageManager: "apt-get", + currentStepId: "verify-git", + steps: [], + interaction: { kind: "none", echo: false }, + }; + } + + throw new Error(`Unexpected op: ${op}`); + }); + + const store = createStoreWithClient(sendCommand); + store.set(wsClientAtom, { + sendCommand, + subscribe: vi.fn((_topics: string[], handler: (topic: string, payload: unknown) => void) => { + subscriptionHandler = handler; + return () => { + subscriptionHandler = undefined; + }; + }), + } as never); + + render( + + + + } /> + + + + ); + + expect(await screen.findByText("Git is missing")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Install Git" })); + expect(await screen.findByText("Package manager: apt-get")).toBeInTheDocument(); + expect(screen.getByLabelText("Administrator password")).toHaveAttribute("type", "password"); + + act(() => { + subscriptionHandler?.("systemDeps.install.job-1.output", { + jobId: "job-1", + chunk: "downloading git\n", + seq: 1, + }); + }); + + expect(await screen.findByText("downloading git")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("Administrator password"), { + target: { value: "hunter2" }, + }); + fireEvent.submit(screen.getByTestId("system-dependency-password-form")); + + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith("systemDeps.install.get", { jobId: "job-1" }, undefined); + }); + + expect(await screen.findByText("Git is ready")).toBeInTheDocument(); + }); + + it("shows missing git on workspace open without disabling the retry action", async () => { + const workspace = createWorkspace("ws-1", "/repo"); + const sendCommand = vi.fn(async (op: string, args?: Record) => { + if (op === "diagnostics.get") { + return createResponse( + { context: "workspace_open", canContinue: true }, + [ + { + id: "workspace-ready", + code: "workspace_path_ready", + status: "ready", + workspacePath: "/repo", + }, + { + id: "git-missing", + code: "git_missing", + status: "needs_attention", + dependencyId: "git", + autoInstallSupported: true, + installReadiness: "ready", + }, + ] as DiagnosticsCheck[] + ); + } + + if (op === "workspace.open") { + return workspace; + } + + if (op === "workspace.lastViewedTarget.set") { + return { workspaceId: "ws-1", updatedAt: 1 }; + } + + throw new Error(`Unexpected op: ${op}`); + }); + + renderDiagnostics("/diagnostics?context=workspace_open&workspacePath=%2Frepo", sendCommand); + + expect(await screen.findByText("Git is missing")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry Opening Workspace" })).toBeEnabled(); + }); +``` + +- [ ] **Step 2: Run the diagnostics tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/diagnostics/index.test.tsx +``` + +Expected: FAIL because the installer hook, install panel, and install CTA do not exist yet. + +- [ ] **Step 3: Implement the installer hook, panel, and diagnostics page integration** + +Create `packages/web/src/features/diagnostics/actions/use-system-dependency-installer.ts`: + +```tsx +import type { + SystemDependencyId, + SystemDependencyInstallJobSnapshot, + SystemDependencyInstallOutputChunk, +} from "@coder-studio/core"; +import { Topics } from "@coder-studio/core"; +import { useAtomValue } from "jotai"; +import { useEffect, useRef, useState } from "react"; +import { dispatchCommandAtom, wsClientAtom } from "../../../atoms/connection"; + +export function useSystemDependencyInstaller(onSucceeded: () => Promise) { + const dispatch = useAtomValue(dispatchCommandAtom); + const wsClient = useAtomValue(wsClientAtom); + const [job, setJob] = useState(null); + const [output, setOutput] = useState(""); + const [submitting, setSubmitting] = useState(false); + const pollTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (pollTimerRef.current !== null) { + window.clearTimeout(pollTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (!job || !wsClient) { + return; + } + + return wsClient.subscribe([Topics.systemDependencyInstallOutput(job.jobId)], (_topic, payload) => { + const chunk = payload as SystemDependencyInstallOutputChunk; + setOutput((prev) => `${prev}${chunk.chunk}`); + }); + }, [job, wsClient]); + + const poll = async (jobId: string) => { + const result = await dispatch("systemDeps.install.get", { jobId }); + if (!result.ok || !result.data) { + return; + } + + setJob(result.data); + + if ( + result.data.status === "queued" || + result.data.status === "running" || + result.data.status === "waiting_input" + ) { + pollTimerRef.current = window.setTimeout(() => { + void poll(jobId); + }, 800); + return; + } + + if (result.data.status === "succeeded") { + await onSucceeded(); + } + }; + + const start = async (dependencyId: SystemDependencyId) => { + setOutput(""); + const result = await dispatch("systemDeps.install.start", { + dependencyId, + }); + if (!result.ok || !result.data) { + return; + } + setJob(result.data); + if (result.data.status !== "succeeded" && result.data.status !== "failed") { + pollTimerRef.current = window.setTimeout(() => { + void poll(result.data!.jobId); + }, 800); + } + }; + + const submitInput = async (text: string) => { + if (!job) return; + setSubmitting(true); + const result = await dispatch("systemDeps.install.input", { + jobId: job.jobId, + text, + }); + setSubmitting(false); + if (result.ok && result.data) { + setJob(result.data); + pollTimerRef.current = window.setTimeout(() => { + void poll(result.data!.jobId); + }, 800); + } + }; + + const cancel = async () => { + if (!job) return; + const result = await dispatch("systemDeps.install.cancel", { + jobId: job.jobId, + }); + if (result.ok && result.data) { + setJob(result.data); + } + }; + + return { job, output, submitting, start, submitInput, cancel }; +} +``` + +Create `packages/web/src/features/diagnostics/components/system-dependency-install-panel.tsx`: + +```tsx +import type { SystemDependencyInstallJobSnapshot } from "@coder-studio/core"; +import { useState } from "react"; +import { Button } from "../../../components/ui"; +import { useTranslation } from "../../../lib/i18n"; + +export function SystemDependencyInstallPanel(props: { + job: SystemDependencyInstallJobSnapshot; + output: string; + submitting: boolean; + onSubmitPassword: (text: string) => Promise; + onCancel: () => Promise; +}) { + const t = useTranslation(); + const [password, setPassword] = useState(""); + + return ( +
+
+ {t("system_deps.install.package_manager")}: {props.job.packageManager ?? "—"} + {t(`system_deps.install.status.${props.job.status}`)} +
+
{props.output}
+ + {props.job.interaction.kind === "sudo_password" ? ( +
{ + event.preventDefault(); + void props.onSubmitPassword(`${password}\n`); + setPassword(""); + }} + > + + setPassword(event.target.value)} + /> + +
+ ) : null} + + {(props.job.status === "queued" || + props.job.status === "running" || + props.job.status === "waiting_input") ? ( + + ) : null} +
+ ); +} +``` + +Update `packages/web/src/features/diagnostics/page.tsx` to mount the hook and panel: + +```tsx +import { useSystemDependencyInstaller } from "./actions/use-system-dependency-installer"; +import { SystemDependencyInstallPanel } from "./components/system-dependency-install-panel"; + +const installer = useSystemDependencyInstaller(async () => { + await loadDiagnostics("diagnostics.recheck"); +}); +``` + +Inside `response.checks.map(...)`, extend actions: + +```tsx +{check.dependencyId && check.status === "needs_attention" && check.autoInstallSupported ? ( + +) : null} +``` + +Render the panel directly under the actions when the active job matches: + +```tsx +{installer.job?.dependencyId === check.dependencyId ? ( + +) : null} +``` + +Leave the primary diagnostics action logic unchanged for `workspace_open`, but keep the session-start `canContinue` derived from server diagnostics so missing `node` blocks session continuation until recheck succeeds. + +- [ ] **Step 4: Run the diagnostics tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/features/diagnostics/index.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 5: Commit the diagnostics installer flow** + +```bash +git add packages/web/src/features/diagnostics/actions/use-system-dependency-installer.ts \ + packages/web/src/features/diagnostics/components/system-dependency-install-panel.tsx \ + packages/web/src/features/diagnostics/page.tsx \ + packages/web/src/features/diagnostics/index.test.tsx +git commit -m "feat(web): add diagnostics system dependency installer" +``` + +### Task 6: Add Copy, Styling, And Final Regression Coverage + +**Files:** +- Modify: `packages/web/src/locales/en.json` +- Modify: `packages/web/src/locales/zh.json` +- Modify: `packages/web/src/styles/components.css` +- Modify: `packages/web/src/styles/components.theme.test.ts` + +- [ ] **Step 1: Write the failing theme/style regression** + +Add this to `packages/web/src/styles/components.theme.test.ts`: + +```ts + it("keeps diagnostics install surfaces on theme tokens", () => { + expect(stylesheet).toContain(".diagnostics-install-panel"); + expect(stylesheet).toContain("var(--bg-surface)"); + expect(stylesheet).toContain("var(--border-default)"); + expect(stylesheet).toContain("var(--text-secondary)"); + }); +``` + +- [ ] **Step 2: Run the web diagnostics and style tests to verify they fail** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/diagnostics/index.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: FAIL because the new locale keys and install panel styles do not exist yet. + +- [ ] **Step 3: Add locales and install panel styles** + +Update `packages/web/src/locales/en.json` with: + +```json +"system_deps": { + "install": { + "install_git": "Install Git", + "install_node": "Install Node.js", + "package_manager": "Package manager", + "password_label": "Administrator password", + "submit_password": "Submit password", + "cancel": "Cancel install", + "status": { + "queued": "Queued", + "running": "Installing", + "waiting_input": "Waiting for password", + "succeeded": "Installed", + "failed": "Install failed", + "cancelled": "Install cancelled" + }, + "git": { + "manual": "Install Git manually if automatic install is not available for this machine." + }, + "node": { + "manual": "Install Node.js manually if automatic install is not available for this machine." + } + } +} +``` + +Update `packages/web/src/locales/zh.json` with: + +```json +"system_deps": { + "install": { + "install_git": "安装 Git", + "install_node": "安装 Node.js", + "package_manager": "包管理器", + "password_label": "管理员密码", + "submit_password": "提交密码", + "cancel": "取消安装", + "status": { + "queued": "等待中", + "running": "安装中", + "waiting_input": "等待输入密码", + "succeeded": "安装完成", + "failed": "安装失败", + "cancelled": "安装已取消" + }, + "git": { + "manual": "如果当前机器不支持自动安装,请手动安装 Git。" + }, + "node": { + "manual": "如果当前机器不支持自动安装,请手动安装 Node.js。" + } + } +} +``` + +Update `packages/web/src/styles/components.css` with: + +```css +.diagnostics-install-panel { + display: grid; + gap: 10px; + margin-top: 12px; + padding: 12px; + border: 1px solid var(--border-default); + border-radius: 14px; + background: var(--bg-surface); +} + +.diagnostics-install-panel__meta { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + color: var(--text-secondary); + font-size: 12px; +} + +.diagnostics-install-panel__log { + min-height: 120px; + max-height: 220px; + overflow: auto; + margin: 0; + padding: 12px; + border-radius: 12px; + background: var(--bg-panel); + border: 1px solid var(--border-subtle); + color: var(--text-primary); +} + +.diagnostics-install-panel__prompt { + display: grid; + gap: 8px; +} + +.diagnostics-install-panel__label { + color: var(--text-secondary); + font-size: 12px; +} + +.diagnostics-install-panel__input { + width: 100%; +} +``` + +- [ ] **Step 4: Run the focused regression suite to verify everything passes** + +Run: + +```bash +pnpm --filter @coder-studio/core exec vitest run src/domain/system-dependency-install.test.ts +pnpm --filter @coder-studio/server exec vitest run \ + src/__tests__/system-deps/runtime-status.test.ts \ + src/__tests__/diagnostics-commands.test.ts \ + src/__tests__/system-deps/interaction-detector.test.ts \ + src/__tests__/system-deps/install-manager.test.ts \ + src/__tests__/system-deps/commands.test.ts +pnpm --filter @coder-studio/web exec vitest run \ + src/features/diagnostics/index.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: all PASS. + +- [ ] **Step 5: Commit the copy, styles, and final regression coverage** + +```bash +git add packages/web/src/locales/en.json \ + packages/web/src/locales/zh.json \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "feat(web): polish system dependency installer diagnostics" +``` diff --git a/docs/superpowers/plans/2026-05-24-workspace-background-material-system.md b/docs/superpowers/plans/2026-05-24-workspace-background-material-system.md new file mode 100644 index 00000000..5bcfa1f8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-workspace-background-material-system.md @@ -0,0 +1,662 @@ +# Workspace Background Material System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace ad hoc workspace background handling with a single workspace-scoped material token system so shells, content layers, xterm, and Monaco follow one consistent transparency and blur policy. + +**Architecture:** Keep the existing theme foundation tokens and appearance runtime inputs, then introduce a new `--ws-*` workspace material layer in `tokens.css`. Migrate workspace CSS to consume semantic `--ws-*` tokens, make layout and content layers transparent, and adapt xterm and Monaco so renderer-backed content stops acting like a separate background system. + +**Tech Stack:** React 19, Jotai, Monaco Editor, xterm.js, CSS custom properties, Vitest, Testing Library, and the shared stylesheet/token system in `packages/web/src/styles`. + +--- + +**Spec reference:** `docs/superpowers/specs/2026-05-24-workspace-background-material-system-design.md` + +**Git hygiene:** The current worktree contains unrelated modified app files and many untracked docs files. Stage only the files listed in each task, and never revert unrelated edits. + +## File Structure + +**Modified files:** +- `packages/web/src/styles/tokens.css` — define the workspace material token layer and the solid/glass/high-contrast resolution rules. +- `packages/web/src/styles/components.css` — migrate workspace shells to semantic `--ws-*` tokens and normalize layout/content layers to transparent. +- `packages/web/src/styles/components.theme.test.ts` — replace local material-formula assertions with workspace token assertions and add guardrails for transparent content layers. +- `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx` — remove xterm’s glass-only background branching and make workspace terminal backgrounds follow the shared content-layer policy. +- `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx` — update xterm theme expectations to assert shared transparent content behavior across workspace modes. +- `packages/web/src/theme/registry.ts` — make Monaco workspace themes use transparent editor backgrounds while keeping syntax, selection, and cursor colors intact. +- `packages/web/src/theme/registry.test.ts` — update Monaco theme assertions to lock transparent workspace editor backgrounds. +- `packages/web/src/features/code-editor/components/monaco-host.test.tsx` — add focused checks that defined Monaco themes carry the transparent editor background. + +**No structural file splits in this phase:** +- `xterm-host.tsx` and `components.css` are already large, but the implementation should stay localized instead of restructuring them during this migration. + +**Testing commands used in this plan:** +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts` +- `pnpm --filter @coder-studio/web exec vitest run src/features/terminal-panel/__tests__/xterm-host.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/theme/registry.test.ts src/features/code-editor/components/monaco-host.test.tsx` +- `pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts src/features/terminal-panel/__tests__/xterm-host.test.tsx src/theme/registry.test.ts src/features/code-editor/components/monaco-host.test.tsx` + +--- + +### Task 1: Define Workspace Material Tokens + +**Files:** +- Modify: `packages/web/src/styles/tokens.css:108-116` +- Modify: `packages/web/src/styles/tokens.css:296-304` +- Modify: `packages/web/src/styles/tokens-touch.test.ts:140-170` + +- [ ] **Step 1: Write the failing token test coverage** + +Add this test to `packages/web/src/styles/tokens-touch.test.ts` near the existing surface token assertions: + +```ts + it("defines workspace material tokens for solid and glass workspace surfaces", () => { + expect(root).toContain("--ws-backdrop-filter: none"); + expect(root).toContain("--ws-content-bg: transparent"); + expect(root).toContain("--ws-sidebar-bg: var(--surface-panel-bg)"); + expect(root).toContain("--ws-terminal-shell-bg: var(--surface-panel-bg)"); + expect(root).toContain("--ws-editor-toolbar-bg: var(--surface-elevated-bg)"); + expect(root).toContain("--ws-level-0: transparent"); + expect(root).toContain("--ws-level-1: color-mix("); + expect(root).toContain("--ws-level-4: color-mix("); + }); +``` + +- [ ] **Step 2: Run the token test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/tokens-touch.test.ts +``` + +Expected: +- FAIL because `--ws-*` tokens are not defined in `tokens.css` + +- [ ] **Step 3: Add the workspace material token layer** + +In `packages/web/src/styles/tokens.css`, add a new `Workspace Material System` section immediately after the existing foundation surface tokens: + +```css + --ws-backdrop-filter: none; + --ws-content-bg: transparent; + + --ws-level-0: transparent; + --ws-level-1: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 40%), + transparent + ); + --ws-level-2: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 56%), + transparent + ); + --ws-level-3: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 72%), + transparent + ); + --ws-level-4: color-mix( + in srgb, + var(--surface-overlay-bg) calc(var(--app-surface-opacity, 0.96) * 88%), + transparent + ); + + --ws-sidebar-bg: var(--surface-panel-bg); + --ws-activitybar-bg: var(--surface-panel-bg); + --ws-statusbar-bg: var(--surface-panel-bg); + --ws-session-bg: var(--surface-panel-bg); + --ws-session-active-bg: var(--surface-elevated-bg); + --ws-session-header-bg: var(--surface-elevated-bg); + --ws-terminal-shell-bg: var(--surface-panel-bg); + --ws-terminal-toolbar-bg: var(--surface-elevated-bg); + --ws-terminal-tabs-bg: var(--surface-elevated-bg); + --ws-editor-shell-bg: var(--surface-panel-bg); + --ws-editor-toolbar-bg: var(--surface-elevated-bg); +``` + +Then, near the theme/runtime state section that already reacts to `data-appearance-glass`, add the glass-state overrides: + +```css +:root[data-appearance-glass="on"] { + --ws-backdrop-filter: var(--app-surface-backdrop-filter, none); + --ws-sidebar-bg: var(--ws-level-3); + --ws-activitybar-bg: var(--ws-level-2); + --ws-statusbar-bg: var(--ws-level-3); + --ws-session-bg: var(--ws-level-2); + --ws-session-active-bg: var(--ws-level-3); + --ws-session-header-bg: var(--ws-level-3); + --ws-terminal-shell-bg: var(--ws-level-3); + --ws-terminal-toolbar-bg: var(--ws-level-2); + --ws-terminal-tabs-bg: var(--ws-level-2); + --ws-editor-shell-bg: var(--ws-level-2); + --ws-editor-toolbar-bg: var(--ws-level-3); +} + +:root[data-theme="hc-dark"], +:root[data-theme="hc-light"] { + --ws-backdrop-filter: none; + --ws-sidebar-bg: var(--surface-panel-bg); + --ws-activitybar-bg: var(--surface-panel-bg); + --ws-statusbar-bg: var(--surface-panel-bg); + --ws-session-bg: var(--surface-panel-bg); + --ws-session-active-bg: var(--surface-elevated-bg); + --ws-session-header-bg: var(--surface-elevated-bg); + --ws-terminal-shell-bg: var(--surface-panel-bg); + --ws-terminal-toolbar-bg: var(--surface-elevated-bg); + --ws-terminal-tabs-bg: var(--surface-elevated-bg); + --ws-editor-shell-bg: var(--surface-panel-bg); + --ws-editor-toolbar-bg: var(--surface-elevated-bg); +} +``` + +- [ ] **Step 4: Run the token test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/tokens-touch.test.ts +``` + +Expected: +- PASS with the new `--ws-*` token assertions succeeding + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/tokens.css packages/web/src/styles/tokens-touch.test.ts +git commit -m "feat: add workspace material tokens" +``` + +### Task 2: Migrate Workspace Shells And Containers To Semantic Tokens + +**Files:** +- Modify: `packages/web/src/styles/components.css:14133-14422` +- Modify: `packages/web/src/styles/components.theme.test.ts:1158-1276` + +- [ ] **Step 1: Write the failing workspace material assertions** + +Update `packages/web/src/styles/components.theme.test.ts` so the `routes settings and workspace shared surfaces through appearance-aware background tokens` test expects semantic workspace tokens instead of local formulas: + +```ts + expect(workspaceSidebarPanel).toContain("background: var(--ws-sidebar-bg)"); + expect(workspaceSidebarPanel).toContain("backdrop-filter: var(--ws-backdrop-filter)"); + expect(workspaceSidebarPanel).not.toContain("var(--surface-overlay-bg)"); + expect(workspaceActivityBar).toContain("background: var(--ws-activitybar-bg)"); + expect(workspaceStatusBar).toContain("background: var(--ws-statusbar-bg)"); + expect(sessionCard).toContain("background: var(--ws-session-bg)"); + expect(activeSessionCard).toContain("background: var(--ws-session-active-bg)"); + expect(activeSessionHeader).toContain("background: var(--ws-session-header-bg)"); + expect(terminalToolbar).toContain("background: var(--ws-terminal-toolbar-bg)"); + expect(bottomTerminalTabs).toContain("background: var(--ws-terminal-tabs-bg)"); + expect(bottomTerminal).toContain("background: var(--ws-terminal-shell-bg)"); +``` + +Also add guardrails for transparent structural/content nodes: + +```ts + expect(workspaceBody).toContain("background: transparent"); + expect(workspaceMainStage).toContain("background: transparent"); + expect(agentPanes).toContain("background: transparent"); + expect(agentPane).toContain("background: transparent"); + expect(paneLayout).toContain("background: transparent"); + expect(paneLayoutChild).toContain("background: transparent"); + expect(bottomTerminalContent).toContain("background: transparent"); + expect(bottomTerminalXterm).toContain("background: transparent"); +``` + +- [ ] **Step 2: Run the theme test to verify it fails** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: +- FAIL because workspace shells still use raw `color-mix(...)` formulas and runtime appearance variables directly + +- [ ] **Step 3: Replace workspace shell backgrounds with `--ws-*` tokens** + +In `packages/web/src/styles/components.css`, edit the workspace appearance block so the shell selectors read like this: + +```css +.workspace-sidebar-panel { + background: var(--ws-sidebar-bg); + border-right: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + backdrop-filter: var(--ws-backdrop-filter); +} + +.workspace-activity-bar { + background: var(--ws-activitybar-bg); + border-right-color: color-mix(in srgb, var(--border) 72%, transparent); + backdrop-filter: var(--ws-backdrop-filter); +} + +.workspace-status-bar { + background: var(--ws-statusbar-bg); + backdrop-filter: var(--ws-backdrop-filter); +} + +.session-card { + background: var(--ws-session-bg); + backdrop-filter: var(--ws-backdrop-filter); +} + +.session-card.session-card--active { + background: var(--ws-session-active-bg); +} + +.session-header, +.session-card.session-card--active > .panel-header, +.session-card.session-card--active .session-header { + background: var(--ws-session-header-bg); + backdrop-filter: var(--ws-backdrop-filter); +} + +.terminal-toolbar { + background: var(--ws-terminal-toolbar-bg); + backdrop-filter: var(--ws-backdrop-filter); +} + +.bottom-terminal-tabs { + background: var(--ws-terminal-tabs-bg); + backdrop-filter: var(--ws-backdrop-filter); +} + +.workspace-bottom-panel > .bottom-terminal { + background: var(--ws-terminal-shell-bg); + box-shadow: none; + backdrop-filter: var(--ws-backdrop-filter); +} + +.workspace-git-editor { + background: var(--ws-editor-shell-bg); + backdrop-filter: var(--ws-backdrop-filter); +} + +.code-editor-header { + background: var(--ws-editor-toolbar-bg); + backdrop-filter: var(--ws-backdrop-filter); +} +``` + +Keep the transparent layout/content rules in place and remove raw workspace-local shell `color-mix(...)` formulas from this block. + +- [ ] **Step 4: Run the theme test to verify it passes** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run src/styles/components.theme.test.ts +``` + +Expected: +- PASS with workspace shell selectors reading from semantic `--ws-*` tokens + +- [ ] **Step 5: Commit** + +```bash +git add packages/web/src/styles/components.css packages/web/src/styles/components.theme.test.ts +git commit -m "feat: migrate workspace shells to material tokens" +``` + +### Task 3: Make xterm Follow The Shared Transparent Content Layer + +**Files:** +- Modify: `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx:2703-2838` +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx:329-338` +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx:528-534` +- Modify: `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx:1376-1384` +- Modify: `packages/web/src/styles/components.css:1853-1858` + +- [ ] **Step 1: Write the failing xterm behavior tests** + +In `packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx`, replace the glass-specific test intent with shared transparent content behavior: + +```tsx + it("uses a transparent xterm background for workspace terminals", async () => { + const { Terminal } = await import("@xterm/xterm"); + + render( + + + + ); + + expect(Terminal).toHaveBeenCalledWith( + expect.objectContaining({ + theme: expect.objectContaining({ + ...getThemeById("mint-dark").terminalTheme, + background: "transparent", + }), + }) + ); + }); + + it("keeps the live xterm background transparent after theme switches", async () => { + const store = createStore(); + store.set(themeAtom, "mint-dark"); + + render( + + + + ); + + await act(async () => { + store.set(themeAtom, "graphite-light"); + }); + + await waitFor(() => { + expect(mockTerminal.options).toEqual( + expect.objectContaining({ + theme: expect.objectContaining({ + ...getThemeById("graphite-light").terminalTheme, + background: "transparent", + }), + }) + ); + }); + }); +``` + +Also update `components.theme.test.ts` expectations so: + +```ts + expect(xtermScreen).toContain("background: var(--ws-content-bg)"); +``` + +and drop the separate `[data-appearance-glass="on"]` selector assertion. + +- [ ] **Step 2: Run the xterm and theme tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- FAIL because xterm still only becomes transparent when glass is enabled +- FAIL because `.xterm-screen` still uses `var(--bg-terminal)` + +- [ ] **Step 3: Remove the glass-only xterm background branch** + +In `packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx`, simplify the helper so it always returns a transparent background: + +```ts +function resolveXtermTheme(themeId: string): TerminalThemeDefinition { + return { + ...getThemeById(themeId).terminalTheme, + background: "transparent", + }; +} +``` + +Then update its call sites: + +```ts + const resolvedTerminalTheme = resolveXtermTheme(uiTheme); +``` + +and + +```ts + theme: resolveXtermTheme(initialThemeRef.current), +``` + +Also remove any no-longer-needed `appearancePersonalization` / `resolveAppearancePersonalizationForViewport` / `glassEnabled` background gating that exists only for the terminal theme. + +In `packages/web/src/styles/components.css`, replace: + +```css +.xterm-host .xterm-screen { + background: var(--bg-terminal); +} + +[data-appearance-glass="on"] .xterm-host .xterm-screen { + background: transparent; +} +``` + +with: + +```css +.xterm-host .xterm-screen { + background: var(--ws-content-bg); +} +``` + +- [ ] **Step 4: Run the xterm and theme tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + src/styles/components.theme.test.ts +``` + +Expected: +- PASS with xterm always following the transparent workspace content-layer policy + +- [ ] **Step 5: Commit** + +```bash +git add \ + packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx \ + packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts +git commit -m "feat: align xterm with workspace content surfaces" +``` + +### Task 4: Make Monaco Use Transparent Workspace Content Backgrounds + +**Files:** +- Modify: `packages/web/src/theme/registry.test.ts:135-176` +- Modify: `packages/web/src/features/code-editor/components/monaco-host.test.tsx:330-350` +- Modify: `packages/web/src/theme/registry.ts:175-184` +- Modify: `packages/web/src/theme/registry.ts:255-264` +- Modify: `packages/web/src/theme/registry.ts:342-351` +- Modify: `packages/web/src/theme/registry.ts:429-438` +- Modify: `packages/web/src/theme/registry.ts:532-541` +- Modify: `packages/web/src/theme/registry.ts:635-644` + +- [ ] **Step 1: Write the failing Monaco theme tests** + +In `packages/web/src/theme/registry.test.ts`, update the light theme assertions so each workspace Monaco palette expects: + +```ts + expect(mintLight?.monaco.colors).toEqual( + expect.objectContaining({ + "editor.background": "#00000000", + "editorCursor.foreground": "#148a7a", + "editor.selectionBackground": "#ddefe5", + }) + ); +``` + +Apply the same `"editor.background": "#00000000"` expectation to `graphiteLight` and `nordLight`. + +Add a focused theme-definition test to `packages/web/src/features/code-editor/components/monaco-host.test.tsx` near the existing `defineTheme` assertion: + +```tsx + it("defines Monaco themes with transparent editor backgrounds for workspace shells", async () => { + renderMonacoHost(); + + expect(mockDefineTheme).toHaveBeenCalledWith( + "coder-studio-mint-light", + expect.objectContaining({ + colors: expect.objectContaining({ + "editor.background": "#00000000", + }), + }) + ); + }); +``` + +- [ ] **Step 2: Run the Monaco/theme tests to verify failure** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/theme/registry.test.ts \ + src/features/code-editor/components/monaco-host.test.tsx +``` + +Expected: +- FAIL because Monaco themes still define opaque editor backgrounds + +- [ ] **Step 3: Change Monaco theme backgrounds to transparent** + +In `packages/web/src/theme/registry.ts`, replace every workspace Monaco `editor.background` value with `#00000000` while keeping all other color keys unchanged: + +```ts + colors: { + "editor.background": "#00000000", + "editor.foreground": "#e5edf3", + "editorLineNumber.foreground": "#4a5b6a", + "editorCursor.foreground": "#78d7b2", + "editor.selectionBackground": "#1e3040", + }, +``` + +Apply the same transparent background change to: + +- `mint-dark` +- `mint-light` +- `graphite-dark` +- `graphite-light` +- `nord-dark` +- `nord-light` + +Do not change the high-contrast Monaco themes in this task. + +- [ ] **Step 4: Run the Monaco/theme tests to verify they pass** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/theme/registry.test.ts \ + src/features/code-editor/components/monaco-host.test.tsx +``` + +Expected: +- PASS with `defineTheme` and registry assertions showing transparent editor backgrounds + +- [ ] **Step 5: Commit** + +```bash +git add \ + packages/web/src/theme/registry.ts \ + packages/web/src/theme/registry.test.ts \ + packages/web/src/features/code-editor/components/monaco-host.test.tsx +git commit -m "feat: make workspace monaco backgrounds transparent" +``` + +### Task 5: Run Integrated Verification And Audit Workspace Rules + +**Files:** +- Modify: `packages/web/src/styles/components.theme.test.ts:860-1276` + +- [ ] **Step 1: Add a guardrail assertion against raw workspace shell formulas** + +Extend `packages/web/src/styles/components.theme.test.ts` with one final assertion inside the workspace material test: + +```ts + expect(workspaceSidebarPanel).not.toContain("calc(var(--app-surface-opacity"); + expect(workspaceActivityBar).not.toContain("calc(var(--app-surface-opacity"); + expect(workspaceStatusBar).not.toContain("calc(var(--app-surface-opacity"); + expect(sessionCard).not.toContain("calc(var(--app-surface-opacity"); + expect(bottomTerminal).not.toContain("calc(var(--app-surface-opacity"); + expect(terminalToolbar).not.toContain("calc(var(--app-surface-opacity"); + expect(bottomTerminalTabs).not.toContain("calc(var(--app-surface-opacity"); +``` + +- [ ] **Step 2: Run the full focused verification suite** + +Run: + +```bash +pnpm --filter @coder-studio/web exec vitest run \ + src/styles/components.theme.test.ts \ + src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + src/theme/registry.test.ts \ + src/features/code-editor/components/monaco-host.test.tsx +``` + +Expected: +- PASS for all four suites + +- [ ] **Step 3: Review the final diff for scope control** + +Run: + +```bash +git diff -- packages/web/src/styles/tokens.css \ + packages/web/src/styles/components.css \ + packages/web/src/styles/components.theme.test.ts \ + packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx \ + packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + packages/web/src/theme/registry.ts \ + packages/web/src/theme/registry.test.ts \ + packages/web/src/features/code-editor/components/monaco-host.test.tsx +``` + +Expected: +- only the planned workspace material, xterm, Monaco, and test files changed + +- [ ] **Step 4: Commit** + +```bash +git add \ + packages/web/src/styles/components.theme.test.ts \ + packages/web/src/styles/tokens.css \ + packages/web/src/styles/components.css \ + packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx \ + packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx \ + packages/web/src/theme/registry.ts \ + packages/web/src/theme/registry.test.ts \ + packages/web/src/features/code-editor/components/monaco-host.test.tsx +git commit -m "test: lock workspace material system behavior" +``` + +## Self-Review + +### Spec coverage + +- Workspace-only scope is covered by Tasks 1-5. +- Transparent layout chain is covered by Task 2. +- Shared shell tokenization is covered by Tasks 1 and 2. +- Transparent content layers are covered by Tasks 2 and 3. +- xterm renderer parity is covered by Task 3. +- Monaco renderer parity is covered by Task 4. +- Guardrail testing against future ad hoc formulas is covered by Task 5. + +### Placeholder scan + +- No `TBD`, `TODO`, or “implement later” placeholders remain. +- Every task lists exact files, exact tests, and concrete commands. +- Each code-changing step includes concrete code to introduce or update. + +### Type consistency + +- The plan consistently uses `--ws-*` token names from the approved spec. +- The xterm helper remains `resolveXtermTheme(...)` rather than introducing a second naming variant. +- Monaco theme expectations use the exact `editor.background` key already present in `registry.ts`. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-24-workspace-background-material-system.md`. Two execution options: + +**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach? diff --git a/docs/superpowers/specs/2026-05-23-workspace-tab-instance-isolation-design.md b/docs/superpowers/specs/2026-05-23-workspace-tab-instance-isolation-design.md new file mode 100644 index 00000000..c67f7790 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-workspace-tab-instance-isolation-design.md @@ -0,0 +1,107 @@ +# Workspace Tab Instance Isolation Design + +## Goal + +Treat each workspace as its own tab instance. Switching workspaces must switch to that workspace's own UI state and view state instead of reusing a shared global workspace shell. + +## Problem + +The current desktop workspace experience mixes two different models: + +- Server/data state is mostly scoped by `workspaceId` +- A large part of workspace UI state is global + +That mismatch causes state bleed: + +- switching workspace carries the active sidebar tab into the next workspace +- search queries and search results leak across workspaces +- sidebar collapse, terminal visibility, focus mode, and split sizes are shared across all workspaces + +This does not match the tab mental model. A workspace switch should feel like switching tabs, not swapping the data source under one shared instance. + +## Desired Behavior + +When the user switches from workspace `A` to workspace `B`: + +- the rendered workspace view becomes a distinct instance for `B` +- `B` restores its own layout state +- `B` restores its own panel/session UI state +- no panel-local `useState` from `A` is visible in `B` + +When the user switches back to `A`: + +- `A` restores the last in-memory state for its own workspace tab instance + +## Scope + +This change covers workspace view instance state in `packages/web`. + +### Persistent workspace-scoped layout state + +- desktop sidebar active view +- sidebar collapsed +- terminal panel visible +- focus mode +- left panel width +- bottom panel height + +### Workspace-scoped session/view state + +- content search panel query, results, expanded groups, loading/error/retry state +- git panel expand/collapse and worktree surface view +- file tree search input and search results +- workspace screen model local view state such as mobile sheet state and create request routing + +### Out of scope + +- keeping every workspace React subtree mounted concurrently +- expanding server `Workspace["uiState"]` protocol for new sidebar/tab visibility fields in this change + +## Architecture + +### 1. Workspace-scoped layout buckets + +Replace the shared layout atoms with `workspaceId`-keyed families. + +- storage-backed families hold per-workspace layout values +- active-workspace adapter atoms preserve existing call sites that operate on "the current workspace" +- adapter atoms fall back to a global default bucket when no workspace is active + +This keeps the top bar, focus mode, command palette, and terminal controls operating on the current workspace tab without rewriting every consumer to plumb `workspaceId`. + +### 2. Workspace root instance boundary + +Key the workspace root view by `workspace.id` so React does not reuse one component instance across different workspaces. + +This prevents panel-local state from silently surviving a workspace switch. + +### 3. Explicit workspace session-state buckets + +For panel state that should restore when returning to the same workspace, move local `useState` into workspace-scoped atoms. + +This applies to: + +- search panel state +- git panel UI state +- file tree search state +- screen model local view state + +These buckets are in-memory by default unless there is a reason to persist them longer. + +## Why not keep hidden workspaces mounted? + +Keeping all workspaces mounted would preserve state automatically, but it also keeps effects, subscriptions, and requests alive for hidden workspaces. That is a poor fit for this app. We want tab-like state restoration without background work from inactive workspace trees. + +## Testing Strategy + +1. Add a desktop workspace integration test proving sidebar view and layout state are isolated per workspace. +2. Add a desktop workspace integration test proving search state belongs to each workspace instance and restores when switching back. +3. Keep existing focused component tests green. + +## Acceptance Criteria + +- switching workspace does not carry the active sidebar tab across workspaces +- switching workspace does not carry search query/results across workspaces +- switching back restores the previous workspace's own state +- top bar toggles and keyboard shortcuts operate on the active workspace only +- existing workspace data flows remain scoped by `workspaceId` diff --git a/docs/superpowers/specs/2026-05-24-git-panel-worktree-list-design.md b/docs/superpowers/specs/2026-05-24-git-panel-worktree-list-design.md new file mode 100644 index 00000000..fb956485 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-git-panel-worktree-list-design.md @@ -0,0 +1,131 @@ +# Git Panel Worktree List Design + +## Goal + +Refine the compact worktree list inside the Git panel so it behaves like a lightweight launcher instead of leaking low-level git details. + +## Problem + +The compact worktree list currently exposes full branch refs such as `refs/heads/develop`, which adds noise without helping the user choose a worktree. + +It also lacks a direct delete action, even though the app already has: + +- server support for `worktree.remove` +- front-end delete handling in the full worktree manager surface +- delete confirmation copy and force-delete behavior for dirty worktrees + +The Git panel also mounts the full `WorktreeManagerSurface`, but only exposes the `create` entry point. There is no visible way to open the list-management view from the Git panel itself. + +## Desired Behavior + +### Compact list rows + +Each row in the Git panel worktree section should show: + +- worktree name +- shortened branch label +- clean/dirty summary +- current chip when applicable + +The compact row must not display: + +- absolute worktree path +- full git ref prefixes such as `refs/heads/` + +Branch presentation rule: + +- if the branch begins with `refs/heads/`, show the suffix only +- otherwise keep the original branch string so detached or remote representations stay intact + +### Delete action + +The compact list should expose an inline delete action for removable entries. + +Deletion must keep the same safety rules already used in the full manager: + +- no delete action for the current worktree +- no delete action for the primary/main worktree entry +- dirty worktrees require the existing force-remove confirmation +- clean worktrees use the existing normal delete confirmation + +### Management entry point + +The Git panel worktree section should expose a visible `Manage` action that opens the full `WorktreeManagerSurface` in list mode. + +The existing `New` action should continue opening the create view. + +## Scope + +### In scope + +- Git panel compact worktree row rendering +- inline delete action from the compact list +- Git panel entry point for full worktree management +- targeted style updates needed to support split row actions +- focused Git panel tests for the new behavior + +### Out of scope + +- changing full worktree manager behavior beyond shared delete reuse +- changing server-side worktree removal rules +- adding path display back elsewhere in the compact list +- changing worktree switching/opening behavior + +## Architecture + +### 1. Keep command/data flow unchanged + +Reuse `useWorktreeManagementActions` from the Git panel for: + +- `list` +- `loadWorktrees` +- `openWorktree` +- `removeWorktreeByPath` + +No server changes are needed because `worktree.remove` already exists and enforces open-worktree safety. + +### 2. Split compact row interactions + +The current compact row is one full-width button. To support delete without breaking open/switch behavior, each row should become: + +- a left-side main button for open/switch +- a right-side icon/button for delete when removable + +This preserves the row click target while isolating destructive behavior. + +### 3. Reuse the existing full manager surface + +The Git panel already mounts `WorktreeManagerSurface` via `worktreeSurfaceView`. + +This design only adds one missing state transition: + +- `Manage` sets `worktreeSurfaceView` to `"list"` + +That exposes existing functionality instead of introducing another management surface. + +## Files Expected To Change + +- `packages/web/src/features/workspace/views/shared/git-panel.tsx` +- `packages/web/src/features/workspace/views/shared/git-panel.test.tsx` +- `packages/web/src/styles/components.css` +- `packages/web/src/styles/components.theme.test.ts` + +## Testing Strategy + +1. Add Git panel tests that fail until the compact list: + - hides `refs/heads/` prefixes + - exposes a manage entry point + - exposes delete only for removable worktrees + - sends `worktree.remove` with `force: true` for dirty worktrees +2. Keep worktree switching behavior covered by existing row-click tests or add focused coverage if needed. +3. Verify compact-row CSS still matches the tight tool-surface constraints asserted in `components.theme.test.ts`. + +## Acceptance Criteria + +- compact Git panel rows no longer show `refs/heads/...` +- compact Git panel rows still show branch context using the shortened branch name +- removable worktrees show an inline delete action +- current and primary worktrees do not show the inline delete action +- clicking a compact row still opens/switches to that worktree +- clicking `Manage` opens the full worktree manager list view +- dirty worktree deletion from the compact list issues `worktree.remove` with `force: true` From ce9809c4518c90667ae0e7fe115085e07ddef616 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:10:51 +0800 Subject: [PATCH 35/36] test(web): align mobile and preview expectations --- .../mobile/mobile-explorer-panel.test.tsx | 7 ++-- .../src/shells/mobile-shell/index.test.tsx | 34 ++++++++++--------- .../src/ui-preview/scenes/showcase-scenes.tsx | 2 +- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx b/packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx index aa6f3b2e..bcf427fc 100644 --- a/packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx +++ b/packages/web/src/features/workspace/views/mobile/mobile-explorer-panel.test.tsx @@ -167,7 +167,7 @@ describe("MobileExplorerPanel", () => { expect(onSelectFile).toHaveBeenCalledWith("README.md"); }); - it("renders shared open editor controls on mobile and closing the active row selects the next file", () => { + it("renders shared open editor controls on mobile and closing the active row exits editor focus", () => { const onSelectFile = vi.fn(); const store = createStore(); store.set(fileTreeAtomFamily("ws-test"), new Map([[".", []]])); @@ -227,13 +227,14 @@ describe("MobileExplorerPanel", () => { }) ); - expect(within(section).getByRole("button", { name: "src/beta.tsx" })).toHaveClass( + expect(within(section).getByRole("button", { name: "src/beta.tsx" })).not.toHaveClass( "workspace-open-editors__item--active" ); expect(within(section).queryByRole("button", { name: "src/alpha.tsx" })).toBeNull(); expect(heading).toHaveTextContent(/(Open Editors|打开的编辑器)\s*\(1\)/i); expect(Object.keys(store.get(openFilesAtomFamily("ws-test")))).toEqual(["src/beta.tsx"]); - expect(store.get(activeFilePathAtomFamily("ws-test"))).toBe("src/beta.tsx"); + expect(store.get(activeFilePathAtomFamily("ws-test"))).toBeNull(); + expect(onSelectFile).not.toHaveBeenCalled(); }); it("renders workspace actions inside the Workspace section and wires mobile callbacks", () => { diff --git a/packages/web/src/shells/mobile-shell/index.test.tsx b/packages/web/src/shells/mobile-shell/index.test.tsx index bd7ac654..d259afe1 100644 --- a/packages/web/src/shells/mobile-shell/index.test.tsx +++ b/packages/web/src/shells/mobile-shell/index.test.tsx @@ -1705,24 +1705,26 @@ describe("MobileShell Phase 2 workspace", () => { }, undefined ); - expect(sendCommand).toHaveBeenCalledWith( - "workspace.uiState.set", - expect.objectContaining({ - workspaceId: "ws-1", - uiState: expect.objectContaining({ - activeSessionId: "sess_3", - paneLayout: expect.objectContaining({ - type: "split", - direction: "vertical", - children: [ - expect.objectContaining({ sessionId: "sess_2" }), - expect.objectContaining({ sessionId: "sess_3" }), - ], + await waitFor(() => { + expect(sendCommand).toHaveBeenCalledWith( + "workspace.uiState.set", + expect.objectContaining({ + workspaceId: "ws-1", + uiState: expect.objectContaining({ + activeSessionId: "sess_3", + paneLayout: expect.objectContaining({ + type: "split", + direction: "horizontal", + children: [ + expect.objectContaining({ sessionId: "sess_2" }), + expect.objectContaining({ sessionId: "sess_3" }), + ], + }), }), }), - }), - undefined - ); + undefined + ); + }); }); it("closes the agent sheet once when selecting an existing session", async () => { diff --git a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx index c6323492..d4f25b60 100644 --- a/packages/web/src/ui-preview/scenes/showcase-scenes.tsx +++ b/packages/web/src/ui-preview/scenes/showcase-scenes.tsx @@ -173,7 +173,7 @@ const readmeDesktopGitStatus: GitStatus = { headSubject: "feat: stage readme screenshot refresh scenes", staged: [{ path: "README.md", status: "modified" }], modified: [ - { path: "packages/web/src/ui-preview/scenes/showcase-scenes.tsx", status: "modified" }, + { path: "packages/web/src/features/topbar/index.tsx", status: "modified" }, { path: "docs/help/assets/screenshot-desktop-workspace-full.png", status: "modified" }, ], untracked: [{ path: "docs/help/assets/screenshot-mobile-progress.png", status: "untracked" }], From b271591eb48f90d33883049e44db639a4afc2b51 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Sun, 24 May 2026 23:16:44 +0800 Subject: [PATCH 36/36] chore(release): add patch changeset --- .changeset/pretty-tigers-float.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/pretty-tigers-float.md diff --git a/.changeset/pretty-tigers-float.md b/.changeset/pretty-tigers-float.md new file mode 100644 index 00000000..b747e12a --- /dev/null +++ b/.changeset/pretty-tigers-float.md @@ -0,0 +1,5 @@ +--- +"@spencer-kit/coder-studio": patch +--- + +Polish workspace background material rendering so personalized glass and background image settings apply more consistently across the main workspace surfaces.