diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 3dacaf2a9..75cec0ff9 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -48,6 +48,7 @@ github:PollyGlot github:RakshithBhat03 github:realAhmedRoach github:Rishet11 +github:ryanrhughes github:saphid github:sethwebster github:shiroyasha9 diff --git a/.macroscope/approvability.md b/.macroscope/approvability.md index cfea7fdd5..37be2dc60 100644 --- a/.macroscope/approvability.md +++ b/.macroscope/approvability.md @@ -1 +1,3 @@ Use Macroscope's default approvability criteria. + +Additionally, any pull request that changes product defaults is not auto-approvable and requires human review. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 3c378adbc..4afc3525c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -48,8 +48,8 @@ "@clerk/expo": "catalog:", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", - "@expo/metro-runtime": "~56.0.15", - "@expo/ui": "~56.0.18", + "@expo/metro-runtime": "~57.0.14", + "@expo/ui": "~57.0.14", "@legendapp/list": "catalog:", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", @@ -72,52 +72,52 @@ "clsx": "^2.1.1", "diff": "8.0.3", "effect": "catalog:", - "expo": "~56.0.12", - "expo-asset": "~56.0.17", - "expo-auth-session": "~56.0.14", - "expo-blur": "~56.0.3", - "expo-build-properties": "~56.0.19", - "expo-camera": "~56.0.8", - "expo-clipboard": "~56.0.4", - "expo-constants": "~56.0.18", - "expo-crypto": "~56.0.4", - "expo-dev-client": "~56.0.20", - "expo-device": "~56.0.4", - "expo-file-system": "~56.0.8", - "expo-font": "~56.0.7", - "expo-glass-effect": "~56.0.4", - "expo-haptics": "~56.0.3", - "expo-image": "~56.0.11", - "expo-image-picker": "~56.0.18", - "expo-linking": "~56.0.14", - "expo-network": "~56.0.5", - "expo-notifications": "~56.0.18", + "expo": "~57.0.18", + "expo-asset": "~57.0.15", + "expo-auth-session": "~57.0.10", + "expo-blur": "~57.0.2", + "expo-build-properties": "~57.0.15", + "expo-camera": "~57.0.4", + "expo-clipboard": "~57.0.1", + "expo-constants": "~57.0.16", + "expo-crypto": "~57.0.2", + "expo-dev-client": "~57.0.16", + "expo-device": "~57.0.1", + "expo-file-system": "~57.0.6", + "expo-font": "~57.0.2", + "expo-glass-effect": "~57.0.1", + "expo-haptics": "~57.0.2", + "expo-image": "~57.0.3", + "expo-image-picker": "~57.0.14", + "expo-linking": "~57.0.8", + "expo-network": "~57.0.1", + "expo-notifications": "~57.0.15", "expo-paste-input": "^0.1.15", "expo-quick-actions": "^6.0.2", - "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.18", - "expo-splash-screen": "~56.0.10", - "expo-sqlite": "~56.0.5", - "expo-symbols": "~56.0.6", - "expo-updates": "~56.0.19", - "expo-web-browser": "~56.0.5", - "expo-widgets": "~56.0.19", + "expo-secure-store": "~57.0.2", + "expo-sharing": "~57.0.16", + "expo-splash-screen": "~57.0.8", + "expo-sqlite": "~57.0.2", + "expo-symbols": "~57.0.2", + "expo-updates": "~57.0.19", + "expo-web-browser": "~57.0.2", + "expo-widgets": "~57.0.15", "punycode": "^2.3.1", "react": "19.2.3", "react-dom": "19.2.3", - "react-native": "0.85.3", - "react-native-gesture-handler": "~2.31.1", + "react-native": "0.86.3", + "react-native-gesture-handler": "~2.32.0", "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.21.13", "react-native-nitro-markdown": "^0.5.0", "react-native-nitro-modules": "0.35.9", - "react-native-reanimated": "4.3.1", + "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2", + "react-native-screens": "~4.26.0", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.4", "react-native-webview": "^13.16.1", - "react-native-worklets": "0.8.3", + "react-native-worklets": "0.10.1", "shiki": "4.2.0", "tailwind-merge": "^3.5.0", "uniwind": "^1.6.2" @@ -126,7 +126,7 @@ "@effect/vitest": "catalog:", "@pierre/trees": "1.0.0-beta.4", "@types/react": "~19.2.0", - "babel-preset-expo": "~56.0.0", + "babel-preset-expo": "~57.0.9", "tailwindcss": "^4.0.0", "typescript": "catalog:" }, @@ -134,6 +134,11 @@ "react-native-nitro-markdown": "file:deps/react-native-nitro-markdown-0.5.0.tgz" }, "expo": { + "install": { + "exclude": [ + "react-native-keyboard-controller" + ] + }, "autolinking": { "buildFromSource": [ "react-native-screens", diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 9419a74e5..a6e2a7fd2 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -1,29 +1,18 @@ import Constants from "expo-constants"; -import type { - NativeStackHeaderItem, - NativeStackNavigationOptions, -} from "@react-navigation/native-stack"; +import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { Platform, View } from "react-native"; import { AppText as Text } from "./AppText"; import { T3Wordmark } from "./T3Wordmark"; import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics"; import { resolveMobileStageLabel } from "../lib/mobileBranding"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass"; - -// Native leading items inherit different UIKit margins than title views. -const IOS_NATIVE_LEADING_TITLE_OFFSET = -6; -const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7; /** * Horizontal correction applied to content rendered in the brand title slot, * shared with the connection-status swap so both align identically. */ -export function brandTitleOffset(nativeLeadingItem: boolean): number { +export function brandTitleOffset(): number { if (Platform.OS !== "ios") return 0; - if (nativeLeadingItem) { - return Platform.isPad ? IPAD_NATIVE_LEADING_TITLE_OFFSET : IOS_NATIVE_LEADING_TITLE_OFFSET; - } return Platform.isPad ? IPAD_HOME_TITLE_OFFSET : 0; } @@ -33,11 +22,10 @@ export function brandTitleOffset(nativeLeadingItem: boolean): number { export function CompactBrandTitle( props: { readonly allowFontScaling?: boolean; - readonly nativeLeadingItem?: boolean; } = {}, ) { const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant); - const titleOffset = brandTitleOffset(props.nativeLeadingItem === true); + const titleOffset = brandTitleOffset(); return ( ; } -export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] { - return [ - { - element: , - hidesSharedBackground: true, - type: "custom", - }, - ]; -} - export function getCompactBrandHeaderOptions( fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"], ): NativeStackNavigationOptions { - if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { - return { - headerTitle: "Threads", - headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, - title: "Threads", - unstable_headerLeftItems: renderCompactBrandHeaderItems, - }; - } - return { headerTitle: renderCompactBrandTitle, headerTitleStyle: fallbackTitleStyle, title: "Threads", + unstable_headerLeftItems: undefined, }; } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index beabf66d9..b9f31a671 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -2,7 +2,7 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useState } from "react"; -import { Platform } from "react-native"; +import { Platform, useWindowDimensions } from "react-native"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useThreadShells } from "../../state/entities"; @@ -25,6 +25,7 @@ import { getConnectionAwareBrandHeaderOptions } from "./WorkspaceConnectionTitle /* ─── Route screen ───────────────────────────────────────────────────── */ export function HomeRouteScreen() { + const { width: windowWidth } = useWindowDimensions(); const { layout } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useThreadShells(); @@ -138,8 +139,10 @@ export function HomeRouteScreen() { shallow-merged. The brand slot also doubles as the connection status surface while an environment reconnects. */} navigation.navigate("SettingsSheet", { screen: "SettingsContent", diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx index d4aaeb4de..9b9333b46 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx +++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx @@ -1,14 +1,14 @@ -import type { - NativeStackHeaderItem, - NativeStackNavigationOptions, -} from "@react-navigation/native-stack"; +import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { useEffect, useRef, useState, type ReactNode } from "react"; -import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-native"; +import { ActivityIndicator, Animated, Pressable, View } from "react-native"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; -import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; +import { + brandTitleOffset, + CompactBrandTitle, + getCompactBrandHeaderOptions, +} from "../../components/CompactBrandTitle"; import { useWorkspaceState } from "../../state/workspace"; import { workspaceConnectionStatusPresentation, @@ -51,7 +51,11 @@ function useDelayedConnectionStatus(): WorkspaceConnectionStatusPresentation | n * native-driver animated nodes blank the re-hosted view entirely. The JS driver * updates opacity through the ordinary style path, which those subviews handle. */ -function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boolean }) { +function StatusFadeIn(props: { + readonly children: ReactNode; + readonly grow?: boolean; + readonly maxWidth?: number; +}) { const opacity = useRef(new Animated.Value(0)).current; useEffect(() => { @@ -67,7 +71,7 @@ function StatusFadeIn(props: { readonly children: ReactNode; readonly grow?: boo return ( @@ -96,6 +100,8 @@ export function WorkspaceConnectionTitle(props: { readonly size?: "navbar" | "pageTitle"; /** Horizontal correction so the status aligns with the brand in native title slots. */ readonly statusOffset?: number; + /** Space available beside the native header actions. */ + readonly maxWidth?: number; }) { const status = useDelayedConnectionStatus(); const size = props.size ?? "navbar"; @@ -111,7 +117,7 @@ export function WorkspaceConnectionTitle(props: { } return ( - + {status.showsProgress ? ( @@ -154,39 +160,24 @@ export function WorkspaceConnectionTitle(props: { * this over the static brand options at mount. */ export function getConnectionAwareBrandHeaderOptions(opts: { + readonly headerWidth: number; + readonly trailingItemCount?: number; readonly onOpenEnvironments: () => void; readonly fallbackTitleStyle?: NativeStackNavigationOptions["headerTitleStyle"]; }): NativeStackNavigationOptions { - if (Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED) { - return { - headerTitle: "Threads", - headerTitleStyle: { color: "transparent", fontSize: 18, fontWeight: "800" }, - title: "Threads", - unstable_headerLeftItems: (): NativeStackHeaderItem[] => [ - { - element: ( - } - onPress={opts.onOpenEnvironments} - statusOffset={brandTitleOffset(true)} - /> - ), - hidesSharedBackground: true, - type: "custom", - }, - ], - }; - } + // Leave room for bar margins, title spacing and the 44-point native actions. + // Long status labels must not push Settings into UIKit's overflow menu. + const maxWidth = Math.max(0, opts.headerWidth - 64 - 44 * (opts.trailingItemCount ?? 1)); return { + ...getCompactBrandHeaderOptions(opts.fallbackTitleStyle), headerTitle: () => ( } + maxWidth={maxWidth} onPress={opts.onOpenEnvironments} - statusOffset={brandTitleOffset(false)} + statusOffset={brandTitleOffset()} /> ), - headerTitleStyle: opts.fallbackTitleStyle, - title: "Threads", }; } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a544140da..66465f59d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,7 +1,15 @@ import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; -import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; +import type { + ChatFileAttachment, + EnvironmentId, + MessageId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; @@ -28,6 +36,7 @@ import { } from "react-native-nitro-markdown"; import { ActivityIndicator, + Alert, Image, Platform, type LayoutChangeEvent, @@ -108,7 +117,10 @@ import { WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { useAssetUrl, useAssetUrlState } from "../../state/assets"; +import { assetEnvironment, useAssetUrl, useAssetUrlState } from "../../state/assets"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { usePreparedConnection } from "../../state/session"; +import * as Option from "effect/Option"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; @@ -208,6 +220,66 @@ function MessageAttachmentImage(props: { ); } +function MessageAttachmentFile(props: { + readonly environmentId: EnvironmentId; + readonly attachment: ChatFileAttachment; +}) { + const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + }); + const preparedConnection = usePreparedConnection(props.environmentId); + const sizeLabel = + props.attachment.sizeBytes >= 1024 * 1024 + ? `${(props.attachment.sizeBytes / (1024 * 1024)).toFixed(1)} MB` + : `${Math.max(1, Math.ceil(props.attachment.sizeBytes / 1024))} KB`; + + return ( + { + if (Option.isNone(preparedConnection)) return; + void (async () => { + const result = await createAssetUrl({ + environmentId: props.environmentId, + input: { + resource: { + _tag: "attachment", + attachmentId: props.attachment.id, + fileName: props.attachment.name, + mimeType: props.attachment.mimeType, + }, + }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + Alert.alert( + "Could not open attachment", + error instanceof Error ? error.message : "The attachment is unavailable.", + ); + return; + } + const url = resolveAssetUrl( + preparedConnection.value.httpBaseUrl, + result.value.relativeUrl, + ); + if (url !== null) { + await tryOpenExternalUrl(url, "file-preview"); + } + })(); + }} + > + + + {props.attachment.name} + + {sizeLabel} + + ); +} + function ThreadMarkdownImageView(props: { readonly uri: string | null; readonly sourceKey: string; @@ -1034,9 +1106,12 @@ function renderFeedEntry( const isUser = message.role === "user"; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); - const attachments = (message.attachments ?? []).filter( + const imageAttachments = (message.attachments ?? []).filter( (attachment) => attachment.type === "image", ); + const fileAttachments = (message.attachments ?? []).filter( + (attachment): attachment is ChatFileAttachment => attachment.type === "file", + ); const hasReviewCommentContext = message.text.includes(" ) : null} - {attachments.map((attachment) => { + {imageAttachments.map((attachment) => { return ( ); })} + {fileAttachments.map((attachment) => ( + + ))} @@ -1115,7 +1197,11 @@ function renderFeedEntry( // Skip empty assistant messages (no text, no attachments) — they would // render as an orphaned timestamp and break adjacent activity-group merging. - if (message.text.trim().length === 0 && attachments.length === 0) { + if ( + message.text.trim().length === 0 && + imageAttachments.length === 0 && + fileAttachments.length === 0 + ) { return null; } @@ -1145,7 +1231,7 @@ function renderFeedEntry( ) ) : null} - {attachments.map((attachment) => { + {imageAttachments.map((attachment) => { return ( ); })} + {fileAttachments.map((attachment) => ( + + ))} {showAssistantMeta ? ( projectCommand, serviceCommand, servicePreflightCommand, + themeCommand, triageCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), diff --git a/apps/server/src/cli/theme.test.ts b/apps/server/src/cli/theme.test.ts new file mode 100644 index 000000000..d3dd69b94 --- /dev/null +++ b/apps/server/src/cli/theme.test.ts @@ -0,0 +1,478 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises the filesystem boundary. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; + +import { cli } from "../bin.ts"; + +const runCli = (args: ReadonlyArray) => + Command.runWith(cli, { version: "0.0.0" })(args).pipe( + Effect.provide(Layer.mergeAll(NodeServices.layer, NetService.layer, TestConsole.layer)), + ); + +const makeBaseDir = () => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3code-theme-cli-")); + +const settingsPathFor = (baseDir: string) => NodePath.join(baseDir, "userdata", "settings.json"); + +const NIGHTFALL_THEME_JSON = `${JSON.stringify({ + name: "Nightfall", + appearance: "dark", + canvas: "#1a1b26", + accent: "#7aa2f7", +})}\n`; +const JUNK_THEME_JSON = `${JSON.stringify({ name: "Junk" })}\n`; + +const readSettings = (baseDir: string): Record => { + const raw = NodeFS.readFileSync(settingsPathFor(baseDir), "utf8"); + return JSON.parse(raw) as Record; +}; + +const writeSettings = (baseDir: string, settings: Record) => { + NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); + NodeFS.writeFileSync(settingsPathFor(baseDir), `${JSON.stringify(settings, null, 2)}\n`); +}; + +describe("t3 theme", () => { + it.effect("writes a default theme when no settings file exists yet", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); + assert.equal(readSettings(baseDir).defaultTheme, "ocean"); + }), + ); + + // A provisioning command runs against settings written by whatever version + // happens to be installed, so it must not drop what it cannot interpret. + it.effect("preserves settings it does not recognise", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, { + enableProviderUpdateChecks: false, + somethingFromANewerBuild: { nested: true }, + }); + + yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); + + const settings = readSettings(baseDir); + assert.equal(settings.defaultTheme, "ocean"); + assert.equal(settings.enableProviderUpdateChecks, false); + assert.deepEqual(settings.somethingFromANewerBuild, { nested: true }); + }), + ); + + it.effect("clears the default back to leaving fresh clients alone", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, { enableProviderUpdateChecks: false }); + + yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); + yield* runCli(["theme", "clear", "--base-dir", baseDir]); + + const settings = readSettings(baseDir); + assert.equal(Object.hasOwn(settings, "defaultTheme"), false); + assert.equal(settings.enableProviderUpdateChecks, false); + }), + ); + + // Publishing a file and pointing at it are one step, so an integration + // (a desktop's theme hook) needs no knowledge of the themes directory. + it.effect("publishes a theme file under its filename and sets it", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); + + const published = NodePath.join(baseDir, "userdata", "themes", "nightfall.json"); + assert.equal(NodeFS.existsSync(published), true); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + // No rollback or staging residue after a successful set. + const residue = NodeFS.readdirSync(NodePath.dirname(published)).filter( + (entry) => !entry.endsWith(".json"), + ); + assert.deepEqual(residue, []); + }), + ); + + it.effect("publishes a theme file under an explicit id", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "t3code.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + yield* runCli(["theme", "set", "--id", "nightfall", themeFile, "--base-dir", baseDir]); + + assert.equal( + NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), + true, + ); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + }), + ); + + it.effect("rejects a file that is not a theme and sets nothing", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "junk.json"); + NodeFS.writeFileSync(themeFile, JUNK_THEME_JSON); + + const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( + Effect.flip, + ); + + assert.include(String(failure), "not a valid theme file"); + assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false); + assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); + }), + ); + + // Publish and set are one command, so a settings file the set step cannot + // use must fail it before the themes directory is mutated -- not after, + // with a half-applied publish left behind. + it.effect("publishes nothing when the settings file cannot be used", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); + NodeFS.writeFileSync(settingsPathFor(baseDir), "{ not json"); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( + Effect.flip, + ); + + assert.include(String(failure), "not a JSON object"); + assert.equal(NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes")), false); + }), + ); + + // set means set: a publish that rode along with a failed default write is + // rolled back rather than left mutating the environment's theme set. The + // userdata directory is made read-only while themes stays writable, so the + // failure lands after the publish -- the case the rollback exists for. + it.effect("rolls back a publish when the default cannot be written", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, {}); + const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); + const themesDir = NodePath.join(userdataDir, "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + NodeFS.chmodSync(userdataDir, 0o555); + try { + const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "Could not write"); + assert.equal(NodeFS.existsSync(NodePath.join(themesDir, "nightfall.json")), false); + } finally { + NodeFS.chmodSync(userdataDir, 0o755); + } + }), + ); + + // A symlink is a normal way to hand this command a theme -- desktop hooks + // symlink the current palette -- so the source is resolved, not refused. + it.effect("publishes a theme file through a symlinked source path", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const realFile = NodePath.join(baseDir, "real-nightfall.json"); + NodeFS.writeFileSync(realFile, NIGHTFALL_THEME_JSON); + const linkPath = NodePath.join(baseDir, "nightfall.json"); + NodeFS.symlinkSync(realFile, linkPath); + + yield* runCli(["theme", "set", linkPath, "--base-dir", baseDir]); + + assert.equal( + NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "nightfall.json")), + true, + ); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + }), + ); + + // The staging entry is created fresh with O_EXCL, so a symlink planted at + // its predictable name is cleared, never followed and written through. + it.effect("never writes through a symlink at the staging path", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themesDir = NodePath.join(baseDir, "userdata", "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + const victim = NodePath.join(baseDir, "victim.txt"); + NodeFS.writeFileSync(victim, "precious"); + NodeFS.symlinkSync(victim, NodePath.join(themesDir, `nightfall.json.staging-${process.pid}`)); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); + + assert.equal(NodeFS.readFileSync(victim, "utf8"), "precious"); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + }), + ); + + // Rollback moves the previous directory entry aside and back, so even an + // entry the watcher would never publish -- here a symlink -- comes back + // exactly as it was when the set fails. + it.effect("restores a non-theme destination entry when the set fails", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, {}); + const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); + const themesDir = NodePath.join(userdataDir, "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + const outside = NodePath.join(baseDir, "outside.json"); + NodeFS.writeFileSync(outside, NIGHTFALL_THEME_JSON); + const destination = NodePath.join(themesDir, "nightfall.json"); + NodeFS.symlinkSync(outside, destination); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + NodeFS.chmodSync(userdataDir, 0o555); + try { + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); + assert.equal(NodeFS.lstatSync(destination).isSymbolicLink(), true); + } finally { + NodeFS.chmodSync(userdataDir, 0o755); + } + }), + ); + + it.effect("restores the previous theme when a re-publish fails to set", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, {}); + const userdataDir = NodePath.dirname(settingsPathFor(baseDir)); + const themesDir = NodePath.join(userdataDir, "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + const publishedPath = NodePath.join(themesDir, "nightfall.json"); + const previous = + '{ "name": "Old Nightfall", "appearance": "dark", "canvas": "#000000", "accent": "#ffffff" }\n'; + NodeFS.writeFileSync(publishedPath, previous); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + NodeFS.chmodSync(userdataDir, 0o555); + try { + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe(Effect.flip); + assert.equal(NodeFS.readFileSync(publishedPath, "utf8"), previous); + } finally { + NodeFS.chmodSync(userdataDir, 0o755); + } + }), + ); + + // A typo'd id written as the theme would silently never resolve anywhere; + // the id branch is as strict as the filename rule. + it.effect("rejects an id no client could resolve", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const failure = yield* runCli(["theme", "set", "Nightfall", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "not a valid theme id"); + assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); + }), + ); + + it.effect("rejects a path that does not exist instead of storing it as an id", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const failure = yield* runCli([ + "theme", + "set", + `${baseDir}/missing.json`, + "--base-dir", + baseDir, + ]).pipe(Effect.flip); + assert.include(String(failure), "Could not read"); + }), + ); + + // File-ness is decided by existence, not extension, so a generated file + // named for its target app still publishes. + it.effect("publishes an extensionless file", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "brand"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); + + assert.equal( + NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "brand.json")), + true, + ); + assert.equal(readSettings(baseDir).defaultTheme, "brand"); + }), + ); + + it.effect("records a set generation and clears it with the theme", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); + const setAt = readSettings(baseDir).defaultThemeSetAt; + assert.equal(typeof setAt, "string"); + + yield* runCli(["theme", "clear", "--base-dir", baseDir]); + const cleared = readSettings(baseDir); + assert.equal(Object.hasOwn(cleared, "defaultTheme"), false); + assert.equal(Object.hasOwn(cleared, "defaultThemeSetAt"), false); + }), + ); + + it.effect("honors T3CODE_HOME like the rest of the CLI", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + yield* runCli(["theme", "set", "ocean"]).pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { T3CODE_HOME: baseDir } })), + ), + ); + assert.equal(readSettings(baseDir).defaultTheme, "ocean"); + }), + ); + + // An unreadable settings file must never read as "no settings": writing a + // fresh sparse file over it would discard every key the user had. + it.effect("refuses to write when the settings file cannot be read", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + writeSettings(baseDir, { enableProviderUpdateChecks: false }); + NodeFS.chmodSync(settingsPathFor(baseDir), 0o000); + + const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + + NodeFS.chmodSync(settingsPathFor(baseDir), 0o644); + assert.include(String(failure), "Could not read"); + assert.equal(readSettings(baseDir).enableProviderUpdateChecks, false); + assert.equal(Object.hasOwn(readSettings(baseDir), "defaultTheme"), false); + }), + ); + + // A typo is syntactically a valid id, so shape validation alone would write + // a theme no client can resolve and report success. + it.effect("rejects an id that names no theme", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const failure = yield* runCli(["theme", "set", "ocian", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "No theme named"); + assert.equal(NodeFS.existsSync(settingsPathFor(baseDir)), false); + }), + ); + + it.effect("accepts an id a published file provides", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "nightfall.json"); + NodeFS.writeFileSync(themeFile, NIGHTFALL_THEME_JSON); + yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]); + + // Now resolvable by bare id, because the file published it. + yield* runCli(["theme", "clear", "--base-dir", baseDir]); + yield* runCli(["theme", "set", "nightfall", "--base-dir", baseDir]); + assert.equal(readSettings(baseDir).defaultTheme, "nightfall"); + }), + ); + + // The watcher skips files it cannot use, so accepting their filename would + // set a theme no client ever receives. + it.effect("rejects an id whose published file the watcher would skip", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themesDir = NodePath.join(baseDir, "userdata", "themes"); + NodeFS.mkdirSync(themesDir, { recursive: true }); + NodeFS.writeFileSync(NodePath.join(themesDir, "broken.json"), "{ not json\n"); + + const failure = yield* runCli(["theme", "set", "broken", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "No theme named"); + }), + ); + + // Web and desktop cannot resolve the mobile default, and mobile does not + // follow this setting, so naming it would be a silent no-op. + it.effect("rejects the mobile default theme id", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const failure = yield* runCli(["theme", "set", "t3-code", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "No theme named"); + }), + ); + + // Deciding on existence alone would publish ./ocean instead of selecting the + // built-in, purely because of what happens to be in the working directory. + it.effect("treats a bare id as an id even when a file shares its name", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const cwdFile = NodePath.join(baseDir, "ocean"); + NodeFS.writeFileSync(cwdFile, NIGHTFALL_THEME_JSON); + + const previous = process.cwd(); + process.chdir(baseDir); + try { + yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]); + } finally { + process.chdir(previous); + } + + assert.equal(readSettings(baseDir).defaultTheme, "ocean"); + assert.equal( + NodeFS.existsSync(NodePath.join(baseDir, "userdata", "themes", "ocean.json")), + false, + ); + }), + ); + + // The watcher would skip an oversized file, so publishing one must not + // report success for a theme no client receives. + it.effect("rejects a theme file larger than the watcher will read", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + const themeFile = NodePath.join(baseDir, "huge.json"); + const padding = "x".repeat(40 * 1024); + NodeFS.writeFileSync( + themeFile, + `{ "name": "Huge", "appearance": "dark", "canvas": "#1a1b26", "accent": "#7aa2f7", "note": "${padding}" }\n`, + ); + + const failure = yield* runCli(["theme", "set", themeFile, "--base-dir", baseDir]).pipe( + Effect.flip, + ); + assert.include(String(failure), "larger than"); + }), + ); + + it.effect("refuses a settings file that is not a JSON object", () => + Effect.gen(function* () { + const baseDir = makeBaseDir(); + NodeFS.mkdirSync(NodePath.dirname(settingsPathFor(baseDir)), { recursive: true }); + NodeFS.writeFileSync(settingsPathFor(baseDir), "[1, 2, 3]\n"); + + const failure = yield* runCli(["theme", "set", "ocean", "--base-dir", baseDir]).pipe( + Effect.flip, + ); + + assert.include(String(failure), "not a JSON object"); + }), + ); +}); diff --git a/apps/server/src/cli/theme.ts b/apps/server/src/cli/theme.ts new file mode 100644 index 000000000..a54e6c37f --- /dev/null +++ b/apps/server/src/cli/theme.ts @@ -0,0 +1,587 @@ +// @effect-diagnostics nodeBuiltinImport:off - publish commits and rollbacks +// move exact directory entries with rename, which the FileSystem service does +// not expose atomically. +/** + * `t3 theme` - inspect and set the environment's theme. Connected web and + * desktop clients switch when it is set; mobile keeps its own appearance + * settings. Each client applies one set once, so a theme the user picks in + * Settings afterwards sticks until the next `t3 theme set`. + * + * Writes `defaultTheme` (and `defaultThemeSetAt`, so a re-set of the same + * value still acts) into the environment's `settings.json`. A running server + * watches that file and pushes the change, so this works before the first + * launch and on a live server alike. + * + * The edit is deliberately a minimal one on the parsed JSON object rather than + * a schema round-trip. Settings files outlive the build that reads them, and a + * provisioning command must not drop keys this version does not recognise. + */ +import * as NodeFS from "node:fs"; + +import { + EnvironmentThemeFile, + EnvironmentThemeId, + environmentThemeFileHasColors, +} from "@t3tools/contracts"; +import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; +import { BUILT_IN_THEME_IDS, UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag } from "effect/unstable/cli"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ServerConfig from "../config.ts"; +import { + MAX_THEME_FILE_BYTES, + readPublishedThemes, + readThemeFileGuarded, +} from "../environmentTheme.ts"; +import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +import { baseDirFlag } from "./config.ts"; + +/** Settings files outlive the build that reads them, so the object is carried + * as-is and only the theme keys are touched. */ +const SparseSettings = Schema.Record(Schema.String, Schema.Unknown); +const decodeSettingsJson = Schema.decodeUnknownEffect(fromLenientJson(SparseSettings)); +const encodeSettingsJson = Schema.encodeEffect(fromJsonStringPretty(SparseSettings)); +const decodeThemeFileJsonExit = Schema.decodeUnknownExit( + Schema.fromJsonString(EnvironmentThemeFile), +); +const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); + +export class ThemeSettingsUnreadableError extends Schema.TaggedErrorClass()( + "ThemeSettingsUnreadableError", + { settingsPath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not read ${this.settingsPath}. Fix its permissions, then run this again.`; + } +} + +export class ThemeSettingsMalformedError extends Schema.TaggedErrorClass()( + "ThemeSettingsMalformedError", + { settingsPath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `${this.settingsPath} is not a JSON object. Fix or remove it, then run this again.`; + } +} + +export class ThemeSettingsBusyError extends Schema.TaggedErrorClass()( + "ThemeSettingsBusyError", + { settingsPath: Schema.String, attempts: Schema.Number }, +) { + override get message(): string { + return `${this.settingsPath} kept changing while writing (gave up after ${this.attempts} attempts). Try again.`; + } +} + +export class ThemeSettingsWriteError extends Schema.TaggedErrorClass()( + "ThemeSettingsWriteError", + { settingsPath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not write ${this.settingsPath}.`; + } +} + +export class ThemeFileUnreadableError extends Schema.TaggedErrorClass()( + "ThemeFileUnreadableError", + // Optional: a path that never existed has no underlying failure to carry, + // and a manufactured string there would only look like a real one. + { filePath: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return `Could not read ${this.filePath}.`; + } +} + +export class ThemeFileInvalidError extends Schema.TaggedErrorClass()( + "ThemeFileInvalidError", + { filePath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `${this.filePath} is not a valid theme file. Use a theme exported from T3 Code, or a seeded file with name, appearance, canvas, and accent.`; + } +} + +export class ThemeFileTooLargeError extends Schema.TaggedErrorClass()( + "ThemeFileTooLargeError", + { filePath: Schema.String, limit: Schema.Number }, +) { + override get message(): string { + return `${this.filePath} is larger than ${this.limit} bytes, which is more than a theme can publish.`; + } +} + +export class ThemeFileColorlessError extends Schema.TaggedErrorClass()( + "ThemeFileColorlessError", + { filePath: Schema.String }, +) { + override get message(): string { + return `${this.filePath} has no colors to publish.`; + } +} + +export class ThemePublishError extends Schema.TaggedErrorClass()( + "ThemePublishError", + { themesDir: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not publish the theme into ${this.themesDir}.`; + } +} + +const INVALID_THEME_ID_REASON = + "is not a valid theme id (lowercase letters, digits, and hyphens; not an appearance keyword)"; + +export class ThemeIdUnknownError extends Schema.TaggedErrorClass()( + "ThemeIdUnknownError", + { themeId: Schema.String, known: Schema.Array(Schema.String) }, +) { + override get message(): string { + return `No theme named "${this.themeId}". Available: ${this.known.join(", ")}. Publish one by passing a theme file instead of an id.`; + } +} + +export class ThemeIdInvalidError extends Schema.TaggedErrorClass()( + "ThemeIdInvalidError", + { themeId: Schema.String }, +) { + override get message(): string { + return `"${this.themeId}" ${INVALID_THEME_ID_REASON}.`; + } +} + +/** A filename that cannot be a theme id, where --id is the way out. */ +export class ThemeFileIdInvalidError extends Schema.TaggedErrorClass()( + "ThemeFileIdInvalidError", + { themeId: Schema.String, filePath: Schema.String }, +) { + override get message(): string { + return `"${this.themeId}" ${INVALID_THEME_ID_REASON}. Pass one with --id.`; + } +} + +export class ThemeTargetMissingError extends Schema.TaggedErrorClass()( + "ThemeTargetMissingError", + {}, +) { + override get message(): string { + return "Provide a theme id or file, or run `t3 theme clear` to remove the theme."; + } +} + +const envT3Home = Config.string("T3CODE_HOME").pipe(Config.option); + +const resolveThemePaths = Effect.fn(function* (explicitBaseDir: Option.Option) { + // Same precedence as the rest of the CLI: --base-dir, then T3CODE_HOME, + // then the default home. A provisioning script exporting T3CODE_HOME must + // not have this one command silently target the default install. + const envHome = Option.filter(yield* envT3Home, (value) => value.trim().length > 0); + const configuredBaseDir = Option.orElse(explicitBaseDir, () => envHome); + const baseDir = yield* resolveBaseDir(Option.getOrUndefined(configuredBaseDir)); + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined, { + baseDirIsExplicit: Option.isSome(configuredBaseDir), + }); + return { + settingsPath: derivedPaths.settingsPath, + themesDir: derivedPaths.environmentThemesDir, + }; +}); + +/** + * Reads the sparse settings object, treating only a genuinely absent file as + * empty. A permission or I/O error must propagate: reading it as "no settings" + * would have the caller write a fresh sparse file over settings it never saw. + */ +const readSettingsObject = Effect.fn(function* (settingsPath: string) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs + .exists(settingsPath) + .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause }))); + if (!exists) return { raw: "", settings: {} }; + + const raw = yield* fs + .readFileString(settingsPath) + .pipe(Effect.mapError((cause) => new ThemeSettingsUnreadableError({ settingsPath, cause }))); + if (raw.trim().length === 0) return { raw, settings: {} }; + + const settings = yield* decodeSettingsJson(raw).pipe( + Effect.mapError((cause) => new ThemeSettingsMalformedError({ settingsPath, cause })), + ); + return { raw, settings }; +}); + +/** + * A running server owns this file too, and its write path is an in-process + * semaphore that cannot serialize against another process. So the document is + * re-read immediately before the rename and the whole edit is retried when it + * moved underneath us, which is what turns "last writer wins" into "last + * writer merges", and an edit that keeps losing the race fails loudly rather + * than overwriting. A write landing inside the remaining rename window is + * still possible; the server's own watcher reconciles the file either way. + */ +const CONCURRENT_WRITE_ATTEMPTS = 5; + +const writeDefaultTheme = Effect.fn(function* (input: { + readonly settingsPath: string; + readonly themeId: string; +}) { + const fs = yield* FileSystem.FileSystem; + + for (let attempt = 1; ; attempt++) { + const { raw, settings } = yield* readSettingsObject(input.settingsPath); + const setAt = DateTime.formatIso(yield* DateTime.now); + const next = + input.themeId.length > 0 + ? // The timestamp is the set-generation: it lets clients apply a re-set + // of the same value they already applied once. + { ...settings, defaultTheme: input.themeId, defaultThemeSetAt: setAt } + : // Clearing removes the keys rather than storing empty strings, so the + // file reads the same as one that never set a theme. + Object.fromEntries( + Object.entries(settings).filter( + ([key]) => key !== "defaultTheme" && key !== "defaultThemeSetAt", + ), + ); + + const contents = yield* encodeSettingsJson(next); + const current = yield* fs + .readFileString(input.settingsPath) + .pipe(Effect.orElseSucceed(() => "")); + if (current !== raw) { + // Falling through here would overwrite whatever landed in between, which + // is exactly the loss this loop exists to prevent. + if (attempt >= CONCURRENT_WRITE_ATTEMPTS) { + return yield* Effect.fail( + new ThemeSettingsBusyError({ + settingsPath: input.settingsPath, + attempts: CONCURRENT_WRITE_ATTEMPTS, + }), + ); + } + continue; + } + + yield* writeFileStringAtomically({ + filePath: input.settingsPath, + contents: `${contents}\n`, + }).pipe( + Effect.mapError( + (cause) => new ThemeSettingsWriteError({ settingsPath: input.settingsPath, cause }), + ), + ); + return; + } +}); + +/** Publishes a theme file into the environment's themes directory and returns + * the id it published under. */ +const publishThemeFile = Effect.fn(function* (input: { + readonly themesDir: string; + readonly filePath: string; + readonly explicitId: Option.Option; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // A preflight for error quality only: it tells a FIFO from an oversized + // file. Enforcement happens at the guarded read below. + const info = yield* fs + .stat(input.filePath) + .pipe( + Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })), + ); + if (info.type !== "File") { + return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: input.filePath })); + } + if (Number(info.size) > MAX_THEME_FILE_BYTES) { + return yield* Effect.fail( + new ThemeFileTooLargeError({ filePath: input.filePath, limit: MAX_THEME_FILE_BYTES }), + ); + } + + // An explicit source path is the user's own input, and a symlink there is a + // normal way to point at a theme (desktop hooks symlink the current + // palette), so it is resolved before the guarded read. The read still goes + // through one opened handle whose type and size checks bind to the file + // actually read, so a FIFO cannot hang the command and an oversized target + // is refused. + const resolvedSource = yield* fs + .realPath(input.filePath) + .pipe( + Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })), + ); + const raw = readThemeFileGuarded(resolvedSource, MAX_THEME_FILE_BYTES); + if (raw === null) { + return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: input.filePath })); + } + + const decoded = decodeThemeFileJsonExit(raw); + if (decoded._tag === "Failure") { + return yield* Effect.fail( + new ThemeFileInvalidError({ filePath: input.filePath, cause: decoded.cause }), + ); + } + if (!environmentThemeFileHasColors(decoded.value)) { + return yield* Effect.fail(new ThemeFileColorlessError({ filePath: input.filePath })); + } + + const fileBasename = path.basename(input.filePath, ".json"); + const themeId = Option.getOrElse(input.explicitId, () => fileBasename); + // The same rules the watcher applies when it reads the directory back, so a + // publish cannot report success for a file that will then be skipped. + if (!isEnvironmentThemeId(themeId) || UNPUBLISHABLE_THEME_IDS.has(themeId)) { + return yield* Effect.fail(new ThemeFileIdInvalidError({ themeId, filePath: input.filePath })); + } + + const destinationPath = path.join(input.themesDir, `${themeId}.json`); + // Neither ends in `.json`, so the watcher never mistakes them for themes. + // Both names carry the pid, so concurrent publishers of one id cannot + // unlink or restore over each other's staging and rollback copies. + const backupPath = `${destinationPath}.rollback-${process.pid}`; + const stagingPath = `${destinationPath}.staging-${process.pid}`; + yield* fs + .makeDirectory(input.themesDir, { recursive: true }) + .pipe(Effect.mapError((cause) => new ThemePublishError({ themesDir: input.themesDir, cause }))); + + const publishFailure = (cause: unknown) => + new ThemePublishError({ themesDir: input.themesDir, cause }); + + // Staged in full before anything moves, so the commit below is two adjacent + // renames with no I/O between them. The staging entry is created O_EXCL + // after clearing any stale leftover, so a symlink or file already at that + // predictable name is never followed or written through. Written verbatim: + // appending so much as a newline could push a file at the size limit past + // it and have the watcher skip what was just accepted. + const stagedIno = yield* Effect.try({ + try: () => { + try { + NodeFS.unlinkSync(stagingPath); + } catch { + // Nothing stale to clear. + } + const fd = NodeFS.openSync( + stagingPath, + NodeFS.constants.O_WRONLY | NodeFS.constants.O_CREAT | NodeFS.constants.O_EXCL, + 0o644, + ); + try { + NodeFS.writeFileSync(fd, raw); + // Rename preserves the inode, so this identifies our published file + // at the destination for as long as it is actually ours. + return NodeFS.fstatSync(fd).ino; + } finally { + NodeFS.closeSync(fd); + } + }, + catch: publishFailure, + }); + + // Whatever occupies the destination -- a theme, a symlink, anything -- is + // moved aside in one atomic step rather than inspected and then replaced: + // there is no window between a check and the commit, and rollback restores + // that exact directory entry instead of a re-read of it. Only "nothing + // there" continues; any other rename failure aborts before the destination + // is touched. + const hadPrevious = yield* Effect.try({ + try: () => { + try { + NodeFS.renameSync(destinationPath, backupPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }, + catch: publishFailure, + }); + + const revert = Effect.sync(() => { + try { + NodeFS.unlinkSync(stagingPath); + } catch { + // Usually already renamed away; a stray staging file is watcher-inert. + } + try { + // The destination is touched only while it is empty or still holds + // the exact file this process put there; a concurrent publisher's + // newer file wins, and this process's obsolete copy is discarded. + const destinationIno = (() => { + try { + return NodeFS.lstatSync(destinationPath).ino; + } catch { + return null; + } + })(); + if (hadPrevious) { + if (destinationIno === null || destinationIno === stagedIno) { + NodeFS.renameSync(backupPath, destinationPath); + } else { + NodeFS.unlinkSync(backupPath); + } + } else if (destinationIno === stagedIno) { + NodeFS.unlinkSync(destinationPath); + } + } catch { + // Best effort; the failure that triggered the revert still surfaces. + } + }); + const cleanup = Effect.sync(() => { + try { + if (hadPrevious) NodeFS.unlinkSync(backupPath); + } catch { + // A stray backup is inert: it is not `.json`, so nothing serves it. + } + }); + + yield* Effect.try({ + try: () => NodeFS.renameSync(stagingPath, destinationPath), + catch: publishFailure, + }).pipe(Effect.onError(() => revert)); + + return { themeId, revert, cleanup }; +}); + +/** + * Ids a client can actually resolve: this build's built-ins plus what the + * machine publishes, read through the same function the watcher uses so a file + * it would skip can never be accepted here. The mobile default is absent on + * purpose -- web and desktop cannot resolve it and mobile does not follow this + * setting, so naming it would be the silent no-op this check exists to stop. + */ +const resolvableThemeIds = Effect.fn(function* (themesDir: string) { + const published = yield* readPublishedThemes(themesDir); + return [...BUILT_IN_THEME_IDS, ...published.map((theme) => theme.id)].toSorted(); +}); + +const themeSetCommand = Command.make("set", { + baseDir: baseDirFlag, + id: Flag.string("id").pipe( + Flag.withDescription("Theme id to publish a file under, instead of its filename."), + Flag.optional, + ), + theme: Argument.string("theme").pipe( + Argument.withDescription( + 'A theme id (a built-in, or one this machine publishes — themes/nightfall.json is "nightfall"), or a path to a theme JSON file to publish and set in one step.', + ), + ), +}).pipe( + Command.withDescription("Set the environment's theme; connected clients switch to it."), + Command.withHandler((flags) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const target = yield* expandHomePath(flags.theme.trim()); + if (target.length === 0) { + return yield* Effect.fail(new ThemeTargetMissingError()); + } + const paths = yield* resolveThemePaths(flags.baseDir); + + // An existing file publishes; anything path-shaped that does not exist + // is a mistake to surface, not an id to store; everything else must be + // a well-formed id, so a typo cannot be written as a theme no client + // will ever resolve. + // Path-shaped first, existence second. Deciding on existence alone would + // make `t3 theme set ocean` publish ./ocean whenever the cwd happens to + // hold a file by that name, instead of selecting the built-in. + const looksLikePath = + target.endsWith(".json") || + target.includes("/") || + target.includes("\\") || + target.startsWith("~"); + const targetIsFile = + looksLikePath && (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false))); + let themeId: string; + let revertPublish: Effect.Effect = Effect.void; + let cleanupPublish: Effect.Effect = Effect.void; + if (targetIsFile) { + // Settings are preflighted before publishing, so a settings file the + // set step cannot read or parse fails the command before it mutates + // the themes directory. + yield* readSettingsObject(paths.settingsPath); + const published = yield* publishThemeFile({ + themesDir: paths.themesDir, + filePath: target, + explicitId: flags.id, + }); + themeId = published.themeId; + revertPublish = published.revert; + cleanupPublish = published.cleanup; + } else if (looksLikePath) { + return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: target })); + } else if (isEnvironmentThemeId(target)) { + const known = yield* resolvableThemeIds(paths.themesDir); + if (!known.includes(target)) { + return yield* Effect.fail(new ThemeIdUnknownError({ themeId: target, known })); + } + themeId = target; + } else { + return yield* Effect.fail(new ThemeIdInvalidError({ themeId: target })); + } + + // set means set: if the default cannot be written, the publish that + // rode along with it is undone rather than left as a side effect of a + // command that reported failure. + yield* writeDefaultTheme({ settingsPath: paths.settingsPath, themeId }).pipe( + Effect.onError(() => revertPublish), + ); + yield* cleanupPublish; + yield* Console.log( + targetIsFile + ? `Published ${target} as "${themeId}" and set it as the environment theme.\n` + : `Environment theme set to "${themeId}" in ${paths.settingsPath}.\n`, + ); + }), + ), +); + +const themeClearCommand = Command.make("clear", { baseDir: baseDirFlag }).pipe( + Command.withDescription("Remove the environment's theme; clients keep what they have."), + Command.withHandler((flags) => + Effect.gen(function* () { + const paths = yield* resolveThemePaths(flags.baseDir); + yield* writeDefaultTheme({ settingsPath: paths.settingsPath, themeId: "" }); + yield* Console.log(`Environment theme cleared in ${paths.settingsPath}.\n`); + }), + ), +); + +const themeShowCommand = Command.make("show", { baseDir: baseDirFlag }).pipe( + Command.withDescription("Show the environment's theme and its published themes."), + Command.withHandler((flags) => + Effect.gen(function* () { + const paths = yield* resolveThemePaths(flags.baseDir); + const { settings } = yield* readSettingsObject(paths.settingsPath); + const defaultTheme = + typeof settings.defaultTheme === "string" && settings.defaultTheme.length > 0 + ? settings.defaultTheme + : null; + + const published = (yield* readPublishedThemes(paths.themesDir)) + .map((theme) => theme.id) + .toSorted(); + + yield* Console.log( + defaultTheme === null + ? "Environment theme: not set.\n" + : `Environment theme: "${defaultTheme}".\n`, + ); + yield* Console.log( + published.length === 0 + ? `Published themes: none (publish into ${paths.themesDir}).\n` + : `Published themes: ${published.join(", ")}.\n`, + ); + }), + ), +); + +export const themeCommand = Command.make("theme").pipe( + Command.withDescription("Inspect and set environment-wide theme defaults."), + Command.withSubcommands([themeSetCommand, themeClearCommand, themeShowCommand]), +); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 74a670e04..b65d468dc 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -35,6 +35,8 @@ export interface ServerDerivedPaths { readonly forkDbPath: string; readonly keybindingsConfigPath: string; readonly settingsPath: string; + /** Palettes this machine publishes for clients to follow, one file per theme. */ + readonly environmentThemesDir: string; readonly providerStatusCacheDir: string; readonly worktreesDir: string; readonly attachmentsDir: string; @@ -124,6 +126,7 @@ export const deriveServerPaths = Effect.fn(function* ( forkDbPath, keybindingsConfigPath: join(stateDir, "keybindings.json"), settingsPath: join(stateDir, "settings.json"), + environmentThemesDir: join(stateDir, "themes"), providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), attachmentsDir, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 3c2bbb65d..bf563cdc6 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -156,6 +156,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadSnooze: true, threadDeltaSubscription: true, + environmentThemes: true, threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts new file mode 100644 index 000000000..0d50e0200 --- /dev/null +++ b/apps/server/src/environmentTheme.test.ts @@ -0,0 +1,272 @@ +import { EnvironmentThemeFile } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "./config.ts"; +import * as EnvironmentTheme from "./environmentTheme.ts"; + +const encodeThemeFile = Schema.encodeSync(Schema.fromJsonString(EnvironmentThemeFile)); + +const NIGHTFALL_THEME: EnvironmentThemeFile = { + name: "Nightfall", + appearance: "dark", + canvas: "#1a1b26", + accent: "#7aa2f7", +}; + +/** The standard exported form: a full palette, no seeds. */ +const SHARED_THEME: EnvironmentThemeFile = { + version: 1, + name: "Shared Light", + appearance: "light", + colors: { canvas: "#eff1f5", accent: "#1e66f5" }, +}; + +/** Seeds theme files before the service starts, as a real machine would. */ +const withEnvironmentThemes = ( + seeds: Readonly>, + body: Effect.Effect< + A, + E, + | EnvironmentTheme.EnvironmentThemeService + | ServerConfig.ServerConfig + | FileSystem.FileSystem + | Path.Path + | Scope.Scope + >, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-environment-theme-" }); + const themesDir = path.join(baseDir, "userdata", "themes"); + yield* fs.makeDirectory(themesDir, { recursive: true }); + for (const [filename, contents] of Object.entries(seeds)) { + yield* fs.writeFileString(path.join(themesDir, filename), contents); + } + + return yield* body.pipe( + Effect.provide( + EnvironmentTheme.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + ), + ), + ); + }).pipe(Effect.scoped); + +const currentThemes = Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + return yield* environmentTheme.current; +}); + +it.layer(NodeServices.layer)("environment theme", (it) => { + it.effect("publishes nothing when the machine has no theme files", () => + withEnvironmentThemes( + {}, + Effect.gen(function* () { + assert.deepEqual(yield* currentThemes, []); + }), + ), + ); + + it.effect("publishes each file under its filename as the id", () => + withEnvironmentThemes( + { + "nightfall.json": encodeThemeFile(NIGHTFALL_THEME), + "shared-light.json": encodeThemeFile(SHARED_THEME), + }, + Effect.gen(function* () { + const themes = yield* currentThemes; + assert.deepEqual( + themes.map((theme) => theme.id), + ["nightfall", "shared-light"], + ); + assert.deepEqual(themes[0], { id: "nightfall", ...NIGHTFALL_THEME }); + assert.deepEqual(themes[1], { id: "shared-light", ...SHARED_THEME }); + }), + ), + ); + + // Read from disk rather than from the watcher's last observation, so a + // client connecting after a missed filesystem event still sees the truth. + it.effect("follows the directory rather than the set read at start", () => + withEnvironmentThemes( + { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* fs.writeFileString( + path.join(environmentThemesDir, "shared-light.json"), + encodeThemeFile(SHARED_THEME), + ); + assert.equal((yield* currentThemes).length, 2); + + yield* fs.remove(path.join(environmentThemesDir, "nightfall.json")); + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.id), + ["shared-light"], + ); + }), + ), + ); + + // One bad file must not take down the machine's other themes: a theme + // script that leaves a template placeholder unresolved, a half-written + // file, or a stray name are each that file's problem alone. + // The subscription is acquired before the current set is read, so nothing + // published while a client connects can fall between snapshot and stream. + it.effect("streams the current set first", () => + withEnvironmentThemes( + { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const first = yield* environmentTheme.streamChanges.pipe(Stream.runHead); + assert.deepEqual(Option.getOrNull(first), [{ id: "nightfall", ...NIGHTFALL_THEME }]); + }), + ), + ); + + // Subscribing happens before the snapshot read, so a publish landing in + // between is queued. It must not replay after the newer snapshot and walk + // clients back onto colors the machine has already moved past. + it.effect("never replays a set older than the snapshot it started from", () => + withEnvironmentThemes( + { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Advance the directory twice without the watcher running, so the + // second read is strictly newer than anything already observed. + yield* fs.writeFileString( + path.join(environmentThemesDir, "shared-light.json"), + encodeThemeFile(SHARED_THEME), + ); + const first = yield* environmentTheme.streamChanges.pipe(Stream.runHead); + assert.deepEqual( + Option.getOrNull(first)?.map((theme) => theme.id), + ["nightfall", "shared-light"], + ); + }), + ), + ); + + it.effect("skips invalid files while keeping valid ones", () => + withEnvironmentThemes( + { + "nightfall.json": encodeThemeFile(NIGHTFALL_THEME), + "unresolved.json": + '{ "name": "X", "appearance": "dark", "canvas": "{{ background }}", "accent": "#7aa2f7" }', + "malformed.json": "{ not json", + "no-colors.json": '{ "name": "Empty", "appearance": "dark" }', + "Bad Name.json": encodeThemeFile(SHARED_THEME), + "ocean.json": encodeThemeFile(SHARED_THEME), + "dark.json": encodeThemeFile(SHARED_THEME), + "notes.txt": "not a theme", + }, + Effect.gen(function* () { + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.id), + ["nightfall"], + ); + }), + ), + ); + + // A symlinked themes directory stays usable, but a symlinked file inside it + // must not publish whatever it points at. + it.effect("ignores a symlinked theme file", () => + withEnvironmentThemes( + {}, + Effect.gen(function* () { + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const outside = path.join(environmentThemesDir, "..", "outside.json"); + yield* fs.writeFileString(outside, encodeThemeFile(NIGHTFALL_THEME)); + yield* fs.symlink(outside, path.join(environmentThemesDir, "nightfall.json")); + assert.deepEqual(yield* currentThemes, []); + }), + ), + ); + + // The aggregate size cap charges only accepted themes, so a pile of + // malformed files cannot spend the budget and hide a valid theme sorted + // after them. + it.effect("does not charge skipped files against the total size limit", () => + withEnvironmentThemes( + { + ...Object.fromEntries( + Array.from({ length: 7 }, (_, index) => [`junk-${index}.json`, "{".repeat(30_000)]), + ), + "zz-valid.json": encodeThemeFile(NIGHTFALL_THEME), + }, + Effect.gen(function* () { + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.id), + ["zz-valid"], + ); + }), + ), + ); +}); + +// The feature's headline claim: rewrite a file and connected clients retint +// without a restart. Live clock and a real filesystem event, so this proves +// the watcher rather than a direct read. Kept outside the it.layer block above +// because only the top-level `it` exposes `live`. +describe("environment theme watching", () => { + it.live("streams a set for every change to the directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-theme-watch-" }); + const themesDir = path.join(baseDir, "userdata", "themes"); + yield* fs.makeDirectory(themesDir, { recursive: true }); + + yield* Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const seen = yield* Queue.unbounded>(); + yield* Stream.runForEach(environmentTheme.streamChanges, (themes) => + Queue.offer(seen, themes), + ).pipe(Effect.forkScoped); + + // Empty to start. + assert.deepEqual(yield* Queue.take(seen), []); + + // Published atomically, the way a theme hook writes it. + const staging = path.join(baseDir, "staged.json"); + yield* fs.writeFileString(staging, encodeThemeFile(NIGHTFALL_THEME)); + yield* fs.rename(staging, path.join(themesDir, "nightfall.json")); + assert.deepEqual( + (yield* Queue.take(seen)).map((theme) => theme.id), + ["nightfall"], + ); + + // Removed again, and the set empties without a restart. + yield* fs.remove(path.join(themesDir, "nightfall.json")); + assert.deepEqual(yield* Queue.take(seen), []); + }).pipe( + Effect.provide( + EnvironmentTheme.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), + ), + ), + Effect.timeout("30 seconds"), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts new file mode 100644 index 000000000..c038af065 --- /dev/null +++ b/apps/server/src/environmentTheme.ts @@ -0,0 +1,297 @@ +// @effect-diagnostics nodeBuiltinImport:off - the guarded file read needs open +// flags (O_NOFOLLOW, O_NONBLOCK) the FileSystem service does not expose. +/** + * EnvironmentTheme - palettes this machine publishes for clients to follow. + * + * A desktop that retints its apps when the user switches system theme writes + * `/themes/.json`; this service watches that directory and + * streams the published set to connected clients so a theme change lands + * without a restart. The filename is the theme id: it stays stable while the + * machine rewrites the colors underneath, so `defaultTheme` and a client\'s + * selection keep pointing at the same theme across recolors. Theming is + * cosmetic, so every failure here degrades to "not published" rather than + * propagating. + * + * @module EnvironmentTheme + */ +import * as NodeFS from "node:fs"; + +import { + EnvironmentTheme, + EnvironmentThemeFile, + EnvironmentThemeId, + environmentThemeFileHasColors, +} from "@t3tools/contracts"; +import { UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "./config.ts"; + +const decodeEnvironmentThemeFileJsonExit = Schema.decodeUnknownExit( + Schema.fromJsonString(EnvironmentThemeFile), +); +const isEnvironmentThemeId = Schema.is(EnvironmentThemeId); + +const THEME_FILE_SUFFIX = ".json"; + +/** + * Bounds on what a machine can publish. The directory is local, so this is not + * a trust boundary -- but an accidental dump of large files there would + * otherwise be read in full, streamed to every client, and repainted, so the + * cost of a mistake is capped rather than unbounded. + */ +const MAX_THEME_FILES = 32; +/** Exported so the publish path cannot accept a file the watcher will skip. */ +export const MAX_THEME_FILE_BYTES = 32 * 1024; +/** + * The set travels whole in a websocket event to every subscriber, so the sum + * matters more than any single file. An exported theme runs a few KB, leaving + * this far above any real directory while keeping a mistake off the wire. + */ +const MAX_THEME_TOTAL_BYTES = 192 * 1024; + +/** The published set with the sequence number it was observed at. */ +interface PublishedThemes { + readonly seq: number; + readonly themes: ReadonlyArray; +} + +export class EnvironmentThemeService extends Context.Service< + EnvironmentThemeService, + { + /** + * The set published right now, read from disk rather than from the + * watcher\'s last observation: a client connecting must see what the + * machine actually publishes even if it missed a filesystem event. + */ + readonly current: Effect.Effect>; + + /** + * The current set followed by every change, with repeats dropped. The + * subscription is acquired before the current set is read, so a publish + * landing while a client connects is delivered rather than lost. + */ + readonly streamChanges: Stream.Stream>; + } +>()("t3/environmentTheme/EnvironmentThemeService") {} + +/** + * Reads a theme file through one opened handle, so every check binds to the + * file actually read rather than to a path that may have been swapped since: + * O_NOFOLLOW rejects a symlink outright (a symlinked themes directory stays + * usable, a symlinked file inside it does not), O_NONBLOCK keeps a FIFO from + * blocking the open, and the fstat type and size gate examines the open + * descriptor. Returns null for anything that is not a small regular file. + */ +export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => { + let fd: number; + try { + fd = NodeFS.openSync( + filePath, + NodeFS.constants.O_RDONLY | NodeFS.constants.O_NOFOLLOW | NodeFS.constants.O_NONBLOCK, + ); + } catch { + return null; + } + try { + const info = NodeFS.fstatSync(fd); + if (!info.isFile() || info.size > maxBytes) return null; + const contents = Buffer.alloc(info.size); + let offset = 0; + while (offset < contents.length) { + const read = NodeFS.readSync(fd, contents, offset, contents.length - offset, offset); + if (read <= 0) break; + offset += read; + } + return contents.subarray(0, offset).toString("utf8"); + } catch { + return null; + } finally { + NodeFS.closeSync(fd); + } +}; + +/** + * Every theme the directory actually publishes. A file that is missing, + * unreadable, malformed, colorless, or misnamed is simply skipped; the rest of + * the set is unaffected. The one place that decides what "published" means, so + * a caller validating an id cannot disagree with the watcher serving it. + */ +export const readPublishedThemes = Effect.fn(function* (themesDir: string) { + const fs = yield* FileSystem.FileSystem; + const entries = yield* fs + .readDirectory(themesDir) + .pipe(Effect.orElseSucceed((): Array => [])); + + const themes: Array = []; + let examined = 0; + let totalBytes = 0; + for (const entry of entries.toSorted()) { + if (!entry.endsWith(THEME_FILE_SUFFIX)) continue; + const id = entry.slice(0, -THEME_FILE_SUFFIX.length); + // A reserved id is either shadowed by a built-in on the client or captures + // clients that never chose it, so it is not publishable. + if (!isEnvironmentThemeId(id) || UNPUBLISHABLE_THEME_IDS.has(id)) continue; + + // Counts files examined, not themes accepted: capping the output would + // let a directory of malformed files be opened, read, and decoded in full + // on every refresh and every client connect. + examined += 1; + if (examined > MAX_THEME_FILES) { + yield* Effect.logWarning("ignoring environment theme files past the limit", { + path: themesDir, + limit: MAX_THEME_FILES, + }); + break; + } + + const filePath = `${themesDir}/${entry}`; + const raw = readThemeFileGuarded(filePath, MAX_THEME_FILE_BYTES); + if (raw === null) { + yield* Effect.logWarning("ignoring unusable environment theme file", { + path: filePath, + limit: MAX_THEME_FILE_BYTES, + }); + continue; + } + if (raw.trim().length === 0) continue; + + const decoded = decodeEnvironmentThemeFileJsonExit(raw); + if (decoded._tag === "Failure") { + yield* Effect.logWarning("ignoring invalid environment theme", { + path: filePath, + detail: Cause.pretty(decoded.cause), + }); + continue; + } + const file = decoded.value; + if (!environmentThemeFileHasColors(file)) { + yield* Effect.logWarning("ignoring environment theme without colors", { path: filePath }); + continue; + } + + // Counted only once accepted: the cap bounds what travels to clients, so + // a skipped file must not eat the budget of valid themes sorted after it. + // Bytes, not string length -- the cap describes wire weight. + totalBytes += Buffer.byteLength(raw); + if (totalBytes > MAX_THEME_TOTAL_BYTES) { + yield* Effect.logWarning("ignoring environment themes past the total size limit", { + path: themesDir, + limit: MAX_THEME_TOTAL_BYTES, + }); + break; + } + + themes.push({ id, ...file }); + } + return themes; +}); + +/** + * Reads the directory and folds it into the sequenced state, publishing only + * a genuine change. Every reader goes through here, so the snapshot a client + * connects on and the events it then receives come from one ordered source + * rather than from disk and the queue independently. + */ + +const make = Effect.gen(function* () { + const { environmentThemesDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + /** + * Sliding with capacity 1: every update carries the complete set, so a + * subscriber that stops consuming holds at most the newest set rather than + * an unbounded backlog. Every observed set carries a sequence number, so a + * subscriber can drop queued events that predate the snapshot it started + * from. Without it a publish landing between subscribing and reading + * replays after the newer value and walks clients backwards onto stale + * colors. + */ + const changes = yield* PubSub.sliding(1); + const published = yield* Ref.make({ seq: 0, themes: [] }); + /** + * Guards the whole read/compare/publish, not just the state update. The + * directory read is async, so two concurrent refreshes can finish out of + * order and a slower read of an older set would publish under a higher + * sequence -- which the subscriber filter, ordering publications rather than + * observations, could not then drop. + */ + const refreshSemaphore = yield* Semaphore.make(1); + const watcherScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); + + const refresh = refreshSemaphore.withPermits(1)( + Effect.gen(function* () { + const themes = yield* readPublishedThemes(environmentThemesDir).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ); + // Structural equality over the whole decoded value: a hand-rolled field + // list here silently drops republishes for any field it forgets. + const [changed, next] = yield* Ref.modify( + published, + (previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => { + if (Equal.equals(previous.themes, themes)) return [[false, previous], previous]; + const updated: PublishedThemes = { seq: previous.seq + 1, themes }; + return [[true, updated], updated]; + }, + ); + if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid); + return next; + }), + ); + + // The directory is created up front so the watcher has something to attach + // to before the first publisher writes into it. + yield* fs + .makeDirectory(environmentThemesDir, { recursive: true }) + .pipe(Effect.ignoreCause({ log: true })); + + // Debounced for the same reason settings watching is: a theme script emits + // several events per save and `fs.watch` can fire before the content is + // flushed. Every event triggers a full re-read, so no event needs filtering. + const watchEvents = fs.watch(environmentThemesDir).pipe(Stream.debounce(Duration.millis(100))); + + // Seeds the dedupe so a watch event that reports no actual change (a touch, + // a rewrite with identical contents) does not retint every client. + yield* refresh; + yield* Stream.runForEach(watchEvents, () => refresh.pipe(Effect.ignoreCause({ log: true }))).pipe( + Effect.ignoreCause({ log: true }), + Effect.forkIn(watcherScope), + Effect.asVoid, + ); + + return { + current: Effect.map(refresh, (state) => state.themes), + get streamChanges() { + return Stream.unwrap( + Effect.gen(function* () { + // Subscribe first so nothing published during the read is missed, + // then drop anything the snapshot already accounts for. + const subscription = yield* PubSub.subscribe(changes); + const snapshot = yield* refresh; + return Stream.concat( + Stream.make(snapshot.themes), + Stream.fromSubscription(subscription).pipe( + Stream.filter((update) => update.seq > snapshot.seq), + Stream.map((update) => update.themes), + ), + ); + }), + ); + }, + } satisfies EnvironmentThemeService["Service"]; +}); + +export const layer = Layer.effect(EnvironmentThemeService, make); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index c7fef4750..6561240a5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1791,92 +1791,209 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("keeps a resumed replacement session during slow stop cleanup", () => { - const queries: FakeClaudeQuery[] = []; - let signalUsageStarted: () => void = () => undefined; - const usageStarted = new Promise((resolve) => { - signalUsageStarted = resolve; + it.effect("completes with result usage without querying current context usage", () => { + const harness = makeHarness(); + let getContextUsageCalls = 0; + Object.assign(harness.query, { + getContextUsage: async () => { + getContextUsageCalls += 1; + return { + totalTokens: 999, + maxTokens: 200000, + isAutoCompactEnabled: true, + }; + }, }); - const layer = Layer.effect( - ClaudeAdapter, - Effect.gen(function* () { - const claudeConfig = decodeClaudeSettings({}); - return yield* makeClaudeAdapter(claudeConfig, { - createQuery: () => { - const query = new FakeClaudeQuery(); - if (queries.length === 0) { - Object.assign(query, { - getContextUsage: async () => { - signalUsageStarted(); - return await new Promise(() => undefined); - }, - }); - } - queries.push(query); - return query; - }, - }); - }), - ).pipe( - Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-adapter-test", "/tmp")), - Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge(NodeServices.layer), - ); - return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( Stream.runCollect, Effect.forkChild, ); - const firstSession = yield* adapter.startSession({ + yield* adapter.startSession({ threadId: THREAD_ID, provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", }); yield* adapter.sendTurn({ - threadId: firstSession.threadId, + threadId: THREAD_ID, input: "hello", attachments: [], }); - const interruptFiber = yield* adapter - .interruptTurn(firstSession.threadId) - .pipe(Effect.forkChild); - yield* Effect.promise(() => usageStarted); - assert.equal(queries[0]?.closeCalls, 1); + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-result-usage", + uuid: "assistant-result-usage-1", + parent_tool_use_id: null, + message: { + id: "assistant-message-result-usage-1", + role: "assistant", + content: [], + usage: { + input_tokens: 80, + output_tokens: 20, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-result-usage", + uuid: "assistant-result-usage-2", + parent_tool_use_id: null, + message: { + id: "assistant-message-result-usage-2", + role: "assistant", + content: [], + usage: { + input_tokens: 180, + output_tokens: 20, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-result-usage", + uuid: "assistant-result-usage-3", + parent_tool_use_id: null, + message: { + id: "assistant-message-result-usage-3", + role: "assistant", + content: [], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 1, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-result-usage", + usage: { + input_tokens: 400, + output_tokens: 50, + }, + modelUsage: { + "claude-opus-4-6": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); - const replacement = yield* adapter.startSession({ + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.equal(getContextUsageCalls, 0); + const usageEvent = runtimeEvents.find((event) => event.type === "thread.token-usage.updated"); + assert.equal(usageEvent?.type, "thread.token-usage.updated"); + if (usageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(usageEvent.payload.usage, { + usedTokens: 200, + lastUsedTokens: 200, + totalProcessedTokens: 450, + inputTokens: 180, + outputTokens: 20, + maxTokens: 200000, + }); + } + assert.equal( + runtimeEvents.find((event) => event.type === "turn.completed")?.type, + "turn.completed", + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("preserves compacted usage when completion follows an older assistant frame", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ threadId: THREAD_ID, provider: ProviderDriverKind.make("claudeAgent"), runtimeMode: "full-access", - resumeCursor: firstSession.resumeCursor, }); - yield* TestClock.adjust("1 second"); - yield* Fiber.join(interruptFiber); + yield* adapter.sendTurn({ + threadId: THREAD_ID, + input: "hello", + attachments: [], + }); + harness.query.emit({ + type: "assistant", + session_id: "sdk-session-compacted-usage", + uuid: "assistant-compacted-usage", + parent_tool_use_id: null, + message: { + id: "assistant-message-compacted-usage", + role: "assistant", + content: [], + usage: { + input_tokens: 180, + output_tokens: 20, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "system", + subtype: "compact_boundary", + compact_metadata: { + pre_tokens: 200, + post_tokens: 40, + }, + session_id: "sdk-session-compacted-usage", + uuid: "compact-boundary-usage", + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1234, + duration_api_ms: 1200, + num_turns: 2, + result: "done", + stop_reason: "end_turn", + session_id: "sdk-session-compacted-usage", + usage: { + input_tokens: 400, + output_tokens: 50, + }, + modelUsage: { + "claude-opus-4-6": { + contextWindow: 200000, + maxOutputTokens: 64000, + }, + }, + } as unknown as SDKMessage); - const activeSessions = yield* adapter.listSessions(); const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); - assert.equal(queries.length, 2); - assert.equal(queries[1]?.closeCalls, 0); - assert.equal(activeSessions.length, 1); - assert.deepEqual(activeSessions[0]?.resumeCursor, replacement.resumeCursor); - assert.deepEqual( - runtimeEvents - .filter((event) => event.type.startsWith("session.")) - .map((event) => event.type), - [ - "session.started", - "session.configured", - "session.state.changed", - "session.started", - "session.configured", - "session.state.changed", - ], + const finalUsageEvent = runtimeEvents.findLast( + (event) => event.type === "thread.token-usage.updated", + ); + assert.equal(finalUsageEvent?.type, "thread.token-usage.updated"); + if (finalUsageEvent?.type === "thread.token-usage.updated") { + assert.deepEqual(finalUsageEvent.payload.usage, { + usedTokens: 40, + lastUsedTokens: 200, + totalProcessedTokens: 450, + maxTokens: 200000, + }); + } + assert.equal( + runtimeEvents.find((event) => event.type === "turn.completed")?.type, + "turn.completed", ); }).pipe( Effect.provideService(Random.Random, makeDeterministicRandomService()), - Effect.provide(layer), + Effect.provide(harness.layer), ); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 7061b0aa6..24f344e48 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -14,7 +14,6 @@ import { type PermissionResult, type PermissionUpdate, type SDKMessage, - type SDKControlGetContextUsageResponse, type SDKResultMessage, type SettingSource, type SDKUserMessage, @@ -69,7 +68,6 @@ import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; -import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -146,6 +144,8 @@ interface ClaudeTurnState { readonly assistantTextBlocks: Map; readonly assistantTextBlockOrder: Array; readonly capturedProposedPlanKeys: Set; + latestAssistantUsage: unknown | undefined; + compactedSinceLatestAssistantUsage: boolean; nextSyntheticAssistantBlockIndex: number; } @@ -322,7 +322,6 @@ interface ClaudeQueryRuntime extends AsyncIterable { readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; - readonly getContextUsage?: () => Promise; readonly close: () => void; } @@ -609,20 +608,6 @@ function normalizeClaudeActiveTokenUsage( }); } -function normalizeClaudeContextUsageApiSnapshot( - value: SDKControlGetContextUsageResponse, - totalProcessedTokens?: number, -): ThreadTokenUsageSnapshot | undefined { - const autoCompactThreshold = finitePositiveInteger(value.autoCompactThreshold); - return makeClaudeTokenUsageSnapshot({ - activeTokens: value.totalTokens, - contextWindow: value.maxTokens, - ...(totalProcessedTokens !== undefined ? { totalProcessedTokens } : {}), - compactsAutomatically: value.isAutoCompactEnabled, - ...(autoCompactThreshold !== undefined ? { autoCompactThreshold } : {}), - }); -} - function compactBoundaryTokenUsageSnapshot( message: Record, contextWindow?: number, @@ -2134,29 +2119,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); }); - const queryCurrentContextUsage = Effect.fn("queryCurrentContextUsage")(function* ( - context: ClaudeSessionContext, - totalProcessedTokens?: number, - ) { - if (!context.query.getContextUsage) { - return undefined; - } - - const usage = yield* Effect.promise(async () => { - try { - return await context.query.getContextUsage?.(); - } catch { - return undefined; - } - }).pipe(Effect.timeoutOption("1 second")); - if (Option.isNone(usage) || !usage.value) { - return undefined; - } - - context.lastKnownContextWindow = usage.value.maxTokens; - return normalizeClaudeContextUsageApiSnapshot(usage.value, totalProcessedTokens); - }); - const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( context: ClaudeSessionContext, input: { @@ -2257,10 +2219,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( context.lastKnownTotalProcessedTokens = accumulatedTotalProcessedTokens; } - const contextUsageSnapshot = yield* queryCurrentContextUsage( - context, - accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, - ); + // Avoid getContextUsage because its token-count fallback can make extra model requests. const resultUsageRecord = result?.usage && typeof result.usage === "object" && !Array.isArray(result.usage) ? (result.usage as Record) @@ -2282,24 +2241,31 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, ) : undefined; + const latestAssistantSnapshot = normalizeClaudeActiveTokenUsage( + context.turnState?.latestAssistantUsage, + maxTokens, + accumulatedTotalProcessedTokens ?? context.lastKnownTotalProcessedTokens, + ); const lastGoodUsage = context.lastKnownTokenUsage; const usageSnapshot: ThreadTokenUsageSnapshot | undefined = - contextUsageSnapshot ?? - (resultTotalOnly && lastGoodUsage - ? { - ...lastGoodUsage, - ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 - ? { maxTokens } - : {}), - ...(typeof accumulatedTotalProcessedTokens === "number" && - Number.isFinite(accumulatedTotalProcessedTokens) && - accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens - ? { - totalProcessedTokens: accumulatedTotalProcessedTokens, - } - : {}), - } - : resultIterationSnapshot) ?? + latestAssistantSnapshot ?? + (context.turnState?.compactedSinceLatestAssistantUsage + ? undefined + : resultTotalOnly && lastGoodUsage + ? { + ...lastGoodUsage, + ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0 + ? { maxTokens } + : {}), + ...(typeof accumulatedTotalProcessedTokens === "number" && + Number.isFinite(accumulatedTotalProcessedTokens) && + accumulatedTotalProcessedTokens > lastGoodUsage.usedTokens + ? { + totalProcessedTokens: accumulatedTotalProcessedTokens, + } + : {}), + } + : resultIterationSnapshot) ?? (lastGoodUsage ? { ...lastGoodUsage, @@ -2951,6 +2917,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + latestAssistantUsage: undefined, + compactedSinceLatestAssistantUsage: false, nextSyntheticAssistantBlockIndex: -1, }; context.session = { @@ -3011,6 +2979,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (context.turnState) { context.turnState.items.push(message.message); + if ( + normalizeClaudeActiveTokenUsage( + message.message.usage, + context.lastKnownContextWindow, + context.lastKnownTotalProcessedTokens, + ) + ) { + context.turnState.latestAssistantUsage = message.message.usage; + context.turnState.compactedSinceLatestAssistantUsage = false; + } yield* backfillAssistantTextBlocksFromSnapshot(context, message); } @@ -3176,6 +3154,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); return; case "compact_boundary": + if (context.turnState) { + context.turnState.latestAssistantUsage = undefined; + context.turnState.compactedSinceLatestAssistantUsage = true; + } yield* emitThreadTokenUsage( context, compactBoundaryTokenUsageSnapshot( @@ -4603,6 +4585,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( assistantTextBlocks: new Map(), assistantTextBlockOrder: [], capturedProposedPlanKeys: new Set(), + latestAssistantUsage: undefined, + compactedSinceLatestAssistantUsage: false, nextSyntheticAssistantBlockIndex: -1, }; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 726cfbff0..5929c9bfe 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -112,6 +112,7 @@ import { } from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; +import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; @@ -398,6 +399,7 @@ const buildAppUnderTest = (options?: { config?: Partial; layers?: { keybindings?: Partial; + environmentTheme?: Partial; providerRegistry?: Partial; providerService?: Partial; serverSettings?: Partial; @@ -634,14 +636,21 @@ const buildAppUnderTest = (options?: { }, ).pipe( Layer.provide( - Layer.mock(Keybindings.Keybindings)({ - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], + Layer.mergeAll( + Layer.mock(Keybindings.Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + ...options?.layers?.keybindings, }), - streamChanges: Stream.empty, - ...options?.layers?.keybindings, - }), + Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ + current: Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.environmentTheme, + }), + ), ), Layer.provide( Layer.mergeAll( @@ -4985,6 +4994,84 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + // An already-shipped client decodes this stream against an event union + // without environmentThemesUpdated, so an ungated emit would kill its whole + // config subscription. Opting in is the only way to receive them. + it.effect("subscribeServerConfig sends published themes to an opt-in subscriber", () => + Effect.gen(function* () { + const themes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ] as const; + + yield* buildAppUnderTest({ + layers: { + environmentTheme: { + current: Effect.succeed(themes), + streamChanges: Stream.succeed(themes), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({ environmentThemes: true }).pipe( + Stream.take(2), + Stream.runCollect, + ), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + // Not in the snapshot as well, or every opt-in client receives the same + // array twice on every connect. + if (first?.type === "snapshot") assert.equal(first.config.environmentThemes, undefined); + assert.equal(second?.type, "environmentThemesUpdated"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("subscribeServerConfig withholds published themes from other subscribers", () => + Effect.gen(function* () { + const themes = [ + { + id: "nightfall", + name: "Nightfall", + appearance: "dark" as const, + canvas: "#1a1b26", + accent: "#7aa2f7", + }, + ] as const; + + yield* buildAppUnderTest({ + layers: { + environmentTheme: { + current: Effect.succeed(themes), + streamChanges: Stream.succeed(themes), + }, + providerRegistry: { streamChanges: Stream.empty }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(1), Stream.runCollect), + ), + ); + + const first = Array.from(events)[0]; + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") assert.equal(first.config.environmentThemes, undefined); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => Effect.gen(function* () { const nextProviders = [ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7c7ac3702..25e6aaae4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -53,6 +53,7 @@ import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; +import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; @@ -398,7 +399,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), - Layer.provideMerge(Keybindings.layer), + // Both read a user-owned file out of the state directory and stream changes + // to clients; neither depends on the other. + Layer.provideMerge(Layer.mergeAll(Keybindings.layer, EnvironmentTheme.layer)), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, // adapter lookup, and runtime ingestion all resolve `ProviderInstanceId` diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 73d1b0fd9..596ea07cd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -74,6 +74,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; +import * as EnvironmentTheme from "./environmentTheme.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { @@ -491,6 +492,7 @@ const makeWsRpcLayer = ( }; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; @@ -2388,7 +2390,7 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "preview" }, ), - [WS_METHODS.subscribeServerConfig]: (_input) => + [WS_METHODS.subscribeServerConfig]: (input) => observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { @@ -2410,6 +2412,23 @@ const makeWsRpcLayer = ( })), Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); + // The only source of published themes: the stream emits the + // current set before any change, so the snapshot carrying it too + // would just send every client the same array twice per connect. + // Gated on the subscriber's capability flag because an + // already-shipped client decodes this stream against the old + // event union and its whole config subscription dies on an + // unknown member. + const environmentThemeUpdates = + input.environmentThemes === true + ? environmentTheme.streamChanges.pipe( + Stream.map((themes) => ({ + version: 1 as const, + type: "environmentThemesUpdated" as const, + payload: { themes }, + })), + ) + : Stream.empty; const settingsUpdates = serverSettings.streamChanges.pipe( Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.map((settings) => ({ @@ -2425,7 +2444,10 @@ const makeWsRpcLayer = ( const liveUpdates = Stream.merge( keybindingsUpdates, - Stream.merge(providerStatuses, settingsUpdates), + Stream.merge( + providerStatuses, + Stream.merge(settingsUpdates, environmentThemeUpdates), + ), ); return Stream.concat( diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index f999006f2..cef92527c 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -36,6 +36,7 @@ import { shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, + shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -593,6 +594,31 @@ describe("shouldShowBranchMismatchBanner", () => { }); }); +describe("shouldShowPlanFollowUpPrompt", () => { + const base = { + pendingUserInputCount: 0, + interactionMode: "plan" as const, + latestTurnSettled: true, + hasActionableProposedPlan: true, + hasComposerAttachments: false, + }; + + it("shows plan actions for a settled actionable plan without attachments", () => { + expect(shouldShowPlanFollowUpPrompt(base)).toBe(true); + }); + + it("hides plan actions while the composer has staged attachments", () => { + expect(shouldShowPlanFollowUpPrompt({ ...base, hasComposerAttachments: true })).toBe(false); + }); + + it("preserves the existing plan follow-up gates", () => { + expect(shouldShowPlanFollowUpPrompt({ ...base, pendingUserInputCount: 1 })).toBe(false); + expect(shouldShowPlanFollowUpPrompt({ ...base, interactionMode: "default" })).toBe(false); + expect(shouldShowPlanFollowUpPrompt({ ...base, latestTurnSettled: false })).toBe(false); + expect(shouldShowPlanFollowUpPrompt({ ...base, hasActionableProposedPlan: false })).toBe(false); + }); +}); + describe("session branch mismatch dismissal", () => { it("tracks dismissed keys and treats other keys as active", () => { expect(isBranchMismatchDismissedForSession("t1:a:b")).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 52da4dde5..454729f41 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -4,6 +4,7 @@ import { ProjectId, type MessageId, type ModelSelection, + type ProviderInteractionMode, type ProviderDriverKind, type ServerProvider, type ScopedProjectRef, @@ -446,6 +447,22 @@ export function shouldShowBranchMismatchBanner(input: { return input.composerHasContent || input.wasShownForCurrentMismatch; } +export function shouldShowPlanFollowUpPrompt(input: { + pendingUserInputCount: number; + interactionMode: ProviderInteractionMode; + latestTurnSettled: boolean; + hasActionableProposedPlan: boolean; + hasComposerAttachments: boolean; +}): boolean { + return ( + input.pendingUserInputCount === 0 && + input.interactionMode === "plan" && + input.latestTurnSettled && + input.hasActionableProposedPlan && + !input.hasComposerAttachments + ); +} + // Session-scoped (module-level so it survives ChatView remounts, e.g. route // changes). Durable cross-device dismissal is planned as a server-side ack. const sessionDismissedBranchMismatchKeys = new Set(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c09e5e706..f2b871b28 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { type ApprovalRequestId, + type ChatFileAttachment, DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, @@ -17,6 +18,7 @@ import { type TurnId, type KeybindingCommand, OrchestrationThreadActivity, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode, ProviderDriverKind, RuntimeMode, @@ -239,6 +241,7 @@ import { beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, composerDraftHasUserContent, + type ComposerFileAttachment, type ComposerImageAttachment, type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, @@ -265,6 +268,7 @@ import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { + environmentServerConfigsAtom, primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, primaryServerSettingsAtom, @@ -354,6 +358,7 @@ import { shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, + shouldShowPlanFollowUpPrompt, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -379,13 +384,19 @@ import { useComposerHandleContext } from "../composerHandleContext"; import { awaitAttachmentUploads, getUploadedAttachments, - releaseAttachmentUploads, + releaseDraftAttachments, startAttachmentUpload, } from "../lib/attachmentUploadQueue"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; import { previewEnvironment } from "../state/preview"; +import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; +import { assetEnvironment } from "../state/assets"; +import { readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { Button } from "./ui/button"; import { AlertDialog, @@ -406,10 +417,10 @@ import { resolveServerSelfUpdateCapability, serverUpdateGuidance, } from "../versionSkew"; -import { useAssetUrls } from "../assets/assetUrls"; +import { resolveAssetUrl, useAssetUrls } from "../assets/assetUrls"; -const IMAGE_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = + "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -1305,6 +1316,9 @@ function ChatViewContent(props: ChatViewProps) { const showLatestMessages = useAtomCommand(environmentThreads.showLatestMessages, { reportFailure: false, }); + const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + reportFailure: false, + }); const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { reportFailure: false, }); @@ -1403,8 +1417,13 @@ function ChatViewContent(props: ChatViewProps) { const composerHasUnsentContent = useComposerDraftStore((store) => composerDraftHasUserContent(store.getComposerDraft(composerDraftTarget)), ); + const composerHasAttachments = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; + }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); + const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); @@ -1431,6 +1450,7 @@ function ChatViewContent(props: ChatViewProps) { ); const promptRef = useRef(""); const composerImagesRef = useRef([]); + const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); @@ -2189,6 +2209,12 @@ function ChatViewContent(props: ChatViewProps) { const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsAttachmentUploads = attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; + const advertisedFileAttachmentBytes = + attachmentEnvironmentConfig?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null; + const maxFileAttachmentBytes = + advertisedFileAttachmentBytes === null + ? null + : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2469,11 +2495,13 @@ function ChatViewContent(props: ChatViewProps) { null ); }, [activeLatestTurn?.turnId, activePlan]); - const showPlanFollowUpPrompt = - pendingUserInputs.length === 0 && - interactionMode === "plan" && - latestTurnSettled && - hasActionableProposedPlan(activeProposedPlan); + const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({ + pendingUserInputCount: pendingUserInputs.length, + interactionMode, + latestTurnSettled, + hasActionableProposedPlan: hasActionableProposedPlan(activeProposedPlan), + hasComposerAttachments: composerHasAttachments, + }); const activePendingApproval = pendingApprovals[0] ?? null; const { beginLocalDispatch, @@ -2559,11 +2587,57 @@ function ChatViewContent(props: ChatViewProps) { }); }, []); const serverMessages = activeThread?.messages; + const downloadFileAttachment = useCallback( + async (attachment: ChatFileAttachment) => { + const connection = readPreparedConnection(environmentId); + if (!connection) { + toastManager.add({ type: "error", title: "The environment is not connected." }); + return; + } + + // fileName and mimeType ride in the signed claims so the download gets + // a real filename and Content-Type even when the anchor's `download` + // attribute is ignored (cross-origin environment servers). + const result = await createAttachmentAssetUrl({ + environmentId, + input: { + resource: { + _tag: "attachment", + attachmentId: attachment.id, + fileName: attachment.name, + mimeType: attachment.mimeType, + }, + }, + }); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: `Could not download ${attachment.name}`, + description: error instanceof Error ? error.message : "The attachment is unavailable.", + }); + return; + } + + const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); + if (!url) { + toastManager.add({ type: "error", title: `Could not download ${attachment.name}` }); + return; + } + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = attachment.name; + anchor.click(); + }, + [createAttachmentAssetUrl, environmentId], + ); const serverAttachmentIds = useMemo(() => { const attachmentIds = new Set(); for (const message of serverMessages ?? []) { for (const attachment of message.attachments ?? []) { - attachmentIds.add(attachment.id); + if (isImageAttachment(attachment)) { + attachmentIds.add(attachment.id); + } } } return [...attachmentIds]; @@ -5648,6 +5722,7 @@ function ChatViewContent(props: ChatViewProps) { } const { images: sendContextImages, + files: composerFiles, terminalContexts: composerTerminalContexts, elementContexts: composerElementContexts, previewAnnotations: sendContextPreviewAnnotations, @@ -5658,9 +5733,17 @@ function ChatViewContent(props: ChatViewProps) { selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; + const annotationImageAlreadyAttached = + directAnnotation?.image !== undefined && + sendContextImages.some((image) => image.id === directAnnotation.image?.id); + // A full composer (e.g. 8 files) cannot take the annotation screenshot; + // over the cap the server rejects the whole turn. + const annotationImageAppended = + directAnnotation?.image !== undefined && + !annotationImageAlreadyAttached && + sendContextImages.length + composerFiles.length < PROVIDER_SEND_TURN_MAX_ATTACHMENTS; const composerImages = - directAnnotation?.image && - !sendContextImages.some((image) => image.id === directAnnotation.image?.id) + directAnnotation?.image && annotationImageAppended ? [...sendContextImages, directAnnotation.image] : sendContextImages; const composerPreviewAnnotations = @@ -5672,9 +5755,13 @@ function ChatViewContent(props: ChatViewProps) { ...sendContextPreviewAnnotations, { ...directAnnotation.annotation, - screenshot: directAnnotation.annotation.screenshot - ? { ...directAnnotation.annotation.screenshot, dataUrl: "" } - : null, + // Claim an attached crop only when the screenshot really rides + // along; a cap-dropped image must not produce a lying prompt. + screenshot: + directAnnotation.annotation.screenshot && + (annotationImageAppended || annotationImageAlreadyAttached) + ? { ...directAnnotation.annotation.screenshot, dataUrl: "" } + : null, }, ] : sendContextPreviewAnnotations; @@ -5686,7 +5773,7 @@ function ChatViewContent(props: ChatViewProps) { hasSendableContent, } = deriveComposerSendState({ prompt: promptForSend, - imageCount: composerImages.length, + imageCount: composerImages.length + composerFiles.length, terminalContexts: composerTerminalContexts, elementContextCount: composerElementContexts.length + @@ -5696,6 +5783,7 @@ function ChatViewContent(props: ChatViewProps) { const feedbackCommand = ctxSelectedProvider === "codex" && composerImages.length === 0 && + composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && composerPreviewAnnotations.length === 0 && @@ -5788,7 +5876,13 @@ function ChatViewContent(props: ChatViewProps) { ); return; } - if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) { + if ( + !directAnnotation && + showPlanFollowUpPrompt && + activeProposedPlan && + composerImages.length === 0 && + composerFiles.length === 0 + ) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, @@ -5817,6 +5911,7 @@ function ChatViewContent(props: ChatViewProps) { const standaloneSlashCommand = settings.planModeEnabled && composerImages.length === 0 && + composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && composerPreviewAnnotations.length === 0 && @@ -5833,6 +5928,7 @@ function ChatViewContent(props: ChatViewProps) { const goalSlashCommand = ctxSelectedProvider === "codex" && composerImages.length === 0 && + composerFiles.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && composerPreviewAnnotations.length === 0 @@ -6004,6 +6100,8 @@ function ChatViewContent(props: ChatViewProps) { } const composerImagesSnapshot = [...composerImages]; + const composerFilesSnapshot = [...composerFiles]; + const composerAttachmentsSnapshot = [...composerImagesSnapshot, ...composerFilesSnapshot]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; @@ -6025,21 +6123,57 @@ function ChatViewContent(props: ChatViewProps) { model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, }); if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { return; } + const readLiveAttachmentCapabilities = () => { + const config = appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId) ?? null; + const liveSupportsAttachmentUploads = + config?.environment.capabilities.attachmentUploads === true; + return { + supportsAttachmentUploads: liveSupportsAttachmentUploads, + fileBlockReason: fileAttachmentCapabilityBlockReason({ + files: composerFilesSnapshot, + attachmentUploadsCapabilityKnown: config !== null, + supportsAttachmentUploads: liveSupportsAttachmentUploads, + maxFileAttachmentBytes: + config?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null, + }), + }; + }; + sendInFlightRef.current = true; - if (supportsAttachmentUploads && composerImagesSnapshot.length > 0) { - for (const image of composerImagesSnapshot) { - startAttachmentUpload({ environmentId, image }); + const attachmentCapabilitiesBeforeUpload = readLiveAttachmentCapabilities(); + if (attachmentCapabilitiesBeforeUpload.fileBlockReason !== null) { + sendInFlightRef.current = false; + setThreadError(threadIdForSend, attachmentCapabilitiesBeforeUpload.fileBlockReason); + return; + } + const turnUsesAttachmentUploads = + composerFilesSnapshot.length > 0 + ? attachmentCapabilitiesBeforeUpload.supportsAttachmentUploads + : supportsAttachmentUploads; + if (turnUsesAttachmentUploads && composerAttachmentsSnapshot.length > 0) { + for (const attachment of composerAttachmentsSnapshot) { + startAttachmentUpload({ + environmentId, + image: attachment, + draftTarget: composerDraftTarget, + }); } - await awaitAttachmentUploads(composerImagesSnapshot.map((image) => image.id)); - if (getUploadedAttachments({ environmentId, images: composerImagesSnapshot }) === null) { + await awaitAttachmentUploads(composerAttachmentsSnapshot.map((attachment) => attachment.id)); + const attachmentCapabilitiesAfterUpload = readLiveAttachmentCapabilities(); + if (attachmentCapabilitiesAfterUpload.fileBlockReason !== null) { sendInFlightRef.current = false; - setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending."); + setThreadError(threadIdForSend, attachmentCapabilitiesAfterUpload.fileBlockReason); + return; + } + if (getUploadedAttachments({ environmentId, images: composerAttachmentsSnapshot }) === null) { + sendInFlightRef.current = false; + setThreadError(threadIdForSend, "Retry or remove failed uploads before sending."); return; } } @@ -6068,6 +6202,16 @@ function ChatViewContent(props: ChatViewProps) { void dockTransition.catch(() => resolveDockStarted?.()); await dockStarted; } + + const attachmentCapabilitiesBeforeDispatch = readLiveAttachmentCapabilities(); + if (attachmentCapabilitiesBeforeDispatch.fileBlockReason !== null) { + sendInFlightRef.current = false; + setThreadError(threadIdForSend, attachmentCapabilitiesBeforeDispatch.fileBlockReason); + setDockedDraftHeroThreadKey((currentThreadKey) => + currentThreadKey === activeThreadKey ? null : currentThreadKey, + ); + return; + } beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree), submissionIntent: resolvedSubmissionIntent, @@ -6076,31 +6220,45 @@ function ChatViewContent(props: ChatViewProps) { const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => { - if (supportsAttachmentUploads) { - const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0]; + composerAttachmentsSnapshot.map(async (attachment) => { + if (turnUsesAttachmentUploads) { + const uploaded = getUploadedAttachments({ environmentId, images: [attachment] })?.[0]; if (!uploaded) { - throw new Error(`Image '${image.name}' did not finish uploading.`); + throw new Error(`Attachment '${attachment.name}' did not finish uploading.`); } return uploaded; } + if (attachment.type !== "image") { + throw new Error("This server does not support file attachments."); + } return { type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: await readFileAsDataUrl(attachment.file), }; }), ); - const optimisticAttachments = composerImagesSnapshot.map((image) => ({ - type: "image" as const, - id: image.id, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - previewUrl: image.previewUrl, - })); + const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) => + attachment.type === "image" + ? { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.previewUrl, + } + : { + type: "file" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + downloadable: false, + }, + ); if (activeThread.messageHistory?.hasMoreAfter === true) { await showLatestMessages({ environmentId, @@ -6168,6 +6326,8 @@ function ChatViewContent(props: ChatViewProps) { if (!titleSeed) { if (firstComposerImageName) { titleSeed = `Image: ${firstComposerImageName}`; + } else if (composerFilesSnapshot[0]) { + titleSeed = `File: ${composerFilesSnapshot[0].name}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else if (composerElementContextsSnapshot.length > 0) { @@ -6214,7 +6374,14 @@ function ChatViewContent(props: ChatViewProps) { } } - const turnAttachmentsResult = await settlePromise(() => turnAttachmentsPromise); + const turnAttachmentsResult = await settlePromise(async () => { + const turnAttachments = await turnAttachmentsPromise; + const liveFileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; + if (liveFileBlockReason !== null) { + throw new Error(liveFileBlockReason); + } + return turnAttachments; + }); if (failure === null && turnAttachmentsResult._tag === "Failure") { failure = turnAttachmentsResult; } @@ -6284,8 +6451,8 @@ function ChatViewContent(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; - if (supportsAttachmentUploads) { - releaseAttachmentUploads(composerImagesSnapshot); + if (turnUsesAttachmentUploads) { + releaseDraftAttachments(composerAttachmentsSnapshot); } acknowledgeActiveThreadWoke(); if (backgroundThreadRef) { @@ -6342,6 +6509,7 @@ function ChatViewContent(props: ChatViewProps) { if ( promptRef.current.length === 0 && composerImagesRef.current.length === 0 && + composerFilesRef.current.length === 0 && composerTerminalContextsRef.current.length === 0 && composerElementContextsRef.current.length === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations @@ -6360,10 +6528,12 @@ function ChatViewContent(props: ChatViewProps) { promptRef.current = promptForSend; const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); composerImagesRef.current = retryComposerImages; + composerFilesRef.current = composerFilesSnapshot; composerTerminalContextsRef.current = composerTerminalContextsSnapshot; composerElementContextsRef.current = composerElementContextsSnapshot; setComposerDraftPrompt(composerDraftTarget, promptForSend); addComposerDraftImages(composerDraftTarget, retryComposerImages); + addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); @@ -7333,6 +7503,7 @@ function ChatViewContent(props: ChatViewProps) { onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} + onFileDownload={downloadFileAttachment} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} @@ -7454,6 +7625,7 @@ function ChatViewContent(props: ChatViewProps) { environmentId={environmentId} attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} supportsAttachmentUploads={supportsAttachmentUploads} + maxFileAttachmentBytes={maxFileAttachmentBytes} routeKind={routeKind} routeThreadRef={routeThreadRef} draftId={draftId} @@ -7508,6 +7680,7 @@ function ChatViewContent(props: ChatViewProps) { gitCwd={gitCwd} promptRef={promptRef} composerImagesRef={composerImagesRef} + composerFilesRef={composerFilesRef} composerTerminalContextsRef={composerTerminalContextsRef} composerElementContextsRef={composerElementContextsRef} onSend={onSend} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 53de0fdb8..6c4b720e9 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -617,7 +617,7 @@ export default function DiffPanel({
diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index f1e5ac2e4..f2cb30e76 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1519,7 +1519,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return result; } const draftStore = useComposerDraftStore.getState(); - releaseProjectDraftUploads(memberProjectRef); + releaseProjectDraftUploads( + memberProjectRef, + sidebarThreads + .filter( + (thread) => + thread.environmentId === member.environmentId && thread.projectId === member.id, + ) + .map((thread) => scopeThreadRef(thread.environmentId, thread.id)), + ); const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); @@ -1527,7 +1535,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec draftStore.clearProjectDraftThreadId(memberProjectRef); return result; }, - [deleteProject], + [deleteProject, sidebarThreads], ); const handleRemoveProject = useCallback( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b36c311e2..0cdb9f56e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -584,6 +584,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { // that only the persisted list is populated, hence max not sum. const attachmentCount = Math.max(composer.images.length, composer.persistedAttachments.length) + + composer.files.length + composer.terminalContexts.length + composer.elementContexts.length + composer.previewAnnotations.length + @@ -3761,7 +3762,10 @@ export default function Sidebar() { - +
{ prompt: string; images: ComposerImageAttachment[]; + files: ComposerFileAttachment[]; terminalContexts: TerminalContextDraft[]; elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; @@ -557,6 +575,7 @@ export interface ChatComposerProps { environmentId: EnvironmentId; attachmentUploadsCapabilityKnown: boolean; supportsAttachmentUploads: boolean; + maxFileAttachmentBytes: number | null; routeKind: "server" | "draft"; routeThreadRef: ScopedThreadRef; draftId: DraftId | null; @@ -630,6 +649,7 @@ export interface ChatComposerProps { // Refs the parent needs kept in sync promptRef: React.RefObject; composerImagesRef: React.RefObject; + composerFilesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; composerElementContextsRef: React.RefObject; composerRef: React.RefObject; @@ -675,6 +695,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, attachmentUploadsCapabilityKnown, supportsAttachmentUploads, + maxFileAttachmentBytes, routeKind, routeThreadRef, draftId, @@ -721,6 +742,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef, composerRef, composerImagesRef, + composerFilesRef, composerTerminalContextsRef, composerElementContextsRef, onSend, @@ -745,21 +767,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ const composerDraft = useComposerThreadDraft(composerDraftTarget); + // Live target key, for async flows that must notice a thread switch that + // happened while they awaited. + const composerDraftTargetKeyRef = useRef(""); + composerDraftTargetKeyRef.current = composerTargetKey(composerDraftTarget); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; + const composerFiles = composerDraft.files; const composerTerminalContexts = composerDraft.terminalContexts; const composerElementContexts = composerDraft.elementContexts; const composerPreviewAnnotations = composerDraft.previewAnnotations; const composerReviewComments = composerDraft.reviewComments; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId); - const attachmentBlockReason = supportsAttachmentUploads - ? attachmentUploadBlockReason({ - imageIds: composerImages.map((image) => image.id), - uploadsByImageId, - environmentId, - }) - : null; + const needsReattachFileCount = composerFiles.filter(composerFileNeedsReattach).length; + const fileStagingLimit = fileAttachmentStagingLimit({ + attachmentUploadsCapabilityKnown, + supportsAttachmentUploads, + maxFileAttachmentBytes, + }); + const fileCapabilityBlockReason = fileAttachmentCapabilityBlockReason({ + files: composerFiles, + attachmentUploadsCapabilityKnown, + supportsAttachmentUploads, + maxFileAttachmentBytes, + }); + const attachmentBlockReason = + fileCapabilityBlockReason ?? + (supportsAttachmentUploads + ? needsReattachFileCount > 0 + ? needsReattachFileCount === 1 + ? "Attach the interrupted file again or remove it" + : "Attach the interrupted files again or remove them" + : attachmentUploadBlockReason({ + imageIds: [...composerImages, ...composerFiles].map((attachment) => attachment.id), + uploadsByImageId, + environmentId, + }) + : null); const sendDisabledReason = externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason); const isSendDisabled = sendDisabledReason !== null; @@ -768,6 +813,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const addComposerDraftImage = useComposerDraftStore((store) => store.addImage); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage); + const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); + const removeComposerDraftFile = useComposerDraftStore((store) => store.removeFile); + const setComposerDraftFileUpload = useComposerDraftStore((store) => store.setFileUpload); const insertComposerDraftTerminalContext = useComposerDraftStore( (store) => store.insertTerminalContext, ); @@ -802,15 +850,76 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } if (!supportsAttachmentUploads) { - for (const image of composerImages) { - releaseAttachmentUpload(image.id); + // The capability can flap on reconnect or version skew. Deleting a + // persisted hydrated upload here would make the next send fail + // verification while the file still sits in the draft. + for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss([ + ...composerImages, + ...composerFiles, + ])) { + releaseAttachmentUpload(attachment.id); } return; } - for (const image of composerImages) { - startAttachmentUpload({ environmentId, image }); + const invalidFiles = + maxFileAttachmentBytes === null + ? composerFiles + : composerFiles.filter((file) => file.sizeBytes > maxFileAttachmentBytes); + for (const attachment of attachmentsToReleaseOnUploadCapabilityLoss(invalidFiles)) { + releaseAttachmentUpload(attachment.id); + } + const uploadableFiles = + maxFileAttachmentBytes === null + ? [] + : composerFiles.filter((file) => file.sizeBytes <= maxFileAttachmentBytes); + const uploadableAttachments = [...composerImages, ...uploadableFiles]; + for (const attachment of uploadableAttachments) { + // A needs-reattach file has no bytes to upload and no upload to verify. + if (attachment.type === "file" && composerFileNeedsReattach(attachment)) { + continue; + } + startAttachmentUpload({ environmentId, image: attachment, draftTarget: composerDraftTarget }); + } + }, [ + attachmentUploadsCapabilityKnown, + composerDraftTarget, + composerFiles, + composerImages, + environmentId, + maxFileAttachmentBytes, + supportsAttachmentUploads, + ]); + + useEffect(() => { + for (const file of composerFiles) { + if ( + !attachmentUploadsCapabilityKnown || + !supportsAttachmentUploads || + maxFileAttachmentBytes === null || + file.sizeBytes > maxFileAttachmentBytes + ) { + continue; + } + const upload = uploadsByImageId[file.id]; + if (upload?.status === "ready" && upload.environmentId === environmentId) { + setComposerDraftFileUpload( + composerDraftTarget, + file.id, + environmentId, + upload.attachmentId, + ); + } } - }, [attachmentUploadsCapabilityKnown, composerImages, environmentId, supportsAttachmentUploads]); + }, [ + attachmentUploadsCapabilityKnown, + composerDraftTarget, + composerFiles, + environmentId, + maxFileAttachmentBytes, + setComposerDraftFileUpload, + supportsAttachmentUploads, + uploadsByImageId, + ]); // ------------------------------------------------------------------ // Model state @@ -1068,6 +1177,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Refs // ------------------------------------------------------------------ const composerEditorRef = useRef(null); + const attachmentInputRef = useRef(null); const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); const providerInputRejectedRef = useRef(false); @@ -1102,7 +1212,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => deriveComposerSendState({ prompt, - imageCount: composerImages.length, + imageCount: composerImages.length + composerFiles.length, terminalContexts: composerTerminalContexts, elementContextCount: composerElementContexts.length + @@ -1111,6 +1221,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [ composerElementContexts.length, + composerFiles.length, composerImages.length, composerPreviewAnnotations.length, composerReviewComments.length, @@ -1414,6 +1525,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [composerDraftTarget, addComposerDraftImages], ); + const addComposerFilesToDraft = useCallback( + (files: ComposerFileAttachment[]) => { + addComposerDraftFiles(composerDraftTarget, files); + }, + [addComposerDraftFiles, composerDraftTarget], + ); + const removeComposerImageFromDraft = useCallback( (imageId: string) => { releaseAttachmentUpload(imageId); @@ -1422,6 +1540,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [composerDraftTarget, removeComposerDraftImage], ); + const removeComposerFileFromDraft = useCallback( + (fileId: string) => { + // Release by the draft attachment, not the bare queue key: a hydrated + // file's upload lives server-side under its persisted attachment id. + const file = composerFilesRef.current.find((candidate) => candidate.id === fileId); + if (file) { + releaseDraftAttachment(file); + } else { + releaseAttachmentUpload(fileId); + } + removeComposerDraftFile(composerDraftTarget, fileId); + }, + [composerDraftTarget, composerFilesRef, removeComposerDraftFile], + ); + const removeComposerTerminalContextFromDraft = useCallback( (contextId: string) => { const contextIndex = composerTerminalContexts.findIndex( @@ -1478,6 +1611,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerImagesRef.current = composerImages; }, [composerImages, composerImagesRef]); + useEffect(() => { + composerFilesRef.current = composerFiles; + }, [composerFiles, composerFilesRef]); + useEffect(() => { composerTerminalContextsRef.current = composerTerminalContexts; }, [composerTerminalContexts, composerTerminalContextsRef]); @@ -2164,9 +2301,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ // Prompt stash (⌘S) // ------------------------------------------------------------------ - // One global queue. Stashed prompts carry only text + images so they can be - // restored into any thread or provider — stash, switch, restore is the - // whole point. + // Files remain tied to the environment that owns their uploaded bytes. const stashQueue = usePromptStashStore((state) => state.entries); const stashEntryToQueue = usePromptStashStore((state) => state.stashEntry); const takeStashEntry = usePromptStashStore((state) => state.takeEntry); @@ -2194,10 +2329,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, []); const restoreStashEntry = useCallback( - (entry: PromptStashEntry) => { - // Remove first so a double activation (click + Enter) can't restore twice. - const { entry: taken, durable } = takeStashEntry(entry.id); - if (!taken) return; + async (menuEntry: PromptStashEntry) => { + const filesToVerify = menuEntry.files ?? []; + if (filesToVerify.some((file) => file.environmentId !== environmentId)) { + toastManager.add({ + type: "error", + title: "Stashed files belong to another environment", + description: "Restore this prompt in the environment that received its files.", + }); + return; + } + setIsStashMenuOpen(false); + + // The server sweeps pending uploads after 24 hours, so ask before + // reattaching. An expired upload restores as a needs-reattach row + // instead of a reference the next send would fail to verify. Verify + // BEFORE taking: the take removes the entry from durable storage, and a + // tab closed during this await must still find it there after reload. + const verifications = await Promise.all( + filesToVerify.map((file) => + verifyStashedAttachmentUpload({ environmentId, attachmentId: file.attachmentId }), + ), + ); + const expiredAttachmentIds = new Set( + filesToVerify + .filter((_, index) => verifications[index]?.status === "missing") + .map((file) => file.attachmentId), + ); + + // A thread switch during the verify await would mix the new thread's + // prompt with this invocation's captured target. Nothing was taken yet, + // so abort and leave the entry restorable where the user now is. + if (composerTargetKey(composerDraftTarget) !== composerDraftTargetKeyRef.current) { + return; + } + + // The take is also the double-activation guard (click + Enter): the + // second caller finds the entry gone and stops here. + const { entry, durable } = takeStashEntry(menuEntry.id); + if (!entry) return; if (!durable) { toastManager.add({ type: "warning", @@ -2207,7 +2377,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data: { hideCopyButton: true }, }); } - setIsStashMenuOpen(false); const currentPrompt = promptRef.current; // An image-only stash must not append blank lines to whatever is @@ -2226,6 +2395,121 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(null); } + let unrestoredFileNames: string[] = []; + const expiredFileNames: string[] = []; + let restoredFileCount = 0; + const stashedFiles = entry.files ?? []; + if (stashedFiles.length > 0) { + const fileDedupKey = (file: { + readonly mimeType: string; + readonly sizeBytes: number; + readonly name: string; + }) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`; + const composerFilesNow = composerFilesRef.current; + const existingFileIds = new Set(composerFilesNow.map((file) => file.id)); + const retainedUploadIds = new Set( + composerFilesNow.flatMap((file) => + file.uploadedAttachmentId ? [file.uploadedAttachmentId] : [], + ), + ); + const existingFileKeys = new Set(composerFilesNow.map(fileDedupKey)); + const reattachMarkerKeys = new Set( + composerFilesNow.filter(composerFileNeedsReattach).map(fileDedupKey), + ); + const duplicateFiles: PersistedComposerFileAttachment[] = []; + const markerReplacements: ComposerFileAttachment[] = []; + const appendedFiles: ComposerFileAttachment[] = []; + for (const file of stashedFiles) { + const expired = expiredAttachmentIds.has(file.attachmentId); + const key = fileDedupKey(file); + const restored: ComposerFileAttachment = { + type: "file", + id: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + file: null, + // An expired upload carries no ids, so it hydrates as a + // needs-reattach row and the "Attach again" flow takes over. + ...(expired + ? {} + : { uploadedAttachmentId: file.attachmentId, uploadEnvironmentId: environmentId }), + }; + if (existingFileIds.has(file.id)) { + if (!expired && !retainedUploadIds.has(file.attachmentId)) { + duplicateFiles.push(file); + } + continue; + } + if (existingFileKeys.has(key)) { + if (reattachMarkerKeys.has(key)) { + // The draft row with this identity is a needs-reattach marker, + // not a real duplicate. Replace it (addFiles swaps a matching + // marker in place) instead of deleting the only uploaded copy. + reattachMarkerKeys.delete(key); + existingFileIds.add(file.id); + if (expired) { + // The draft's marker already says "attach again"; nothing to + // restore or release, but say why the stash copy is gone. + expiredFileNames.push(file.name); + } else { + retainedUploadIds.add(file.attachmentId); + markerReplacements.push(restored); + } + continue; + } + if (!expired && !retainedUploadIds.has(file.attachmentId)) { + duplicateFiles.push(file); + } + continue; + } + existingFileIds.add(file.id); + existingFileKeys.add(key); + if (expired) { + expiredFileNames.push(file.name); + } else { + retainedUploadIds.add(file.attachmentId); + } + appendedFiles.push(restored); + } + const capacity = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - + composerImagesRef.current.length - + composerFilesNow.length, + ); + // Marker replacements reuse their marker's slot; only appended files + // consume capacity. + const filesToAppend = appendedFiles.slice(0, capacity); + const skippedFiles = appendedFiles.slice(capacity); + unrestoredFileNames = skippedFiles.map((file) => file.name); + // A non-durable take can resurrect the stash entry after a reload; + // deleting these uploads would leave it pointing at nothing. + if (durable) { + for (const file of duplicateFiles) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId, + attachmentId: file.attachmentId, + }); + } + for (const file of skippedFiles) { + if (file.uploadedAttachmentId) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId, + attachmentId: file.uploadedAttachmentId, + }); + } + } + } + const restoredFiles = [...markerReplacements, ...filesToAppend]; + if (restoredFiles.length > 0) { + addComposerDraftFiles(composerDraftTarget, restoredFiles); + restoredFileCount = filesToAppend.length; + } + } + let unrestoredImageNames: string[] = []; if (entry.attachments.length > 0) { const existingIds = new Set(composerImagesRef.current.map((image) => image.id)); @@ -2240,7 +2524,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const capacity = Math.max( 0, - PROVIDER_SEND_TURN_MAX_ATTACHMENTS - composerImagesRef.current.length, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - + composerImagesRef.current.length - + composerFilesRef.current.length - + restoredFileCount, ); const pending = entry.attachments.filter( (attachment) => @@ -2279,13 +2566,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } if (unrestoredImageNames.length > 0) { missingImageReasons.push( - `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-image limit.`, + `${unrestoredImageNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, + ); + } + if (unrestoredFileNames.length > 0) { + missingImageReasons.push( + `${unrestoredFileNames.join(", ")} could not be restored: the composer is at its ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}-attachment limit.`, + ); + } + if (expiredFileNames.length > 0) { + missingImageReasons.push( + `${expiredFileNames.join(", ")}: stashed files are kept for 24 hours and this upload expired. Attach the file again.`, ); } if (missingImageReasons.length > 0) { toastManager.add({ type: "warning", - title: "Some images were not restored", + title: "Some attachments were not restored", description: missingImageReasons.join(" "), }); } @@ -2299,9 +2596,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } }, [ + addComposerDraftFiles, addComposerDraftImages, composerDraftTarget, + composerFilesRef, composerImagesRef, + environmentId, promptRef, setComposerDraftPrompt, takeStashEntry, @@ -2310,7 +2610,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const deleteStashEntry = useCallback( (entry: PromptStashEntry) => { - const { durable } = takeStashEntry(entry.id); + const { entry: removed, durable } = takeStashEntry(entry.id); + if (durable && removed) { + for (const file of removed.files ?? []) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId: file.environmentId, + attachmentId: file.attachmentId, + }); + } + } if (!durable) { toastManager.add({ type: "warning", @@ -2329,17 +2638,45 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // round-trip, so they are stripped from the stashed prompt. const prompt = promptRef.current.split(INLINE_TERMINAL_CONTEXT_PLACEHOLDER).join("").trim(); const images = [...composerImagesRef.current]; - if (prompt.length === 0 && images.length === 0) { + const files = [...composerFilesRef.current]; + if (prompt.length === 0 && images.length === 0 && files.length === 0) { setIsStashMenuOpen((open) => !open); return; } + const stashedFiles: PersistedComposerFileAttachment[] = []; + for (const file of files) { + if (composerFileNeedsReattach(file)) { + toastManager.add({ + type: "error", + title: "Attach dropped files again or remove them before stashing", + }); + return; + } + const upload = readAttachmentUpload(file.id); + if (upload?.status !== "ready" || upload.environmentId !== environmentId) { + toastManager.add({ + type: "error", + title: "Wait for file uploads before stashing this prompt", + }); + return; + } + stashedFiles.push({ + id: file.id, + name: file.name, + mimeType: file.mimeType, + sizeBytes: file.sizeBytes, + attachmentId: upload.attachmentId, + environmentId, + }); + } // A repeat ⌘S on the *same* still-unencoded snapshot would stash it // twice. Guard on the snapshot itself rather than a bare boolean: once // the composer has been cleared the user can type something genuinely // new (or switch threads) while encoding continues, and that deserves its // own entry. const snapshotKey = `${String(composerDraftTarget)}${prompt}${images - .map((image) => image.id) + .map((image) => `image:${image.id}`) + .concat(files.map((file) => `file:${file.id}`)) .join(",")}`; if (stashInFlightRef.current.has(snapshotKey)) return; stashInFlightRef.current.add(snapshotKey); @@ -2357,6 +2694,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) createdAt: new Date().toISOString(), prompt, attachments: [], + ...(stashedFiles.length > 0 ? { files: stashedFiles } : {}), droppedImageNames: [], unreadableImageNames: [], pendingImageCount: images.length, @@ -2389,9 +2727,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); } - // Only the prompt and images are cleared — terminal/element contexts, - // preview annotations, and review comments are not stashable, so - // destroying them here would be unrecoverable. + // Terminal and preview context stays behind because the stash cannot restore it. promptRef.current = ""; clearComposerDraftPromptAndImages(stashTarget); for (const image of images) { @@ -2402,6 +2738,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pulseStashBadge(); if (evicted) { + for (const file of evicted.files ?? []) { + releasePersistedAttachmentUpload({ + id: file.id, + environmentId: file.environmentId, + attachmentId: file.attachmentId, + }); + } toastManager.add({ type: "warning", title: "Oldest stashed prompt discarded", @@ -2477,7 +2820,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }, [ clearComposerDraftPromptAndImages, composerDraftTarget, + composerFilesRef, composerImagesRef, + environmentId, finalizeStashEntryImages, promptRef, pulseStashBadge, @@ -2623,14 +2968,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); // ------------------------------------------------------------------ - // Callbacks: images + // Callbacks: attachments // ------------------------------------------------------------------ - const addComposerImages = async (files: File[]) => { + const addComposerAttachments = async (files: File[]) => { if (!activeThreadId || files.length === 0) return; if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", - title: "Attach images after answering plan questions.", + title: "Attach files after answering plan questions.", }); return; } @@ -2643,34 +2988,75 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // accepted files reserve their attachment slots (via the pending counter) // before the first await, keeping the total under the limit. const pendingCount = pendingImageCompressionsRef.current.get(threadId) ?? 0; - let reservedCount = composerImagesRef.current.length + pendingCount; - const acceptedFiles: File[] = []; + let reservedCount = + composerImagesRef.current.length + composerFilesRef.current.length + pendingCount; + // A pick that matches a needs-reattach marker replaces it in the draft, so + // it must not consume a slot; a draft full of markers would otherwise hit + // the capacity error before the replacement path could run. + const reattachKeys = new Set( + composerFilesRef.current + .filter(composerFileNeedsReattach) + .map((file) => `${file.mimeType}\u0000${file.sizeBytes}\u0000${file.name}`), + ); + const acceptedImages: File[] = []; + const acceptedFiles: ComposerFileAttachment[] = []; let error: string | null = null; for (const file of files) { - const isHeicImage = isHeicImageFile(file); - if (!file.type.startsWith("image/") && !isHeicImage) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; + const attachmentKind = classifyComposerAttachmentFile(file); + const replacesReattachMarker = + attachmentKind === "file" && + reattachKeys.delete( + `${file.type || "application/octet-stream"}\u0000${file.size}\u0000${file.name || "file"}`, + ); + if (!replacesReattachMarker && reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`; + // Keep scanning: a later file in this batch can still replace a + // needs-reattach marker without needing a free slot. continue; } - if (!isHeicImage && !isProviderSendTurnSupportedImageMimeType(file.type)) { + if (attachmentKind === "unsupported-image") { error = `'${file.name}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.`; continue; } - if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; - break; + if (attachmentKind === "image") { + acceptedImages.push(normalizeComposerImageFileMimeType(file)); + } else { + if (fileStagingLimit === null) { + error = "This server does not support file attachments."; + continue; + } + if (file.size <= 0) { + error = `'${file.name}' is empty or could not be read.`; + continue; + } + if (file.size > fileStagingLimit) { + error = fileAttachmentTooLargeMessage(file.name, fileStagingLimit); + continue; + } + acceptedFiles.push({ + type: "file", + id: randomUUID(), + name: file.name || "file", + mimeType: file.type || "application/octet-stream", + sizeBytes: file.size, + file, + }); + } + if (!replacesReattachMarker) { + reservedCount += 1; } - acceptedFiles.push(file); - reservedCount += 1; } setThreadError(threadId, error); - if (acceptedFiles.length === 0) return; + if (acceptedFiles.length > 0) { + addComposerFilesToDraft(acceptedFiles); + } + if (acceptedImages.length === 0) return; - pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedFiles.length); + pendingImageCompressionsRef.current.set(threadId, pendingCount + acceptedImages.length); try { const nextImages: ComposerImageAttachment[] = []; let compressionError: string | null = null; - for (const file of acceptedFiles) { + for (const file of acceptedImages) { // Images over the wire cap are downscaled to fit rather than // refused; files already within it pass through byte-for-byte. const compressed = await prepareImageForAttachment( @@ -2710,7 +3096,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } } finally { const remaining = - (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedFiles.length; + (pendingImageCompressionsRef.current.get(threadId) ?? 0) - acceptedImages.length; if (remaining > 0) { pendingImageCompressionsRef.current.set(threadId, remaining); } else { @@ -2728,13 +3114,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); - if (files.length === 0) return; - const imageFiles = files.filter( - (file) => file.type.startsWith("image/") || isHeicImageFile(file), - ); - if (imageFiles.length === 0) return; + // Claimable pastes go through even when plan questions are pending or the + // composer is at its attachment limit: `addComposerAttachments` surfaces + // those as a toast and a thread error. An early return here would swallow + // the paste with no feedback. + if ( + files.length === 0 || + !activeThreadId || + !shouldHandleComposerAttachmentPaste({ + files, + plainText: event.clipboardData.getData("text/plain"), + }) + ) { + return; + } event.preventDefault(); - void addComposerImages(imageFiles); + void addComposerAttachments(files); }; const insertComposerTextAtEnd = ( @@ -2860,7 +3255,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(cursor); }, addDroppedFiles: (files: File[]) => { - void addComposerImages(files); + void addComposerAttachments(files); focusComposer(); }, insertTextAtEnd: insertComposerTextAtEnd, @@ -2931,6 +3326,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) getSendContext: () => ({ prompt: promptRef.current, images: composerImagesRef.current, + files: composerFilesRef.current, terminalContexts: composerTerminalContextsRef.current, elementContexts: composerElementContextsRef.current, previewAnnotations: composerPreviewAnnotations, @@ -2956,13 +3352,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [ activeThread, - addComposerImages, + addComposerAttachments, composerDraftTarget, composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext, promptRef, composerImagesRef, + composerFilesRef, composerTerminalContextsRef, composerElementContextsRef, composerPreviewAnnotations, @@ -3278,7 +3675,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? { uploadsByImageId, onRetryUpload: (image: ComposerImageAttachment) => - retryAttachmentUpload({ environmentId, image }), + retryAttachmentUpload({ + environmentId, + image, + draftTarget: composerDraftTarget, + }), } : {})} onRemove={(annotationId) => { @@ -3404,7 +3805,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) size="icon-xs" className="absolute bottom-1 left-1 bg-background/85 hover:bg-background/95" onClick={() => - retryAttachmentUpload({ environmentId, image }) + retryAttachmentUpload({ + environmentId, + image, + draftTarget: composerDraftTarget, + }) } aria-label={`Retry upload for ${image.name}`} /> @@ -3435,6 +3840,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
)} + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerFiles.length > 0 && ( +
+ {composerFiles.map((file) => { + const fileCanUpload = + supportsAttachmentUploads && + maxFileAttachmentBytes !== null && + file.sizeBytes <= maxFileAttachmentBytes; + const upload = fileCanUpload ? uploadsByImageId[file.id] : undefined; + const needsReattach = composerFileNeedsReattach(file); + const canReattachFile = + fileStagingLimit !== null && file.sizeBytes <= fileStagingLimit; + return ( +
+ + {file.name} + + {needsReattach + ? canReattachFile + ? "Attach again" + : "Remove to send" + : upload?.status === "uploading" + ? formatAttachmentUploadProgress(upload.progress) + : formatAttachmentSize(file.sizeBytes)} + + {!needsReattach && upload?.status === "failed" ? ( + + + retryAttachmentUpload({ + environmentId, + image: file, + draftTarget: composerDraftTarget, + }) + } + aria-label={`Retry upload for ${file.name}`} + /> + } + > + + + + {upload.reason} + + + ) : null} + +
+ ); + })} +
+ )} +
+ {pendingUserInputs.length === 0 ? ( + <> + { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ""; + void addComposerAttachments(files); + focusComposer(); + }} + /> + + event.preventDefault()} + onClick={() => attachmentInputRef.current?.click()} + aria-label="Attach files" + /> + } + > + + + Attach files + + + ) : null} {showMobilePendingAnswerActions ? null : inlineTasksBadge} {showMobilePendingAnswerActions ? null : inlineStashBadge} { expect(markup).toContain("size-3.5 stroke-2"); expect(markup).not.toContain("bg-background/90"); }); + + it("labels mixed file and image stashes without treating images as files", () => { + const markup = renderToStaticMarkup( + {}} + onDelete={() => {}} + onClose={() => {}} + />, + ); + + expect(markup).toContain("(2 attachments)"); + expect(markup).toContain("size-3.5 text-secondary-label"); + expect(markup).not.toContain("(2 files)"); + }); }); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 9bc29f474..f719e563d 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -1,4 +1,4 @@ -import { XIcon } from "lucide-react"; +import { FileIcon, XIcon } from "lucide-react"; import { memo, useEffect, useRef, useState } from "react"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -20,7 +20,13 @@ function stashEntrySnippet(entry: PromptStashEntry): string { return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; } const imageCount = entry.attachments.length + entry.droppedImageNames.length; - return imageCount > 0 ? `(${imageCount} image${imageCount === 1 ? "" : "s"})` : "(empty)"; + const fileCount = entry.files?.length ?? 0; + const attachmentCount = imageCount + fileCount; + if (attachmentCount === 0) { + return "(empty)"; + } + const label = imageCount > 0 && fileCount > 0 ? "attachment" : fileCount > 0 ? "file" : "image"; + return `(${attachmentCount} ${label}${attachmentCount === 1 ? "" : "s"})`; } /** @@ -180,6 +186,12 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { ))} ) : null} + {(entry.files?.length ?? 0) > 0 ? ( + + + {entry.files!.length} + + ) : null} {formatRelativeTimeLabel(entry.createdAt)} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index de94a7cd3..4c7cf2996 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -558,6 +558,116 @@ describe("MessagesTimeline", () => { expect(onAnchorReady).not.toHaveBeenCalled(); }); + it("renders generic attachments as download links instead of image previews", () => { + const entry = { + ...buildUserTimelineEntry("Read the report."), + message: { + ...buildUserTimelineEntry("Read the report.").message, + attachments: [ + { + type: "file" as const, + id: "attachment-report-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + previewUrl: "https://environment.test/api/assets/report.pdf", + }, + ], + }, + }; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain( + '', + ); + expect(markup).not.toContain('alt="report.pdf"'); + }); + + it("renders a file download button without creating its URL in advance", () => { + const entry = { + ...buildUserTimelineEntry("Read the report."), + message: { + ...buildUserTimelineEntry("Read the report.").message, + attachments: [ + { + type: "file" as const, + id: "attachment-report-pdf", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + }, + ], + }, + }; + + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain( + ' + ); + })} + {unknownAttachments.map((attachment) => ( +
+ + {attachment.name} +
+ ))} +
+ ) : null} {elementContexts.length > 0 ? (
{elementContexts.map((context) => ( diff --git a/apps/web/src/components/chat/composerAttachmentFiles.test.ts b/apps/web/src/components/chat/composerAttachmentFiles.test.ts new file mode 100644 index 000000000..d650e0b10 --- /dev/null +++ b/apps/web/src/components/chat/composerAttachmentFiles.test.ts @@ -0,0 +1,280 @@ +import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; +import { + attachmentsToReleaseOnUploadCapabilityLoss, + classifyComposerAttachmentFile, + fileAttachmentCapabilityBlockReason, + fileAttachmentStagingLimit, + inferImageMimeTypeFromName, + normalizeComposerImageFileMimeType, + shouldHandleComposerAttachmentPaste, +} from "./composerAttachmentFiles"; + +describe("composer attachment files", () => { + it("keeps supported images and HEIC photos on the image path", () => { + expect(classifyComposerAttachmentFile({ name: "photo.png", type: "image/png" })).toBe("image"); + expect(classifyComposerAttachmentFile({ name: "photo.heic", type: "" })).toBe("image"); + }); + + it("rejects unsupported image types instead of attaching them as generic files", () => { + expect(classifyComposerAttachmentFile({ name: "diagram.svg", type: "image/svg+xml" })).toBe( + "unsupported-image", + ); + expect(classifyComposerAttachmentFile({ name: "photo.tiff", type: "image/tiff" })).toBe( + "unsupported-image", + ); + expect(classifyComposerAttachmentFile({ name: "report.pdf", type: "application/pdf" })).toBe( + "file", + ); + }); + + it("preserves text paste when an application adds a synthetic generic file", () => { + const file = new File(["clipboard"], "clipboard.rtf", { type: "application/rtf" }); + + expect( + shouldHandleComposerAttachmentPaste({ + files: [file], + plainText: "Copied text", + }), + ).toBe(false); + }); + + it("claims unsupported image pastes so the composer can report them", () => { + const images = [ + new File(["svg"], "diagram.svg", { type: "image/svg+xml" }), + new File(["tiff"], "photo.tiff", { type: "image/tiff" }), + ]; + + for (const image of images) { + expect( + shouldHandleComposerAttachmentPaste({ + files: [image], + plainText: "Image caption", + }), + ).toBe(true); + } + }); + + it("claims generic file-only pastes so the composer can report validation errors", () => { + const file = new File(["report"], "report.pdf", { type: "application/pdf" }); + + expect(shouldHandleComposerAttachmentPaste({ files: [file], plainText: "" })).toBe(true); + }); + + it("routes empty and oversized generic files to composer feedback", () => { + const empty = new File([], "empty.txt", { type: "text/plain" }); + const oversized = new File([new Uint8Array(1024)], "large.zip", { + type: "application/zip", + }); + + expect(shouldHandleComposerAttachmentPaste({ files: [empty], plainText: "" })).toBe(true); + expect(shouldHandleComposerAttachmentPaste({ files: [oversized], plainText: "" })).toBe(true); + }); + + it("ignores an empty clipboard", () => { + expect(shouldHandleComposerAttachmentPaste({ files: [], plainText: "" })).toBe(false); + }); + + it("falls back to the extension when an image arrives without a MIME type", () => { + expect(classifyComposerAttachmentFile({ name: "photo.jpg", type: "" })).toBe("image"); + expect(classifyComposerAttachmentFile({ name: "shot.PNG", type: "" })).toBe("image"); + expect(classifyComposerAttachmentFile({ name: "archive.zip", type: "" })).toBe("file"); + expect(classifyComposerAttachmentFile({ name: "no-extension", type: "" })).toBe("file"); + expect(inferImageMimeTypeFromName("photo.jpg")).toBe("image/jpeg"); + expect(inferImageMimeTypeFromName("archive.zip")).toBeNull(); + }); + + it("infers supported image types from octet-stream files", () => { + const jpeg = new File(["jpeg"], "photo.jpg", { type: "application/octet-stream" }); + const png = new File(["png"], "shot.PNG", { type: "application/octet-stream" }); + + expect(classifyComposerAttachmentFile(jpeg)).toBe("image"); + expect(classifyComposerAttachmentFile(png)).toBe("image"); + expect(normalizeComposerImageFileMimeType(jpeg).type).toBe("image/jpeg"); + expect(normalizeComposerImageFileMimeType(png).type).toBe("image/png"); + }); + + it("does not infer images for unknown extensions or specific conflicting MIME types", () => { + const binary = new File(["binary"], "archive.bin", { type: "application/octet-stream" }); + const unknownDocument = new File(["pdf"], "report.pdf", { + type: "application/octet-stream", + }); + const document = new File(["pdf"], "photo.jpg", { type: "application/pdf" }); + const explicitImage = new File(["png"], "photo.jpg", { type: "image/png" }); + + expect(classifyComposerAttachmentFile(binary)).toBe("file"); + expect(classifyComposerAttachmentFile(unknownDocument)).toBe("file"); + expect(classifyComposerAttachmentFile(document)).toBe("file"); + expect(classifyComposerAttachmentFile(explicitImage)).toBe("image"); + expect(normalizeComposerImageFileMimeType(binary)).toBe(binary); + expect(normalizeComposerImageFileMimeType(document)).toBe(document); + expect(normalizeComposerImageFileMimeType(explicitImage)).toBe(explicitImage); + }); + + it("uses the hard local limit while server config is unknown", () => { + expect( + fileAttachmentStagingLimit({ + attachmentUploadsCapabilityKnown: false, + supportsAttachmentUploads: false, + maxFileAttachmentBytes: null, + }), + ).toBe(PROVIDER_SEND_TURN_MAX_FILE_BYTES); + expect( + fileAttachmentCapabilityBlockReason({ + files: [{ name: "pending.zip", sizeBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }], + attachmentUploadsCapabilityKnown: false, + supportsAttachmentUploads: false, + maxFileAttachmentBytes: null, + }), + ).toBe("Waiting for the server before file attachments can send"); + }); + + it("rejects local staging and send when known config has no file support", () => { + const unsupportedReason = + "This server does not accept file attachments right now. Remove the files to send."; + expect( + fileAttachmentStagingLimit({ + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: true, + maxFileAttachmentBytes: null, + }), + ).toBeNull(); + expect( + fileAttachmentStagingLimit({ + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: false, + maxFileAttachmentBytes: 50 * 1024 * 1024, + }), + ).toBeNull(); + expect( + fileAttachmentCapabilityBlockReason({ + files: [{ name: "report.pdf", sizeBytes: 1024 }], + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: true, + maxFileAttachmentBytes: null, + }), + ).toBe(unsupportedReason); + expect( + fileAttachmentCapabilityBlockReason({ + files: [{ name: "report.pdf", sizeBytes: 1024 }], + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: false, + maxFileAttachmentBytes: 50 * 1024 * 1024, + }), + ).toBe(unsupportedReason); + }); + + it("blocks retained files that exceed a newly lower server limit", () => { + expect( + fileAttachmentCapabilityBlockReason({ + files: [{ name: "large.zip", sizeBytes: 2 * 1024 * 1024 }], + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: true, + maxFileAttachmentBytes: 1024 * 1024, + }), + ).toBe("'large.zip' exceeds the 1 MB attachment limit."); + }); + + it("uses the confirmed server limit without exceeding the hard cap", () => { + expect( + fileAttachmentStagingLimit({ + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: true, + maxFileAttachmentBytes: 1024 * 1024, + }), + ).toBe(1024 * 1024); + expect( + fileAttachmentStagingLimit({ + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: true, + maxFileAttachmentBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES * 2, + }), + ).toBe(PROVIDER_SEND_TURN_MAX_FILE_BYTES); + expect( + fileAttachmentCapabilityBlockReason({ + files: [{ name: "report.pdf", sizeBytes: 1024 }], + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: true, + maxFileAttachmentBytes: 50 * 1024 * 1024, + }), + ).toBeNull(); + }); + + it("does not block empty or image-only composers on legacy servers", () => { + expect( + fileAttachmentCapabilityBlockReason({ + files: [], + attachmentUploadsCapabilityKnown: true, + supportsAttachmentUploads: false, + maxFileAttachmentBytes: null, + }), + ).toBeNull(); + expect( + fileAttachmentCapabilityBlockReason({ + files: [], + attachmentUploadsCapabilityKnown: false, + supportsAttachmentUploads: false, + maxFileAttachmentBytes: null, + }), + ).toBeNull(); + }); + + it("keeps draft-persisted file uploads when the upload capability flips off", () => { + const environmentId = EnvironmentId.make("environment-1"); + const image: ComposerImageAttachment = { + type: "image", + id: "image-1", + name: "photo.png", + mimeType: "image/png", + sizeBytes: 3, + previewUrl: "blob:photo", + file: new File([new Uint8Array([1, 2, 3])], "photo.png", { type: "image/png" }), + }; + const uploadingFile: ComposerFileAttachment = { + type: "file", + id: "file-uploading", + name: "fresh.pdf", + mimeType: "application/pdf", + sizeBytes: 3, + file: new File([new Uint8Array([1, 2, 3])], "fresh.pdf", { type: "application/pdf" }), + }; + const hydratedFile: ComposerFileAttachment = { + type: "file", + id: "file-hydrated", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 3, + file: null, + uploadedAttachmentId: "pending-report-pdf", + uploadEnvironmentId: environmentId, + }; + const uploadedLocalFile: ComposerFileAttachment = { + ...uploadingFile, + id: "file-uploaded-local", + uploadedAttachmentId: "pending-fresh-pdf", + uploadEnvironmentId: environmentId, + }; + + const released = attachmentsToReleaseOnUploadCapabilityLoss([ + image, + uploadingFile, + hydratedFile, + uploadedLocalFile, + ]); + + expect(released.map((attachment) => attachment.id)).toEqual(["image-1", "file-uploading"]); + }); + + it("claims image pastes even when clipboard text is present", () => { + const image = new File(["image"], "photo.heic", { type: "image/heic" }); + + expect( + shouldHandleComposerAttachmentPaste({ + files: [image], + plainText: "Image caption", + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/composerAttachmentFiles.ts b/apps/web/src/components/chat/composerAttachmentFiles.ts new file mode 100644 index 000000000..58ca40508 --- /dev/null +++ b/apps/web/src/components/chat/composerAttachmentFiles.ts @@ -0,0 +1,153 @@ +import { + isProviderSendTurnSupportedImageMimeType, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, +} from "@t3tools/contracts"; +import { + clampFileAttachmentUploadBytes, + fileAttachmentTooLargeMessage, +} from "@t3tools/client-runtime/state/attachments"; + +import type { ComposerFileAttachment, ComposerImageAttachment } from "../../composerDraftStore"; +import { isHeicImageFile } from "../../lib/imageCompression"; + +type ComposerAttachmentFileKind = "image" | "file" | "unsupported-image"; + +interface FileAttachmentCapabilityState { + readonly attachmentUploadsCapabilityKnown: boolean; + readonly supportsAttachmentUploads: boolean; + readonly maxFileAttachmentBytes: number | null; +} + +const IMAGE_MIME_TYPE_BY_EXTENSION: Readonly> = { + gif: "image/gif", + jpeg: "image/jpeg", + jpg: "image/jpeg", + png: "image/png", + webp: "image/webp", +}; + +/** + * Some sources (drags from other apps, files piped through a shell) hand over + * a `File` with an empty or generic MIME type. Maps the extension to a + * provider-supported image type so a plain `photo.jpg` still lands on the + * image path; anything unrecognized stays a generic file. + */ +export function inferImageMimeTypeFromName(name: string): string | null { + const dotIndex = name.lastIndexOf("."); + if (dotIndex <= 0) { + return null; + } + return IMAGE_MIME_TYPE_BY_EXTENSION[name.slice(dotIndex + 1).toLowerCase()] ?? null; +} + +function inferImageMimeTypeForUnknownFile(file: Pick): string | null { + const mimeType = file.type.toLowerCase(); + if (mimeType !== "" && mimeType !== "application/octet-stream") { + return null; + } + return inferImageMimeTypeFromName(file.name); +} + +/** Give extension-recognized images a concrete type before compression. */ +export function normalizeComposerImageFileMimeType(file: File): File { + const inferredMimeType = inferImageMimeTypeForUnknownFile(file); + if (!inferredMimeType) { + return file; + } + return new File([file], file.name, { + type: inferredMimeType, + lastModified: file.lastModified, + }); +} + +export function classifyComposerAttachmentFile( + file: Pick, +): ComposerAttachmentFileKind { + if (isHeicImageFile(file)) { + return "image"; + } + if (inferImageMimeTypeForUnknownFile(file)) { + return "image"; + } + if (!file.type.toLowerCase().startsWith("image/")) { + return "file"; + } + return isProviderSendTurnSupportedImageMimeType(file.type) ? "image" : "unsupported-image"; +} + +/** Byte limit for adding a generic file to the local composer draft. */ +export function fileAttachmentStagingLimit(input: FileAttachmentCapabilityState): number | null { + if (!input.attachmentUploadsCapabilityKnown) { + return PROVIDER_SEND_TURN_MAX_FILE_BYTES; + } + if (!input.supportsAttachmentUploads || input.maxFileAttachmentBytes === null) { + return null; + } + return clampFileAttachmentUploadBytes(input.maxFileAttachmentBytes); +} + +/** Why retained generic files cannot send with the current server config. */ +export function fileAttachmentCapabilityBlockReason( + input: FileAttachmentCapabilityState & { + readonly files: ReadonlyArray<{ readonly name: string; readonly sizeBytes: number }>; + }, +): string | null { + if (input.files.length === 0) { + return null; + } + if (!input.attachmentUploadsCapabilityKnown) { + return "Waiting for the server before file attachments can send"; + } + const maxFileAttachmentBytes = fileAttachmentStagingLimit(input); + if (maxFileAttachmentBytes === null) { + return "This server does not accept file attachments right now. Remove the files to send."; + } + const oversizedFile = input.files.find((file) => file.sizeBytes > maxFileAttachmentBytes); + if (oversizedFile) { + return fileAttachmentTooLargeMessage(oversizedFile.name, maxFileAttachmentBytes); + } + return null; +} + +/** + * When `capabilities.attachmentUploads` flips off (reconnect, version skew), + * tear down only uploads that have not been persisted onto a draft file. + * Once `uploadedAttachmentId` is stamped, the draft references that server + * copy after reload even if its local `File` is still available in memory. + * Explicit attachment removal releases persisted uploads through + * `releaseDraftAttachment`. + */ +export function attachmentsToReleaseOnUploadCapabilityLoss( + attachments: ReadonlyArray, +): Array { + return attachments.filter( + (attachment) => !(attachment.type === "file" && attachment.uploadedAttachmentId !== undefined), + ); +} + +/** + * Whether a paste's files should be claimed as composer attachments instead of + * falling through to the default text paste. Deliberately no capacity or + * pending-plan-question gate here: `addComposerAttachments` owns those limits + * and reports them, while a gate at this layer would swallow the paste with no + * feedback. + */ +export function shouldHandleComposerAttachmentPaste(input: { + readonly files: ReadonlyArray; + readonly plainText: string; +}): boolean { + if ( + input.files.some((file) => { + const classification = classifyComposerAttachmentFile(file); + return classification === "image" || classification === "unsupported-image"; + }) + ) { + return true; + } + + if (input.plainText.length > 0) { + return false; + } + + return input.files.some((file) => classifyComposerAttachmentFile(file) === "file"); +} diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index ccbd1f065..10a7b7e66 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -3,7 +3,6 @@ import { CircleXIcon, EllipsisIcon, FileJsonIcon, - InfoIcon, MinusIcon, PlusIcon, SearchIcon, @@ -44,12 +43,12 @@ import { serverEnvironment, } from "../../state/server"; import { usePrimaryEnvironment } from "../../state/environments"; +import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd, KbdGroup } from "../ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; -import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; import { Toggle } from "../ui/toggle"; import { toastManager } from "../ui/toast"; @@ -69,17 +68,23 @@ import { unknownWhenVariables, whenAstToExpression, } from "./KeybindingsSettings.logic"; -import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { useAtomCommand } from "../../state/use-atom-command"; function KeybindingPill({ value }: { value: string }) { - const parts = value.split("+"); + // Keys dedupe repeated parts; a literal "+" in a shortcut splits into empty strings. + const seenParts = new Map(); + const parts = value.split("+").map((part) => { + const seen = seenParts.get(part) ?? 0; + seenParts.set(part, seen + 1); + return { part, key: seen === 0 ? part : `${part}-${seen}` }; + }); return ( - {parts.map((part) => ( - + {parts.map(({ part, key }) => ( + {part === "mod" ? navigator.platform.toLowerCase().includes("mac") ? "⌘" @@ -231,19 +236,18 @@ function defaultWhenGroup(operator: BooleanOperator = "and"): KeybindingWhenNode }; } -function UnknownWhenVariableWarning({ - identifiers, +/** Warning glyph whose explanation lives in a tooltip; the one owner of that affordance here. */ +function WarningTooltipIcon({ + label, focusable = true, + className, + children, }: { - identifiers: ReadonlyArray; + label: string; focusable?: boolean; + className?: string | undefined; + children: ReactNode; }) { - if (identifiers.length === 0) return null; - const label = - identifiers.length === 1 - ? `Unknown condition: ${identifiers[0]}` - : `Unknown conditions: ${identifiers.join(", ")}`; - return ( - - + className={cn( + "inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-warning outline-none transition-colors hover:bg-warning/10 focus-visible:ring-[3px] focus-visible:ring-warning/25", + className, + )} + /> } - /> + > + + - T3 Code does not recognize this condition yet. It can still be saved, but it may not match - unless the runtime provides it. + {children} ); } +function UnknownWhenVariableWarning({ + identifiers, + focusable = true, +}: { + identifiers: ReadonlyArray; + focusable?: boolean; +}) { + if (identifiers.length === 0) return null; + const label = + identifiers.length === 1 + ? `Unknown condition: ${identifiers[0]}` + : `Unknown conditions: ${identifiers.join(", ")}`; + + return ( + + T3 Code does not recognize this condition yet. It can still be saved, but it may not match + unless the runtime provides it. + + ); +} + function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray }) { if (labels.length === 0) return null; const description = @@ -273,22 +300,9 @@ function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray } : `Conflicts with ${labels.slice(0, 3).join(", ")}${labels.length > 3 ? ", and more" : ""}.`; return ( - - - - - } - /> - - {description} The most recent matching binding wins when both conditions can apply. - - + + {description} The most recent matching binding wins when both conditions can apply. + ); } @@ -733,32 +747,20 @@ function rowKeybindingTarget(row: KeybindingRow): ServerRemoveKeybindingInput { }; } -function KeybindingTableRow({ +/** Draft state and actions for editing one existing binding; layouts decide how to render it. */ +function useKeybindingRowEditor({ row, allRows, - variables, - isSaving, onSave, - onReset, - onRemove, }: { row: KeybindingRow; allRows: ReadonlyArray; - variables: ReadonlyArray; - isSaving: boolean; onSave: (input: ServerUpsertKeybindingInput) => void; - onReset: (row: KeybindingRow) => void; - onRemove: (row: KeybindingRow) => void; }) { const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft); const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; const whenDraftExpression = whenAstToExpression(whenDraft); const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; - const displayShortcut = formatShortcutLabel(row.binding.shortcut); - const canReset = row.source === "Custom" && row.defaultKey !== null; - const canRemove = row.source !== "Default"; - const hasRowActions = canReset || canRemove; - const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; const conflictLabels = keybindingConflictLabels(allRows, { rowId: row.id, key: keyDraft, @@ -786,139 +788,277 @@ function KeybindingTableRow({ setDraft({ keyDraft: next, isRecording: false }); }; + return { + keyDraft, + whenDraft, + isRecording, + isWhenDraftValid, + whenDraftExpression, + isDirty, + conflictLabels, + setDraft, + save, + captureKeybinding, + }; +} + +type KeybindingRowEditor = ReturnType; + +interface KeybindingRowActions { + allRows: ReadonlyArray; + variables: ReadonlyArray; + onSave: (input: ServerUpsertKeybindingInput) => void; + onReset: (row: KeybindingRow) => void; + onRemove: (row: KeybindingRow) => void; +} + +type KeybindingRowProps = KeybindingRowActions & { row: KeybindingRow; isSaving: boolean }; + +/** Shortcut pill that turns into a capture input when clicked, plus Save once the draft changes. */ +function KeybindingKeyControl({ + row, + editor, + isSaving, + pillClassName, +}: { + row: KeybindingRow; + editor: KeybindingRowEditor; + isSaving: boolean; + pillClassName?: string | undefined; +}) { + const { keyDraft, isRecording, isDirty, isWhenDraftValid, setDraft, save, captureKeybinding } = + editor; + const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; + return ( -
-
-
- - - } - > - {commandLabel(row.command)} - - {row.command} - -
-
-
- {showPill ? ( - - ) : ( - setDraft({ isRecording: true })} - onBlur={() => setDraft({ isRecording: false })} - onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} - onKeyDown={captureKeybinding} + <> + {isDirty ? ( + + ) : null} + {showPill ? ( + + ) : ( + setDraft({ isRecording: true })} + onBlur={() => setDraft({ isRecording: false })} + onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} + onKeyDown={captureKeybinding} + /> + )} + + ); +} + +/** Quiet inline trigger showing the when clause; opens the expression builder. */ +function WhenClauseControl({ + label, + expression, + value, + variables, + onChange, + onValidityChange, +}: { + label: string; + expression: string; + value: KeybindingWhenNode | undefined; + variables: ReadonlyArray; + onChange: (value: KeybindingWhenNode | undefined) => void; + onValidityChange: (valid: boolean) => void; +}) { + return ( + + - )} - {isDirty ? ( + } + aria-label={`Edit when clause for ${label}`} + > + {expression || "Always"} + + + + + + + ); +} + +function KeybindingRowMenu({ + row, + isSaving, + onReset, + onRemove, +}: { + row: KeybindingRow; + isSaving: boolean; + onReset: (row: KeybindingRow) => void; + onRemove: (row: KeybindingRow) => void; +}) { + const canReset = row.source === "Custom" && row.defaultKey !== null; + const canRemove = row.source !== "Default"; + if (!canReset && !canRemove) return null; + + return ( + + - {isSaving ? "Saving" : "Save"} - + type="button" + variant="ghost" + size="icon-sm" + className="size-7 text-muted-foreground hover:text-foreground sm:size-7" + disabled={isSaving} + aria-label={`Actions for ${commandLabel(row.command)}`} + /> + } + > + + + + {canReset ? ( + onReset(row)}> + Reset to default + ) : null} -
-
- - - {whenDraftExpression || "Always"} - - - - setDraft({ whenDraft: nextWhenDraft })} - onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })} - /> - - -
-
- - {hasRowActions ? ( - - - } - > - - - - {canReset ? ( - onReset(row)}> - Reset to default - - ) : null} - {canRemove ? ( - onRemove(row)}> - Remove - - ) : null} - - + {canRemove ? ( + onRemove(row)}> + Remove + ) : null} - {displayShortcut} -
-
+ + ); } -function NewKeybindingTableRow({ - commandOptions, - allRows, +function KeybindingSourceBadge({ source }: { source: KeybindingRow["source"] }) { + if (source === "Default") return null; + return ( + + {source} + + ); +} + +function KeybindingRowTitle({ row }: { row: KeybindingRow }) { + return ( + + }> + {commandLabel(row.command)} + + + {row.command} + + ); +} + +function KeybindingRowWhen({ + row, + editor, variables, - isSaving, - onSave, - onCancel, }: { - commandOptions: ReadonlyArray; - allRows: ReadonlyArray; + row: KeybindingRow; + editor: KeybindingRowEditor; variables: ReadonlyArray; +}) { + return ( + + When + editor.setDraft({ whenDraft })} + onValidityChange={(isWhenDraftValid) => editor.setDraft({ isWhenDraftValid })} + /> + + ); +} + +/** Row actions that stay hidden until the row is hovered or holds focus. */ +function KeybindingHoverRowMenu(props: { + row: KeybindingRow; isSaving: boolean; + onReset: (row: KeybindingRow) => void; + onRemove: (row: KeybindingRow) => void; +}) { + return ( + + + + ); +} + +/** One binding as a settings row: pills flush right, actions fading in beside them on hover. */ +function KeybindingSettingsRow(props: KeybindingRowProps) { + const { row, isSaving, allRows, variables, onSave, onReset, onRemove } = props; + const editor = useKeybindingRowEditor({ row, allRows, onSave }); + + return ( + } + description={} + control={ +
+ + + +
+ } + /> + ); +} + +/** Draft state for a binding that does not exist yet. */ +function useNewKeybindingDraft({ + allRows, + onSave, +}: { + allRows: ReadonlyArray; onSave: (input: ServerUpsertKeybindingInput) => void; - onCancel: () => void; }) { const [commandDraft, setCommandDraft] = useState(""); const [draft, setDraft] = useReducer(keybindingRowDraftReducer, { @@ -935,6 +1075,7 @@ function NewKeybindingTableRow({ when: whenDraftExpression, }); const commandLabelText = commandDraft ? commandLabel(commandDraft) : "new keybinding"; + const canSave = Boolean(commandDraft) && keyDraft.trim().length > 0 && isWhenDraftValid; const save = () => { if (!commandDraft) return; @@ -957,93 +1098,222 @@ function NewKeybindingTableRow({ setDraft({ keyDraft: next, isRecording: false }); }; + return { + commandDraft, + setCommandDraft, + keyDraft, + whenDraft, + whenDraftExpression, + isRecording, + conflictLabels, + commandLabelText, + canSave, + setDraft, + save, + captureKeybinding, + }; +} + +type NewKeybindingDraft = ReturnType; + +interface NewKeybindingProps { + commandOptions: ReadonlyArray; + allRows: ReadonlyArray; + variables: ReadonlyArray; + isSaving: boolean; + onSave: (input: ServerUpsertKeybindingInput) => void; + onCancel: () => void; +} + +function NewKeybindingCommandSelect({ + draft, + commandOptions, + className, +}: { + draft: NewKeybindingDraft; + commandOptions: ReadonlyArray; + className?: string | undefined; +}) { return ( -
-
- -
-
- setDraft({ isRecording: true })} - onBlur={() => setDraft({ isRecording: false })} - onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} - onKeyDown={captureKeybinding} + + ); +} + +function NewKeybindingKeyInput({ + draft, + autoFocus = false, + className, +}: { + draft: NewKeybindingDraft; + autoFocus?: boolean; + className?: string | undefined; +}) { + return ( + draft.setDraft({ isRecording: true })} + onBlur={() => draft.setDraft({ isRecording: false })} + onChange={(event) => draft.setDraft({ keyDraft: event.currentTarget.value })} + onKeyDown={draft.captureKeybinding} + /> + ); +} + +function NewKeybindingWhen({ + draft, + variables, +}: { + draft: NewKeybindingDraft; + variables: ReadonlyArray; +}) { + return ( + draft.setDraft({ whenDraft })} + onValidityChange={(isWhenDraftValid) => draft.setDraft({ isWhenDraftValid })} + /> + ); +} + +function NewKeybindingCancelIcon({ + isSaving, + onCancel, +}: { + isSaving: boolean; + onCancel: () => void; +}) { + return ( + + + } + > + + + Cancel + + ); +} + +/** Add-binding form shaped like the binding rows below it. */ +function NewKeybindingSettingsRow(props: NewKeybindingProps) { + const { commandOptions, allRows, variables, isSaving, onSave, onCancel } = props; + const draft = useNewKeybindingDraft({ allRows, onSave }); + + return ( + + When + + + } + control={ +
+ + + + + +
+ } + /> + ); +} + +interface KeybindingsListProps extends KeybindingRowActions { + rows: ReadonlyArray; + commandOptions: ReadonlyArray; + savingCommand: KeybindingCommand | null; + isAddingBinding: boolean; + onCancelAdd: () => void; +} + +/** The add-binding row, one settings row per binding, and the empty state. */ +function KeybindingsList(props: KeybindingsListProps) { + const { rows, commandOptions, savingCommand, isAddingBinding, onCancelAdd, ...rowActions } = + props; + const newProps: NewKeybindingProps = { + commandOptions, + allRows: rows, + variables: rowActions.variables, + isSaving: savingCommand !== null, + onSave: rowActions.onSave, + onCancel: onCancelAdd, + }; + return ( +
+ {isAddingBinding ? : null} + {rows.map((row) => ( + - -
-
- - - {whenDraftExpression || "Always"} - - - - setDraft({ whenDraft: nextWhenDraft })} - onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })} - /> - - -
-
- - - - } - > - - - Cancel - -
+ ))} + {rows.length === 0 && !isAddingBinding ? ( +
+ No keybindings match your search. +
+ ) : null} +
+ ); +} + +/** Shown in the browser build only; the desktop app receives every shortcut. */ +function BrowserKeybindingNotice() { + return ( +
+ + + Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app + for better keybinding support. +
); } @@ -1187,6 +1457,8 @@ export function KeybindingsSettingsPanel() { [saveKeybinding], ); + const cancelAdd = useCallback(() => setIsAddingBinding(false), []); + const bindingsCount = ( {rows.length + (isAddingBinding ? 1 : 0)}{" "} @@ -1194,8 +1466,21 @@ export function KeybindingsSettingsPanel() { ); + const listProps: KeybindingsListProps = { + rows, + allRows: rows, + commandOptions, + variables: whenVariables, + savingCommand, + isAddingBinding, + onCancelAdd: cancelAdd, + onSave: saveKeybinding, + onReset: resetKeybinding, + onRemove: removeKeybinding, + }; + return ( - + } > - {!isElectron ? ( -
- -

- Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop - app for better keybinding support. -

-
- ) : null} + {!isElectron ? : null} - -
-
Command
-
Keybinding
-
When
-
Status
-
-
- {isAddingBinding ? ( - setIsAddingBinding(false)} - /> - ) : null} - {rows.map((row) => ( - - ))} - {rows.length === 0 && !isAddingBinding ? ( -
- No keybindings match your search. -
- ) : null} -
-
+
); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index fa01e263a..dd9c89943 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -6,7 +6,7 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { AsyncResult } from "effect/unstable/reactivity"; import { deriveProjectGroupingOverrideKey, @@ -733,7 +733,10 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { return; } const projectRef = scopeProjectRef(member.environmentId, member.id); - releaseProjectDraftUploads(projectRef); + releaseProjectDraftUploads( + projectRef, + memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), + ); const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index 83ddc4c5a..ed62ff055 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -41,7 +41,7 @@ describe("deriveProviderModelsForDisplay", () => { ).toEqual(["server-model", "kept-custom"]); }); - it("shows a redacted provider email in Configuration", () => { + it("shows a redacted provider email in the editor header status line", () => { const instanceId = ProviderInstanceId.make("codex"); const driver = ProviderDriverKind.make("codex"); const liveProvider: ServerProvider = { @@ -75,9 +75,48 @@ describe("deriveProviderModelsForDisplay", () => { }), ); - expect(markup).toContain("Account email"); + expect(markup).toContain("Authenticated as"); expect(markup).toContain('aria-label="Toggle account email visibility"'); expect(markup).toContain("blur-[2px]"); expect(markup).not.toContain("developer@example.com"); }); + it("surfaces a failed probe message in both the list row and the editor", () => { + const instanceId = ProviderInstanceId.make("codex_work"); + const driver = ProviderDriverKind.make("codex"); + const message = + "Codex app-server provider probe failed: Cannot create Codex shadow home entry 'auth.json' because '/home/me/.codex-t3/work/auth.json' already exists and is not a symlink."; + const liveProvider: ServerProvider = { + instanceId, + driver, + enabled: true, + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + checkedAt: "2026-08-28T12:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + message, + }; + const props = { + instanceId, + instance: { driver }, + driverOption: undefined, + liveProvider, + onUpdate: () => undefined, + hiddenModels: [], + favoriteModels: [], + modelOrder: [], + onHiddenModelsChange: () => undefined, + onFavoriteModelsChange: () => undefined, + onModelOrderChange: () => undefined, + } as const; + + for (const mode of ["list", "editor"] as const) { + const markup = renderToStaticMarkup(createElement(ProviderInstanceCard, { ...props, mode })); + expect(markup).toContain("Unavailable"); + expect(markup).toContain("is not a symlink"); + } + }); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 4e2d2b7f4..a82cc2f4a 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -426,18 +426,26 @@ export function ProviderInstanceCard({ onRunUpdate, isUpdating = false, }: ProviderInstanceCardProps) { - const [activeTab, setActiveTab] = useState<"models" | "configuration">("configuration"); + const [activeTab, setActiveTab] = useState<"configuration" | "models">("configuration"); const enabled = resolveProviderInstanceEnabled(instance); - // A locally disabled provider stays neutral even if its last server status - // is stale. Enabled providers use the server status when one is available. + // A locally disabled provider reads "Disabled" with a muted dot even if its + // last server status is stale. Enabled providers use the server status. const statusKey: ProviderStatusKey = enabled ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning") : "disabled"; const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; - const rawSummary = getProviderSummary(liveProvider); - const summary = enabled ? rawSummary : { headline: "Disabled", detail: null }; - const authEmail = liveProvider?.auth.email; - const showEditorStatus = enabled && (statusKey === "warning" || statusKey === "error"); + const summary = enabled + ? getProviderSummary(liveProvider) + : { headline: "Disabled", detail: null }; + const authEmail = liveProvider?.auth.email?.trim(); + // The editor header folds the account email into the status line — + // "Authenticated as · " — with the email redacted until its + // reveal toggle is clicked. + const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated"; + const authLabel = + enabled && liveProvider?.auth.status === "authenticated" + ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null) + : null; const versionLabel = getProviderVersionLabel(liveProvider?.version); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); const updateCommand = versionAdvisory?.updateCommand ?? null; @@ -607,19 +615,34 @@ export function ProviderInstanceCard({ {versionLabel} ) : null; + // Healthy and disabled rows read fine from their text; only trouble gets a dot. + const statusDotNode = + statusKey === "warning" || statusKey === "error" ? ( + + ) : null; + const statusHeadlineNode = {summary.headline}; + // Trouble states carry the server's explanation (a failed probe, a shadow + // home entry that is not a symlink, a missing binary). Show it wherever the + // headline shows so the user can act without opening the editor. + const needsAttention = statusKey === "warning" || statusKey === "error"; + const statusLineClassName = + "flex min-w-0 flex-wrap items-center gap-x-1.5 text-[13px] leading-[1.45] text-muted-foreground/80"; + if (mode === "list") { return (
- {selected ? : null} - updateEnabled(Boolean(checked))} - aria-label={`Enable ${displayName}`} - /> + + updateEnabled(Boolean(checked))} + aria-label={`Enable ${displayName}`} + /> +
); } return (
-
+
{titleHeadNode} {versionCodeNode} - {versionAdvisory ? ( - - - - - } - /> - -
-
-

- Update available -

-

+ {versionAdvisory ? ( + + - {versionAdvisory.detail} -

-
- {onRunUpdate ? ( - - ) : null} - {onRunUpdate && updateCommand ? ( -
- - or, update manually using - -
- ) : null} - {updateCommand ? ( -
- - - {updateCommand} - - - - - copyToClipboard(updateCommand, { - providerName: displayName, - }) - } - aria-label="Copy update command" - > - - - } - /> - Copy command - + } + /> + +
+
+

+ Update available +

+

+ {versionAdvisory.detail} +

- ) : null} -
-
- - ) : null} - {titleTailNode} + {onRunUpdate ? ( + + ) : null} + {onRunUpdate && updateCommand ? ( +
+ + or, update manually using + +
+ ) : null} + {updateCommand ? ( +
+ + + {updateCommand} + + + + + copyToClipboard(updateCommand, { + providerName: displayName, + }) + } + aria-label="Copy update command" + > + + + } + /> + Copy command + +
+ ) : null} +
+ + + ) : null} + {titleTailNode} +
- {showEditorStatus ? ( -

- {summary.headline} - {summary.detail ? · {summary.detail} : null} +

+ {statusDotNode} + {isAuthenticated && authEmail ? ( + <> + Authenticated as + + {authLabel ? · {authLabel} : null} + + ) : ( + statusHeadlineNode + )} + {summary.detail && !needsAttention ? · {summary.detail} : null} +

+ {summary.detail && needsAttention ? ( +

+ {summary.detail}

) : null}
+ {driverOption !== undefined ? (
-
-
+
+ {driverOption !== undefined ? ( -