From d9c1732b2791f762bce06e4898cdb6679ce24961 Mon Sep 17 00:00:00 2001 From: Rishet11 <154429365+Rishet11@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:37:26 +0530 Subject: [PATCH 01/91] fix(desktop): keep tailscale spawn defects from breaking advertised endpoints (#7116) --- packages/tailscale/src/tailscale.test.ts | 39 ++++++++++++++++++++++++ packages/tailscale/src/tailscale.ts | 25 +++++++++------ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 24c22454d9de..09d9def21066 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -212,6 +212,45 @@ describe("tailscale", () => { }); }); + it.effect("turns spawn defects into typed spawn failures", () => { + // A non-directory entry on PATH makes node's spawn throw ENOTDIR + // synchronously. The platform spawner calls `NodeChildProcess.spawn` from + // inside an `Effect.callback` registration, so that throw arrives as a + // defect rather than a typed error - the shape reproduced here. + const defect = Object.assign(new Error("spawn tailscale ENOTDIR"), { code: "ENOTDIR" }); + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.callback(() => { + throw defect; + }), + ), + ); + + return Effect.gen(function* () { + const statusError = yield* readTailscaleStatus.pipe(Effect.flip, Effect.provide(layer)); + assert.instanceOf(statusError, TailscaleCommandSpawnError); + assert.equal(statusError.subcommand, "status"); + assert.strictEqual(statusError.cause, defect); + + const serveError = yield* ensureTailscaleServe({ localPort: 13773, servePort: 8443 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + assert.instanceOf(serveError, TailscaleCommandSpawnError); + assert.equal(serveError.subcommand, "serve"); + assert.strictEqual(serveError.cause, defect); + + // What callers actually rely on: the desktop endpoint providers recover + // with `Effect.orElseSucceed`, which only sees the typed error channel. + const degraded = yield* readTailscaleStatus.pipe( + Effect.orElseSucceed(() => null), + Effect.provide(layer), + ); + assert.equal(degraded, null); + }); + }); + it.effect("keeps nonzero exit diagnostics structured", () => { const layer = mockSpawnerLayer(() => ({ code: 7, diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 7260a9de11bb..fedde02ee76f 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -228,11 +228,15 @@ export const readTailscaleStatus = Effect.gen(function* () { argumentCount: args.length, }; return yield* Effect.gen(function* () { - const child = yield* spawner - .spawn(ChildProcess.make(executable, args)) - .pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - ); + const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + // Spawning can also fail as a defect rather than a typed error - a + // non-directory entry on PATH makes node throw ENOTDIR synchronously. + // `mapError` never sees that, so it would escape as an uncaught error. + Effect.catchDefect((cause) => + Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), + ), + ); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStdout(child.stdout), @@ -299,11 +303,12 @@ const runTailscaleCommand = ( }; const timeout = Duration.fromInputUnsafe(timeoutInput); return yield* Effect.gen(function* () { - const child = yield* spawner - .spawn(ChildProcess.make(executable, args)) - .pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - ); + const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + Effect.catchDefect((cause) => + Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), + ), + ); const [stderr, exitCode] = yield* Effect.all( [collectStderr(child.stderr), child.exitCode.pipe(Effect.map(Number))], { concurrency: "unbounded" }, From dedcd99a9d16240327ce763b885b326aff607bdb Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 22 Aug 2026 13:07:56 -0700 Subject: [PATCH 02/91] fix(web): keep Codex service tier labels readable (#4503) --- .../src/components/chat/TraitsPicker.test.ts | 49 ++++++++++++++----- apps/web/src/components/chat/TraitsPicker.tsx | 21 ++++---- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/TraitsPicker.test.ts b/apps/web/src/components/chat/TraitsPicker.test.ts index b457f515ff77..7f98554a2708 100644 --- a/apps/web/src/components/chat/TraitsPicker.test.ts +++ b/apps/web/src/components/chat/TraitsPicker.test.ts @@ -16,6 +16,22 @@ function fastModeDescriptor( return { id: "fastMode", label: "Fast Mode", type: "boolean", currentValue }; } +function serviceTierDescriptor( + currentValue: "default" | "priority" | "flex", +): Extract { + return { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + { id: "flex", label: "Flex" }, + ], + currentValue, + }; +} + const EFFORT = selectDescriptor( "reasoningEffort", [ @@ -59,26 +75,35 @@ describe("buildTraitsTriggerDisplay", () => { }); }); - it("renders Codex's Standard and Fast service tiers as fast mode", () => { - const serviceTier = selectDescriptor( - "serviceTier", - [ - { id: "default", label: "Standard", isDefault: true }, - { id: "priority", label: "Fast" }, - ], - "default", - ); - - expect(display([EFFORT, serviceTier])).toEqual({ + it("treats Codex standard and fast service tiers as fast mode states", () => { + expect(display([EFFORT, serviceTierDescriptor("default")])).toEqual({ label: "High", showFastModeIcon: false, }); - expect(display([EFFORT, { ...serviceTier, currentValue: "priority" }])).toEqual({ + expect(display([EFFORT, serviceTierDescriptor("priority")])).toEqual({ label: "High", showFastModeIcon: true, }); }); + it("keeps other Codex service tiers in the label", () => { + expect(display([EFFORT, serviceTierDescriptor("flex")])).toEqual({ + label: "High · Flex", + showFastModeIcon: false, + }); + }); + + it("keeps the Codex service tier readable when it is the only trait", () => { + expect(display([serviceTierDescriptor("default")])).toEqual({ + label: "Standard", + showFastModeIcon: false, + }); + expect(display([serviceTierDescriptor("priority")])).toEqual({ + label: "Fast", + showFastModeIcon: false, + }); + }); + it("keeps non-fastMode booleans as text labels", () => { const thinking: Extract = { id: "thinking", diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 670982c52145..32a797286ed9 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -396,10 +396,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ /** * Build the traits trigger's text label plus whether the fast-mode bolt should - * render. Fast mode is a lightning bolt when on and nothing at all when off — - * "Normal" is the near-universal case and isn't worth the horizontal space. The - * one exception is when fast mode is the only trait, where a bare bolt (or bare - * chevron) would leave the trigger unreadable. + * render. Claude and Cursor expose fast mode as a boolean, while Codex exposes + * it through the Standard/Fast service tiers. In either form, fast mode is a + * lightning bolt when on and nothing at all when off. The one exception is when + * fast mode is the only trait, where a bare bolt (or bare chevron) would leave + * the trigger unreadable. */ export function buildTraitsTriggerDisplay(input: { provider: ProviderDriverKind; @@ -407,13 +408,13 @@ export function buildTraitsTriggerDisplay(input: { primarySelectDescriptorId: string | null; ultrathinkPromptControlled: boolean; }): { label: string; showFastModeIcon: boolean } { - let hasFastMode = false; + let fastModeFallbackLabel: string | null = null; let fastModeEnabled = false; const labels: Array = []; for (const descriptor of input.descriptors) { if (descriptor.id === "fastMode" && descriptor.type === "boolean") { - hasFastMode = true; fastModeEnabled = descriptor.currentValue === true; + fastModeFallbackLabel = fastModeEnabled ? "Fast" : "Normal"; continue; } if ( @@ -424,8 +425,10 @@ export function buildTraitsTriggerDisplay(input: { const currentValue = getProviderOptionCurrentValue(descriptor); const fastTier = descriptor.options.find(({ label }) => label === "Fast"); if (fastTier && (currentValue === "default" || currentValue === fastTier.id)) { - hasFastMode = true; fastModeEnabled = currentValue === fastTier.id; + fastModeFallbackLabel = + descriptor.options.find(({ id }) => id === currentValue)?.label ?? + (fastModeEnabled ? "Fast" : "Normal"); continue; } } @@ -443,8 +446,8 @@ export function buildTraitsTriggerDisplay(input: { // Only fall back to text when fast mode is genuinely the sole trait. Keying // off an empty label list alone would also catch descriptors that resolved to // no label at all, printing a bogus "Normal" for a model without fast mode. - if (labels.length === 0 && hasFastMode) { - return { label: fastModeEnabled ? "Fast" : "Normal", showFastModeIcon: false }; + if (labels.length === 0 && fastModeFallbackLabel !== null) { + return { label: fastModeFallbackLabel, showFastModeIcon: false }; } return { label: labels.join(" · "), showFastModeIcon: fastModeEnabled }; } From 77c9d1eb5b6a48d38f6f0a2bb4a8cff8e4752ade Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:51:59 -0700 Subject: [PATCH 03/91] fix: render workspace images in chat markdown (#6433) --- .../src/NativeMarkdownBlock.ios.tsx | 18 ++- .../src/SelectableMarkdownText.ios.tsx | 71 +++++---- .../src/SelectableMarkdownText.tsx | 2 + .../src/SelectableMarkdownText.types.ts | 15 ++ .../src/features/threads/ThreadFeed.tsx | 141 +++++++++++++++++- .../src/native/SelectableMarkdownText.ios.tsx | 2 + .../src/native/SelectableMarkdownText.tsx | 2 + apps/mobile/src/state/assets.ts | 25 +++- apps/web/src/components/ChatMarkdown.tsx | 119 ++++++++++++++- .../ChatMarkdown.workspace-images.test.tsx | 135 +++++++++++++++++ apps/web/src/markdown-links.test.ts | 24 +++ apps/web/src/markdown-links.ts | 5 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/markdownImages.test.ts | 62 ++++++++ packages/client-runtime/src/markdownImages.ts | 98 ++++++++++++ 15 files changed, 678 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.workspace-images.test.tsx create mode 100644 packages/client-runtime/src/markdownImages.test.ts create mode 100644 packages/client-runtime/src/markdownImages.ts diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 5fbe6d4dff44..b0934e873a7b 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; import type { MarkdownNode } from "react-native-nitro-markdown/headless"; @@ -9,10 +9,14 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios import type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "./SelectableMarkdownText.types"; +/** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ +export const MarkdownImageRendererContext = createContext(null); + type HighlightedCode = ReadonlyArray>; const highlightedCodeCache = new Map(); @@ -379,6 +383,7 @@ function NativeMarkdownImage(props: { readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { + const renderImage = useContext(MarkdownImageRendererContext); const href = props.node.href; if (!href) { return ( @@ -391,6 +396,17 @@ function NativeMarkdownImage(props: { ); } + if (renderImage) { + const rendered = renderImage({ + href, + alt: props.node.alt ?? null, + title: props.node.title ?? null, + }); + if (rendered != null) { + return <>{rendered}; + } + } + return ( = []; export type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, @@ -36,6 +38,7 @@ export function SelectableMarkdownText({ highlightCode, preserveSoftBreaks = false, onLinkPress, + renderImage, marginTop = 0, marginBottom = 0, }: SelectableMarkdownTextProps) { @@ -59,38 +62,40 @@ export function SelectableMarkdownText({ }, [markdown, preserveSoftBreaks, skills]); return ( - // A percentage width here creates a cyclic intrinsic measurement inside - // shrink-to-fit containers such as user-message bubbles. Yoga then gives - // the native text node an unbounded second pass and the parent only clips - // the resulting single-line width instead of reflowing it. - - {chunks.map((chunk, index) => { - const content = - chunk.kind === "rich" ? ( - - ) : ( - - ); + + {/* A percentage width here creates a cyclic intrinsic measurement inside + shrink-to-fit containers such as user-message bubbles. Yoga then gives + the native text node an unbounded second pass and the parent only clips + the resulting single-line width instead of reflowing it. */} + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); - return ( - - {content} - - ); - })} - + return ( + + {content} + + ); + })} + + ); } diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx index fcb2472f6488..006d33e7259d 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types export type { MarkdownCodeHighlighter, MarkdownHighlightedToken, + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, SelectableMarkdownTextProps, diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts index 42cc3cd6fb63..00260b0c4f27 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -36,6 +36,20 @@ export interface SelectableMarkdownSkill { readonly displayName?: string | null; } +export interface MarkdownImageRequest { + readonly href: string; + readonly alt: string | null; + readonly title: string | null; +} + +/** + * App-supplied renderer for markdown images. The module cannot load + * workspace-relative image paths itself — the host app resolves them (for + * example through a signed asset URL) and returns the element to show. + * Returning null falls back to the module's plain remote-URI rendering. + */ +export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; + export interface SelectableMarkdownTextProps { readonly markdown: string; readonly textStyle: NativeMarkdownTextStyle; @@ -43,6 +57,7 @@ export interface SelectableMarkdownTextProps { readonly skills?: ReadonlyArray; readonly preserveSoftBreaks?: boolean; readonly onLinkPress?: (href: string) => void; + readonly renderImage?: MarkdownImageRenderer; readonly marginTop?: number; readonly marginBottom?: number; } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d3aa65673bbb..145737523514 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -2,6 +2,7 @@ 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 { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import { SymbolView } from "../../components/AppSymbol"; @@ -54,6 +55,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, + type MarkdownImageRenderer, type NativeMarkdownTextStyle, type SelectableMarkdownSkill, } from "../../native/SelectableMarkdownText"; @@ -101,7 +103,7 @@ import { WORK_GROUP_TOGGLE_HEIGHT, } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; -import { useAssetUrl } from "../../state/assets"; +import { useAssetUrl, useAssetUrlState } from "../../state/assets"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { @@ -194,6 +196,98 @@ function MessageAttachmentImage(props: { ); } +/** Markdown image whose src is a workspace file — loads through a signed asset URL. */ +function ThreadMarkdownImage(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly path: string; + readonly alt: string | null; + readonly onPressImage: (uri: string) => void; +}) { + const codeBackground = useThemeColor("--color-md-code-bg"); + const [failedUri, setFailedUri] = useState(null); + const assetUrl = useAssetUrlState(props.environmentId, { + _tag: "workspace-file", + threadId: props.threadId, + path: props.path, + }); + const uri = assetUrl._tag === "Success" ? assetUrl.url : null; + const failed = assetUrl._tag === "Failure" || (uri !== null && failedUri === uri); + + return ( + + {uri === null || failed ? ( + + {failed ? ( + Image unavailable + ) : ( + + )} + + ) : ( + props.onPressImage(uri)} + > + setFailedUri(uri)} + style={{ + width: "100%", + aspectRatio: 16 / 9, + borderRadius: 10, + backgroundColor: codeBackground, + }} + /> + + )} + {props.alt ? ( + + {props.alt} + + ) : null} + + ); +} + +function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { + const codeBackground = useThemeColor("--color-md-code-bg"); + return ( + + + Image unavailable + + {props.alt ? ( + + {props.alt} + + ) : null} + + ); +} + const MARKDOWN_MONO_FONT = Platform.select({ ios: "ui-monospace", android: "monospace", @@ -409,7 +503,10 @@ function useReviewCommentColors(): ReviewCommentColors { ); } -function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { +function useMarkdownStyles( + onLinkPress: (href: string) => void, + renderImage: MarkdownImageRenderer, +): MarkdownStyleSets { const { appearance, themeAppearance } = useAppearancePreferences(); const markdownFontSizes = useMemo( () => resolveMarkdownFontSizes(appearance.baseFontSize), @@ -614,6 +711,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe })} ), + image: ({ node }) => + node.href + ? (renderImage({ + href: node.href, + alt: node.alt ?? null, + title: node.title ?? null, + }) ?? undefined) + : undefined, code_inline: ({ content }) => { const value = content ?? ""; return ( @@ -787,6 +892,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe nativeMarkdownTypography, onLinkPress, regularFontFamily, + renderImage, themeMode, userBubbleForegroundMuted, userBubbleSkillForeground, @@ -806,6 +912,7 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: string) => void; + readonly renderMarkdownImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -904,6 +1011,7 @@ function renderFeedEntry( reviewCommentColors={props.reviewCommentColors} skills={props.skills} onLinkPress={props.onMarkdownLinkPress} + renderImage={props.renderMarkdownImage} /> ) : null} {attachments.map((attachment) => { @@ -955,6 +1063,7 @@ function renderFeedEntry( skills={props.skills} textStyle={styles.nativeTextStyle} onLinkPress={props.onMarkdownLinkPress} + renderImage={props.renderMarkdownImage} /> ) : ( ; readonly onLinkPress: (href: string) => void; + readonly renderImage: MarkdownImageRenderer; }) { const segments = parseReviewCommentMessageSegments(props.text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); @@ -1052,6 +1162,7 @@ function UserMessageContent(props: { textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks onLinkPress={props.onLinkPress} + renderImage={props.renderImage} /> ); } @@ -1093,6 +1204,7 @@ function UserMessageContent(props: { textStyle={props.markdownStyles.nativeTextStyle} preserveSoftBreaks onLinkPress={props.onLinkPress} + renderImage={props.renderImage} /> ) : ( ( + (image) => { + const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); + if (imageSource._tag === "Direct") { + return null; + } + if (imageSource._tag === "Blocked") { + return ; + } + return ( + setExpandedImage({ uri })} + /> + ); + }, + [props.environmentId, props.threadId, props.workspaceRoot], + ); + const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); const reviewCommentColors = useReviewCommentColors(); // LegendList does not invalidate visible rows when only the renderItem closure changes. // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. @@ -1805,6 +1938,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressImage, onMarkdownLinkPress, + renderMarkdownImage, iconSubtleColor, userBubbleColor, markdownStyles, @@ -1832,6 +1966,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkRow, props.environmentId, props.skills, + renderMarkdownImage, ], ); diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx index 488766f36954..7c2c037eed33 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter" type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "@t3tools/mobile-markdown-text/types"; diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx index 403f32a1de48..7ee4d21b1560 100644 --- a/apps/mobile/src/native/SelectableMarkdownText.tsx +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/ type MobileSelectableMarkdownTextProps = Omit; export type { + MarkdownImageRenderer, + MarkdownImageRequest, NativeMarkdownTextStyle, SelectableMarkdownSkill, } from "@t3tools/mobile-markdown-text/types"; diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index b8b827585ea2..611a1ed8b99b 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -12,18 +12,35 @@ const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial(false)) Atom.withLabel("mobile-asset-url:empty"), ); -export function useAssetUrl( +export type AssetUrlState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Failure" } + | { readonly _tag: "Success"; readonly url: string }; + +export function useAssetUrlState( environmentId: EnvironmentId | null, resource: AssetResource | null, -): string | null { +): AssetUrlState { const preparedConnection = usePreparedConnection(environmentId); const result = useAtomValue( environmentId === null || resource === null ? EMPTY_ASSET_URL_ATOM : assetEnvironment.createUrl({ environmentId, input: { resource } }), ); + if (result._tag === "Failure") { + return { _tag: "Failure" }; + } if (preparedConnection._tag === "None" || result._tag !== "Success") { - return null; + return { _tag: "Loading" }; } - return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); + return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; +} + +export function useAssetUrl( + environmentId: EnvironmentId | null, + resource: AssetResource | null, +): string | null { + const state = useAssetUrlState(environmentId, resource); + return state._tag === "Success" ? state.url : null; } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 81f901d7f015..a157cd6e329b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -19,6 +19,7 @@ import { squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import React, { @@ -83,6 +84,7 @@ import { type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; +import { useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { useRightPanelStore } from "../rightPanelStore"; import { useActiveEnvironmentId } from "../state/entities"; @@ -179,6 +181,36 @@ export function orderedListGutterStyle( return { "--list-gutter": `${markerWidth + 1}ch` }; } +type MarkdownHtmlAstNode = { + type?: string; + tagName?: string; + properties?: Record; + children?: MarkdownHtmlAstNode[]; +}; + +/** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */ +function rehypeNormalizeWindowsImageSrc() { + return (tree: MarkdownHtmlAstNode) => { + const visit = (node: MarkdownHtmlAstNode) => { + const src = node.properties?.src; + if ( + node.type === "element" && + node.tagName === "img" && + typeof src === "string" && + /^[A-Za-z]:[\\/]/.test(src) + ) { + node.properties = { + ...node.properties, + src: `file:///${src.replaceAll("\\", "/")}`, + }; + } + node.children?.forEach(visit); + }; + + visit(tree); + }; +} + const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { @@ -190,6 +222,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { protocols: { ...defaultSchema.protocols, href: [...(defaultSchema.protocols?.href ?? []), "file"], + src: [...(defaultSchema.protocols?.src ?? []), "file"], }, } satisfies Parameters[0]; @@ -212,6 +245,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, + rehypeNormalizeWindowsImageSrc, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; @@ -943,6 +977,62 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: ); }); +const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = + "h-auto w-auto max-h-[30rem] max-w-[min(100%,30rem)] object-contain"; + +// block! outranks the unlayered `.chat-markdown img { display: inline-block }` +// rule, keeping workspace images on the same block layout as their placeholder. +const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( + CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, + "my-1 block! rounded-lg border border-border/40", +); + +function ChatMarkdownImageFallback(props: { readonly alt: string }) { + return ( + + + {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} + + ); +} + +/** Markdown images whose src is a workspace file path load through a signed asset URL. */ +const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: { + readonly threadRef: ScopedThreadRef; + readonly path: string; + readonly alt: string; +}) { + const assetUrl = useAssetUrlState(props.threadRef.environmentId, { + _tag: "workspace-file", + threadId: props.threadRef.threadId, + path: props.path, + }); + const [failedUrl, setFailedUrl] = useState(null); + + if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { + return ; + } + if (assetUrl._tag !== "Success") { + return ( + + ); + } + return ( + {props.alt} setFailedUrl(assetUrl.url)} + /> + ); +}); + function leadingExternalLinkTextLength(text: string): number { const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; if (protocol) return protocol.length; @@ -1712,9 +1802,6 @@ function ChatMarkdown({ props.className, ); }, - img({ node: _node, title: _title, ...props }) { - return ; - }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); @@ -1731,6 +1818,32 @@ function ChatMarkdown({ ); }, + img({ node: _node, title: _title, src, alt, ...props }) { + const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : ""; + const altText = alt ?? ""; + const imageSource = classifyMarkdownImageSource(srcString, cwd); + if (imageSource._tag === "Direct") { + return ( + {altText} + ); + } + if (imageSource._tag === "WorkspaceFile" && threadRef) { + return ( + + ); + } + return ; + }, table({ node: _node, ...props }) { return ; }, diff --git a/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx new file mode 100644 index 000000000000..0b7838afa86e --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.workspace-images.test.tsx @@ -0,0 +1,135 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => ({ + resources: [] as Array, + assetState: "success" as "success" | "loading", +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); +vi.mock("../assets/assetUrls", () => ({ + useAssetUrlState: (_environmentId: unknown, resource: unknown) => { + testState.resources.push(resource); + return testState.assetState === "loading" + ? { _tag: "Loading" } + : { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; + }, +})); +vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../state/session", async (importOriginal) => ({ + ...(await importOriginal()), + usePreparedConnection: () => ({ _tag: "Loading" }), +})); +vi.mock("../state/entities", () => ({ + useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), +})); +vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); +vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() })); + +import ChatMarkdown from "./ChatMarkdown"; + +const threadRef = { + environmentId: EnvironmentId.make("env-windows"), + threadId: ThreadId.make("thread-windows"), +}; + +function render(markdown: string): string { + return renderToStaticMarkup( + , + ); +} + +function renderWithoutThread(markdown: string): string { + return renderToStaticMarkup(); +} + +describe("ChatMarkdown workspace images", () => { + beforeEach(() => { + testState.resources = []; + testState.assetState = "success"; + }); + + it("loads every Windows workspace path form through a signed asset URL", () => { + const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg"; + const html = render( + [ + "![relative](.t3/workspace-image.svg)", + `![absolute](${imagePath})`, + `![file URL](file:///${imagePath})`, + "![UNC file URL](file://server/share/workspace-image.svg)", + ].join("\n\n"), + ); + + expect(testState.resources).toEqual([ + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", + }, + { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "\\\\server\\share\\workspace-image.svg", + }, + ]); + expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4); + expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4); + expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4); + expect(html).not.toContain("Image unavailable"); + }); + + it("normalizes a drive-absolute src in raw image HTML", () => { + const html = render(String.raw`raw`); + + expect(testState.resources).toEqual([ + { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "D:/screens/workspace-image.svg", + }, + ]); + expect(html).toContain("https://signed.test/workspace-image.svg"); + }); + + it("uses a static placeholder while a signed asset URL loads", () => { + testState.assetState = "loading"; + + const html = render("![loading](.t3/workspace-image.svg)"); + + expect(html).toContain('aria-label="Loading image"'); + expect(html).not.toContain("animate-pulse"); + }); + + it("never passes a workspace source to a raw image when thread context is unavailable", () => { + const html = renderWithoutThread( + "![file URL](file:///C:/Users/shawn/project/workspace-image.svg)", + ); + + expect(testState.resources).toEqual([]); + expect(html).toContain("Image unavailable"); + expect(html).not.toContain("file://"); + }); + + it("blocks unsupported image schemes instead of passing them to a raw image", () => { + const html = render("![unsupported](content://media/image/1)"); + + expect(testState.resources).toEqual([]); + expect(html).toContain("Image unavailable"); + expect(html).not.toContain("content://"); + }); + + it("keeps remote images directly loadable", () => { + const html = render("![remote](https://example.com/image.png)"); + + expect(testState.resources).toEqual([]); + expect(html).toContain('src="https://example.com/image.png"'); + expect(html).toContain("max-w-[min(100%,30rem)]"); + expect(html).toContain("max-h-[30rem]"); + expect(html).not.toContain("Image unavailable"); + }); +}); diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index af88a5e76827..a1d1094bb8d1 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -90,6 +90,18 @@ describe("rewriteMarkdownFileUriHref", () => { ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69"); }); + it("preserves file uri authorities as windows UNC paths", () => { + expect(rewriteMarkdownFileUriHref("file://server/share/workspace-image.svg")).toBe( + "\\\\server\\share\\workspace-image.svg", + ); + }); + + it("treats a localhost file uri as a local path", () => { + expect(rewriteMarkdownFileUriHref("file://localhost/home/me/notes.md")).toBe( + "/home/me/notes.md", + ); + }); + it("unwraps angle-bracketed file uri hrefs", () => { expect( rewriteMarkdownFileUriHref(" "), @@ -138,6 +150,18 @@ describe("resolveMarkdownFileLinkTarget", () => { ); }); + it("resolves file uri authorities as windows UNC paths", () => { + expect(resolveMarkdownFileLinkTarget("file://server/share/workspace-image.svg")).toBe( + "\\\\server\\share\\workspace-image.svg", + ); + }); + + it("resolves a localhost file uri as a local path", () => { + expect(resolveMarkdownFileLinkTarget("file://localhost/home/me/notes.md")).toBe( + "/home/me/notes.md", + ); + }); + it("formats tooltip display paths relative to the cwd when possible", () => { expect( resolveMarkdownFileLinkMeta( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index e1fb73edac5e..6ba2c78e13fb 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -108,7 +108,10 @@ function parseFileUrlHref( const parsed = new URL(href); if (parsed.protocol.toLowerCase() !== "file:") return null; - const rawPath = parsed.pathname; + const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; + const rawPath = uncHostname + ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` + : parsed.pathname; if (rawPath.length === 0) return null; // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f75be5bc44bb..abed33998966 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -15,6 +15,10 @@ "types": "./src/environment/index.ts", "default": "./src/environment/index.ts" }, + "./markdown-images": { + "types": "./src/markdownImages.ts", + "default": "./src/markdownImages.ts" + }, "./errors": { "types": "./src/errors/index.ts", "default": "./src/errors/index.ts" diff --git a/packages/client-runtime/src/markdownImages.test.ts b/packages/client-runtime/src/markdownImages.test.ts new file mode 100644 index 000000000000..a4160c3da4c1 --- /dev/null +++ b/packages/client-runtime/src/markdownImages.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { classifyMarkdownImageSource } from "./markdownImages.js"; + +describe("classifyMarkdownImageSource", () => { + it.each([ + "https://example.com/image.png", + "HTTP://example.com/image.png", + "data:image/png;base64,AAAA", + "blob:https://app.t3.codes/image-id", + "//cdn.example.com/image.png", + ])("keeps %s directly loadable", (uri) => { + expect(classifyMarkdownImageSource(uri, "/workspace/project")).toEqual({ + _tag: "Direct", + uri, + }); + }); + + it.each([ + ["images/result.png", "/workspace/project", "/workspace/project/images/result.png"], + ["./images/result.png", "/workspace/project", "/workspace/project/./images/result.png"], + [ + "images/result.png", + "C:\\Users\\dara\\project", + "C:\\Users\\dara\\project\\images\\result.png", + ], + [ + "images\\result.png", + "C:\\Users\\dara\\project", + "C:\\Users\\dara\\project\\images\\result.png", + ], + ["/workspace/project/image.png", null, "/workspace/project/image.png"], + ["/C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"], + ["C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"], + ["\\\\server\\share\\image.png", null, "\\\\server\\share\\image.png"], + ["file:///workspace/project/image%20one.png", null, "/workspace/project/image one.png"], + ["file:///C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"], + ["file://localhost/C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"], + ["file://server/share/image.png", null, "\\\\server\\share\\image.png"], + ])("maps %s to a workspace file", (source, workspaceRoot, path) => { + expect(classifyMarkdownImageSource(source, workspaceRoot)).toEqual({ + _tag: "WorkspaceFile", + path, + }); + }); + + it.each([ + null, + "", + "#image", + "?image=1", + "image.png", + "~/image.png", + "javascript:alert(1)", + "ftp://example.com/image.png", + "content://media/image/1", + "custom:image.png", + "file://%", + ])("blocks unsupported or unresolved source %s", (source) => { + expect(classifyMarkdownImageSource(source)).toEqual({ _tag: "Blocked" }); + }); +}); diff --git a/packages/client-runtime/src/markdownImages.ts b/packages/client-runtime/src/markdownImages.ts new file mode 100644 index 000000000000..404f828390ac --- /dev/null +++ b/packages/client-runtime/src/markdownImages.ts @@ -0,0 +1,98 @@ +const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; +const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; + +export type MarkdownImageSource = + | { readonly _tag: "Direct"; readonly uri: string } + | { readonly _tag: "WorkspaceFile"; readonly path: string } + | { readonly _tag: "Blocked" }; + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function normalizeSource(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; +} + +function normalizeWindowsDrivePath(value: string): string { + return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value; +} + +function parseFileUrl(value: string): string | null { + try { + const parsed = new URL(value); + if (parsed.protocol.toLowerCase() !== "file:") return null; + + if (parsed.hostname.length > 0 && parsed.hostname.toLowerCase() !== "localhost") { + const pathname = safeDecode(parsed.pathname).replaceAll("/", "\\"); + return `\\\\${safeDecode(parsed.hostname)}${pathname}`; + } + + const pathname = safeDecode(parsed.pathname); + if (pathname.length === 0) return null; + return normalizeWindowsDrivePath(pathname); + } catch { + return null; + } +} + +function stripSearchAndHash(value: string): string { + const searchIndex = value.indexOf("?"); + const hashIndex = value.indexOf("#"); + const end = [searchIndex, hashIndex] + .filter((index) => index >= 0) + .reduce((lowest, index) => Math.min(lowest, index), value.length); + return value.slice(0, end); +} + +function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { + const windows = + WINDOWS_DRIVE_PATH_PATTERN.test(workspaceRoot) || workspaceRoot.startsWith("\\\\"); + const separator = windows ? "\\" : "/"; + const root = workspaceRoot.replace(/[\\/]+$/, ""); + const path = relativePath.replace(/[\\/]/g, separator).replace(/^[\\/]+/, ""); + return `${root}${separator}${path}`; +} + +/** + * Classifies a markdown image source by where its bytes must be loaded from. + * Filesystem paths belong to the environment host and must never reach a + * browser or native image component without first becoming a signed asset URL. + */ +export function classifyMarkdownImageSource( + value: string | null | undefined, + workspaceRoot?: string | null, +): MarkdownImageSource { + if (value === null || value === undefined) return { _tag: "Blocked" }; + + const source = normalizeSource(value); + if (source.length === 0 || source.startsWith("#") || source.startsWith("?")) { + return { _tag: "Blocked" }; + } + if (DIRECT_IMAGE_SOURCE_PATTERN.test(source)) { + return { _tag: "Direct", uri: source }; + } + + if (/^file:/i.test(source)) { + const path = parseFileUrl(source); + return path === null ? { _tag: "Blocked" } : { _tag: "WorkspaceFile", path }; + } + + const path = normalizeWindowsDrivePath(safeDecode(stripSearchAndHash(source))); + if (path.length === 0) return { _tag: "Blocked" }; + if (path.startsWith("/") || WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith("\\\\")) { + return { _tag: "WorkspaceFile", path }; + } + if (URI_SCHEME_PATTERN.test(path) || path.startsWith("~/") || path.startsWith("~\\")) { + return { _tag: "Blocked" }; + } + if (!workspaceRoot) return { _tag: "Blocked" }; + + return { _tag: "WorkspaceFile", path: joinWorkspacePath(workspaceRoot, path) }; +} From 6c693baecf75454cac96d1ae3f78afc73f2cbe53 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 22 Aug 2026 14:59:39 -0700 Subject: [PATCH 04/91] fix(clients): keep opening responses visible after turns settle (#7723) --- apps/mobile/src/lib/threadActivity.test.ts | 71 ++++++++++++++++-- apps/mobile/src/lib/threadActivity.ts | 19 +++-- .../chat/MessagesTimeline.logic.test.ts | 72 +++++++++++++++++-- .../components/chat/MessagesTimeline.logic.ts | 21 +++--- 4 files changed, 160 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e1d46fd858e9..148b588c1103 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -371,7 +371,7 @@ describe("buildThreadFeed", () => { expect(serializedToolOutputs).toBe(1); }); - it("folds settled turn work while leaving the terminal answer visible", () => { + it("keeps the first and terminal assistant messages visible around settled work", () => { const turnId = TurnId.make("turn-1"); const thread = makeThread({ id: ThreadId.make("thread-3"), @@ -387,9 +387,9 @@ describe("buildThreadFeed", () => { }, messages: [ { - id: MessageId.make("assistant-commentary"), + id: MessageId.make("assistant-first"), role: "assistant", - text: "I am checking.", + text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.", turnId, streaming: false, createdAt: "2026-04-01T00:00:02.000Z", @@ -424,8 +424,12 @@ describe("buildThreadFeed", () => { const feed = buildThreadFeed(thread); const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); - expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); - expect(collapsed[0]).toMatchObject({ + expect(collapsed.map((entry) => entry.id)).toEqual([ + "assistant-first", + "turn-fold:turn-1", + "assistant-final", + ]); + expect(collapsed[1]).toMatchObject({ type: "turn-fold", label: "Worked for 17s", expanded: false, @@ -433,13 +437,68 @@ describe("buildThreadFeed", () => { const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); expect(expanded.map((entry) => entry.id)).toEqual([ + "assistant-first", "turn-fold:turn-1", - "assistant-commentary", "tool-completed", "assistant-final", ]); }); + it("folds assistant messages between the first and terminal messages", () => { + const turnId = TurnId.make("turn-1"); + const thread = makeThread({ + id: ThreadId.make("thread-middle-message"), + projectId: ProjectId.make("project-1"), + title: "Bounded narration", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:06.000Z", + assistantMessageId: MessageId.make("assistant-final"), + }, + messages: [ + { + id: MessageId.make("assistant-first"), + role: "assistant", + text: "The main result is ready.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:01.000Z", + updatedAt: "2026-04-01T00:00:02.000Z", + }, + { + id: MessageId.make("assistant-middle"), + role: "assistant", + text: "I am checking one more detail.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:03.000Z", + updatedAt: "2026-04-01T00:00:04.000Z", + }, + { + id: MessageId.make("assistant-final"), + role: "assistant", + text: "Verification finished.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:05.000Z", + updatedAt: "2026-04-01T00:00:06.000Z", + }, + ], + }); + + const feed = buildThreadFeed(thread); + const rows = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + + expect(rows.map((entry) => entry.id)).toEqual([ + "assistant-first", + "turn-fold:turn-1", + "assistant-final", + ]); + }); + it("measures a steer-superseded turn from its user boundary through trailing work", () => { const firstTurnId = TurnId.make("turn-1"); const secondTurnId = TurnId.make("turn-2"); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2a..63c22607efc6 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1144,9 +1144,13 @@ function deriveThreadFeedTurnFolds( feed: ReadonlyArray, latestTurn: ThreadFeedLatestTurn | null, ): ReadonlyMap { + const firstAssistantMessageIdByTurn = new Map(); const terminalAssistantMessageIdByTurn = new Map(); for (const entry of feed) { if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + if (!firstAssistantMessageIdByTurn.has(entry.message.turnId)) { + firstAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); + } terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); } } @@ -1194,17 +1198,24 @@ function deriveThreadFeedTurnFolds( continue; } + const firstAssistantMessageId = firstAssistantMessageIdByTurn.get(turnId); const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); const hiddenEntryIds = new Set( - entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id), + entries + .filter( + (entry) => + entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId, + ) + .map((entry) => entry.id), ); if (hiddenEntryIds.size === 0) { continue; } const firstEntry = entries[0]; + const firstHiddenEntry = entries.find((entry) => hiddenEntryIds.has(entry.id)); const lastEntry = entries.at(-1); - if (!firstEntry || !lastEntry) { + if (!firstEntry || !firstHiddenEntry || !lastEntry) { continue; } const terminalEntry = terminalAssistantMessageId @@ -1233,9 +1244,9 @@ function deriveThreadFeedTurnFolds( ? `Worked for ${duration}` : "Worked"; - foldsByAnchorId.set(firstEntry.id, { + foldsByAnchorId.set(firstHiddenEntry.id, { turnId, - createdAt: firstEntry.createdAt, + createdAt: firstHiddenEntry.createdAt, hiddenEntryIds, label, }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index fae5df1e4097..ae05289afe98 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -452,7 +452,7 @@ describe("deriveMessagesTimelineRows", () => { expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary); }); - it("folds settled-turn commentary and work behind a Worked-for row", () => { + it("keeps the first and terminal assistant messages visible around settled work", () => { const timelineEntries = [ { id: "user-entry", @@ -469,13 +469,13 @@ describe("deriveMessagesTimelineRows", () => { }, }, { - id: "assistant-thought-entry", + id: "assistant-first-entry", kind: "message" as const, createdAt: "2026-01-01T00:00:05Z", message: { - id: "assistant-thought" as never, + id: "assistant-first" as never, role: "assistant" as const, - text: "Looking around first.", + text: "Synthetic deployment checklist\n1. Confirm the deployment is ready.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:05Z", updatedAt: "2026-01-01T00:00:06Z", @@ -528,6 +528,7 @@ describe("deriveMessagesTimelineRows", () => { expect(foldRow?.label).toBe("Worked for 22s"); expect(collapsedRows.map((row) => row.id)).toEqual([ "user-entry", + "assistant-first-entry", "turn-fold:turn-1", "assistant-final-entry", ]); @@ -543,8 +544,8 @@ describe("deriveMessagesTimelineRows", () => { expect(expandedRows.map((row) => row.id)).toEqual([ "user-entry", + "assistant-first-entry", "turn-fold:turn-1", - "assistant-thought-entry", "work-toggle:work-entry-1", "assistant-final-entry", ]); @@ -553,6 +554,67 @@ describe("deriveMessagesTimelineRows", () => { ).toBeDefined(); }); + it("folds assistant messages between the first and terminal messages", () => { + const timelineEntries = [ + { + id: "assistant-first-entry", + kind: "message" as const, + createdAt: "2026-01-01T00:00:01Z", + message: { + id: "assistant-first" as never, + role: "assistant" as const, + text: "The main result is ready.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:01Z", + updatedAt: "2026-01-01T00:00:02Z", + streaming: false, + }, + }, + { + id: "assistant-middle-entry", + kind: "message" as const, + createdAt: "2026-01-01T00:00:03Z", + message: { + id: "assistant-middle" as never, + role: "assistant" as const, + text: "I am checking one more detail.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:03Z", + updatedAt: "2026-01-01T00:00:04Z", + streaming: false, + }, + }, + { + id: "assistant-final-entry", + kind: "message" as const, + createdAt: "2026-01-01T00:00:05Z", + message: { + id: "assistant-final" as never, + role: "assistant" as const, + text: "Verification finished.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:05Z", + updatedAt: "2026-01-01T00:00:06Z", + streaming: false, + }, + }, + ]; + + const rows = deriveMessagesTimelineRows({ + timelineEntries, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.map((row) => row.id)).toEqual([ + "assistant-first-entry", + "turn-fold:turn-1", + "assistant-final-entry", + ]); + }); + it("derives a sane duration for a steer-superseded turn with one instant commentary message", () => { // A steer ends the previous turn early: its only message completes the // instant it is created, and trailing work entries land after it. The diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index fbe0f5b916a5..8e2b295fc5df 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -521,9 +521,10 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null { } /** - * Settled turns fold their commentary and tool activity behind a - * "Worked for ..." row anchored at the turn's first foldable entry; the - * terminal assistant message stays visible below the fold. + * Settled turns keep their first and terminal assistant messages visible. + * Everything between them folds behind a "Worked for ..." row anchored at + * the first hidden entry. Keeping both ends prevents a short follow-up from + * hiding a substantive opening response while still bounding noisy turns. */ function deriveTurnFolds(input: { timelineEntries: ReadonlyArray; @@ -593,9 +594,12 @@ function deriveTurnFolds(input: { if (group.hasStreamingMessage) { continue; } + const firstAssistantEntry = group.entries.find( + (entry): entry is Extract => entry.kind === "message", + ); const hiddenEntryIds = new Set(); for (const entry of group.entries) { - if (entry.id === group.terminalEntry?.id) { + if (entry.id === firstAssistantEntry?.id || entry.id === group.terminalEntry?.id) { continue; } // Agent-spawn CTA rows never fold: workflows outlive their launching @@ -611,8 +615,9 @@ function deriveTurnFolds(input: { } const firstEntry = group.entries[0]; + const firstHiddenEntry = group.entries.find((entry) => hiddenEntryIds.has(entry.id)); const lastEntry = group.entries.at(-1); - if (!firstEntry || !lastEntry) { + if (!firstEntry || !firstHiddenEntry || !lastEntry) { continue; } @@ -641,10 +646,10 @@ function deriveTurnFolds(input: { ? `Worked for ${duration}` : "Worked"; - foldsByAnchorEntryId.set(firstEntry.id, { + foldsByAnchorEntryId.set(firstHiddenEntry.id, { turnId, - anchorEntryId: firstEntry.id, - createdAt: firstEntry.createdAt, + anchorEntryId: firstHiddenEntry.id, + createdAt: firstHiddenEntry.createdAt, hiddenEntryIds, label, }); From 6e9c57f7ba9eab9e987da7a660664a1df683e9cf Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:00:21 -0400 Subject: [PATCH 05/91] feat(web): add appearance contrast control (#7906) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- .../check-run-agents/ui-consistency.md | 3 + .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/appearanceContrast.test.ts | 53 ++++ apps/web/src/appearanceContrast.ts | 10 + apps/web/src/browser/annotationTheme.ts | 12 +- apps/web/src/components/ChatMarkdown.tsx | 2 +- .../src/components/chat/ChangedFilesTree.tsx | 2 +- apps/web/src/components/chat/ModelListRow.tsx | 2 +- .../components/chat/ModelPickerSidebar.tsx | 4 +- .../components/chat/ProviderModelPicker.tsx | 2 +- .../components/clerk/clerkAppearance.test.ts | 10 +- .../src/components/clerk/clerkAppearance.ts | 10 +- apps/web/src/components/color-selector.tsx | 2 +- .../src/components/files/FileBrowserPanel.tsx | 2 +- .../pullRequest/pullRequestPresentation.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 62 +++++ .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/components/ui/button.test.tsx | 2 +- apps/web/src/components/ui/button.tsx | 8 +- .../src/components/usage/usageProviders.ts | 2 +- apps/web/src/contextMenuFallback.test.ts | 2 +- apps/web/src/contextMenuFallback.ts | 13 +- apps/web/src/index.css | 254 +++++++++++++----- apps/web/src/routes/__root.tsx | 12 + packages/contracts/src/settings.test.ts | 16 ++ packages/contracts/src/settings.ts | 12 + 26 files changed, 404 insertions(+), 102 deletions(-) create mode 100644 apps/web/src/appearanceContrast.test.ts create mode 100644 apps/web/src/appearanceContrast.ts diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index 8ec720742759..c2c091b205cf 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -47,6 +47,9 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put - light-only declarations use `@variant light`; - raw `.dark` should remain only in the `dark` and `light` custom-variant definitions. - Preserve custom themes and runtime token bridges. Removing a variable or selector is safe only when all runtime, inspector, generated, and theme-palette consumers are accounted for. +- Contrast and accessibility settings that target app chrome must derive from semantic color tokens. Do not apply `filter` to `html`, `body`, or the app root: it also changes user media, previews, terminals, glass backdrop ownership, and view-transition snapshots. +- Preserve alpha and surface ownership when deriving contrast tokens. Soften translucent borders and inputs toward transparent rather than an opaque canvas, use a modest semantic-foreground mix for stronger borders, and adjust card, popover, accent, secondary, and message foregrounds against their own surfaces when the base foreground changes. +- Runtime-adjusted roles must be ordinary custom properties shared by the Tailwind bridge, global CSS, imperative style strings, and bridge snapshots sent to other renderers. Audit literal `var(--foreground)`, `var(--border)`, and related role reads so headings, markdown chrome, menus, previews, and utilities do not split into adjusted and unadjusted colors. - Inspect emitted production CSS after unusual variants, arbitrary selectors, nested pseudo-elements, or attribute matching. Source syntax that looks valid is insufficient. - Flag malformed or empty emitted selectors such as empty `:is()` or `:not(:is())`, selector branches that can never match their own class attribute, and transformations that silently drop the intended rule. - Prefer source-level logic over clever selectors when behavior depends on consumer-provided class strings. Preserve `MenuPopup`'s current defaulting contract: a string `className` containing a `w-*`, `min-w-*`, or `max-w-*` utility after variant prefixes are stripped suppresses `min-w-32`; a string without one and a functional/non-string `className` keep the default. Arbitrary width values count as width utilities, and the consumer class must be merged last so it retains control. Do not replace this with a raw class-attribute substring selector. diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1c17d58215ea..1a304d582bb0 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + appearanceContrast: 100, browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, browserDefaultAppearance: "dark", diff --git a/apps/web/src/appearanceContrast.test.ts b/apps/web/src/appearanceContrast.test.ts new file mode 100644 index 000000000000..3e6c1fad0448 --- /dev/null +++ b/apps/web/src/appearanceContrast.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { applyAppearanceContrast } from "./appearanceContrast"; + +function makeRoot() { + const setProperty = vi.fn(); + return { + root: { style: { setProperty } } as unknown as HTMLElement, + setProperty, + }; +} + +describe("applyAppearanceContrast", () => { + it("boosts semantic contrast above the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 135); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "35%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "8.75%"); + }); + + it("supports the maximum contrast boost", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 200); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "25%"); + }); + + it("softens semantic contrast below the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 70); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "70%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); + }); + + it("disables contrast mixing at the default", () => { + const { root, setProperty } = makeRoot(); + + applyAppearanceContrast(root, 100); + + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-base", "100%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-boost", "0%"); + expect(setProperty).toHaveBeenCalledWith("--appearance-contrast-border-boost", "0%"); + }); +}); diff --git a/apps/web/src/appearanceContrast.ts b/apps/web/src/appearanceContrast.ts new file mode 100644 index 000000000000..a26dca0131e6 --- /dev/null +++ b/apps/web/src/appearanceContrast.ts @@ -0,0 +1,10 @@ +import type { AppearanceContrast } from "@t3tools/contracts/settings"; + +export function applyAppearanceContrast(root: HTMLElement, contrast: AppearanceContrast): void { + root.style.setProperty("--appearance-contrast-base", `${Math.min(contrast, 100)}%`); + root.style.setProperty("--appearance-contrast-boost", `${Math.max(contrast - 100, 0)}%`); + root.style.setProperty( + "--appearance-contrast-border-boost", + `${Math.max(contrast - 100, 0) / 4}%`, + ); +} diff --git a/apps/web/src/browser/annotationTheme.ts b/apps/web/src/browser/annotationTheme.ts index e12c667d23d7..cb3382449598 100644 --- a/apps/web/src/browser/annotationTheme.ts +++ b/apps/web/src/browser/annotationTheme.ts @@ -10,17 +10,17 @@ export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme { colorScheme: root.classList.contains("dark") ? "dark" : "light", radius: readVariable(styles, "--radius", "0.625rem"), background: readVariable(styles, "--background", "white"), - foreground: readVariable(styles, "--foreground", "oklch(0.269 0 0)"), + foreground: readVariable(styles, "--contrast-foreground", "oklch(0.269 0 0)"), popover: readVariable(styles, "--popover", "white"), - popoverForeground: readVariable(styles, "--popover-foreground", "oklch(0.269 0 0)"), + popoverForeground: readVariable(styles, "--contrast-popover-foreground", "oklch(0.269 0 0)"), primary: readVariable(styles, "--primary", "oklch(0.488 0.217 264)"), primaryForeground: readVariable(styles, "--primary-foreground", "white"), muted: readVariable(styles, "--muted", "rgb(0 0 0 / 4%)"), - mutedForeground: readVariable(styles, "--muted-foreground", "oklch(0.556 0 0)"), + mutedForeground: readVariable(styles, "--contrast-muted-foreground", "oklch(0.556 0 0)"), accent: readVariable(styles, "--accent", "rgb(0 0 0 / 4%)"), - accentForeground: readVariable(styles, "--accent-foreground", "oklch(0.269 0 0)"), - border: readVariable(styles, "--border", "rgb(0 0 0 / 8%)"), - input: readVariable(styles, "--input", "rgb(0 0 0 / 10%)"), + accentForeground: readVariable(styles, "--contrast-accent-foreground", "oklch(0.269 0 0)"), + border: readVariable(styles, "--contrast-border", "rgb(0 0 0 / 8%)"), + input: readVariable(styles, "--contrast-input", "rgb(0 0 0 / 10%)"), ring: readVariable(styles, "--ring", "oklch(0.488 0.217 264)"), fontSans: readVariable(styles, "--font-sans", styles.fontFamily || "system-ui, sans-serif"), fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"), diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a157cd6e329b..13024a7516ff 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1412,7 +1412,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ > {/* The full path: the chip already shows the shortened form, and a link to the workspace root collapses to a bare label that repeats it. */} -
+
{targetPath}
diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 1212030ba339..906bf4c34cb4 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -66,7 +66,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { className={cn( "flex items-center justify-between gap-2 rounded-xl", expanded && - "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]", + "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--contrast-foreground)_2.5%,var(--background))]", )} >
+ + updateSettings({ + appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, + }) + } + /> + ) : null + } + control={ +
+ + {settings.appearanceContrast}% + + { + const appearanceContrast = Number(event.currentTarget.value); + if ( + Number.isInteger(appearanceContrast) && + appearanceContrast >= MIN_APPEARANCE_CONTRAST && + appearanceContrast <= MAX_APPEARANCE_CONTRAST + ) { + updateSettings({ appearanceContrast }); + } + }} + step={5} + style={appearanceContrastSliderStyle} + type="range" + value={settings.appearanceContrast} + /> +
+ } + /> + { ); expect(html).toContain("rounded-[var(--control-radius)]"); - expect(html).toContain("[--control-icon-color:var(--muted-foreground)]"); + expect(html).toContain("[--control-icon-color:var(--contrast-muted-foreground)]"); expect(html).toContain("text-[var(--control-icon-color)]"); expect(html).not.toContain("opacity-80"); }); diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index f11ebd6fe21c..274eb00d7dba 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -43,14 +43,14 @@ const buttonVariants = cva( "destructive-outline": "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4", ghost: - "[--control-icon-color:var(--muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent", + "[--control-icon-color:var(--contrast-muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent", "ghost-muted": - "[--control-icon-color:var(--muted-foreground)] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground", + "[--control-icon-color:var(--contrast-muted-foreground)] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground", glass: - "surface-glass [--control-icon-color:var(--muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border", + "surface-glass [--control-icon-color:var(--contrast-muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border", link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", outline: - "[--control-icon-color:var(--muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64", + "[--control-icon-color:var(--contrast-muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64", secondary: "border-transparent bg-secondary text-secondary-foreground [:active,[data-pressed]]:bg-secondary/80 [:hover,[data-pressed]]:bg-secondary/90", }, diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 615980cd460a..74ee27a2b8e9 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -16,7 +16,7 @@ type UsageProviderPresentation = { export const PROVIDER_PRESENTATION = { codex: { label: "Codex", - color: "var(--foreground)", + color: "var(--contrast-foreground)", mark: OpenAI, }, claude: { diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 92efa723933b..ddc5da05f806 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -315,7 +315,7 @@ describe("showContextMenuFallback", () => { expect(siblingButton).toBeTruthy(); expect(childButton?.focused).toBe(true); expect(childButton?.style.background).toBe("var(--accent)"); - expect(childButton?.style.color).toBe("var(--accent-foreground)"); + expect(childButton?.style.color).toBe("var(--contrast-accent-foreground)"); siblingButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); expect(childButton?.focused).toBe(false); expect(childButton?.style.background).toBe("transparent"); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index f2c7f42a0617..ce8b8950a8b9 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -278,7 +278,7 @@ export function showContextMenuFallback( menu.className = "dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none"; menu.style.cssText = - "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--popover-foreground);outline:none;pointer-events:auto;"; + "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--contrast-popover-foreground);outline:none;pointer-events:auto;"; menu.style.left = `${preferredLeft}px`; menu.style.top = `${preferredTop}px`; menu.dataset.level = String(level); @@ -293,7 +293,8 @@ export function showContextMenuFallback( if (item.separatorBefore === true && inner.children.length > 0) { const separator = document.createElement("div"); separator.className = "mx-2 my-1 h-px bg-border"; - separator.style.cssText = "height:1px;margin:0.25rem 0.5rem;background:var(--border);"; + separator.style.cssText = + "height:1px;margin:0.25rem 0.5rem;background:var(--contrast-border);"; separator.dataset.contextMenuSeparator = "true"; separator.setAttribute("role", "separator"); inner.appendChild(separator); @@ -323,12 +324,12 @@ export function showContextMenuFallback( ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground` : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`; button.style.cssText = - "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;"; + "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--contrast-foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;"; if (isLeafDestructive) { button.style.color = "var(--destructive-foreground)"; } if (isDisabled) { - button.style.color = "var(--muted-foreground)"; + button.style.color = "var(--contrast-muted-foreground)"; button.style.opacity = "0.64"; button.style.pointerEvents = "none"; } @@ -373,10 +374,10 @@ export function showContextMenuFallback( button.style.color = isHighlighted ? isLeafDestructive ? "var(--destructive-foreground)" - : "var(--accent-foreground)" + : "var(--contrast-accent-foreground)" : isLeafDestructive ? "var(--destructive-foreground)" - : "var(--foreground)"; + : "var(--contrast-foreground)"; }; button.addEventListener("mouseenter", () => { button.focus({ preventScroll: true }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 63751f589019..f69adb9cf08e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -78,6 +78,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil :root { --app-scrollbar-width: 6px; + --appearance-contrast-base: 100%; + --appearance-contrast-boost: 0%; + --appearance-contrast-border-boost: 0%; + --appearance-contrast-target: black; --app-scrollbar-thumb: rgb(217 217 217); --app-scrollbar-thumb-hover: rgb(191 191 191); /* @@ -87,7 +91,11 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --control-radius: 0.5rem; --sidebar-content-inset: 0.5rem; --sidebar-control-gap: 0.5rem; - --sidebar-icon-color: color-mix(in srgb, var(--sidebar-muted-foreground) 60%, var(--sidebar)); + --sidebar-icon-color: color-mix( + in srgb, + var(--contrast-sidebar-muted-foreground) 60%, + var(--sidebar) + ); --sidebar-row-content-inset: 0.625rem; --command-shell-inset: 0.5rem; --command-content-inset: 1rem; @@ -105,6 +113,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --workspace-titlebar-control-gap: 0.75rem; @variant dark { + --appearance-contrast-target: white; --app-scrollbar-thumb: rgb(255 255 255 / 8%); --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%); --glass-blur: 16px; @@ -156,40 +165,40 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --color-info: var(--info); --color-destructive-foreground: var(--destructive-foreground); --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); + --color-input: var(--contrast-input); + --color-border: var(--contrast-border); --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); + --color-accent-foreground: var(--contrast-accent-foreground); --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); + --color-muted-foreground: var(--contrast-muted-foreground); --color-muted: var(--muted); - --color-placeholder: var(--placeholder); - --color-secondary-label: var(--secondary-label); - --color-icon-muted: var(--icon-muted); - --color-secondary-foreground: var(--secondary-foreground); + --color-placeholder: var(--contrast-placeholder); + --color-secondary-label: var(--contrast-secondary-label); + --color-icon-muted: var(--contrast-icon-muted); + --color-secondary-foreground: var(--contrast-secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); + --color-popover-foreground: var(--contrast-popover-foreground); --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); + --color-card-foreground: var(--contrast-card-foreground); --color-card: var(--card); - --color-foreground: var(--foreground); + --color-foreground: var(--contrast-foreground); --color-background: var(--background); --color-surface-raised: var(--surface-raised); --color-message: var(--message-surface); - --color-message-foreground: var(--message-foreground); + --color-message-foreground: var(--contrast-message-foreground); --color-message-action: var(--message-action); --color-message-action-foreground: var(--message-action-foreground); --color-message-action-hover: var(--message-action-hover); --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-muted-foreground: var(--sidebar-muted-foreground); + --color-sidebar-foreground: var(--contrast-sidebar-foreground); + --color-sidebar-muted-foreground: var(--contrast-sidebar-muted-foreground); --color-sidebar-control-surface: var(--sidebar-control-surface); --color-sidebar-row-hover: var(--sidebar-row-hover); --color-sidebar-row-active: var(--sidebar-row-active); --color-sidebar-row-selected: var(--sidebar-row-selected); - --color-sidebar-border: var(--sidebar-border); + --color-sidebar-border: var(--contrast-sidebar-border); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -304,7 +313,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - border-color: color-mix(in srgb, var(--foreground) 10%, transparent); + border-color: color-mix(in srgb, var(--contrast-foreground) 10%, transparent); box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%); @variant dark { @@ -337,7 +346,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil ); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--contrast-foreground) 10%, transparent); @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { background: var(--popover) !important; @@ -757,7 +766,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil .chat-composer-shoulder-tab { border-color: var( --chat-composer-outline, - color-mix(in srgb, var(--foreground) 8%, transparent) + color-mix(in srgb, var(--contrast-foreground) 8%, transparent) ); background: color-mix( in srgb, @@ -781,7 +790,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card)); --chat-composer-attached-outline: var( --chat-composer-outline, - color-mix(in srgb, var(--foreground) 8%, transparent) + color-mix(in srgb, var(--contrast-foreground) 8%, transparent) ); background: color-mix( in srgb, @@ -817,7 +826,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card)); --chat-composer-attached-outline: var( --chat-composer-outline, - color-mix(in srgb, var(--foreground) 8%, transparent) + color-mix(in srgb, var(--contrast-foreground) 8%, transparent) ); --chat-composer-attached-tint: transparent; position: relative; @@ -1238,17 +1247,17 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil background: linear-gradient( to right, var(--primary) 0 var(--settings-slider-fill-position), - color-mix(in srgb, var(--muted-foreground) 22%, transparent) + color-mix(in srgb, var(--contrast-muted-foreground) 22%, transparent) var(--settings-slider-fill-position) 100% ); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--contrast-border) 55%, transparent); } .settings-slider::-moz-range-track { height: 0.375rem; border-radius: 9999px; - background: color-mix(in srgb, var(--muted-foreground) 22%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 55%, transparent); + background: color-mix(in srgb, var(--contrast-muted-foreground) 22%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--contrast-border) 55%, transparent); } .settings-slider::-moz-range-progress { @@ -1265,7 +1274,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil border: 2px solid var(--primary); border-radius: 9999px; background: var(--background); - box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent); + box-shadow: 0 1px 2px color-mix(in srgb, var(--contrast-foreground) 12%, transparent); transition: transform 120ms ease, box-shadow 120ms ease; @@ -1277,7 +1286,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil border: 2px solid var(--primary); border-radius: 9999px; background: var(--background); - box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent); + box-shadow: 0 1px 2px color-mix(in srgb, var(--contrast-foreground) 12%, transparent); transition: transform 120ms ease, box-shadow 120ms ease; @@ -1285,12 +1294,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil .settings-slider:hover::-webkit-slider-thumb { transform: scale(1.08); - box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent); + box-shadow: 0 1px 3px color-mix(in srgb, var(--contrast-foreground) 20%, transparent); } .settings-slider:hover::-moz-range-thumb { transform: scale(1.08); - box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 20%, transparent); + box-shadow: 0 1px 3px color-mix(in srgb, var(--contrast-foreground) 20%, transparent); } .settings-slider:active::-webkit-slider-thumb { @@ -1524,9 +1533,9 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-foreground: var(--foreground); --sidebar-muted-foreground: var(--muted-foreground); --sidebar-control-surface: var(--muted); - --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); - --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); - --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); + --sidebar-row-hover: color-mix(in srgb, var(--contrast-foreground) 8%, transparent); + --sidebar-row-active: color-mix(in srgb, var(--contrast-foreground) 11%, transparent); + --sidebar-row-selected: color-mix(in srgb, var(--contrast-foreground) 7%, transparent); --sidebar-border: var(--border); --sidebar-stage-fade: var(--card); } @@ -1711,11 +1720,11 @@ html[data-theme-token-probe] *::after { max-width: 12rem; overflow: hidden; padding: 0.2rem 0.45rem; - border: 1px solid color-mix(in oklab, var(--ring) 48%, var(--border)); + border: 1px solid color-mix(in oklab, var(--ring) 48%, var(--contrast-border)); border-radius: 0.4rem; background: var(--popover); box-shadow: 0 4px 16px rgb(0 0 0 / 24%); - color: var(--popover-foreground); + color: var(--contrast-popover-foreground); font-size: 0.6875rem; font-weight: 600; line-height: 1rem; @@ -1736,16 +1745,16 @@ html[data-theme-token-probe] *::after { dark-mode whites while preserving the same theme file across clients. */ html[data-theme-id] [data-chat-header] { background-color: var(--toolbar-background); - color: var(--toolbar-foreground); + color: var(--contrast-toolbar-foreground); } html[data-theme-id] [data-chat-header] [data-slot="button"], html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"], html[data-theme-id] [data-chat-header] [data-toolbar-control] { - --control-icon-color: var(--toolbar-control-foreground); + --control-icon-color: var(--contrast-toolbar-control-foreground); background-color: var(--toolbar-control); - border-color: var(--toolbar-border); - color: var(--toolbar-control-foreground); + border-color: var(--contrast-toolbar-border); + color: var(--contrast-toolbar-control-foreground); } /* The panel layout toggles stay ghost: they render both inside the header and @@ -1757,8 +1766,8 @@ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { - --control-icon-color: var(--toolbar-foreground); - color: var(--toolbar-foreground); + --control-icon-color: var(--contrast-toolbar-foreground); + color: var(--contrast-toolbar-foreground); } html[data-theme-id] [data-chat-header] [data-slot="button"]:hover, @@ -1771,7 +1780,7 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control][data-pressed] { } html[data-theme-id] [data-chat-header] [data-slot="separator"] { - background-color: var(--toolbar-border); + background-color: var(--contrast-toolbar-border); } /* Chat code blocks join the other code surfaces on the code tokens. The @@ -1779,7 +1788,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] { surfaces with the accent, which fights syntax highlighting. */ html[data-theme-id] .chat-markdown .chat-markdown-codeblock { background-color: var(--code-background); - border-color: var(--border); + border-color: var(--contrast-border); color: var(--code-foreground); } @@ -1814,7 +1823,7 @@ html[data-theme-id="t3-chat"] { } & .chat-markdown :not(pre) > code { - color: var(--message-foreground); + color: var(--contrast-message-foreground); } } } @@ -1844,10 +1853,10 @@ html[data-theme-id] [data-app-sidebar] { --sidebar-row-selected: var(--app-theme-sidebar-row-selected); --sidebar-border: var(--app-theme-sidebar-border); --sidebar-stage-fade: var(--app-theme-sidebar); - border-color: color-mix(in srgb, var(--sidebar-foreground) 10%, transparent); + border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 10%, transparent); @variant dark { - border-color: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent); + border-color: color-mix(in srgb, var(--contrast-sidebar-foreground) 8%, transparent); } } @@ -1855,10 +1864,127 @@ html[data-theme-id] [data-app-sidebar] { keeps that color while hovered. Do not neutralize this branded edge. */ html[data-theme-id="t3-chat"] [data-app-sidebar] { @variant dark { - border-color: var(--sidebar-border); + border-color: var(--contrast-sidebar-border); } } +/* Contrast stays in ordinary custom properties so both Tailwind utilities and + global/imperative chrome styles resolve the same adjusted role. Redeclare on + the sidebar because it owns a local semantic palette. */ +:root, +[data-app-sidebar] { + --contrast-toolbar-foreground: color-mix( + in oklab, + color-mix( + in oklab, + var(--toolbar-foreground) var(--appearance-contrast-base), + var(--toolbar-background) + ), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-toolbar-border: color-mix( + in srgb, + color-mix(in srgb, var(--toolbar-border) var(--appearance-contrast-base), transparent), + var(--toolbar-foreground) var(--appearance-contrast-border-boost) + ); + --contrast-toolbar-control-foreground: color-mix( + in oklab, + color-mix( + in oklab, + var(--toolbar-control-foreground) var(--appearance-contrast-base), + var(--toolbar-control) + ), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-input: color-mix( + in srgb, + color-mix(in srgb, var(--input) var(--appearance-contrast-base), transparent), + var(--foreground) var(--appearance-contrast-border-boost) + ); + --contrast-border: color-mix( + in srgb, + color-mix(in srgb, var(--border) var(--appearance-contrast-base), transparent), + var(--foreground) var(--appearance-contrast-border-boost) + ); + --contrast-foreground: color-mix( + in oklab, + color-mix(in oklab, var(--foreground) var(--appearance-contrast-base), var(--background)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-muted-foreground: color-mix( + in oklab, + color-mix(in oklab, var(--muted-foreground) var(--appearance-contrast-base), var(--background)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-placeholder: color-mix( + in oklab, + color-mix(in oklab, var(--placeholder) var(--appearance-contrast-base), var(--background)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-secondary-label: color-mix( + in oklab, + color-mix(in oklab, var(--secondary-label) var(--appearance-contrast-base), var(--background)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-icon-muted: color-mix( + in oklab, + color-mix(in oklab, var(--icon-muted) var(--appearance-contrast-base), var(--background)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-card-foreground: color-mix( + in oklab, + color-mix(in oklab, var(--card-foreground) var(--appearance-contrast-base), var(--card)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-popover-foreground: color-mix( + in oklab, + color-mix(in oklab, var(--popover-foreground) var(--appearance-contrast-base), var(--popover)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-accent-foreground: color-mix( + in oklab, + color-mix(in oklab, var(--accent-foreground) var(--appearance-contrast-base), var(--accent)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-secondary-foreground: color-mix( + in oklab, + color-mix( + in oklab, + var(--secondary-foreground) var(--appearance-contrast-base), + var(--secondary) + ), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-message-foreground: color-mix( + in oklab, + color-mix( + in oklab, + var(--message-foreground) var(--appearance-contrast-base), + var(--message-surface) + ), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-sidebar-foreground: color-mix( + in oklab, + color-mix(in oklab, var(--sidebar-foreground) var(--appearance-contrast-base), var(--sidebar)), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-sidebar-muted-foreground: color-mix( + in oklab, + color-mix( + in oklab, + var(--sidebar-muted-foreground) var(--appearance-contrast-base), + var(--sidebar) + ), + var(--appearance-contrast-target) var(--appearance-contrast-boost) + ); + --contrast-sidebar-border: color-mix( + in srgb, + color-mix(in srgb, var(--sidebar-border) var(--appearance-contrast-base), transparent), + var(--sidebar-foreground) var(--appearance-contrast-border-boost) + ); +} + body { /* Reference the theme token (not a literal stack) so the Settings -> Appearance runtime override of --font-sans reaches all interface text. */ @@ -2004,7 +2130,7 @@ code { margin: 1.25rem 0 0.5rem; font-weight: 600; line-height: 1.3; - color: var(--foreground); + color: var(--contrast-foreground); } .chat-markdown h1 { @@ -2026,7 +2152,7 @@ code { } .chat-markdown h6 { - color: var(--muted-foreground); + color: var(--contrast-muted-foreground); } .chat-markdown ul { @@ -2103,16 +2229,16 @@ code { } .chat-markdown blockquote { - border-left: 2px solid var(--border); + border-left: 2px solid var(--contrast-border); padding-left: 0.8rem; - color: var(--muted-foreground); + color: var(--contrast-muted-foreground); } .chat-markdown section[data-footnotes] { margin-top: 1.25rem; - border-top: 1px solid var(--border); + border-top: 1px solid var(--contrast-border); padding-top: 0.75rem; - color: var(--muted-foreground); + color: var(--contrast-muted-foreground); font-size: 0.75rem; } @@ -2137,17 +2263,17 @@ code { } .chat-markdown :not(pre) > code { - border: 1px solid var(--border); + border: 1px solid var(--contrast-border); border-radius: 0.375rem; background: var(--muted); padding: 0.1rem 0.35rem; - color: var(--foreground); + color: var(--contrast-foreground); font-size: 0.75rem; } .chat-markdown a.chat-markdown-file-link, .chat-markdown a.chat-markdown-file-link:hover { - color: var(--foreground); + color: var(--contrast-foreground); text-decoration: none; } @@ -2159,12 +2285,12 @@ code { .chat-markdown pre { max-width: 100%; overflow-x: auto; - border: 1px solid var(--border); + border: 1px solid var(--contrast-border); border-radius: 0.75rem; background: var(--muted); padding: 0.8rem 0.9rem; scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; + scrollbar-color: color-mix(in srgb, var(--contrast-border) 78%, transparent) transparent; } .chat-markdown pre code { @@ -2179,21 +2305,21 @@ code { .chat-markdown pre::-webkit-scrollbar-thumb { border-radius: 999px; - background: color-mix(in srgb, var(--border) 78%, transparent); + background: color-mix(in srgb, var(--contrast-border) 78%, transparent); } .chat-markdown .chat-markdown-codeblock-header, .chat-markdown .chat-markdown-chrome-action { - color: color-mix(in srgb, var(--foreground) 72%, transparent); + color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent); } .chat-markdown .chat-markdown-chrome-action:hover { - color: var(--foreground); + color: var(--contrast-foreground); } .chat-markdown .chat-markdown-chrome-action[aria-pressed="true"] { - color: var(--foreground); - background: color-mix(in srgb, var(--foreground) 8%, transparent); + color: var(--contrast-foreground); + background: color-mix(in srgb, var(--contrast-foreground) 8%, transparent); } .chat-markdown .chat-markdown-codeblock pre { @@ -2233,14 +2359,14 @@ code { } .chat-markdown thead th { - border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); padding-block: 0.55rem; font-weight: 600; white-space: nowrap; } .chat-markdown tbody td { - border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--contrast-border) 60%, transparent); } /* Collapsed (default): single-line cells truncate so arbitrary chat content diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 91381301418e..7c715dff9e95 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -30,6 +30,7 @@ import { } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; +import { applyAppearanceContrast } from "~/appearanceContrast"; import { useClientSettings } from "../hooks/useSettings"; import { PlanAgentSelectionHeal } from "../planAgentSelectionHeal"; import { @@ -131,6 +132,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} @@ -152,6 +154,16 @@ function RootRouteView() { ); } +function ContrastAppearanceSync() { + const appearanceContrast = useClientSettings((settings) => settings.appearanceContrast); + + useEffect(() => { + applyAppearanceContrast(document.documentElement, appearanceContrast); + }, [appearanceContrast]); + + return null; +} + function GlassAppearanceSync() { const glassOpacity = useClientSettings((settings) => settings.glassOpacity); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 0f59da5ece14..55023bcc48e7 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -51,6 +51,22 @@ describe("ClientSettings glass opacity", () => { }); }); +describe("ClientSettings appearance contrast", () => { + it("defaults to the theme's original contrast", () => { + expect(decodeClientSettings({}).appearanceContrast).toBe(100); + }); + + it.each([49, 201, 92.5])("rejects an invalid appearance contrast: %s", (value) => { + expect(() => decodeClientSettings({ appearanceContrast: value })).toThrow(); + expect(() => decodeClientSettingsPatch({ appearanceContrast: value })).toThrow(); + }); + + it.each([50, 100, 150, 200])("accepts an appearance contrast in range: %s", (value) => { + expect(decodeClientSettings({ appearanceContrast: value }).appearanceContrast).toBe(value); + expect(decodeClientSettingsPatch({ appearanceContrast: value }).appearanceContrast).toBe(value); + }); +}); + describe("ClientSettings environment identification", () => { it("defaults to artwork and accepts each presentation mode", () => { expect(decodeClientSettings({}).environmentIdentificationMode).toBe("artwork"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0502d303d249..ba4facaf53ce 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -75,6 +75,14 @@ export const GlassOpacity = Schema.Int.check( ); export type GlassOpacity = typeof GlassOpacity.Type; export const DEFAULT_GLASS_OPACITY: GlassOpacity = 80; + +export const MIN_APPEARANCE_CONTRAST = 50; +export const MAX_APPEARANCE_CONTRAST = 200; +export const AppearanceContrast = Schema.Int.check( + Schema.isBetween({ minimum: MIN_APPEARANCE_CONTRAST, maximum: MAX_APPEARANCE_CONTRAST }), +); +export type AppearanceContrast = typeof AppearanceContrast.Type; +export const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100; /** * Font size preferences, in CSS pixels. The ranges are deliberately narrow: * the interface size scales every rem-based dimension in the app, so the @@ -133,6 +141,9 @@ export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIE export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true; export const ClientSettingsSchema = Schema.Struct({ + appearanceContrast: AppearanceContrast.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), + ), browserDefaultViewport: PreviewViewportSetting.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_VIEWPORT)), ), @@ -860,6 +871,7 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + appearanceContrast: Schema.optionalKey(AppearanceContrast), browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor), browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference), From 4e00471d1ab340b46742565bb6ce6462160b4d0c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 22 Aug 2026 15:42:22 -0700 Subject: [PATCH 06/91] fix(server): stop completed Codex threads from staying stuck on working (#7937) --- .../src/provider/Layers/CodexAdapter.test.ts | 61 +++++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 11 +--- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index da7f6fb1576a..3dae02feac84 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -513,6 +513,67 @@ function startLifecycleRuntime() { } lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("does not reactivate an idle child after a parent interaction", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe( + Effect.forkChild, + ); + + const childEvent = (id: string, method: string, payload: Record) => ({ + id: asEventId(id), + kind: "notification" as const, + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload, + }); + + yield* runtime.emit( + childEvent("evt-child-running", "collabAgent/turnStarted", { + agentThreadId: "child-1", + agentPath: "/root/audit", + }), + ); + yield* runtime.emit( + childEvent("evt-child-idle", "collabAgent/turnCompleted", { + agentThreadId: "child-1", + agentPath: "/root/audit", + turn: { status: "completed" }, + }), + ); + yield* runtime.emit( + childEvent("evt-child-interacted", "collabAgent/activity", { + agentThreadId: "child-1", + agentPath: "/root/audit", + activityKind: "interacted", + }), + ); + yield* runtime.emit( + childEvent("evt-other-child-running", "collabAgent/turnStarted", { + agentThreadId: "child-2", + agentPath: "/root/other", + }), + ); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + NodeAssert.deepStrictEqual( + events.map((event) => + event.type === "task.updated" + ? { taskId: event.payload.taskId, status: event.payload.status } + : { type: event.type }, + ), + [ + { taskId: "child-1", status: "running" }, + { taskId: "child-1", status: "idle" }, + { taskId: "child-2", status: "running" }, + ], + ); + }), + ); + it.effect("maps completed agent message items to canonical item.completed events", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index cf82ffd40dff..7cef4911bc0c 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -590,14 +590,9 @@ function mapCollabAgentEvent( }, ]; } - // interacted → the child is (again) actively driven. - return [ - { - ...base, - type: "task.updated", - payload: { taskId, status: "running", ...statusLinkage }, - }, - ]; + // Reading a child's result also emits "interacted" after its turn is idle. + // Only the child's turn or thread lifecycle can prove it resumed work. + return []; } case "collabAgent/turnStarted": return [ From 5a7a7cf2925c88388a023f0d4eb6b9096884e817 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:24:57 -0700 Subject: [PATCH 07/91] fix(mobile): preserve markdown image dimensions (#7940) --- .../src/features/threads/ThreadFeed.tsx | 150 ++++++++++++------ .../threads/markdownImageSize.test.ts | 62 ++++++++ .../src/features/threads/markdownImageSize.ts | 37 +++++ 3 files changed, 203 insertions(+), 46 deletions(-) create mode 100644 apps/mobile/src/features/threads/markdownImageSize.test.ts create mode 100644 apps/mobile/src/features/threads/markdownImageSize.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 145737523514..280ab4ecafa4 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -40,6 +40,7 @@ import { type ColorValue, useWindowDimensions, View, + type ViewStyle, } from "react-native"; import { TouchableOpacity } from "react-native-gesture-handler"; import ImageViewing from "react-native-image-viewing"; @@ -105,6 +106,7 @@ import { import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { useAssetUrl, useAssetUrlState } from "../../state/assets"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; +import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", @@ -196,31 +198,50 @@ function MessageAttachmentImage(props: { ); } -/** Markdown image whose src is a workspace file — loads through a signed asset URL. */ -function ThreadMarkdownImage(props: { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly path: string; +function ThreadMarkdownImageView(props: { + readonly uri: string | null; + readonly sourceKey: string; + readonly unavailable: boolean; readonly alt: string | null; readonly onPressImage: (uri: string) => void; }) { const codeBackground = useThemeColor("--color-md-code-bg"); + const [availableWidth, setAvailableWidth] = useState(0); + const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); - const assetUrl = useAssetUrlState(props.environmentId, { - _tag: "workspace-file", - threadId: props.threadId, - path: props.path, - }); - const uri = assetUrl._tag === "Success" ? assetUrl.url : null; - const failed = assetUrl._tag === "Failure" || (uri !== null && failedUri === uri); + const activeUriRef = useRef(props.uri); + activeUriRef.current = props.uri; + + useEffect(() => { + setSourceSize(null); + }, [props.sourceKey]); + + useEffect(() => { + setFailedUri(null); + }, [props.uri]); + + const displaySize = + sourceSize === null + ? null + : resolveMarkdownImageDisplaySize({ + sourceWidth: sourceSize.width, + sourceHeight: sourceSize.height, + availableWidth, + }); + const failed = props.unavailable || (props.uri !== null && failedUri === props.uri); + const placeholderWidth: ViewStyle["width"] = + availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%"; + const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 }; return ( - - {uri === null || failed ? ( + setAvailableWidth(event.nativeEvent.layout.width)} + style={{ alignSelf: "stretch", gap: 6 }} + > + {props.uri === null || failed ? ( props.onPressImage(uri)} + onPress={() => props.onPressImage(props.uri!)} + style={{ alignSelf: "flex-start" }} > - setFailedUri(uri)} + + > + { + if (activeUriRef.current !== props.uri) return; + const { width, height } = event.nativeEvent.source; + setSourceSize({ width, height }); + }} + onError={() => setFailedUri(props.uri)} + style={{ + width: "100%", + height: "100%", + opacity: displaySize === null ? 0 : 1, + }} + /> + {displaySize === null ? : null} + )} {props.alt ? ( @@ -263,28 +301,40 @@ function ThreadMarkdownImage(props: { ); } +/** Markdown image whose src is a workspace file — loads through a signed asset URL. */ +function ThreadMarkdownImage(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly path: string; + readonly alt: string | null; + readonly onPressImage: (uri: string) => void; +}) { + const assetUrl = useAssetUrlState(props.environmentId, { + _tag: "workspace-file", + threadId: props.threadId, + path: props.path, + }); + + return ( + + ); +} + function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { - const codeBackground = useThemeColor("--color-md-code-bg"); return ( - - - Image unavailable - - {props.alt ? ( - - {props.alt} - - ) : null} - + undefined} + /> ); } @@ -1530,7 +1580,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (image) => { const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); if (imageSource._tag === "Direct") { - return null; + return ( + setExpandedImage({ uri })} + /> + ); } if (imageSource._tag === "Blocked") { return ; diff --git a/apps/mobile/src/features/threads/markdownImageSize.test.ts b/apps/mobile/src/features/threads/markdownImageSize.test.ts new file mode 100644 index 000000000000..76170890d519 --- /dev/null +++ b/apps/mobile/src/features/threads/markdownImageSize.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + MARKDOWN_IMAGE_MAX_HEIGHT, + MARKDOWN_IMAGE_MAX_WIDTH, + resolveMarkdownImageDisplaySize, +} from "./markdownImageSize"; + +describe("resolveMarkdownImageDisplaySize", () => { + it("keeps small images at their intrinsic size", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 96, + sourceHeight: 96, + availableWidth: 332, + }), + ).toEqual({ width: 96, height: 96 }); + }); + + it("fits wide images to the available chat width", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 960, + sourceHeight: 540, + availableWidth: 332, + }), + ).toEqual({ width: 332, height: 186.75 }); + }); + + it("caps wide images at 480 points on larger screens", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 960, + sourceHeight: 540, + availableWidth: 900, + }), + ).toEqual({ width: MARKDOWN_IMAGE_MAX_WIDTH, height: 270 }); + }); + + it("caps tall images by height without changing their aspect ratio", () => { + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 400, + sourceHeight: 800, + availableWidth: 332, + }), + ).toEqual({ width: 240, height: MARKDOWN_IMAGE_MAX_HEIGHT }); + }); + + it("rejects dimensions that cannot produce a stable layout", () => { + expect( + resolveMarkdownImageDisplaySize({ sourceWidth: 0, sourceHeight: 100, availableWidth: 332 }), + ).toBeNull(); + expect( + resolveMarkdownImageDisplaySize({ + sourceWidth: 100, + sourceHeight: Number.NaN, + availableWidth: 332, + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/markdownImageSize.ts b/apps/mobile/src/features/threads/markdownImageSize.ts new file mode 100644 index 000000000000..0fb6f8fbcc6d --- /dev/null +++ b/apps/mobile/src/features/threads/markdownImageSize.ts @@ -0,0 +1,37 @@ +export const MARKDOWN_IMAGE_MAX_WIDTH = 480; +export const MARKDOWN_IMAGE_MAX_HEIGHT = 480; + +export interface MarkdownImageDisplaySize { + readonly width: number; + readonly height: number; +} + +/** Keeps small images intrinsic while fitting larger images inside the chat viewport. */ +export function resolveMarkdownImageDisplaySize(input: { + readonly sourceWidth: number; + readonly sourceHeight: number; + readonly availableWidth: number; +}): MarkdownImageDisplaySize | null { + if ( + !Number.isFinite(input.sourceWidth) || + !Number.isFinite(input.sourceHeight) || + !Number.isFinite(input.availableWidth) || + input.sourceWidth <= 0 || + input.sourceHeight <= 0 || + input.availableWidth <= 0 + ) { + return null; + } + + const scale = Math.min( + 1, + input.availableWidth / input.sourceWidth, + MARKDOWN_IMAGE_MAX_WIDTH / input.sourceWidth, + MARKDOWN_IMAGE_MAX_HEIGHT / input.sourceHeight, + ); + + return { + width: input.sourceWidth * scale, + height: input.sourceHeight * scale, + }; +} From 4e169df1dd12e0ed960dcb310e9460897759a55e Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Sun, 23 Aug 2026 05:16:07 +0500 Subject: [PATCH 08/91] fix(web): remove duplicate provider update progress (#7761) --- ...iderUpdateLaunchNotification.logic.test.ts | 16 ++++ .../ProviderUpdateLaunchNotification.logic.ts | 4 + .../ProviderUpdatePrimaryNotification.tsx | 76 +++++++------------ 3 files changed, 49 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 223960f8314d..2ee06a6b6620 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -31,6 +31,7 @@ import { parseWslDistroFromInstanceId, providerUpdateNotificationKey, resolveEnvironmentUpdateRowStatus, + shouldShowPrimaryProviderUpdateToast, type LocalEnvironmentProvidersInput, type LocalEnvironmentUpdateGroup, type LocalProviderUpdateOutcome, @@ -325,6 +326,21 @@ describe("provider update launch notification logic", () => { type: "loading", title: "Updating provider", }); + expect(shouldShowPrimaryProviderUpdateToast(view)).toBe(false); + }); + + it("keeps the initial prompt and terminal outcomes visible as toasts", () => { + expect( + shouldShowPrimaryProviderUpdateToast( + getProviderUpdateInitialToastView({ + updateProviders: [updateCandidate({ driver: driver("codex") })], + oneClickProviders: [updateCandidate({ driver: driver("codex") })], + }), + ), + ).toBe(true); + expect( + shouldShowPrimaryProviderUpdateToast(getProviderUpdateRejectedToastView(1, "boom")), + ).toBe(true); }); it("uses server failure state for failed progress", () => { diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 55999d2a31d8..8d8abf73e312 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -231,6 +231,10 @@ export function getProviderUpdateInitialToastView(input: { }; } +export function shouldShowPrimaryProviderUpdateToast(view: ProviderUpdateToastView): boolean { + return view.phase !== "running"; +} + export function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { return { phase: "running", diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index 00112ccec198..639f07c38c13 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -16,8 +16,8 @@ import { getProviderUpdateInitialToastView, getProviderUpdateProgressToastView, getProviderUpdateRejectedToastView, - getProviderUpdateRunningToastView, providerUpdateNotificationKey, + shouldShowPrimaryProviderUpdateToast, type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast"; @@ -31,7 +31,6 @@ type ActiveProviderUpdateToast = | { readonly kind: "update"; readonly key: string; - readonly toastId: ProviderUpdateToastId; readonly providerInstanceIds: ReadonlySet; readonly providerCount: number; }; @@ -57,20 +56,16 @@ function ProviderUpdateToastIcon({ provider }: { provider: ProviderDriverKind }) ); } -function updateProviderUpdateToast(input: { - readonly toastId: ProviderUpdateToastId; +function addProviderUpdateToast(input: { readonly view: ProviderUpdateToastView; - readonly openSettings: () => void; + readonly openSettings: (toastId: ProviderUpdateToastId) => void; }) { if (input.view.type === "loading" || input.view.type === "success") { - toastManager.update(input.toastId, { + return toastManager.add({ type: input.view.type, title: input.view.title, description: input.view.description, timeout: 0, - // Base UI merges toast updates and omits `undefined` keys, so `undefined` - // would leave the prompt's Update button in place. Replace it with a - // defined empty action so the CTA cannot linger while the update runs. actionProps: hiddenToastActionProps, data: { hideCopyButton: true, @@ -79,11 +74,10 @@ function updateProviderUpdateToast(input: { : {}), }, }); - return; } - toastManager.update( - input.toastId, + let toastId!: ProviderUpdateToastId; + toastId = toastManager.add( stackedThreadToast({ type: input.view.type, title: input.view.title, @@ -91,7 +85,7 @@ function updateProviderUpdateToast(input: { timeout: 0, actionProps: { children: "Settings", - onClick: input.openSettings, + onClick: () => input.openSettings(toastId), }, actionVariant: "outline", data: { @@ -99,10 +93,7 @@ function updateProviderUpdateToast(input: { }, }), ); -} - -function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { - return view.phase === "failed" || view.phase === "unchanged" || view.phase === "succeeded"; + return toastId; } /** @@ -126,10 +117,10 @@ export function ProviderUpdatePrimaryNotification() { useEffect(() => { return () => { const activeToast = activeToastRef.current; - if (activeToast) { + if (activeToast?.kind === "prompt") { toastManager.close(activeToast.toastId); - activeToastRef.current = null; } + activeToastRef.current = null; }; }, []); @@ -149,10 +140,14 @@ export function ProviderUpdatePrimaryNotification() { const activeToast = activeToastRef.current; if (toastId !== undefined) { toastManager.close(toastId); - } else if (activeToast) { + } else if (activeToast?.kind === "prompt") { toastManager.close(activeToast.toastId); } - if (activeToast && (toastId === undefined || activeToast.toastId === toastId)) { + if ( + activeToast && + (toastId === undefined || + (activeToast.kind === "prompt" && activeToast.toastId === toastId)) + ) { activeToastRef.current = null; } void navigate({ to: "/settings/providers" }); @@ -173,15 +168,12 @@ export function ProviderUpdatePrimaryNotification() { providers: activeProviders, providerCount: activeToast.providerCount, }); - updateProviderUpdateToast({ - toastId: activeToast.toastId, - view, - openSettings: () => openProviderSettings(activeToast.toastId), - }); - - if (isTerminalProviderUpdateToastView(view)) { - activeToastRef.current = null; + if (!shouldShowPrimaryProviderUpdateToast(view)) { + return; } + + addProviderUpdateToast({ view, openSettings: openProviderSettings }); + activeToastRef.current = null; }, [providers, openProviderSettings]); useEffect(() => { @@ -219,19 +211,15 @@ export function ProviderUpdatePrimaryNotification() { const providerCount = oneClickProviders.length; const providerInstanceIds = new Set(oneClickProviders.map((provider) => provider.instanceId)); - activeToastRef.current = { + const activeUpdate: ActiveProviderUpdateToast = { kind: "update", key: notificationKey, - toastId, providerInstanceIds, providerCount, }; + activeToastRef.current = activeUpdate; - updateProviderUpdateToast({ - toastId, - view: getProviderUpdateRunningToastView(providerCount), - openSettings, - }); + toastManager.close(toastId); void (async () => { const results = []; @@ -248,16 +236,15 @@ export function ProviderUpdatePrimaryNotification() { } const activeUpdateToast = activeToastRef.current; - if (activeUpdateToast?.kind !== "update" || activeUpdateToast.toastId !== toastId) { + if (activeUpdateToast !== activeUpdate) { return; } const failedMessage = firstFailedProviderUpdateMessage(results); if (failedMessage) { - updateProviderUpdateToast({ - toastId, + addProviderUpdateToast({ view: getProviderUpdateRejectedToastView(providerCount, failedMessage), - openSettings, + openSettings: openProviderSettings, }); activeToastRef.current = null; return; @@ -271,13 +258,8 @@ export function ProviderUpdatePrimaryNotification() { providers: updatedProviderSnapshots, providerCount, }); - updateProviderUpdateToast({ - toastId, - view, - openSettings, - }); - - if (isTerminalProviderUpdateToastView(view)) { + if (shouldShowPrimaryProviderUpdateToast(view)) { + addProviderUpdateToast({ view, openSettings: openProviderSettings }); activeToastRef.current = null; } })(); From 30be31195883635aba96031a8d79c255fb28b438 Mon Sep 17 00:00:00 2001 From: Rishet11 <154429365+Rishet11@users.noreply.github.com> Date: Sun, 23 Aug 2026 05:46:25 +0530 Subject: [PATCH 09/91] fix(server): fall back to the remote default branch instead of assuming main (#7078) --- apps/server/src/git/GitManager.test.ts | 51 +++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 12 ++++++ apps/server/src/vcs/GitVcsDriver.ts | 4 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 1 + 4 files changed, 68 insertions(+) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6291b3f33b2f..01a9d43195a9 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -2404,6 +2404,57 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("create_pr targets the remote default branch when it is not main", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + // A repository whose default branch is master, with no main anywhere. + yield* runGit(repoDir, ["push", "origin", "HEAD:master"]); + yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, ["remote", "set-head", "origin", "master"]); + + yield* runGit(repoDir, ["checkout", "-b", "feature/master-default"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "master-default.txt"), "master default\n"); + yield* runGit(repoDir, ["add", "master-default.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Master default"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + // Mirrors a provider that cannot report a default branch, as the Azure + // DevOps CLI does when it cannot detect the repository. + defaultBranch: "", + prListSequence: [ + "[]", + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 505, + title: "Master default", + url: "https://github.com/pingdotgg/codething-mvp/pull/505", + baseRefName: "master", + headRefName: "feature/master-default", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect( + ghCalls.some((call) => + call.includes("pr create --base master --head feature/master-default"), + ), + ).toBe(true); + }), + ); + it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index c135051260fe..0729d2282fb7 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1489,6 +1489,18 @@ export const make = Effect.gen(function* () { return defaultFromProvider; } + // The provider lookup can fail for reasons unrelated to the branch, so fall + // back to what the remote itself records before assuming a name. A repository + // whose default branch is master would otherwise get a base branch that does + // not exist. + const defaultFromRemote = yield* gitCore.resolvePrimaryRemoteName(cwd).pipe( + Effect.flatMap((remoteName) => gitCore.resolveDefaultBranchName(cwd, remoteName)), + Effect.orElseSucceed(() => null), + ); + if (defaultFromRemote) { + return defaultFromRemote; + } + return "main"; }); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index b9ef992122ae..0282ffaa4d89 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -289,6 +289,10 @@ export class GitVcsDriver extends Context.Service< ) => Effect.Effect; readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect; + readonly resolveDefaultBranchName: ( + cwd: string, + remoteName: string, + ) => Effect.Effect; readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect; readonly remoteExists: (input: GitRemoteExistsInput) => Effect.Effect; readonly resolveRemoteTrackingCommit: ( diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index cd16c70291a4..800ec6d4e722 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3189,6 +3189,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* withListRefsInvalidation(input.cwd, refreshCheckedOutBranch(input)), ensureRemote: (input) => withListRefsInvalidation(input.cwd, ensureRemote(input)), resolvePrimaryRemoteName, + resolveDefaultBranchName, fetchRemote: (input) => withListRefsInvalidation(input.cwd, fetchRemote(input)), remoteExists, resolveRemoteTrackingCommit, From fdd1572b69537c95e31b45fdfafe88bbe964807a Mon Sep 17 00:00:00 2001 From: Ishaan Kothari Date: Sat, 22 Aug 2026 22:37:39 -0700 Subject: [PATCH 10/91] fix(web): give sidebar project menu rows the same side padding as other menus (#7913) --- apps/web/src/components/Sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af7cdc8a94a2..a8f2ea52995a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3515,7 +3515,7 @@ export default function Sidebar() { All projects @@ -3527,7 +3527,7 @@ export default function Sidebar() { key={scopeKey} value={scopeKey} closeOnClick - className="h-8 min-h-8 px-1 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2" + className="h-8 min-h-8 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2" > Date: Sat, 22 Aug 2026 23:25:35 -0700 Subject: [PATCH 11/91] fix(clients): reconnect after credentials fail during remote server updates (#7953) --- .../client-runtime/src/state/server.test.ts | 24 +++++++++++++++++++ packages/client-runtime/src/state/server.ts | 18 ++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 8edecae5646e..8ee312f61b21 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -124,6 +124,30 @@ describe("update restart reconnect nudges", () => { yield* Fiber.join(fiber); }).pipe(Effect.provide(TestClock.layer())), ); + + it.effect("retries rejected credentials only while the update restart is in progress", () => + Effect.gen(function* () { + const retries = yield* Ref.make(0); + + yield* nudgeReconnectDuringUpdateRestart({ + stateChanges: Stream.fromIterable([ + { phase: "blocked", lastFailure: { reason: "permission" } }, + { + phase: "blocked", + lastFailure: { + reason: "authentication", + detail: "The environment credential is invalid.", + }, + }, + { phase: "blocked", lastFailure: { reason: "configuration" } }, + ]), + retryNow: Ref.update(retries, (count) => count + 1), + interval: Duration.zero, + }); + + expect(yield* Ref.get(retries)).toBe(1); + }), + ); }); describe("server state projection", () => { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..2fef689a9bbb 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -160,16 +160,30 @@ export function validateServerUpdateReadyEvent( * each nudge is the pacer: a connection that fails instantly re-enters backoff * immediately and would otherwise spin a tight retry loop. * + * A newly restarted server can also reject the first environment credential. + * Authentication blocks need the same paced retry during this known restart; + * permission and configuration failures remain blocked. + * * Callers fork this as a child of the update command so it is interrupted as * soon as the update settles, whether it succeeds, fails, or times out. */ export function nudgeReconnectDuringUpdateRestart(input: { - readonly stateChanges: Stream.Stream<{ readonly phase: string }, unknown>; + readonly stateChanges: Stream.Stream< + { + readonly phase: string; + readonly lastFailure?: { readonly reason: string } | null; + }, + unknown + >; readonly retryNow: Effect.Effect; readonly interval?: Duration.Duration; }): Effect.Effect { return input.stateChanges.pipe( - Stream.filter((state) => state.phase === "backoff"), + Stream.filter( + (state) => + state.phase === "backoff" || + (state.phase === "blocked" && state.lastFailure?.reason === "authentication"), + ), Stream.runForEach(() => Effect.sleep(input.interval ?? Duration.seconds(1)).pipe(Effect.andThen(input.retryNow)), ), From 3db38b8814c49cfd783543371fad8cd4adbc19fd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 00:00:36 -0700 Subject: [PATCH 12/91] feat(codex): submit thread feedback to OpenAI (#7949) --- .../src/features/threads/ThreadComposer.tsx | 5 +- apps/mobile/src/lib/threadActivity.test.ts | 67 ++++++++ apps/mobile/src/lib/threadActivity.ts | 6 +- .../src/state/use-thread-composer-state.ts | 112 +++++++++++- ...ProviderSessionStartup.integration.test.ts | 1 + apps/server/src/auth/RpcAuthorization.test.ts | 6 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 1 + .../Layers/ProviderRuntimeIngestion.test.ts | 1 + .../src/provider/Layers/CodexAdapter.test.ts | 44 +++++ .../src/provider/Layers/CodexAdapter.ts | 12 ++ .../src/provider/Layers/CodexProvider.ts | 7 + .../provider/Layers/CodexSessionRuntime.ts | 13 ++ .../Layers/ProviderAdapterRegistry.test.ts | 1 + .../provider/Layers/ProviderRegistry.test.ts | 7 + .../provider/Layers/ProviderService.test.ts | 160 +++++++++++++++++ .../src/provider/Layers/ProviderService.ts | 43 +++++ .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/Services/CodexAdapter.ts | 6 +- .../src/provider/Services/ProviderAdapter.ts | 9 + .../src/provider/Services/ProviderService.ts | 9 + apps/server/src/server.test.ts | 92 ++++++++-- .../serverRuntimeStartup.reconcile.test.ts | 1 + apps/server/src/ws.ts | 17 ++ apps/web/src/components/ChatView.tsx | 146 +++++++++++++++- .../chat/ComposerPrimaryActions.test.tsx | 11 +- .../components/chat/MessagesTimeline.test.tsx | 56 ++++++ docs/user/providers-codex.md | 6 + .../src/state/threadCommands.ts | 13 +- .../src/state/threadFeedback.test.ts | 162 ++++++++++++++++++ .../src/state/threadFeedback.ts | 87 ++++++++++ packages/client-runtime/src/state/threads.ts | 1 + packages/contracts/src/provider.test.ts | 39 +++++ packages/contracts/src/provider.ts | 23 +++ packages/contracts/src/rpc.ts | 15 ++ 36 files changed, 1152 insertions(+), 30 deletions(-) create mode 100644 packages/client-runtime/src/state/threadFeedback.test.ts create mode 100644 packages/client-runtime/src/state/threadFeedback.ts diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index f4b78f181e7d..c771aaebcb6e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -552,7 +552,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); try { - await onSendMessage(); + const messageId = await onSendMessage(); + if (messageId === null) { + return; + } // Sending a prompt starts agent work: arm the lock-screen card while the // app is foregrounded and the activity token can be registered. Armed // after the send so its preference read and native Activity start don't diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 148b588c1103..55dcaa9fbad3 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; import { EventId, @@ -22,6 +23,34 @@ import { type ThreadFeedEntry, } from "./threadActivity"; +describe("Codex feedback pseudo-messages", () => { + it("keeps pending and completed feedback messages in the mobile thread body", () => { + const pending = { + id: MessageId.make("feedback-command"), + command: "/feedback The agent stopped early.", + createdAt: "2026-08-23T00:00:00.000Z", + status: "uploading" as const, + }; + const entries = [codexFeedbackMessage(pending), codexFeedbackMessage(pending, "assistant")].map( + (message) => ({ + type: "message" as const, + id: message.id, + createdAt: message.createdAt, + message, + }), + ); + + expect(deriveThreadFeedPresentation(entries, null, new Set())).toEqual(entries); + expect(entries[1]?.message.text).toBe("Sending feedback to OpenAI..."); + + const completed = codexFeedbackMessage( + { ...pending, status: "sent", feedbackId: "codex-thread-1" }, + "assistant", + ); + expect(completed.text).toContain("codex-thread-1"); + }); +}); + const singleSelectQuestion = { id: "runtime", header: "Runtime", @@ -151,6 +180,44 @@ function makeThread( } describe("buildThreadFeed", () => { + it("keeps older local feedback before newer messages returned by the server", () => { + const submission = { + id: MessageId.make("feedback-command-ordering"), + command: "/feedback The agent stopped early.", + createdAt: "2026-08-23T00:00:01.000Z", + status: "sent" as const, + feedbackId: "codex-thread-1", + }; + const laterMessage = { + id: MessageId.make("later-server-message"), + role: "assistant" as const, + text: "Newer server response", + turnId: null, + createdAt: "2026-08-23T00:00:02.000Z", + updatedAt: "2026-08-23T00:00:02.000Z", + streaming: false, + }; + const thread = makeThread({ + id: ThreadId.make("thread-feedback-ordering"), + projectId: ProjectId.make("project-1"), + title: "Feedback ordering", + messages: [laterMessage], + }); + + const feed = buildThreadFeed(thread, { + localMessages: [ + codexFeedbackMessage(submission), + codexFeedbackMessage(submission, "assistant"), + ], + }); + + expect(feed.map((entry) => entry.id)).toEqual([ + "feedback-command-ordering", + "feedback-command-ordering:feedback", + "later-server-message", + ]); + }); + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 63c22607efc6..fbde33da8514 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1526,15 +1526,19 @@ export function buildThreadFeed( thread: OrchestrationThread, options?: { readonly loadedMessages?: ReadonlyArray; + readonly localMessages?: ReadonlyArray; }, ): ThreadFeedEntry[] { const loadedMessages = options?.loadedMessages ?? thread.messages; + const messages = options?.localMessages + ? [...loadedMessages, ...options.localMessages] + : loadedMessages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; const workLogEntries = deriveWorkLogEntries(thread.activities); const entries = Arr.sortWith( [ - ...loadedMessages.map((message) => ({ + ...messages.map((message) => ({ type: "message", id: message.id, createdAt: message.createdAt, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 721c82a0e38e..dd7ace60ad99 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,5 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert } from "react-native"; +import * as Cause from "effect/Cause"; import { CommandId, @@ -11,6 +13,13 @@ import { type ThreadId, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + codexFeedbackMessage, + parseCodexFeedbackCommand, + submitCodexFeedback, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; @@ -21,6 +30,7 @@ import { } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; import { buildThreadFeed } from "../lib/threadActivity"; import { appAtomRegistry } from "../state/atom-registry"; import { @@ -41,6 +51,8 @@ import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; import { enqueueThreadOutboxMessage } from "./thread-outbox"; import { useThreadOutboxMessages } from "./use-thread-outbox"; +import { threadEnvironment } from "./threads"; +import { useAtomCommand } from "./use-atom-command"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -74,10 +86,16 @@ export function useThreadDraftForThread(input: { } export function useThreadComposerState() { - const { selectedThread: selectedThreadShell } = useThreadSelection(); + const { selectedThread: selectedThreadShell, selectedEnvironmentRuntime } = useThreadSelection(); const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< + Record> + >({}); + const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { + reportFailure: false, + }); useEffect(() => { ensureComposerDraftsLoaded(); @@ -90,10 +108,21 @@ export function useThreadComposerState() { () => (selectedThreadKey ? (queuedMessagesByThreadKey[selectedThreadKey] ?? []) : []), [queuedMessagesByThreadKey, selectedThreadKey], ); - const selectedThreadFeed = useMemo( - () => (selectedThreadDetail ? buildThreadFeed(selectedThreadDetail) : []), - [selectedThreadDetail], - ); + const selectedThreadFeed = useMemo(() => { + if (!selectedThreadDetail) { + return []; + } + const submissions = selectedThreadKey + ? (feedbackSubmissionsByThreadKey[selectedThreadKey] ?? []) + : []; + return buildThreadFeed(selectedThreadDetail, { + localMessages: submissions.flatMap((submission) => + submission.status === "interrupted" + ? [] + : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], + ), + }); + }, [feedbackSubmissionsByThreadKey, selectedThreadDetail, selectedThreadKey]); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; const draftMessage = selectedDraft?.text ?? ""; @@ -143,6 +172,70 @@ export function useThreadComposerState() { return null; } + const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find( + (entry) => entry.instanceId === thread.modelSelection.instanceId, + ); + const feedbackCommand = + attachments.length === 0 && + (provider?.driver === "codex" || thread.session?.providerName === "codex") + ? parseCodexFeedbackCommand(text) + : null; + if (feedbackCommand) { + if (thread.session === null) { + Alert.alert("Start a Codex thread first", "Send a message before you submit feedback."); + return null; + } + const metadata = makeQueuedMessageMetadata(); + const result = await submitCodexFeedback({ + submission: { + id: MessageId.make(metadata.messageId), + command: text, + createdAt: metadata.createdAt, + }, + clearDraft: () => clearComposerDraftContent(threadKey), + onUpdate: (submission) => { + setFeedbackSubmissionsByThreadKey((current) => { + const existing = current[threadKey] ?? []; + const found = existing.some((entry) => entry.id === submission.id); + return { + ...current, + [threadKey]: found + ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) + : [...existing, submission], + }; + }); + }, + upload: () => + uploadThreadFeedback({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + ...feedbackCommand, + }, + }), + }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + return null; + } + const error = Cause.squash(result.cause); + Alert.alert( + "Could not send feedback to OpenAI", + error instanceof Error ? error.message : "An error occurred.", + ); + return null; + } + const feedbackId = result.value.feedbackId; + Alert.alert("Feedback sent to OpenAI", `Thread ID: ${feedbackId}`, [ + { text: "OK", style: "cancel" }, + { + text: "Copy ID", + onPress: () => copyTextWithHaptic(feedbackId, { target: "Codex feedback thread ID" }), + }, + ]); + return null; + } + const metadata = makeQueuedMessageMetadata(); const messageId = MessageId.make(metadata.messageId); // Enqueue publishes the queued atom synchronously (the durable write @@ -175,7 +268,12 @@ export function useThreadComposerState() { ); }); return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + }, [ + selectedEnvironmentRuntime?.serverConfig?.providers, + selectedThreadDetail, + selectedThreadShell, + uploadThreadFeedback, + ]); const onChangeDraftMessage = useCallback( (value: string) => { diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 2a351cd6bb48..78a33364f5a3 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -116,6 +116,7 @@ const startupDependencies = Layer.mergeAll( getCapabilities: () => Effect.die("unused"), getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), + uploadFeedback: () => Effect.die("unused"), streamEvents: Stream.empty, }), ); diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 790be9386e6e..25971b0c0aec 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -37,6 +37,12 @@ describe("RPC authorization scopes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); }); + it("requires permission to operate on a thread before uploading feedback", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.providerUploadFeedback)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..70227cdd4ebf 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -84,6 +84,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, + [WS_METHODS.providerUploadFeedback]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 08ea1437bb29..ca4cb7afd9ab 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -125,6 +125,7 @@ function createProviderServiceHarness( }, }), rollbackConversation, + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..8766e8cb76f6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -340,6 +340,7 @@ describe("ProviderCommandReactor", () => { }); }, rollbackConversation: () => unsupported(), + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1e1374c966b6..84858b6affe9 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -125,6 +125,7 @@ function createProviderServiceHarness() { }); }, rollbackConversation: () => unsupported(), + uploadFeedback: () => unsupported(), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3dae02feac84..26fb1b166f61 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -104,6 +104,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { }), ); + public readonly uploadFeedbackImpl = vi.fn((_reason?: string) => + Promise.resolve({ threadId: "provider-thread-1" }), + ); + public readonly respondToRequestImpl = vi.fn( (_requestId: ApprovalRequestId, _decision: ProviderApprovalDecision): Promise => Promise.resolve(undefined), @@ -142,6 +146,10 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { return Effect.promise(() => this.rollbackThreadImpl(numTurns)); } + uploadFeedback(reason?: string) { + return Effect.promise(() => this.uploadFeedbackImpl(reason)); + } + respondToRequest(requestId: ApprovalRequestId, decision: ProviderApprovalDecision) { return Effect.promise(() => this.respondToRequestImpl(requestId, decision)); } @@ -328,6 +336,42 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("uploads feedback for the active Codex thread", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("thread-feedback"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + + const result = yield* adapter.uploadFeedback({ + threadId, + reason: "The agent stopped early.", + }); + + NodeAssert.deepStrictEqual(result, { feedbackId: "provider-thread-1" }); + NodeAssert.deepStrictEqual(runtime.uploadFeedbackImpl.mock.calls, [ + ["The agent stopped early."], + ]); + }), + ); + + it.effect("rejects feedback for an unknown Codex thread", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const result = yield* adapter + .uploadFeedback({ threadId: asThreadId("thread-feedback-missing") }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); + }), + ); + it.effect("maps codex model options before sending a turn", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 7cef4911bc0c..bc48f94b3866 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1891,6 +1891,17 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ); }; + const uploadFeedback: CodexAdapterShape["uploadFeedback"] = (input) => + requireSession(input.threadId).pipe( + Effect.flatMap((session) => session.runtime.uploadFeedback(input.reason)), + Effect.map(({ threadId }) => ({ feedbackId: threadId })), + Effect.mapError((cause) => + cause._tag === "ProviderAdapterSessionNotFoundError" + ? cause + : mapCodexRuntimeError(input.threadId, "feedback/upload", cause), + ), + ); + const respondToRequest: CodexAdapterShape["respondToRequest"] = (threadId, requestId, decision) => requireSession(threadId).pipe( Effect.flatMap((session) => session.runtime.respondToRequest(requestId, decision)), @@ -1978,6 +1989,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( interruptTurn, readThread, rollbackThread, + uploadFeedback, respondToRequest, respondToUserInput, stopSession, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 6e485dd0a287..93730046dc49 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -607,6 +607,13 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu checkedAt, models: snapshot.models, skills: snapshot.skills, + slashCommands: [ + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ], probe: { installed: true, version: snapshot.version ?? null, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 29bb992611c1..fd926e43d7bf 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -142,6 +142,9 @@ export interface CodexSessionRuntimeShape { readonly rollbackThread: ( numTurns: number, ) => Effect.Effect; + readonly uploadFeedback: ( + reason?: string, + ) => Effect.Effect; readonly respondToRequest: ( requestId: ApprovalRequestId, decision: ProviderApprovalDecision, @@ -1914,6 +1917,16 @@ export const makeCodexSessionRuntime = ( }); return parseThreadSnapshot(response); }), + uploadFeedback: (reason) => + Effect.gen(function* () { + const providerThreadId = yield* readProviderThreadId; + return yield* client.request("feedback/upload", { + classification: "bug", + includeLogs: true, + ...(reason ? { reason } : {}), + threadId: providerThreadId, + }); + }), respondToRequest: (requestId, decision) => Effect.gen(function* () { const pending = (yield* Ref.get(pendingApprovalsRef)).get(requestId); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index c4145ecf1a0e..280601275a7e 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -40,6 +40,7 @@ const fakeCodexAdapter: CodexAdapter.CodexAdapterShape = { hasSession: vi.fn(), readThread: vi.fn(), rollbackThread: vi.fn(), + uploadFeedback: vi.fn(), stopAll: vi.fn(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index cc994f8e5fc2..f7ae95d8a927 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -381,6 +381,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te shortDescription: "Debug failing GitHub Actions checks", }, ]); + assert.deepStrictEqual(status.slashCommands, [ + { + name: "feedback", + description: "Send this thread and Codex logs to OpenAI", + input: { hint: "Describe the issue (optional)" }, + }, + ]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 67b4bd9bd37c..bd89dc4f8812 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -9,6 +9,8 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderTurnStartResult, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -197,6 +199,13 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { Effect.succeed({ threadId, turns: [] }), ); + const uploadFeedback = vi.fn( + ( + input: ProviderUploadFeedbackInput, + ): Effect.Effect => + Effect.succeed({ feedbackId: `feedback-${input.threadId}` }), + ); + const stopAll = vi.fn( (): Effect.Effect => Effect.sync(() => { @@ -219,6 +228,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { hasSession, readThread, rollbackThread, + ...(provider === CODEX_DRIVER ? { uploadFeedback } : {}), stopAll, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); @@ -254,6 +264,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { hasSession, readThread, rollbackThread, + uploadFeedback, stopAll, }; } @@ -595,6 +606,68 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance const routing = makeProviderServiceLayer(); +it.effect( + "ProviderServiceLive uploads feedback through the adapter that recovered the session", + () => + Effect.gen(function* () { + const original = makeFakeCodexAdapter(); + const replacement = makeFakeCodexAdapter(); + const baseRegistry = makeAdapterRegistryMock({ [CODEX_DRIVER]: original.adapter }); + let swapAfterFirstLookup = false; + let feedbackLookupCount = 0; + const registry: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"] = { + ...baseRegistry, + getByInstance: (instanceId) => { + if (instanceId !== codexInstanceId) { + return baseRegistry.getByInstance(instanceId); + } + const useReplacement = swapAfterFirstLookup && feedbackLookupCount++ > 0; + return Effect.succeed(useReplacement ? replacement.adapter : original.adapter); + }, + }; + const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry.ProviderAdapterRegistry, registry)), + Layer.provide(directoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(serverConfigTestLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-adapter-replacement"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* original.stopSession(threadId); + original.uploadFeedback.mockClear(); + replacement.uploadFeedback.mockClear(); + swapAfterFirstLookup = true; + + const result = yield* provider.uploadFeedback({ threadId }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.strictEqual(original.uploadFeedback.mock.calls.length, 0); + assert.deepStrictEqual(replacement.uploadFeedback.mock.calls, [[{ threadId }]]); + }).pipe(Effect.provide(providerLayer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("ProviderServiceLive writes canonical events to the emitting thread segment", () => Effect.gen(function* () { const codex = makeFakeCodexAdapter(); @@ -941,6 +1014,93 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("routes feedback to the Codex adapter and returns its feedback ID", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-route"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + runtimeMode: "full-access", + }); + routing.codex.uploadFeedback.mockClear(); + + const result = yield* provider.uploadFeedback({ + threadId, + reason: "The agent stopped early.", + }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.deepStrictEqual(routing.codex.uploadFeedback.mock.calls, [ + [{ threadId, reason: "The agent stopped early." }], + ]); + }), + ); + + it.effect("recovers a stopped Codex session before uploading feedback", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-recover"); + yield* provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/feedback-project", + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(threadId); + routing.codex.startSession.mockClear(); + routing.codex.uploadFeedback.mockClear(); + + const result = yield* provider.uploadFeedback({ threadId }); + + assert.deepStrictEqual(result, { feedbackId: `feedback-${threadId}` }); + assert.strictEqual(routing.codex.startSession.mock.calls.length, 1); + assert.deepStrictEqual(routing.codex.uploadFeedback.mock.calls, [[{ threadId }]]); + }), + ); + + it.effect("rejects feedback for providers that do not support uploads", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-claude"); + yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + + const error = yield* provider.uploadFeedback({ threadId }).pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.include(error.issue, "does not support feedback uploads"); + routing.claude.startSession.mockClear(); + }), + ); + + it.effect("does not restart an unsupported provider before rejecting feedback", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("thread-feedback-unsupported-stopped"); + yield* provider.startSession(threadId, { + provider: CLAUDE_AGENT_DRIVER, + providerInstanceId: claudeAgentInstanceId, + threadId, + runtimeMode: "full-access", + }); + yield* routing.claude.stopSession(threadId); + routing.claude.startSession.mockClear(); + + const error = yield* provider.uploadFeedback({ threadId }).pipe(Effect.flip); + + assert.instanceOf(error, ProviderValidationError); + assert.include(error.issue, "does not support feedback uploads"); + assert.strictEqual(routing.claude.startSession.mock.calls.length, 0); + }), + ); + it.effect("appends attachment file paths to the turn input text", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8e7f9147dc3e..b8cd0df539ac 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -19,6 +19,7 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, + ProviderUploadFeedbackInput, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, @@ -1117,6 +1118,47 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); }); + const uploadFeedback: ProviderServiceMethod<"uploadFeedback"> = Effect.fn("uploadFeedback")( + function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.uploadFeedback", + schema: ProviderUploadFeedbackInput, + payload: rawInput, + }); + let routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.uploadFeedback", + allowRecovery: false, + }); + if (routed.adapter.uploadFeedback === undefined) { + return yield* toValidationError( + "ProviderService.uploadFeedback", + `Provider '${routed.adapter.provider}' does not support feedback uploads.`, + ); + } + if (!routed.isActive) { + routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.uploadFeedback", + allowRecovery: true, + }); + } + const uploadFeedback = routed.adapter.uploadFeedback; + if (uploadFeedback === undefined) { + return yield* toValidationError( + "ProviderService.uploadFeedback", + `Provider '${routed.adapter.provider}' does not support feedback uploads.`, + ); + } + yield* Effect.annotateCurrentSpan({ + "provider.operation": "upload-feedback", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + }); + return yield* uploadFeedback(input); + }, + ); + const runStopAll = Effect.fn("runStopAll")(function* () { const threadIds = yield* directory.listThreadIds(); const currentAdapters = yield* getAdapterEntries; @@ -1188,6 +1230,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( getCapabilities, getInstanceInfo, rollbackConversation, + uploadFeedback, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 1281b2f70fe8..0b1bc9e149f7 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -184,6 +184,7 @@ describe("ProviderSessionReaper", () => { }); }, rollbackConversation: () => unsupported(), + uploadFeedback: () => unsupported(), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Services/CodexAdapter.ts b/apps/server/src/provider/Services/CodexAdapter.ts index 33fe0fa12be0..a0d9c0c28e9e 100644 --- a/apps/server/src/provider/Services/CodexAdapter.ts +++ b/apps/server/src/provider/Services/CodexAdapter.ts @@ -16,4 +16,8 @@ import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; * CodexAdapterShape — per-instance Codex adapter contract. Carries * a branded driver kind as the nominal discriminant. */ -export interface CodexAdapterShape extends ProviderAdapterShape {} +export interface CodexAdapterShape extends ProviderAdapterShape { + readonly uploadFeedback: NonNullable< + ProviderAdapterShape["uploadFeedback"] + >; +} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd7..634745832b37 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -16,6 +16,8 @@ import type { ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, ThreadId, ProviderTurnStartResult, TurnId, @@ -114,6 +116,13 @@ export interface ProviderAdapterShape { numTurns: number, ) => Effect.Effect; + /** + * Upload a thread to the provider when the adapter supports feedback. + */ + readonly uploadFeedback?: ( + input: ProviderUploadFeedbackInput, + ) => Effect.Effect; + /** * Stop all sessions owned by this adapter. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 4d4cb4fa01a7..545641d2e866 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -21,6 +21,8 @@ import type { ProviderSession, ProviderSessionStartInput, ProviderStopSessionInput, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, ThreadId, ProviderTurnStartResult, } from "@t3tools/contracts"; @@ -105,6 +107,13 @@ export interface ProviderServiceShape { readonly numTurns: number; }) => Effect.Effect; + /** + * Upload a thread and return the provider's shareable feedback identifier. + */ + readonly uploadFeedback: ( + input: ProviderUploadFeedbackInput, + ) => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index de3f5101f53e..02a367c08792 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -114,6 +114,8 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; +import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -387,6 +389,7 @@ const buildAppUnderTest = (options?: { layers?: { keybindings?: Partial; providerRegistry?: Partial; + providerService?: Partial; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -629,18 +632,24 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), - refresh: () => Effect.succeed([]), - refreshInstance: () => Effect.succeed([]), - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), - ), - setProviderMaintenanceActionState: () => Effect.succeed([]), - streamChanges: Stream.empty, - ...options?.layers?.providerRegistry, - }), + Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + refresh: () => Effect.succeed([]), + refreshInstance: () => Effect.succeed([]), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + ), + setProviderMaintenanceActionState: () => Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.providerRegistry, + }), + Layer.mock(ProviderService.ProviderService)({ + uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"), + ...options?.layers?.providerService, + }), + ), ), Layer.provide( Layer.mock(ServerSettings.ServerSettingsService)({ @@ -4452,6 +4461,65 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("uploads Codex thread feedback through websocket rpc", () => + Effect.gen(function* () { + const input = { + threadId: ThreadId.make("thread-feedback"), + reason: "The agent stopped early.", + }; + const uploadFeedback = vi.fn( + () => Effect.succeed({ feedbackId: "codex-thread-feedback" }), + ); + yield* buildAppUnderTest({ + layers: { + providerService: { uploadFeedback }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.providerUploadFeedback](input)), + ); + + assert.deepStrictEqual(response, { feedbackId: "codex-thread-feedback" }); + assert.deepStrictEqual(uploadFeedback.mock.calls, [[input]]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("keeps feedback errors structured across websocket rpc", () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-feedback-failure"); + yield* buildAppUnderTest({ + layers: { + providerService: { + uploadFeedback: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "feedback/upload", + detail: "private provider detail", + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const error = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.providerUploadFeedback]({ threadId }).pipe(Effect.flip), + ), + ); + + assert.strictEqual(error._tag, "ProviderUploadFeedbackError"); + if (error._tag === "ProviderUploadFeedbackError") { + assert.strictEqual(error.threadId, threadId); + assert.strictEqual(error.message, `Failed to upload feedback for thread ${threadId}.`); + assert.isDefined(error.cause); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("shares one preview automation broker across websocket sessions", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 60cb8d61bc06..485cd5bb08a4 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -55,6 +55,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray = []) => getCapabilities: () => Effect.die("unused"), getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), + uploadFeedback: () => Effect.die("unused"), streamEvents: Stream.empty, }) satisfies ProviderService.ProviderService["Service"]; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c3caea225704..11c659e28a70 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -42,6 +42,7 @@ import { ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, + ProviderUploadFeedbackError, RelayClientInstallFailedError, type RelayClientInstallProgressEvent, type ServerSelfUpdateError, @@ -81,6 +82,7 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -432,6 +434,7 @@ const makeWsRpcLayer = ( const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const providerService = yield* ProviderService.ProviderService; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; @@ -1534,6 +1537,20 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.providerUploadFeedback]: (input) => + observeRpcEffect( + WS_METHODS.providerUploadFeedback, + providerService.uploadFeedback(input).pipe( + Effect.mapError( + (cause) => + new ProviderUploadFeedbackError({ + threadId: input.threadId, + cause, + }), + ), + ), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 20966deb0c4f..bbee2d1709f3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -33,6 +33,12 @@ import { effectiveSnoozed, threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; +import { + codexFeedbackMessage, + parseCodexFeedbackCommand, + submitCodexFeedback, + type CodexFeedbackSubmission, +} from "@t3tools/client-runtime/state/threads"; import { parseScopedThreadKey, scopedThreadKey, @@ -124,6 +130,7 @@ import { type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; +import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; @@ -1268,6 +1275,9 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const uploadThreadFeedback = useAtomCommand(threadEnvironment.uploadFeedback, { + reportFailure: false, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1383,6 +1393,16 @@ function ChatViewContent(props: ChatViewProps) { const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [feedbackSubmissionsByThreadKey, setFeedbackSubmissionsByThreadKey] = useState< + Record> + >({}); + const feedbackSubmissions = useMemo( + () => feedbackSubmissionsByThreadKey[routeThreadKey] ?? [], + [feedbackSubmissionsByThreadKey, routeThreadKey], + ); + const feedbackUploading = feedbackSubmissions.some( + (submission) => submission.status === "uploading", + ); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< @@ -1444,6 +1464,7 @@ function ChatViewContent(props: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); useLayoutEffect(() => { @@ -2603,16 +2624,29 @@ function ChatViewContent(props: ChatViewProps) { return changed ? { ...message, attachments } : message; }); - if (optimisticUserMessages.length === 0) { + const localMessages = [ + ...optimisticUserMessages, + ...feedbackSubmissions.flatMap((submission) => + submission.status === "interrupted" + ? [] + : [codexFeedbackMessage(submission), codexFeedbackMessage(submission, "assistant")], + ), + ]; + if (localMessages.length === 0) { return serverMessagesWithPreviewHandoff; } const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); - const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); + const pendingMessages = localMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { return serverMessagesWithPreviewHandoff; } return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); + }, [ + attachmentPreviewHandoffByMessageId, + displayServerMessages, + feedbackSubmissions, + optimisticUserMessages, + ]); const timelineEntries = useMemo( () => deriveTimelineEntries( @@ -5061,7 +5095,8 @@ function ChatViewContent(props: ChatViewProps) { isSendBusy || isConnecting || threadDetailLoading || - sendInFlightRef.current + sendInFlightRef.current || + feedbackUploadsInFlightRef.current.has(routeThreadKey) ) { notifyDirectAnnotationAttached(); return; @@ -5136,6 +5171,101 @@ function ChatViewContent(props: ChatViewProps) { composerPreviewAnnotations.length + composerReviewComments.length, }); + const feedbackCommand = + ctxSelectedProvider === "codex" && + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0 + ? parseCodexFeedbackCommand(trimmed) + : null; + if (feedbackCommand) { + if (!isServerThread || activeThread.session === null) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Start a Codex thread first", + description: "Send a message before you submit feedback.", + }), + ); + return; + } + feedbackUploadsInFlightRef.current.add(routeThreadKey); + const result = await submitCodexFeedback({ + submission: { + id: newMessageId(), + command: trimmed, + createdAt: new Date().toISOString(), + }, + clearDraft: () => { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + scrollToEnd(); + }, + onUpdate: (submission) => { + setFeedbackSubmissionsByThreadKey((current) => { + const existing = current[routeThreadKey] ?? []; + const found = existing.some((entry) => entry.id === submission.id); + return { + ...current, + [routeThreadKey]: found + ? existing.map((entry) => (entry.id === submission.id ? submission : entry)) + : [...existing, submission], + }; + }); + }, + upload: () => + uploadThreadFeedback({ + environmentId, + input: { + threadId: activeThread.id, + ...feedbackCommand, + }, + }), + }).finally(() => { + feedbackUploadsInFlightRef.current.delete(routeThreadKey); + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not send feedback to OpenAI", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + } + return; + } + const feedbackId = result.value.feedbackId; + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Feedback sent to OpenAI", + description: `Thread ID: ${feedbackId}`, + timeout: 0, + actionProps: { + children: "Copy ID", + onClick: () => { + void writeTextToClipboard(feedbackId, "Codex feedback thread ID").catch( + (error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy thread ID", + description: chatActionErrorMessage(error), + }), + ); + }, + ); + }, + }, + }), + ); + return; + } if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, @@ -6633,7 +6763,13 @@ function ChatViewContent(props: ChatViewProps) { phase={phase} isConnecting={isConnecting} isSendBusy={isSendBusy} - sendDisabledReason={threadDetailLoading ? "Messages loading" : null} + sendDisabledReason={ + feedbackUploading + ? "Sending feedback" + : threadDetailLoading + ? "Messages loading" + : null + } isPreparingWorktree={isPreparingWorktree} externalDrawerAttached={externalComposerDrawerAttached} environmentUnavailable={activeEnvironmentUnavailableState} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index c48f029f7f9b..d180f2c699c3 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -87,7 +87,7 @@ function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: ); } -function renderSendButton() { +function renderSendButton(sendDisabledReason: string | null = null) { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { compact: true, @@ -96,7 +96,7 @@ function renderSendButton() { showPlanFollowUpPrompt: false, promptHasText: true, isSendBusy: false, - sendDisabledReason: null, + sendDisabledReason, isConnecting: false, isEnvironmentUnavailable: false, isPreparingWorktree: false, @@ -204,6 +204,13 @@ describe("formatPendingPrimaryActionLabel", () => { }); describe("ComposerPrimaryActions", () => { + it("disables and labels the send button while feedback is uploading", () => { + const markup = renderSendButton("Sending feedback"); + + expect(markup).toContain("disabled"); + expect(markup).toContain('aria-label="Sending feedback"'); + }); + it("offers Stop generation while a running turn is waiting for user input", () => { expect(renderPendingActions(true)).toContain('aria-label="Stop generation"'); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0c2c785fc120..4647384fcf71 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,4 +1,5 @@ import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; +import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; import { createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; @@ -236,6 +237,61 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("renders a feedback command and its pending response as normal thread messages", () => { + const submission = { + id: MessageId.make("feedback-command"), + command: "/feedback The agent stopped early.", + createdAt: MESSAGE_CREATED_AT, + status: "uploading" as const, + }; + const messages = [ + codexFeedbackMessage(submission), + codexFeedbackMessage(submission, "assistant"), + ]; + const markup = renderToStaticMarkup( + ({ + id: message.id, + kind: "message" as const, + createdAt: message.createdAt, + message, + }))} + />, + ); + + expect(markup).toContain("/feedback The agent stopped early."); + expect(markup).toContain("Sending feedback to OpenAI..."); + }); + + it("renders the returned Codex thread ID in the feedback response", () => { + const submission = { + id: MessageId.make("feedback-command"), + command: "/feedback The agent stopped early.", + createdAt: MESSAGE_CREATED_AT, + status: "sent" as const, + feedbackId: "codex-thread-1", + }; + const messages = [ + codexFeedbackMessage(submission), + codexFeedbackMessage(submission, "assistant"), + ]; + const markup = renderToStaticMarkup( + ({ + id: message.id, + kind: "message" as const, + createdAt: message.createdAt, + message, + }))} + />, + ); + + expect(markup).toContain("Feedback sent to OpenAI."); + expect(markup).toContain("codex-thread-1"); + }); + it("renders the worked-for row at assistant response text size", () => { const turnId = TurnId.make("turn-with-fold"); const assistantEntry = buildAssistantTimelineEntry("Done."); diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 7c5ea91f043b..2396ce4028d2 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -28,6 +28,12 @@ Log in with Codex normally: codex login ``` +## Send feedback to OpenAI + +In an existing Codex thread, send `/feedback` or `/feedback` followed by a description of the +issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy +and share with OpenAI employees. + ## I Want Work And Personal Codex Accounts Use one real Codex home and one shadow home. diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index ed3537e4f83b..c540644289df 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -1,7 +1,12 @@ import * as Crypto from "effect/Crypto"; import { Atom } from "effect/unstable/reactivity"; +import { WS_METHODS } from "@t3tools/contracts"; -import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime.ts"; +import { + createAtomCommandScheduler, + createEnvironmentCommand, + createEnvironmentRpcCommand, +} from "./runtime.ts"; import { type ArchiveThreadInput, type CreateThreadInput, @@ -199,5 +204,11 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + uploadFeedback: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:thread:upload-feedback", + tag: WS_METHODS.providerUploadFeedback, + scheduler, + concurrency, + }), }; } diff --git a/packages/client-runtime/src/state/threadFeedback.test.ts b/packages/client-runtime/src/state/threadFeedback.test.ts new file mode 100644 index 000000000000..14ce5185f4ad --- /dev/null +++ b/packages/client-runtime/src/state/threadFeedback.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vite-plus/test"; +import { MessageId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { + codexFeedbackMessage, + parseCodexFeedbackCommand, + submitCodexFeedback, + type CodexFeedbackSubmission, +} from "./threadFeedback.ts"; + +describe("parseCodexFeedbackCommand", () => { + it("accepts feedback without a reason", () => { + expect(parseCodexFeedbackCommand(" /feedback ")).toEqual({}); + }); + + it("preserves a feedback description", () => { + expect(parseCodexFeedbackCommand("/feedback The agent stopped early.")).toEqual({ + reason: "The agent stopped early.", + }); + }); + + it("accepts mixed-case feedback commands", () => { + expect(parseCodexFeedbackCommand("/Feedback Retry failed.")).toEqual({ + reason: "Retry failed.", + }); + }); + + it("ignores other slash commands and ordinary messages", () => { + expect(parseCodexFeedbackCommand("/feedback-status")).toBeNull(); + expect(parseCodexFeedbackCommand("Please send /feedback")).toBeNull(); + }); +}); + +describe("submitCodexFeedback", () => { + const submission = { + id: MessageId.make("feedback-message-1"), + command: "/feedback The agent stopped early.", + createdAt: "2026-08-23T00:00:00.000Z", + } as const; + + it("shows the command and clears the draft before the upload finishes", async () => { + let draft: string = submission.command; + let finishUpload: + | ((result: ReturnType>) => void) + | undefined; + const states: CodexFeedbackSubmission[] = []; + const upload = new Promise>>( + (resolve) => { + finishUpload = resolve; + }, + ); + + const result = submitCodexFeedback({ + submission, + clearDraft: () => { + draft = ""; + }, + onUpdate: (state) => states.push(state), + upload: () => { + expect(draft).toBe(""); + return upload; + }, + }); + + expect(draft).toBe(""); + expect(states).toEqual([{ ...submission, status: "uploading" }]); + expect(codexFeedbackMessage(states[0]!)).toMatchObject({ + id: submission.id, + role: "user", + text: submission.command, + }); + expect(codexFeedbackMessage(states[0]!, "assistant").text).toBe( + "Sending feedback to OpenAI...", + ); + + draft = "Keep this newer message."; + finishUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" })); + await result; + + expect(draft).toBe("Keep this newer message."); + expect(states.at(-1)).toEqual({ + ...submission, + status: "sent", + feedbackId: "codex-thread-1", + }); + expect(codexFeedbackMessage(states.at(-1)!, "assistant").text).toContain("codex-thread-1"); + }); + + it("records a failed upload without losing its user-facing error", async () => { + const states: CodexFeedbackSubmission[] = []; + const error = new Error("Upload rejected."); + + await submitCodexFeedback({ + submission, + clearDraft: () => undefined, + onUpdate: (state) => states.push(state), + upload: () => + Promise.resolve(AsyncResult.failure<{ feedbackId: string }, Error>(Cause.fail(error))), + }); + + expect(states.at(-1)).toEqual({ + ...submission, + status: "failed", + errorMessage: "Upload rejected.", + }); + }); + + it("marks interruptions without reporting them as upload failures", async () => { + const states: CodexFeedbackSubmission[] = []; + + await submitCodexFeedback({ + submission, + clearDraft: () => undefined, + onUpdate: (state) => states.push(state), + upload: () => + Promise.resolve(AsyncResult.failure<{ feedbackId: string }, never>(Cause.interrupt(1))), + }); + + expect(states.at(-1)).toEqual({ ...submission, status: "interrupted" }); + }); + + it("lets another feedback submission finish while the first remains in flight", async () => { + let finishFirstUpload: + | ((result: ReturnType>) => void) + | undefined; + const firstUpload = new Promise>>( + (resolve) => { + finishFirstUpload = resolve; + }, + ); + const firstStates: CodexFeedbackSubmission[] = []; + const secondStates: CodexFeedbackSubmission[] = []; + + const first = submitCodexFeedback({ + submission, + clearDraft: () => undefined, + onUpdate: (state) => firstStates.push(state), + upload: () => firstUpload, + }); + const second = await submitCodexFeedback({ + submission: { + ...submission, + id: MessageId.make("feedback-message-2"), + }, + clearDraft: () => undefined, + onUpdate: (state) => secondStates.push(state), + upload: () => Promise.resolve(AsyncResult.success({ feedbackId: "codex-thread-2" })), + }); + + expect(firstStates.at(-1)?.status).toBe("uploading"); + expect(second._tag).toBe("Success"); + expect(secondStates.at(-1)).toMatchObject({ + status: "sent", + feedbackId: "codex-thread-2", + }); + + finishFirstUpload?.(AsyncResult.success({ feedbackId: "codex-thread-1" })); + await first; + }); +}); diff --git a/packages/client-runtime/src/state/threadFeedback.ts b/packages/client-runtime/src/state/threadFeedback.ts new file mode 100644 index 000000000000..29abb2689310 --- /dev/null +++ b/packages/client-runtime/src/state/threadFeedback.ts @@ -0,0 +1,87 @@ +import { + MessageId, + type OrchestrationMessage, + type ProviderUploadFeedbackResult, +} from "@t3tools/contracts"; + +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, + type AtomCommandResult, +} from "./runtime.ts"; + +type CodexFeedbackSubmissionDetails = { + readonly id: MessageId; + readonly command: string; + readonly createdAt: string; +}; + +export type CodexFeedbackSubmission = CodexFeedbackSubmissionDetails & + ( + | { readonly status: "uploading" | "interrupted" } + | { readonly status: "sent"; readonly feedbackId: string } + | { readonly status: "failed"; readonly errorMessage: string } + ); + +export function parseCodexFeedbackCommand(text: string): { readonly reason?: string } | null { + const match = /^\/feedback(?:\s+([\s\S]*))?$/iu.exec(text.trim()); + if (!match) { + return null; + } + const reason = match[1]?.trim(); + return reason ? { reason } : {}; +} + +export function codexFeedbackMessage( + submission: CodexFeedbackSubmission, + role: "user" | "assistant" = "user", +): OrchestrationMessage { + const text = + role === "user" + ? submission.command + : submission.status === "sent" + ? `Feedback sent to OpenAI.\n\nThread ID: \`${submission.feedbackId}\`` + : submission.status === "failed" + ? `Could not send feedback to OpenAI.\n\n${submission.errorMessage}` + : "Sending feedback to OpenAI..."; + + return { + id: role === "user" ? submission.id : MessageId.make(`${submission.id}:feedback`), + role, + text, + turnId: null, + streaming: false, + createdAt: submission.createdAt, + updatedAt: submission.createdAt, + }; +} + +export async function submitCodexFeedback(input: { + readonly submission: CodexFeedbackSubmissionDetails; + readonly clearDraft: () => void; + readonly onUpdate: (submission: CodexFeedbackSubmission) => void; + readonly upload: () => Promise>; +}): Promise> { + input.onUpdate({ ...input.submission, status: "uploading" }); + input.clearDraft(); + + const result = await input.upload(); + if (result._tag === "Success") { + input.onUpdate({ + ...input.submission, + status: "sent", + feedbackId: result.value.feedbackId, + }); + } else if (isAtomCommandInterrupted(result)) { + input.onUpdate({ ...input.submission, status: "interrupted" }); + } else { + const error = squashAtomCommandFailure(result); + input.onUpdate({ + ...input.submission, + status: "failed", + errorMessage: error instanceof Error ? error.message : "An error occurred.", + }); + } + + return result; +} diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 4ba5a0e9df18..39561afdb416 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -729,6 +729,7 @@ export * from "./checkpointDiff.ts"; export * from "./threadSnapshotHttp.ts"; export * from "./composerPathSearch.ts"; export * from "./threadCommands.ts"; +export * from "./threadFeedback.ts"; export * from "./threadDetail.ts"; export * from "./threadReducer.ts"; export * from "./threadShell.ts"; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index ffccd20fa653..ba7ca63745b6 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -1,17 +1,23 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; +import { ThreadId } from "./baseSchemas.ts"; import { ProviderEvent, ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, + ProviderUploadFeedbackError, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, } from "./provider.ts"; const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput); const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput); const decodeProviderSession = Schema.decodeUnknownSync(ProviderSession); const decodeProviderEvent = Schema.decodeUnknownSync(ProviderEvent); +const decodeProviderUploadFeedbackInput = Schema.decodeUnknownSync(ProviderUploadFeedbackInput); +const decodeProviderUploadFeedbackResult = Schema.decodeUnknownSync(ProviderUploadFeedbackResult); function getOptionValue( options: ReadonlyArray<{ id: string; value: unknown }> | undefined, @@ -153,6 +159,39 @@ describe("ProviderSendTurnInput", () => { }); }); +describe("provider feedback", () => { + it("accepts a thread and an optional feedback reason", () => { + expect( + decodeProviderUploadFeedbackInput({ + threadId: "thread-1", + reason: "The agent stopped early.", + }), + ).toEqual({ threadId: "thread-1", reason: "The agent stopped early." }); + expect(decodeProviderUploadFeedbackInput({ threadId: "thread-1" })).toEqual({ + threadId: "thread-1", + }); + }); + + it("returns the shareable Codex feedback identifier", () => { + expect(decodeProviderUploadFeedbackResult({ feedbackId: "provider-thread-1" })).toEqual({ + feedbackId: "provider-thread-1", + }); + }); + + it("keeps the failed thread and original cause without exposing upstream text", () => { + const cause = new Error("provider request secret"); + const error = new ProviderUploadFeedbackError({ + threadId: ThreadId.make("thread-1"), + cause, + }); + + expect(error.threadId).toBe("thread-1"); + expect(error.cause).toBe(cause); + expect(error.message).toBe("Failed to upload feedback for thread thread-1."); + expect(error.message).not.toContain("provider request secret"); + }); +}); + describe("providerInstanceId routing key (slice-2 invariant)", () => { it("decodes a ProviderSessionStartInput without providerInstanceId (legacy producer)", () => { const parsed = decodeProviderSessionStartInput({ diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index c84ad43c4e78..42a943923037 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -110,6 +110,29 @@ export const ProviderRespondToUserInputInput = Schema.Struct({ }); export type ProviderRespondToUserInputInput = typeof ProviderRespondToUserInputInput.Type; +export const ProviderUploadFeedbackInput = Schema.Struct({ + threadId: ThreadId, + reason: Schema.optional(TrimmedNonEmptyString), +}); +export type ProviderUploadFeedbackInput = typeof ProviderUploadFeedbackInput.Type; + +export const ProviderUploadFeedbackResult = Schema.Struct({ + feedbackId: TrimmedNonEmptyString, +}); +export type ProviderUploadFeedbackResult = typeof ProviderUploadFeedbackResult.Type; + +export class ProviderUploadFeedbackError extends Schema.TaggedErrorClass()( + "ProviderUploadFeedbackError", + { + threadId: ThreadId, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to upload feedback for thread ${this.threadId}.`; + } +} + const ProviderEventKind = Schema.Literals(["session", "notification", "request", "error"]); export const ProviderEvent = Schema.Struct({ diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..45bf581de084 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -66,6 +66,11 @@ import { OrchestrationRpcSchemas, OrchestrationGetWorkflowScriptError, } from "./orchestration.ts"; +import { + ProviderUploadFeedbackError, + ProviderUploadFeedbackInput, + ProviderUploadFeedbackResult, +} from "./provider.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; import { PullRequestActionInput, @@ -211,6 +216,9 @@ export const WS_METHODS = { filesystemBrowse: "filesystem.browse", assetsCreateUrl: "assets.createUrl", + // Provider methods + providerUploadFeedback: "provider.uploadFeedback", + // VCS methods vcsPull: "vcs.pull", vcsRefreshStatus: "vcs.refreshStatus", @@ -665,6 +673,12 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), }); +export const WsProviderUploadFeedbackRpc = Rpc.make(WS_METHODS.providerUploadFeedback, { + payload: ProviderUploadFeedbackInput, + success: ProviderUploadFeedbackResult, + error: Schema.Union([ProviderUploadFeedbackError, EnvironmentAuthorizationError]), +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -1034,6 +1048,7 @@ export const WsRpcGroup = RpcGroup.make( WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, + WsProviderUploadFeedbackRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, From 4d12e52223f3fcd4813b9bc52cd9cb3f2bd19539 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 00:02:34 -0700 Subject: [PATCH 13/91] fix(server): stop kills lingering Claude work (#5891) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 191 ++++++++++++++++-- .../src/provider/Layers/ClaudeAdapter.ts | 175 ++++++---------- 2 files changed, 241 insertions(+), 125 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 1f971a6126ed..6ec0a1ab6288 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -54,12 +54,11 @@ class FakeClaudeQuery implements AsyncIterable { private done = false; private failure: unknown | undefined; - public readonly interruptCalls: Array = []; - public readonly stopTaskCalls: Array = []; public readonly setModelCalls: Array = []; public readonly setPermissionModeCalls: Array = []; public readonly setMaxThinkingTokensCalls: Array = []; public closeCalls = 0; + public closeError: unknown | undefined; emit(message: SDKMessage): void { if (this.done) { @@ -95,14 +94,6 @@ class FakeClaudeQuery implements AsyncIterable { } } - readonly interrupt = async (): Promise => { - this.interruptCalls.push(undefined); - }; - - readonly stopTask = async (taskId: string): Promise => { - this.stopTaskCalls.push(taskId); - }; - readonly setModel = async (model?: string): Promise => { this.setModelCalls.push(model); }; @@ -117,6 +108,9 @@ class FakeClaudeQuery implements AsyncIterable { readonly close = (): void => { this.closeCalls += 1; + if (this.closeError !== undefined) { + throw this.closeError; + } this.finish(); }; @@ -1580,7 +1574,7 @@ describe("ClaudeAdapterLive", () => { ); }); - it.effect("interruptTurn settles every acknowledged live task before interrupting", () => { + it.effect("interruptTurn settles live tasks and closes the provider session", () => { const harness = makeHarness(); return Effect.gen(function* () { const adapter = yield* ClaudeAdapter; @@ -1645,9 +1639,12 @@ describe("ClaudeAdapterLive", () => { ); yield* adapter.interruptTurn(session.threadId); - // Only the still-live task is stopped; interrupt always fires after. - assert.deepEqual(harness.query.stopTaskCalls, ["task-live"]); - assert.equal(harness.query.interruptCalls.length, 1); + // Closing the session is the hard stop because SDK interrupt can leave + // resumed background work alive. + assert.equal(harness.query.closeCalls, 1); + + const sessions = yield* adapter.listSessions(); + assert.equal(sessions.length, 0); const stoppedTaskEvents = Array.from(yield* Fiber.join(stoppedTaskEventFiber)); assert.equal(stoppedTaskEvents.length, 1); @@ -1665,6 +1662,172 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("keeps the session available when process close fails", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + harness.query.closeError = new Error("close failed"); + + const result = yield* adapter.interruptTurn(session.threadId).pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "ProviderAdapterProcessError"); + } + assert.equal(harness.query.closeCalls, 1); + assert.equal(yield* adapter.hasSession(session.threadId), true); + assert.equal((yield* adapter.listSessions())[0]?.status, "ready"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("stopAll attempts every session when one process close fails", () => { + const queries: FakeClaudeQuery[] = []; + const layer = Layer.effect( + ClaudeAdapter, + Effect.gen(function* () { + const claudeConfig = decodeClaudeSettings({}); + return yield* makeClaudeAdapter(claudeConfig, { + createQuery: () => { + const query = new FakeClaudeQuery(); + 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; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.startSession({ + threadId: RESUME_THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + const firstQuery = queries[0]; + if (!firstQuery) { + return; + } + firstQuery.closeError = new Error("close failed"); + + const result = yield* adapter.stopAll().pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + assert.equal(queries[0]?.closeCalls, 1); + assert.equal(queries[1]?.closeCalls, 1); + assert.equal(yield* adapter.hasSession(THREAD_ID), true); + assert.equal(yield* adapter.hasSession(RESUME_THREAD_ID), false); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + + 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; + }); + 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( + Stream.runCollect, + Effect.forkChild, + ); + const firstSession = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: firstSession.threadId, + input: "hello", + attachments: [], + }); + + const interruptFiber = yield* adapter + .interruptTurn(firstSession.threadId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => usageStarted); + assert.equal(queries[0]?.closeCalls, 1); + + const replacement = 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); + + 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", + ], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("workflow member coalescing: identical snapshots suppress, changes emit", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5715b68a1e45..02d73e372d2b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -282,9 +282,6 @@ interface ClaudeSessionContext { } interface ClaudeQueryRuntime extends AsyncIterable { - readonly interrupt: () => Promise; - /** SDK Query.stopTask — present on real queries; optional for test doubles. */ - readonly stopTask?: (taskId: string) => Promise; readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; @@ -2106,13 +2103,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } catch { return undefined; } - }); - if (!usage) { + }).pipe(Effect.timeoutOption("1 second")); + if (Option.isNone(usage) || !usage.value) { return undefined; } - context.lastKnownContextWindow = usage.maxTokens; - return normalizeClaudeContextUsageApiSnapshot(usage, totalProcessedTokens); + context.lastKnownContextWindow = usage.value.maxTokens; + return normalizeClaudeContextUsageApiSnapshot(usage.value, totalProcessedTokens); }); const emitProposedPlanCompleted = Effect.fn("emitProposedPlanCompleted")(function* ( @@ -3636,8 +3633,42 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) { if (context.stopped) return; + // Schedule process termination before any cleanup that can wait on the + // provider. The SDK closes stdin, then escalates from SIGTERM to SIGKILL. + yield* Effect.try({ + try: () => context.query.close(), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: "Failed to close Claude runtime query.", + cause, + }), + }); + context.stopped = true; + for (const taskId of Array.from(context.liveTaskIds)) { + if (!context.liveTaskIds.delete(taskId)) { + continue; + } + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.completed", + eventId: stamp.eventId, + provider: PROVIDER, + createdAt: stamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + payload: { + taskId: RuntimeTaskId.make(taskId), + status: "stopped", + ...taskLinkageFor(context.taskAgents, taskId), + }, + providerRefs: nativeProviderRefs(context), + }); + } + for (const [requestId, pending] of context.pendingApprovals) { yield* Deferred.succeed(pending.decision, "cancel"); const stamp = yield* makeEventStamp(); @@ -3676,26 +3707,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* Fiber.interrupt(streamFiber); } - yield* Effect.try({ - try: () => context.query.close(), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: context.session.threadId, - detail: "Failed to close Claude runtime query.", - cause, - }), - }).pipe( - Effect.catch((error) => - emitRuntimeError(context, "Failed to close Claude runtime query.", { - errorTag: error._tag, - provider: error.provider, - threadId: error.threadId, - detail: error.detail, - }), - ), - ); - const updatedAt = yield* nowIso; context.session = { ...context.session, @@ -3704,7 +3715,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( updatedAt, }; - if (options?.emitExitEvent !== false) { + if (options?.emitExitEvent !== false && sessions.get(context.session.threadId) === context) { const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.exited", @@ -3720,7 +3731,9 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - sessions.delete(context.session.threadId); + if (sessions.get(context.session.threadId) === context) { + sessions.delete(context.session.threadId); + } }); const requireSession = ( @@ -3765,16 +3778,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); yield* stopSessionInternal(existingContext, { emitExitEvent: false, - }).pipe( - // Replacement cleanup is best-effort: never block the new session on - // either typed failures or unexpected defects from tearing down the old one. - Effect.catchCause((cause) => - Effect.logWarning("claude.session.replace.stop-failed", { - threadId: input.threadId, - cause, - }), - ), - ); + }); } const startedAt = yield* nowIso; @@ -4466,62 +4470,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const interruptTurn: ClaudeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, _turnId) { const context = yield* requireSession(threadId); - // Stop-everything semantics: users reach for Stop precisely when a - // fleet ran away. interrupt() alone only ends the parent turn — - // background subagents/shells keep running and keep burning tokens. - // Stop every live task first (best-effort per task: one refusal must - // not strand the rest or block the turn interrupt), then interrupt. - if (context.query.stopTask && context.liveTaskIds.size > 0) { - const liveIds = Array.from(context.liveTaskIds); - // Bounded: a wedged child's stopTask promise may never settle - // (Effect.ignore handles rejection, not non-resolution), and the - // parent interrupt below MUST still run — Stop matters most during - // runaway fleets (review finding). Per-task timeout keeps one hung - // child from consuming the whole budget. - yield* Effect.forEach( - liveIds, - (taskId) => - Effect.gen(function* () { - const stopAcknowledged = yield* Effect.tryPromise({ - // Invoke through the query object: SDK methods rely on `this`. - try: () => context.query.stopTask!(taskId), - catch: () => undefined, - }).pipe( - Effect.timeoutOption("3 seconds"), - Effect.orElseSucceed(() => Option.none()), - ); - if (Option.isNone(stopAcknowledged) || !context.liveTaskIds.delete(taskId)) { - return; - } - - // stopTask only acknowledges the control request. Its separate - // task_notification can lose the race with interrupt(), so make - // the acknowledged stop authoritative for the durable UI state. - const stamp = yield* makeEventStamp(); - yield* offerRuntimeEvent({ - type: "task.completed", - eventId: stamp.eventId, - provider: PROVIDER, - createdAt: stamp.createdAt, - threadId: context.session.threadId, - ...(context.turnState - ? { turnId: asCanonicalTurnId(context.turnState.turnId) } - : {}), - payload: { - taskId: RuntimeTaskId.make(taskId), - status: "stopped", - ...taskLinkageFor(context.taskAgents, taskId), - }, - providerRefs: nativeProviderRefs(context), - }); - }).pipe(Effect.ignore), - { concurrency: 8, discard: true }, - ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); - } - yield* Effect.tryPromise({ - try: () => context.query.interrupt(), - catch: (cause) => toRequestError(threadId, "turn/interrupt", cause), - }); + // interrupt() can acknowledge while resumed background tasks keep the + // CLI alive. Stop is a hard session boundary for Claude, so close the + // query and let the SDK escalate to SIGKILL when graceful exit fails. + yield* stopSessionInternal(context); }, ); @@ -4594,25 +4546,26 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return context !== undefined && !context.stopped; }); - const stopAll: ClaudeAdapterShape["stopAll"] = () => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: true, - }), - { discard: true }, + const stopSessions = Effect.fn("stopSessions")(function* ( + contexts: ReadonlyArray, + emitExitEvent: boolean, + ) { + const results = yield* Effect.forEach(contexts, (context) => + stopSessionInternal(context, { emitExitEvent }).pipe(Effect.result), ); + for (const result of results) { + if (result._tag === "Failure") { + return yield* Effect.fail(result.failure); + } + } + }); + + const stopAll: ClaudeAdapterShape["stopAll"] = () => + stopSessions(Array.from(sessions.values()), true); + yield* Effect.addFinalizer(() => - Effect.forEach( - sessions, - ([, context]) => - stopSessionInternal(context, { - emitExitEvent: false, - }), - { discard: true }, - ).pipe( + stopSessions(Array.from(sessions.values()), false).pipe( Effect.catch((cause) => Effect.logError("Failed to emit Claude session shutdown event.", { cause }), ), From 2433f4c1c01cf9e9eca983c9aeec375524e29273 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 00:31:45 -0700 Subject: [PATCH 14/91] fix(ci): let Macroscope approve pull requests again (#7970) --- .macroscope/approvability.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .macroscope/approvability.md diff --git a/.macroscope/approvability.md b/.macroscope/approvability.md new file mode 100644 index 000000000000..cfea7fdd57c2 --- /dev/null +++ b/.macroscope/approvability.md @@ -0,0 +1 @@ +Use Macroscope's default approvability criteria. From f70eeeeb06d6d96f292be7eef1ed8948103e68dc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 04:28:10 -0700 Subject: [PATCH 15/91] fix(clients): move settled pinned threads into the settled section (#7969) --- .../features/threads/thread-list-v2-items.tsx | 12 ++- .../src/features/threads/threadListV2.test.ts | 80 ++++++++++++++++++- .../src/features/threads/threadListV2.ts | 13 +-- apps/web/src/components/Sidebar.tsx | 78 ++++++++---------- docs/user/thread-sidebar.md | 3 + packages/contracts/src/orchestration.ts | 4 +- 6 files changed, 126 insertions(+), 64 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index fa1e752d619f..e6589cd56300 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -506,7 +506,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } satisfies MenuAction, ] : []), - pinnedRow + thread.pinnedAt != null ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] @@ -517,6 +517,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { props.canMovePinnedUp, props.pinReorderSupported, props.pinningSupported, + thread.pinnedAt, ], ); const titleRegenerationMenuItems = useMemo( @@ -552,8 +553,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [pinMenuItem, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( - () => [SLIM_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + SLIM_MENU_ACTIONS[0]!, + ...(thread.pinnedAt != null ? pinMenuItem : []), + ...titleRegenerationMenuItems, + SLIM_MENU_ACTIONS[1]!, + ], + [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c58dbb67517b..4439ea194778 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -309,7 +309,7 @@ describe("buildThreadListV2Items", () => { expect(layout.snoozedCount).toBe(1); }); - it("renders pinned threads first and exempts them from auto-settle — parity with web", () => { + it("places settled pinned threads in the settled shelf", () => { const layout = buildThreadListV2Items({ threads: [ makeThread({ id: ThreadId.make("active"), title: "Active" }), @@ -317,7 +317,6 @@ describe("buildThreadListV2Items", () => { id: ThreadId.make("pinned-settled"), title: "Pinned while settled", pinnedAt: "2026-06-01T12:00:00.000Z", - // Stale settled state (the decider clears it on pin): the pin wins. settledOverride: "settled", settledAt: "2026-06-01T12:00:00.000Z", }), @@ -327,8 +326,81 @@ describe("buildThreadListV2Items", () => { now: NOW, }); - expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]); - expect(layout.items.map((item) => item.pinned)).toEqual([true, false]); + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-settled"]); + expect(layout.items.map((item) => item.pinned)).toEqual([false, false]); + expect(layout.settledCount).toBe(1); + }); + + it("moves pinned threads to the settled shelf when their pull request merges", () => { + const merged = makeThread({ + id: ThreadId.make("pinned-merged"), + title: "Pinned merged pull request", + pinnedAt: "2026-06-01T12:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [makeThread({ id: ThreadId.make("active"), title: "Active" }), merged], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["active", "pinned-merged"]); + expect(layout.items.map((item) => item.variant)).toEqual(["card", "slim"]); + expect(layout.items[1]?.thread.pinnedAt).toBe("2026-06-01T12:00:00.000Z"); + expect(layout.settledCount).toBe(1); + }); + + it("moves inactive pinned threads to the settled shelf", () => { + const inactive = makeThread({ + id: ThreadId.make("pinned-inactive"), + title: "Pinned inactive thread", + createdAt: "2026-05-20T00:00:00.000Z", + pinnedAt: "2026-05-21T00:00:00.000Z", + latestTurn: { + turnId: TurnId.make("turn-inactive"), + state: "completed", + requestedAt: "2026-05-21T00:00:00.000Z", + startedAt: "2026-05-21T00:00:01.000Z", + completedAt: "2026-05-21T00:00:02.000Z", + assistantMessageId: null, + }, + }); + const layout = buildThreadListV2Items({ + threads: [inactive], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(layout.items[0]).toMatchObject({ + thread: { id: "pinned-inactive" }, + variant: "slim", + pinned: false, + }); + expect(layout.settledCount).toBe(1); + }); + + it("keeps pinned merged threads pinned when auto-settle on merge is off", () => { + const merged = makeThread({ + id: ThreadId.make("pinned-merged"), + title: "Pinned merged pull request", + pinnedAt: "2026-06-01T12:00:00.000Z", + }); + const layout = buildThreadListV2Items({ + threads: [merged], + environmentId: null, + searchQuery: "", + changeRequestByKey: new Map([[`${environmentId}:${merged.id}`, { state: "merged" }]]), + autoSettleOnMerge: false, + now: NOW, + }); + + expect(layout.items[0]).toMatchObject({ + thread: { id: "pinned-merged" }, + variant: "card", + pinned: true, + }); expect(layout.settledCount).toBe(0); }); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 45079bac6e7f..11ac0e9dcb64 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -386,10 +386,7 @@ export function buildThreadListV2Items(input: { const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequest = input.changeRequestByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - // Visibility parity with web: snooze outranks everything, including a - // pin — a snoozed thread leaves the list until it wakes (or raises its - // hand). The pin (and its pinOrderKey) survives underneath, so a woken - // thread reappears at its exact spot in the pinned block. + // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -401,12 +398,6 @@ export function buildThreadListV2Items(input: { } continue; } - // A pin otherwise overrides the lifecycle: pinned threads render above - // the inbox and never auto-settle out of sight. - if (thread.pinnedAt != null) { - pinned.push(thread); - continue; - } if ( supportsSettlement && effectiveSettled(thread, { @@ -417,6 +408,8 @@ export function buildThreadListV2Items(input: { }) ) { settled.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); } else { active.push(thread); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a8f2ea52995a..971ead810f07 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -701,13 +701,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { autoSettleOnMerge: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; - // Renders the pin glyph. Pinned cards keep the full settle/snooze quick - // actions: settling clears the pin server-side, and snoozing hides the - // card until wake with the pin intact underneath. The glyph is also the - // in-row pin state cue (the pinned block has no header), so it always - // shows while pinned; it only becomes a clickable unpin quick-action once - // the pinning capability is confirmed, and stays a passive marker while - // the descriptor is not loaded. Pinning itself lives in the context menu. + // Pinned threads show the same pin marker in active, settled, and snoozed + // rows. The marker can unpin the thread when the server supports pinning. pinningSupported: boolean; isPinned: boolean; // Present only on pinned cards whose server supports reordering: dnd-kit @@ -1192,6 +1187,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
) : null; + const pinIndicator = props.isPinned ? ( + props.pinningSupported ? ( + + + } + > + + + Unpin thread + + ) : ( + + ) + ) : null; if (variant === "slim") { return ( @@ -1233,6 +1253,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> {title} + {pinIndicator} {terminalStatusIcon} {isRegeneratingTitle ? ( @@ -1395,31 +1416,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( )} - {props.isPinned ? ( - props.pinningSupported ? ( - - - } - > - - - Unpin thread - - ) : ( - - ) - ) : null} + {pinIndicator} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim @@ -2043,20 +2040,9 @@ export default function Sidebar() { snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) ? snapshot.pr : null; - // Snooze outranks everything, including a pin: "hide until Tuesday" - // temporarily suspends "keep on top". The pin survives underneath — - // and so does its pinOrderKey, so on wake the thread reappears at - // its exact slot in the pinned block. (For unpinned threads - // this is also the snooze-beats-auto-settle rule: the wake time is a - // stronger statement about when the thread matters again.) + // Snooze outranks settlement and pinning until the thread wakes. if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); - // A pin otherwise overrides the lifecycle: pinned threads never - // auto-settle out of sight. (The decider clears settled state on - // pin and the pin on settle, so pin-vs-settled conflicts only - // arise from stale or raced writes.) - } else if (thread.pinnedAt != null) { - pinned.push(thread); } else if ( supportsSettlement && effectiveSettled(thread, { @@ -2067,6 +2053,8 @@ export default function Sidebar() { }) ) { settled.push(thread); + } else if (thread.pinnedAt != null) { + pinned.push(thread); } else { active.push(thread); } @@ -3697,7 +3685,7 @@ export default function Sidebar() { serverConfigs.get(thread.environmentId)?.environment.capabilities .threadPinning === true } - isPinned={section === "pinned"} + isPinned={thread.pinnedAt != null} sortable={sortable} snoozeWakeLabelText={ section === "snoozed" && thread.snoozedUntil != null diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 70b3cccc962a..274f596bbc50 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -4,6 +4,9 @@ Pin a thread from its context menu to keep it in the pinned section above your a Pinned threads are shown independently of their project, including when you connect to more than one environment. +Pinned threads still move to **Settled** when they become inactive. They also move when their pull +request merges if **Auto-settle merged threads** is enabled. + On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu and choose **Move up** or **Move down**. The order is stored by the server and appears on your other connected devices. diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index adb17879ff2f..1c27e6d3c6b4 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -401,8 +401,8 @@ export const OrchestrationThread = Schema.Struct({ // Optional so payloads from pre-snooze servers still decode. snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)), snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), - // A pin overrides the settled/snoozed lifecycle: while pinnedAt is set the - // thread renders in the pinned block and never classifies into a shelf. + // Active pinned threads render in the pinned block. Settled and snoozed + // threads remain in their respective shelves even when pinned. // Optional so payloads from pre-pinning servers still decode. pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), // Fractional index for user-arranged pinned order. Keyed threads sort by From 25dcee00a6e12db2781a17b326e6e34de0d4ced7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 05:36:09 -0700 Subject: [PATCH 16/91] perf(ci): speed up release builds and Windows packaging (#7975) --- .github/workflows/release.yml | 82 +++++++++++++---- docs/operations/release.md | 3 +- pnpm-lock.yaml | 85 ++---------------- pnpm-workspace.yaml | 9 ++ scripts/build-desktop-artifact.test.ts | 120 ++++++++++++++++++++++++- scripts/build-desktop-artifact.ts | 72 ++++++++++----- 6 files changed, 251 insertions(+), 120 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6abd702bf889..df41129960bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,9 +100,6 @@ jobs: cache: true run-install: true - - name: Ensure Electron runtime is installed - run: vp run --filter @t3tools/desktop ensure:electron - - id: release_meta name: Resolve release version shell: bash @@ -157,6 +154,40 @@ jobs: fi fi + - id: previous_tag + name: Resolve previous release tag + run: | + node scripts/resolve-previous-release-tag.ts \ + --channel "${{ steps.release_meta.outputs.release_channel }}" \ + --current-tag "${{ steps.release_meta.outputs.tag }}" \ + --github-output + + quality: + name: Release quality checks + needs: [preflight] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} + runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.preflight.outputs.ref }} + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + - name: Check run: vp check @@ -166,14 +197,6 @@ jobs: - name: Test run: vp run test - - id: previous_tag - name: Resolve previous release tag - run: | - node scripts/resolve-previous-release-tag.ts \ - --channel "${{ steps.release_meta.outputs.release_channel }}" \ - --current-tag "${{ steps.release_meta.outputs.tag }}" \ - --github-output - relay_public_config: name: Resolve T3 Connect public config needs: preflight @@ -385,14 +408,34 @@ jobs: uses: voidzero-dev/setup-vp@v1 with: node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/desktop... - - --filter=t3... - - --filter=@t3tools/scripts... + cache: ${{ matrix.platform != 'win' }} + run-install: false + + - name: Resolve Windows package cache path + if: matrix.platform == 'win' + id: package_cache_path + shell: pwsh + run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' + + - name: Cache Windows packages + if: matrix.platform == 'win' + uses: actions/cache@v6 + with: + path: ${{ steps.package_cache_path.outputs.path }} + key: windows-release-packages-v1-${{ matrix.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install desktop dependencies + run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... + + - name: Cache resource monitor + id: resource_monitor_cache + uses: actions/cache@v6 + with: + path: native/resource-monitor/target/${{ matrix.rust_target }}/release/t3-resource-monitor${{ matrix.platform == 'win' && '.exe' || '' }} + key: resource-monitor-${{ matrix.rust_target }}-${{ hashFiles('native/resource-monitor/Cargo.lock', 'native/resource-monitor/Cargo.toml', 'native/resource-monitor/src/**') }} - name: Setup Rust + if: steps.resource_monitor_cache.outputs.cache-hit != 'true' uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.rust_target }} @@ -518,6 +561,7 @@ jobs: - name: Build desktop artifact shell: bash env: + T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: ${{ steps.resource_monitor_cache.outputs.cache-hit == 'true' }} CSC_LINK: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} @@ -664,8 +708,8 @@ jobs: publish_cli: name: Publish CLI to npm - needs: [preflight, relay_public_config, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} + needs: [preflight, relay_public_config, quality, build] + if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.quality.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 permissions: diff --git a/docs/operations/release.md b/docs/operations/release.md index 1cec84054cb0..520af51d9ea7 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -11,7 +11,7 @@ This document covers the unified release workflow for stable and nightly desktop - push tag matching `v*.*.*` for stable releases - scheduled nightly check every three hours - manual `workflow_dispatch` for either channel -- Runs quality gates first: lint, typecheck, test. +- Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. - Builds four artifacts in parallel for both channels: - macOS `arm64` DMG @@ -365,6 +365,7 @@ Checklist: 4. Push tag. 5. Verify workflow steps: - preflight passes + - release quality checks pass - all matrix builds pass - `publish_cli` publishes the exact release version before the release job - release job uploads expected files diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f66f69b87f0..4b550bebb15e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,14 @@ catalogs: version: 0.2.2 overrides: + '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-darwin-arm64': '-' + '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-darwin-x64': '-' + '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-arm64': '-' + '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-arm64-musl': '-' + '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-x64': '-' + '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-x64-musl': '-' + '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-arm64': '-' + '@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-x64': '-' '@clerk/backend': 3.14.0 '@clerk/clerk-js': 6.29.2 '@clerk/clerk-js>@base-org/account': '-' @@ -960,50 +968,6 @@ packages: '@alchemy.run/node-utils@0.0.5': resolution: {integrity: sha512-5agdhQxWBodxa5hDRyjnpx91RTU3g+qd5fxYB7uNDCaOzB0XC47UU/KHR6zf6jhz/NXf61gJS+vhYwn8NHeRoQ==} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170': - resolution: {integrity: sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg==} - cpu: [arm64] - os: [darwin] - - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170': - resolution: {integrity: sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ==} - cpu: [x64] - os: [darwin] - - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170': - resolution: {integrity: sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170': - resolution: {integrity: sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170': - resolution: {integrity: sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170': - resolution: {integrity: sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170': - resolution: {integrity: sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==} - cpu: [arm64] - os: [win32] - - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170': - resolution: {integrity: sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA==} - cpu: [x64] - os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.170': resolution: {integrity: sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==} engines: {node: '>=18.0.0'} @@ -10546,44 +10510,11 @@ snapshots: '@alchemy.run/node-utils@0.0.5': {} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.170': - optional: true - - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.170': - optional: true - - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.170': - optional: true - - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.170': - optional: true - - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.170': - optional: true - - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.170': - optional: true - - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.170': - optional: true - - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.170': - optional: true - '@anthropic-ai/claude-agent-sdk@0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 - optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.170 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.170 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.170 '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f7d41b694ad8..0a4d3cc7cb50 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -78,6 +78,15 @@ minimumReleaseAgeExclude: - "@legendapp/list@3.3.5" overrides: + # The SDK always receives the user's Claude executable, so its bundled binaries are unused. + "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-darwin-arm64": "-" + "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-darwin-x64": "-" + "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-arm64": "-" + "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "-" + "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-x64": "-" + "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-linux-x64-musl": "-" + "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-arm64": "-" + "@anthropic-ai/claude-agent-sdk>@anthropic-ai/claude-agent-sdk-win32-x64": "-" "@clerk/backend": "catalog:" "@clerk/clerk-js": "catalog:" "@clerk/clerk-js>@base-org/account": "-" diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index b7383de236aa..d1c0d54589df 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -40,6 +40,7 @@ import { resolveDesktopUpdateChannel, resolveDesktopWebAssetBrand, resolveResourceMonitorRustTargets, + resolveWindowsServerAsarIgnoreGlobs, resourceMonitorExecutableName, resolveGitHubPublishConfig, resolveMockUpdateServerPort, @@ -47,6 +48,7 @@ import { resolvePackageManagerUserAgent, stageLinuxIconSize, stageDesktopDmgBackground, + stageResourceMonitor, STAGE_INSTALL_ARGS, ancestorNodeModulesPaths, copyDirectoryPreservingSymlinks, @@ -118,7 +120,7 @@ const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(fu yield* fs.writeFileString(nativePath, "native-binary"); const generatedAsarPath = path.join(tempDir, WINDOWS_SERVER_ASAR_RESOURCE); - yield* packWindowsServerAsar({ sourceDir, asarPath: generatedAsarPath }); + yield* packWindowsServerAsar({ sourceDir, asarPath: generatedAsarPath, arch: "x64" }); const stageDistDir = path.join(tempDir, "dist"); const packagedAppDir = path.join(stageDistDir, "win-unpacked"); @@ -488,6 +490,121 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), ); + it("excludes node-pty binaries for the other Windows architecture", () => { + assert.deepStrictEqual(resolveWindowsServerAsarIgnoreGlobs("x64"), [ + ...WINDOWS_SERVER_ASAR_IGNORE_GLOBS, + "**/node_modules/node-pty/prebuilds/win32-arm64", + "**/node_modules/node-pty/prebuilds/win32-arm64/**", + "**/node_modules/node-pty/third_party/conpty/*/win10-arm64", + "**/node_modules/node-pty/third_party/conpty/*/win10-arm64/**", + ]); + assert.deepStrictEqual(resolveWindowsServerAsarIgnoreGlobs("arm64"), [ + ...WINDOWS_SERVER_ASAR_IGNORE_GLOBS, + "**/node_modules/node-pty/prebuilds/win32-x64", + "**/node_modules/node-pty/prebuilds/win32-x64/**", + "**/node_modules/node-pty/third_party/conpty/*/win10-x64", + "**/node_modules/node-pty/third_party/conpty/*/win10-x64/**", + ]); + }); + + it.effect( + "keeps target and WSL native files while excluding the other Windows architecture", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-windows-architecture-test-", + }); + const sourceDir = path.join(tempDir, "server"); + const nativeFiles = [ + "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", + "node_modules/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe", + "node_modules/node-pty/prebuilds/linux-x64/pty.node", + "node_modules/node-pty/third_party/conpty/1.0.0/win10-x64/OpenConsole.exe", + "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64/OpenConsole.exe", + ]; + + for (const nativeFile of nativeFiles) { + const nativePath = path.join(sourceDir, nativeFile); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString(nativePath, "native"); + } + + const asarPath = path.join(tempDir, "server.asar"); + yield* packWindowsServerAsar({ sourceDir, asarPath, arch: "x64" }); + const unpackedRoot = `${asarPath}.unpacked`; + + assert.isTrue( + yield* fs.exists( + path.join( + unpackedRoot, + "node_modules/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe", + ), + ), + ); + assert.isTrue( + yield* fs.exists( + path.join(unpackedRoot, "node_modules/node-pty/prebuilds/linux-x64/pty.node"), + ), + ); + assert.isFalse( + yield* fs.exists( + path.join(unpackedRoot, "node_modules/node-pty/prebuilds/win32-arm64"), + ), + ); + assert.isFalse( + yield* fs.exists( + path.join(unpackedRoot, "node_modules/node-pty/third_party/conpty/1.0.0/win10-arm64"), + ), + ); + }), + ), + ); + + it.effect("stages a cached resource monitor without invoking Cargo", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-cache-test-", + }); + const binaryPath = path.join( + repoRoot, + "native/resource-monitor/target/x86_64-unknown-linux-gnu/release/t3-resource-monitor", + ); + const stageResourcesDir = path.join(repoRoot, "stage"); + yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(binaryPath, "cached monitor"); + + yield* stageResourceMonitor({ + repoRoot, + stageResourcesDir, + platform: "linux", + arch: "x64", + verbose: false, + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR: "true" }, + }), + ), + ), + ); + + assert.equal( + yield* fs.readFileString( + path.join(stageResourcesDir, "resource-monitor/t3-resource-monitor"), + ), + "cached monitor", + ); + }), + ), + ); + it.effect("validates every ASAR-unpacked native in the packaged Windows payload", () => Effect.scoped( Effect.gen(function* () { @@ -504,6 +621,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { yield* packWindowsServerAsar({ sourceDir: fixture.sourceDir, asarPath: secondAsarPath, + arch: "x64", }); const [firstAsar, secondAsar] = yield* Effect.all([ fs.readFile(fixture.generatedAsarPath), diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index bf36029bc75b..3abe682b51a1 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -819,6 +819,21 @@ export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [ "**/node_modules/.bin", "**/node_modules/.bin/**", ] as const; + +export function resolveWindowsServerAsarIgnoreGlobs(arch: typeof BuildArch.Type) { + const unusedArch = arch === "arm64" ? "x64" : "arm64"; + const unusedPrebuild = `**/node_modules/node-pty/prebuilds/win32-${unusedArch}`; + const unusedConpty = `**/node_modules/node-pty/third_party/conpty/*/win10-${unusedArch}`; + + return [ + ...WINDOWS_SERVER_ASAR_IGNORE_GLOBS, + unusedPrebuild, + `${unusedPrebuild}/**`, + unusedConpty, + `${unusedConpty}/**`, + ]; +} + export const WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT = 80; export const WINDOWS_SERVER_RESOURCE_SOURCE_DIR = "apps/desktop/prod-resources/windows-server"; export const WINDOWS_SERVER_EXTRA_RESOURCES = [ @@ -1643,7 +1658,7 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel }, ); -const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: { +export const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: { readonly repoRoot: string; readonly stageResourcesDir: string; readonly platform: typeof BuildPlatform.Type; @@ -1655,28 +1670,33 @@ const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: const manifestPath = path.join(input.repoRoot, "native/resource-monitor/Cargo.toml"); const executableName = resourceMonitorExecutableName(input.platform); const rustTargets = resolveResourceMonitorRustTargets(input.platform, input.arch); + const reuseResourceMonitor = yield* Config.boolean("T3CODE_DESKTOP_REUSE_RESOURCE_MONITOR").pipe( + Config.withDefault(false), + ); const builtBinaries: string[] = []; for (const rustTarget of rustTargets) { - const spawnCommand = yield* resolveSpawnCommand("cargo", [ - "build", - "--locked", - "--release", - "--manifest-path", - manifestPath, - "--target", - rustTarget, - ]); - yield* runCommand( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: input.repoRoot, - shell: spawnCommand.shell, - }), - { - label: `cargo build resource monitor (${rustTarget})`, - verbose: input.verbose, - }, - ); + if (!reuseResourceMonitor) { + const spawnCommand = yield* resolveSpawnCommand("cargo", [ + "build", + "--locked", + "--release", + "--manifest-path", + manifestPath, + "--target", + rustTarget, + ]); + yield* runCommand( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.repoRoot, + shell: spawnCommand.shell, + }), + { + label: `cargo build resource monitor (${rustTarget})`, + verbose: input.verbose, + }, + ); + } const binaryPath = path.join( input.repoRoot, @@ -1693,6 +1713,9 @@ const stageResourceMonitor = Effect.fn("stageResourceMonitor")(function* (input: arch: input.arch, }); } + if (reuseResourceMonitor) { + yield* Effect.log(`[desktop-artifact] Reusing cached resource monitor (${rustTarget}).`); + } builtBinaries.push(binaryPath); } @@ -2253,6 +2276,7 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* ( export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: { readonly sourceDir: string; readonly asarPath: string; + readonly arch: typeof BuildArch.Type; }) { const fs = yield* FileSystem.FileSystem; yield* Effect.tryPromise({ @@ -2260,7 +2284,7 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function createPackageWithOptions(input.sourceDir, input.asarPath, { dot: true, unpack: WINDOWS_SERVER_ASAR_UNPACK_GLOB, - globOptions: { ignore: [...WINDOWS_SERVER_ASAR_IGNORE_GLOBS] }, + globOptions: { ignore: resolveWindowsServerAsarIgnoreGlobs(input.arch) }, }), catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }), }); @@ -2354,7 +2378,11 @@ export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")( yield* Effect.log("[desktop-artifact] Packing server.asar..."); yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true }); - yield* packWindowsServerAsar({ sourceDir: serverStageDir, asarPath: input.asarPath }); + yield* packWindowsServerAsar({ + sourceDir: serverStageDir, + asarPath: input.asarPath, + arch: input.arch, + }); const packedStat = yield* fs.stat(input.asarPath); yield* Effect.log( `[desktop-artifact] Packed server.asar (${String(packedStat.size)} bytes) + unpacked natives.`, From ea8c9e5ca3ace89cbf6cf0a2aa03047aab1d3ef9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 05:42:56 -0700 Subject: [PATCH 17/91] fix(web): stop tool calls from leaving a blank page in threads (#7971) --- .../web/src/components/ChatView.logic.test.ts | 109 ++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 27 +++++ apps/web/src/components/ChatView.tsx | 34 +++++- .../components/chat/MessagesTimeline.test.tsx | 43 +++++++ 4 files changed, 208 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 66e83f1f7e62..cb814dace2e5 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -34,6 +34,7 @@ import { scheduleEnvironmentReconnectWarning, startNewThreadForProject, shouldDockDraftHeroForSubmission, + shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -77,6 +78,114 @@ describe("draft hero submission transition", () => { }); }); +describe("shouldReleaseTimelineAnchorForToolActivity", () => { + const activeTurnId = TurnId.make("active-turn"); + const anchorMessageId = MessageId.make("anchored-message"); + const activeToolEntry = { + id: "tool-entry", + kind: "work" as const, + createdAt: now, + entry: { + id: "active-tool", + createdAt: now, + turnId: activeTurnId, + label: "Run command", + tone: "tool" as const, + command: "git status", + }, + }; + + it("releases the send anchor for tool activity in the active turn", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }), + ).toBe(true); + }); + + it("keeps the anchor while the user reads history", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: false, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }), + ).toBe(false); + }); + + it("ignores tool activity from earlier turns", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [ + { + ...activeToolEntry, + entry: { + ...activeToolEntry.entry, + turnId: TurnId.make("previous-turn"), + }, + }, + ], + }), + ).toBe(false); + }); + + it("ignores thinking and error rows without tool activity", () => { + expect( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [ + { + ...activeToolEntry, + entry: { + id: "thinking-entry", + createdAt: now, + turnId: activeTurnId, + label: "Thinking", + tone: "thinking", + }, + }, + { + ...activeToolEntry, + id: "error-entry", + entry: { + id: "error-entry", + createdAt: now, + turnId: activeTurnId, + label: "Provider error", + tone: "error", + }, + }, + ], + }), + ).toBe(false); + }); + + it("does nothing without an anchor or running turn", () => { + const input = { + anchorMessageId, + liveFollowEnabled: true, + runningTurnId: activeTurnId, + timelineEntries: [activeToolEntry], + }; + + expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( + false, + ); + expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( + false, + ); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b790aa025a1f..83bea23b65e2 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -2,6 +2,7 @@ import { type EnvironmentId, isProviderDriverKind, ProjectId, + type MessageId, type ModelSelection, type ProviderDriverKind, type ServerProvider, @@ -22,6 +23,7 @@ import { } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import type { ComposerSubmissionIntent } from "../composer-logic"; +import type { TimelineEntry } from "../session-logic"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -42,6 +44,31 @@ export function shouldDockDraftHeroForSubmission(input: { ); } +export function shouldReleaseTimelineAnchorForToolActivity(input: { + anchorMessageId: MessageId | null; + liveFollowEnabled: boolean; + runningTurnId: TurnId | null; + timelineEntries: ReadonlyArray; +}): boolean { + if (input.anchorMessageId === null || !input.liveFollowEnabled || input.runningTurnId === null) { + return false; + } + + return input.timelineEntries.some((timelineEntry) => { + if (timelineEntry.kind !== "work" || timelineEntry.entry.turnId !== input.runningTurnId) { + return false; + } + + const entry = timelineEntry.entry; + return ( + entry.tone === "tool" || + entry.itemType !== undefined || + entry.requestKind !== undefined || + (entry.command?.trim().length ?? 0) > 0 + ); + }); +} + export function resolveDraftHeroState(input: { isLocalDraftThread: boolean; hasTimelineEntries: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bbee2d1709f3..46ed051154a6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -328,6 +328,7 @@ import { hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, shouldDockDraftHeroForSubmission, + shouldReleaseTimelineAnchorForToolActivity, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -1756,6 +1757,9 @@ function ChatViewContent(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; + const activeRunningTurnId = + (activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null) ?? + (activeLatestTurn?.state === "running" ? activeLatestTurn.turnId : null); // Reading a finished thread clears the sidebar's Done badge. The visit is // stamped at the turn's completion time — not now/updatedAt — so it clears // exactly the completion the user is looking at: a wake or completion that @@ -3895,6 +3899,8 @@ function ChatViewContent(props: ChatViewProps) { liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; setTimelineLiveFollowEnabled(true); pendingTimelineAnchorRef.current = null; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); @@ -3903,6 +3909,28 @@ function ChatViewContent(props: ChatViewProps) { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); + useLayoutEffect(() => { + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } + + if ( + shouldReleaseTimelineAnchorForToolActivity({ + anchorMessageId: timelineAnchorMessageId, + liveFollowEnabled: timelineLiveFollowEnabled, + runningTurnId: activeRunningTurnId, + timelineEntries, + }) + ) { + scrollToEnd(); + } + }, [ + activeRunningTurnId, + scrollToEnd, + timelineAnchorMessageId, + timelineEntries, + timelineLiveFollowEnabled, + ]); useEffect(() => { let removeListeners: (() => void) | null = null; let frame: number | null = null; @@ -6640,11 +6668,7 @@ function ChatViewContent(props: ChatViewProps) { listRef={legendListRef} timelineEntries={timelineEntries} latestTurn={activeLatestTurn} - runningTurnId={ - activeThread.session?.status === "running" - ? activeThread.session.activeTurnId - : null - } + runningTurnId={activeRunningTurnId} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} activeThreadEnvironmentId={activeThread.environmentId} routeThreadKey={routeThreadKey} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 4647384fcf71..6d007a5b6568 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -558,6 +558,49 @@ describe("MessagesTimeline", () => { expect(onAnchorReady).not.toHaveBeenCalled(); }); + it("keeps reserved end space when tool work starts while reading history", () => { + const turnId = TurnId.make("turn-with-active-tool"); + const firstEntry = buildUserTimelineEntry("Run the command."); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-anchor-index="0"'); + expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); + }); + it("hands end-following back to the list once the send anchor is released", () => { const firstEntry = buildUserTimelineEntry("First prompt."); const secondEntry = { From b1670ac7d9b5b7bb9d7ebd969f27384daee22813 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 23 Aug 2026 06:34:48 -0700 Subject: [PATCH 18/91] fix(web): stop recovered tool failures from marking work logs red (#7999) --- .../chat/MessagesTimeline.logic.test.ts | 97 +++++++++---------- .../components/chat/MessagesTimeline.logic.ts | 9 +- .../components/chat/MessagesTimeline.test.tsx | 50 ++++++++++ 3 files changed, 103 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index ae05289afe98..81c9fdacdb9a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1470,57 +1470,54 @@ describe("deriveMessagesTimelineRows", () => { }); }); - it("keeps a failure visible when other hidden entries succeeded", () => { - const rows = deriveMessagesTimelineRows({ - timelineEntries: [ - { - id: "failed-work-entry", - kind: "work", - createdAt: "2026-01-01T00:00:01Z", - entry: { - id: "failed-work", - createdAt: "2026-01-01T00:00:01Z", - label: "Ran command", - tone: "tool", - toolLifecycleStatus: "failed", - }, - }, - { - id: "completed-work-entry", - kind: "work", - createdAt: "2026-01-01T00:00:02Z", - entry: { - id: "completed-work", - createdAt: "2026-01-01T00:00:02Z", - label: "Ran command", - tone: "tool", - toolLifecycleStatus: "completed", - }, - }, - { - id: "visible-info-entry", - kind: "work", - createdAt: "2026-01-01T00:00:03Z", - entry: { - id: "visible-info", - createdAt: "2026-01-01T00:00:03Z", - label: "Status updated", - tone: "info", - }, - }, - ], - isWorking: false, - activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), - }); + it.each([ + ["the later success is hidden", ["failed", "completed", "info"], false], + ["the later success is visible", ["failed", "info", "completed"], false], + ["an error-toned entry recovers", ["error", "info", "completed"], false], + ["the final failure is hidden", ["completed", "failed", "info"], true], + ["the final failure is visible", ["failed", "info", "failed"], true], + ["the only failure is visible", ["completed", "info", "failed"], false], + ] as const)( + "uses the final tool call for mixed work groups when %s", + (_, statuses, hasFailure) => { + const timelineEntries = statuses.map((status, index) => { + const id = `work-${index}`; + const createdAt = `2026-01-01T00:00:0${index}Z`; + + return { + id: `work-entry-${index}`, + kind: "work" as const, + createdAt, + entry: + status === "info" + ? { id, createdAt, label: "Status updated", tone: "info" as const } + : status === "error" + ? { id, createdAt, label: "Command failed", tone: "error" as const } + : { + id, + createdAt, + label: "Ran command", + tone: "tool" as const, + toolLifecycleStatus: status, + }, + }; + }); - expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ - hiddenCount: 2, - summary: null, - hasFailure: true, - }); - }); + const rows = deriveMessagesTimelineRows({ + timelineEntries, + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaryByAssistantMessageId: new Map(), + revertTurnCountByUserMessageId: new Map(), + }); + + expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ + hiddenCount: 2, + summary: null, + hasFailure, + }); + }, + ); }); describe("computeStableMessagesTimelineRows", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 8e2b295fc5df..d398583430f5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -953,6 +953,8 @@ export function deriveMessagesTimelineRows(input: { } if (hiddenEntries.length > 0) { + const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike); + nextRows.push({ kind: "work-toggle", id: `work-toggle:${timelineEntry.id}`, @@ -963,9 +965,10 @@ export function deriveMessagesTimelineRows(input: { onlyToolEntries: hiddenEntries.every(workLogEntryIsToolLike), summary: null, summaryKind: null, - hasFailure: hiddenEntries.some((entry) => - workEntryDisplayIndicatesToolFailure(entry), - ), + hasFailure: + latestToolEntry !== undefined && + workEntryDisplayIndicatesToolFailure(latestToolEntry) && + hiddenEntries.some(workEntryDisplayIndicatesToolFailure), }); } } diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 6d007a5b6568..7ee4514c3709 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -973,6 +973,56 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain('aria-label="Tool call failed"'); }); + it("keeps mixed work logs neutral after a later tool call succeeds", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("+2 previous log entries"); + expect(markup).not.toContain('aria-label="Hidden work includes a failure"'); + }); + it("shows the animated one-line label for a live tool group", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( From 55c9093344a5fdbc390d713675429599fff27dbd Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:38:06 -0700 Subject: [PATCH 19/91] fix(mobile): isolate markdown image requests (#7942) --- .../src/features/threads/ThreadFeed.tsx | 53 +++++++++++++------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 280ab4ecafa4..53b8a528ee00 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -209,8 +209,6 @@ function ThreadMarkdownImageView(props: { const [availableWidth, setAvailableWidth] = useState(0); const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null); const [failedUri, setFailedUri] = useState(null); - const activeUriRef = useRef(props.uri); - activeUriRef.current = props.uri; useEffect(() => { setSourceSize(null); @@ -272,23 +270,12 @@ function ThreadMarkdownImageView(props: { overflow: "hidden", }} > - { - if (activeUriRef.current !== props.uri) return; - const { width, height } = event.nativeEvent.source; - setSourceSize({ width, height }); - }} + setFailedUri(props.uri)} - style={{ - width: "100%", - height: "100%", - opacity: displaySize === null ? 0 : 1, - }} /> - {displaySize === null ? : null} )} @@ -301,6 +288,38 @@ function ThreadMarkdownImageView(props: { ); } +function ThreadMarkdownImageRequest(props: { + readonly uri: string; + readonly onLoad: (sourceSize: { width: number; height: number }) => void; + readonly onError: () => void; +}) { + const [loaded, setLoaded] = useState(false); + + return ( + <> + { + setLoaded(true); + props.onLoad(event.nativeEvent.source); + }} + onError={props.onError} + style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} + /> + {loaded ? null : ( + + Loading image… + + )} + + ); +} + /** Markdown image whose src is a workspace file — loads through a signed asset URL. */ function ThreadMarkdownImage(props: { readonly environmentId: EnvironmentId; From 9da0fab08e0ee444affdfb4916e294c579158bb6 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:19:13 +0300 Subject: [PATCH 20/91] feat(web): redesign skills in `$` menu and in `/` menu (#8009) --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/chat/ChatComposer.tsx | 54 +++++++++++-------- .../chat/ComposerCommandMenu.test.tsx | 43 ++++++++++----- .../components/chat/ComposerCommandMenu.tsx | 40 ++++++++------ .../chat/composerSlashCommandSearch.test.ts | 31 ++++++----- .../chat/composerSlashCommandSearch.ts | 12 +++-- .../components/settings/SettingsPanels.tsx | 31 +++++++++++ .../src/components/settings/settingsSearch.ts | 5 ++ docs/user/composer.md | 11 ++++ .../client-runtime/src/providerSkills.test.ts | 43 +++++++++++++++ packages/client-runtime/src/providerSkills.ts | 17 +++++- packages/contracts/src/settings.ts | 2 + 12 files changed, 219 insertions(+), 71 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1a304d582bb0..11030fcc5fa4 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -36,6 +36,7 @@ const clientSettings: ClientSettings = { fontSmoothing: true, glassOpacity: 80, planModeEnabled: false, + showSkillsInSlashMenu: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarAutoSettleOnMerge: true, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f06a9658225f..f29d6c2b4f6a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -252,7 +252,11 @@ import type { SessionPhase, Thread } from "../../types"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow"; -import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; +import { + formatProviderSkillDisplayName, + getProviderSlashCommandsForSlashMenu, + getProviderSkillsForSlashMenu, +} from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -1107,30 +1111,33 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ] as const) : []), ] satisfies ReadonlyArray>; - const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( - (command) => ({ - id: `provider-slash-command:${selectedProvider}:${command.name}`, - type: "provider-slash-command" as const, - provider: selectedProvider, - command, - label: `/${command.name}`, - description: command.description ?? command.input?.hint ?? "Run provider command", - }), + const slashMenuSkills = getProviderSkillsForSlashMenu( + selectedProviderStatus?.skills ?? [], + settings.showSkillsInSlashMenu, ); + const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu( + selectedProviderStatus?.slashCommands ?? [], + slashMenuSkills, + ).map((command) => ({ + id: `provider-slash-command:${selectedProvider}:${command.name}`, + type: "provider-slash-command" as const, + provider: selectedProvider, + command, + label: `/${command.name}`, + description: command.description ?? command.input?.hint ?? "Run provider command", + })); const query = composerTrigger.query.trim().toLowerCase(); - const skillItems = (selectedProviderStatus?.skills ?? []) - .filter((skill) => skill.enabled) - .map((skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: `skill:${skill.name}`, - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : ""), - })); + const skillItems = slashMenuSkills.map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: `/skill:${skill.name}`, + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : ""), + })); const slashCommandItems = [ ...builtInSlashCommandItems, ...providerSlashCommandItems, @@ -1159,6 +1166,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planModeUiEnabled, selectedProvider, selectedProviderStatus, + settings.showSkillsInSlashMenu, workspaceEntries.entries, ]); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx index 350a09f9abb6..cf0a1747dd4c 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx @@ -51,10 +51,12 @@ describe("ComposerCommandMenu", () => { expect(markup).not.toContain(" { + it("renders the skill source icon inside its badge", () => { const markup = renderToStaticMarkup( { ); expect(markup).toContain("Browser"); - expect(markup).toContain('App skill'); + expect(markup).toContain('data-slot="badge"'); + expect(markup).toContain(">App Skill"); + expect(markup).toContain("Open and control the in-app browser"); + expect(markup).toContain("max-w-[48ch]"); + expect(markup).toContain("text-secondary-label text-xs"); + expect(markup).toContain("ms-auto"); + expect(markup).toContain("text-current"); + expect(markup.indexOf("Open and control the in-app browser")).toBeLessThan( + markup.indexOf(">App Skill"), + ); expect(markup).toContain(" { + it("keeps slash skills aligned with the source icon inside the badge", () => { const markup = renderToStaticMarkup( {}} onSelect={() => {}} />, ); - expect(markup).toContain('skill:browser'); - expect(markup).toContain("Open and control the in-app browser"); + expect(markup).toContain('/skill:Ask Matt'); + expect(markup).toContain('data-slot="badge"'); + expect(markup).toContain("lucide-folder"); + expect(markup).toContain(">Repo"); + expect(markup).toContain("Find the right skill or workflow"); expect(markup).not.toContain("font-medium text-secondary-label"); - expect(markup).not.toContain(" - ) : skillSourceKind && !slashSkill ? ( - ) : null} - - - {slashSkill ? ( + + + {isSlashSkill ? ( <> - skill: - {slashSkill.name} + /skill: + {formatProviderSkillDisplayName(isSlashSkill)} ) : ( props.item.label )} - + {props.item.description} + {skillSourceKind ? ( + + ) : null} ); @@ -190,7 +195,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { const SKILL_SOURCE_ICON_BY_KIND: Record = { app: BlocksIcon, - repo: FolderGit2Icon, + repo: FolderIcon, project: FolderIcon, personal: UserRoundIcon, system: SettingsIcon, @@ -203,15 +208,16 @@ const SKILL_SOURCE_LABEL_BY_KIND: Record = { project: "Project", personal: "Personal", system: "System", - other: "Other", + other: "Provider", }; -function SkillSourceIcon(props: { kind: ProviderSkillSourceKind }) { +function SkillSourceBadge(props: { kind: ProviderSkillSourceKind; showSkillSuffix: boolean }) { const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind]; return ( - <> -