diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index c41bb34bb433..3acaf7154508 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -53,6 +53,34 @@ describe("ElectronDialog", () => { }).pipe(Effect.provide(ElectronDialog.layer)), ); + it.effect("opens a single-file picker when multiple selections are disabled", () => + Effect.gen(function* () { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ["/pictures/icon.png"], + }); + const dialog = yield* ElectronDialog.ElectronDialog; + + const paths = yield* dialog.pickFiles({ + owner: Option.none(), + defaultPath: Option.some("/project"), + filters: [{ name: "Images", extensions: ["png"] }], + multiple: false, + }); + + assert.deepEqual(paths, ["/pictures/icon.png"]); + assert.deepEqual(showOpenDialogMock.mock.calls, [ + [ + { + defaultPath: "/project", + filters: [{ name: "Images", extensions: ["png"] }], + properties: ["openFile"], + }, + ], + ]); + }).pipe(Effect.provide(ElectronDialog.layer)), + ); + it.effect("preserves message box request context and cause", () => Effect.gen(function* () { const cause = new Error("message box failed"); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index c33a24befcf8..4300d9ab0d39 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -84,6 +84,7 @@ export interface ElectronDialogPickFilesInput { readonly owner: Option.Option; readonly defaultPath: Option.Option; readonly filters: readonly Electron.FileFilter[]; + readonly multiple: boolean; } export class ElectronDialog extends Context.Service< @@ -144,7 +145,7 @@ export const make = ElectronDialog.of({ }); const defaultPath = Option.getOrNull(input.defaultPath); const openDialogOptions: Electron.OpenDialogOptions = { - properties: ["openFile", "multiSelections"], + properties: input.multiple ? ["openFile", "multiSelections"] : ["openFile"], filters: [...input.filters], ...(defaultPath === null ? {} : { defaultPath }), }; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 37fd873a1b03..8e8317db7971 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -39,6 +39,7 @@ import { openExternal, probeRemoteEditors, pickFolder, + pickProjectFavicon, pickThemeFiles, setTheme, showContextMenu, @@ -82,6 +83,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setWslOnly); yield* ipc.handle(pickFolder); + yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 180e02810801..c4ef82ec8cb7 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d39563..203151c2660e 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -2,13 +2,19 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { vi } from "vite-plus/test"; import type * as Electron from "electron"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; -import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; +import { + getLocalEnvironmentBootstraps, + getWindowFullscreenState, + pickProjectFavicon, +} from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { executablePath: "wsl.exe", @@ -146,3 +152,38 @@ describe("getWindowFullscreenState", () => { ); }); }); + +describe("pickProjectFavicon", () => { + it.effect("opens a single-image picker from the project directory", () => + Effect.gen(function* () { + const pickFiles = vi.fn(() => Effect.succeed(["/pictures/icon.png"])); + const result = yield* pickProjectFavicon.handler("/project").pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(Option.none()), + }), + ), + ), + ); + + assert.strictEqual(result, "/pictures/icon.png"); + assert.deepEqual(pickFiles.mock.calls, [ + [ + { + owner: Option.none(), + defaultPath: Option.some("/project"), + multiple: false, + filters: [ + { + name: "Images", + extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"], + }, + ], + }, + ], + ]); + }), + ); +}); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 0c7e90b95072..edae8394302c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -12,6 +12,7 @@ import { type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; +import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; import { isCommandAvailable } from "@t3tools/shared/shell"; import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; @@ -234,6 +235,28 @@ export const pickFolder = DesktopIpc.makeIpcMethod({ }), }); +export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, + payload: Schema.UndefinedOr(Schema.String), + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.window.pickProjectFavicon")(function* (initialPath) { + const dialog = yield* ElectronDialog.ElectronDialog; + const electronWindow = yield* ElectronWindow.ElectronWindow; + const paths = yield* dialog.pickFiles({ + owner: yield* electronWindow.focusedMainOrFirst, + defaultPath: Option.fromNullishOr(initialPath), + multiple: false, + filters: [ + { + name: "Images", + extensions: WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.map((extension) => extension.slice(1)), + }, + ], + }); + return paths[0] ?? null; + }), +}); + export const setTheme = DesktopIpc.makeIpcMethod({ channel: IpcChannels.SET_THEME_CHANNEL, payload: DesktopThemeSchema, @@ -323,6 +346,7 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ owner: yield* electronWindow.focusedMainOrFirst, defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(), filters: [{ name: "JSON", extensions: ["json"] }], + multiple: true, }); if (paths.length === 0) { return null; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 60faba26a8aa..3b66dfd63e32 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -113,6 +113,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { return null; } }, + pickProjectFavicon: (initialPath) => + ipcRenderer.invoke(IpcChannels.PICK_PROJECT_FAVICON_CHANNEL, initialPath), pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined), setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 9d6bbaea6bcb..78ea56e75131 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -58,9 +58,6 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); it("does not throw on out-of-range numeric entities and keeps the literal", () => { - expect(() => - normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), - ).not.toThrow(); const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); }); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 852535d9d10b..8e699e4c24fd 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import Constants from "expo-constants"; import * as Network from "expo-network"; import { AppState } from "react-native"; @@ -166,7 +167,7 @@ const capabilitiesLayer = Layer.effectContext( Context.add( ClientPresentation, ClientPresentation.of({ - metadata: authClientMetadata(), + metadata: authClientMetadata(Constants.expoConfig?.version), scopes: AuthStandardClientScopes, }), ), diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts index 75f78738ade5..6d07f40a52dd 100644 --- a/apps/mobile/src/features/connection/environmentSections.test.ts +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import { describe, expect, it } from "vite-plus/test"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; -import { splitEnvironmentSections } from "./environmentSections"; +import { relayManagedEnvironmentIds, splitEnvironmentSections } from "./environmentSections"; function connectedEnvironment( input: Omit, "environmentId"> & { @@ -34,6 +34,17 @@ function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { }; } +describe("relayManagedEnvironmentIds", () => { + it("leaves out a backend that was saved directly", () => { + const ids = relayManagedEnvironmentIds([ + connectedEnvironment({ environmentId: "environment-local", isRelayManaged: false }), + connectedEnvironment({ environmentId: "environment-cloud", isRelayManaged: true }), + ]); + + expect([...ids]).toEqual([EnvironmentId.make("environment-cloud")]); + }); +}); + describe("mobile environment settings sections", () => { it("keeps saved relay-managed connections under T3 Connect", () => { const local = connectedEnvironment({ @@ -111,6 +122,24 @@ describe("mobile environment settings sections", () => { expect(sections.availableCloudEnvironments).toEqual([]); }); + it("still offers a cloud environment saved directly as a local backend", () => { + const local = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: false, + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [local], + cloudEnvironments: [cloudEnvironment("environment-cloud")], + }); + + expect(sections.localEnvironments).toEqual([local]); + expect(sections.connectedCloudEnvironments).toEqual([]); + expect( + sections.availableCloudEnvironments.map((environment) => environment.environmentId), + ).toEqual([EnvironmentId.make("environment-cloud")]); + }); + it("keeps failed relay environments in the local connection row", () => { const cloud = connectedEnvironment({ environmentId: "environment-cloud", diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts index fc6db479c2ff..10ba636dc576 100644 --- a/apps/mobile/src/features/connection/environmentSections.ts +++ b/apps/mobile/src/features/connection/environmentSections.ts @@ -1,3 +1,4 @@ +import type { EnvironmentId } from "@t3tools/contracts"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; @@ -12,10 +13,25 @@ export interface EnvironmentSections { readonly availableCloudEnvironments: ReadonlyArray; } -export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { - const savedEnvironmentIds = new Set( - input.connectedEnvironments.map((environment) => environment.environmentId), +/** + * Ids of the environments that already occupy a T3 Connect slot. A backend saved directly is + * not one of them, so it must not suppress the cloud environment that happens to share its id. + */ +export function relayManagedEnvironmentIds( + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly isRelayManaged: boolean; + }>, +): ReadonlySet { + return new Set( + environments + .filter((environment) => environment.isRelayManaged) + .map((environment) => environment.environmentId), ); +} + +export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { + const savedEnvironmentIds = relayManagedEnvironmentIds(input.connectedEnvironments); return { localEnvironments: input.connectedEnvironments.filter( diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts index bad6b6f17209..faa34477569d 100644 --- a/apps/mobile/src/features/connection/useConnectionController.ts +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -20,6 +20,7 @@ import { useEnvironments } from "../../state/environments"; import { relayEnvironmentDiscovery } from "../../state/relay"; import { useAtomCommand } from "../../state/use-atom-command"; import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel"; +import { relayManagedEnvironmentIds } from "./environmentSections"; export interface RelayEnvironmentView { readonly environment: RelayClientEnvironmentRecord; @@ -49,7 +50,7 @@ export function useConnectionController() { [environments], ); const registeredIds = useMemo( - () => new Set(connectedEnvironments.map((environment) => environment.environmentId)), + () => relayManagedEnvironmentIds(connectedEnvironments), [connectedEnvironments], ); const relayEnvironments = useMemo>( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e234838394ba..2c6860199722 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -79,6 +79,7 @@ import { } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThreadShell; @@ -257,9 +258,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const listRef = useRef(null); const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const selectedThreadKeyRef = useRef(selectedThreadKey); - const lastScrolledAnchorMessageIdRef = useRef(null); + const lastScrolledSubmittedMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); + const [submittedMessageId, setSubmittedMessageId] = useState(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); // Android keys the safe-area padding on keyboard visibility (#5988): the // back gesture closes the keyboard while the editor stays focused, and a @@ -458,17 +460,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { setAnchorMessageId(null); - lastScrolledAnchorMessageIdRef.current = null; + setSubmittedMessageId(null); + lastScrolledSubmittedMessageIdRef.current = null; setEndFollowEnabled(true); freeze.set(false); }, [freeze, selectedThreadKey]); useEffect(() => { if ( - anchorMessageId === null || - lastScrolledAnchorMessageIdRef.current === anchorMessageId || + submittedMessageId === null || + lastScrolledSubmittedMessageIdRef.current === submittedMessageId || contentPresentationKind !== "ready" || - !selectedThreadFeed.some((entry) => entry.type === "message" && entry.id === anchorMessageId) + !selectedThreadFeed.some( + (entry) => entry.type === "message" && entry.id === submittedMessageId, + ) ) { return; } @@ -478,7 +483,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread if (selectedThreadKeyRef.current !== targetThreadKey) { return; } - lastScrolledAnchorMessageIdRef.current = anchorMessageId; + lastScrolledSubmittedMessageIdRef.current = submittedMessageId; // Wait for the keyboard dismissal (started by blur() on send) to finish // before scrolling: scrollMessageToEnd freezes keyboard-driven inset // updates while it runs, and a close event swallowed by that freeze @@ -488,7 +493,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread .then(() => { if ( selectedThreadKeyRef.current !== targetThreadKey || - lastScrolledAnchorMessageIdRef.current !== anchorMessageId + lastScrolledSubmittedMessageIdRef.current !== submittedMessageId ) { return; } @@ -497,17 +502,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread .catch(() => { if ( selectedThreadKeyRef.current !== targetThreadKey || - lastScrolledAnchorMessageIdRef.current !== anchorMessageId + lastScrolledSubmittedMessageIdRef.current !== submittedMessageId ) { return; } - lastScrolledAnchorMessageIdRef.current = null; + lastScrolledSubmittedMessageIdRef.current = null; freeze.set(false); }); }); return () => cancelAnimationFrame(frame); }, [ - anchorMessageId, + submittedMessageId, freeze, contentPresentationKind, selectedThreadFeed, @@ -517,15 +522,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const handleSendMessage = useCallback(async () => { const targetThreadKey = selectedThreadKey; + const hasUserMessage = selectedThreadFeed.some( + (entry) => entry.type === "message" && entry.message.role === "user", + ); const messageId = await props.onSendMessage(); if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { return messageId; } - setAnchorMessageId(messageId); + setSubmittedMessageId(messageId); + setAnchorMessageId( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: anchorMessageId, + submittedMessageId: messageId, + hasStartedTurn: props.selectedThread.latestTurn !== null, + hasUserMessage, + queuedMessageCount: props.selectedThreadQueueCount, + }), + ); composerEditorRef.current?.blur(); return messageId; - }, [props.onSendMessage, selectedThreadKey]); + }, [ + anchorMessageId, + props.onSendMessage, + props.selectedThread.latestTurn, + props.selectedThreadQueueCount, + selectedThreadFeed, + selectedThreadKey, + ]); const collapseComposer = useCallback(() => { composerEditorRef.current?.blur(); @@ -595,6 +619,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} + submittedMessageId={submittedMessageId} contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} contentBottomInset={estimatedOverlayHeight} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f00736772766..d3aa65673bbb 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -151,6 +151,7 @@ export interface ThreadFeedProps { readonly listRef: RefObject; readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; + readonly submittedMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; readonly contentTopInset?: number; readonly contentBottomInset?: number; @@ -1546,12 +1547,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { transitionEndFollow({ type: "reset" }); }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); useEffect(() => { - if (props.anchorMessageId !== null) { + if (props.submittedMessageId !== null) { clearUserScrollSettle(); userScrollSessionRef.current = false; transitionEndFollow({ type: "reset" }); } - }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); + }, [clearUserScrollSettle, props.submittedMessageId, transitionEndFollow]); const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); @@ -1600,7 +1601,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { resolveChatListAnchoredEndSpace( presentedFeed, props.anchorMessageId, - (entry) => (entry.type === "message" ? entry.id : null), + (entry) => (entry.type === "message" && entry.message.role === "user" ? entry.id : null), { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, ), [presentedFeed, props.anchorMessageId, anchorTopInset], diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 8cc68cb3c525..2ea207923429 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -1,6 +1,71 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow"; +import { + resolveThreadFeedLiveFollow, + resolveThreadFeedSubmissionAnchor, +} from "./thread-feed-live-follow"; + +describe("resolveThreadFeedSubmissionAnchor", () => { + it("anchors the first user message in a thread", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: null, + submittedMessageId: "first-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBe("first-message"); + }); + + it("preserves the first-message anchor when another message is queued", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 1, + }), + ).toBe("first-message"); + }); + + it("preserves the first-message anchor after its outbox entry drains", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBe("first-message"); + }); + + it("does not anchor a follow-up after a user message appears", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: "first-message", + submittedMessageId: "second-message", + hasStartedTurn: false, + hasUserMessage: true, + queuedMessageCount: 0, + }), + ).toBeNull(); + }); + + it("does not anchor a thread that has already started a turn", () => { + expect( + resolveThreadFeedSubmissionAnchor({ + currentAnchorMessageId: null, + submittedMessageId: "second-message", + hasStartedTurn: true, + hasUserMessage: false, + queuedMessageCount: 0, + }), + ).toBeNull(); + }); +}); describe("resolveThreadFeedLiveFollow", () => { it("pauses immediately when the user starts scrolling", () => { diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index babe18f0c1cb..312fd67473e5 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -12,6 +12,24 @@ export type ThreadFeedLiveFollowEvent = readonly userScrollSessionActive: boolean; }; +export function resolveThreadFeedSubmissionAnchor(input: { + readonly currentAnchorMessageId: AnchorId | null; + readonly submittedMessageId: AnchorId; + readonly hasStartedTurn: boolean; + readonly hasUserMessage: boolean; + readonly queuedMessageCount: number; +}): AnchorId | null { + if (input.hasStartedTurn || input.hasUserMessage) { + return null; + } + + if (input.currentAnchorMessageId !== null) { + return input.currentAnchorMessageId; + } + + return input.queuedMessageCount > 0 ? null : input.submittedMessageId; +} + export function resolveThreadFeedLiveFollow( current: boolean, event: ThreadFeedLiveFollowEvent, diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index 09897b6186e1..5189c34f5806 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -1,10 +1,12 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; import { Platform } from "react-native"; -export function authClientMetadata(): AuthClientPresentationMetadata { +export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { return { label: "T3 Code Mobile", deviceType: "mobile", ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), + surface: "mobile", + ...(appVersion ? { appVersion } : {}), }; } diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index f1f30b298b66..8ec0fb8bd892 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -26,6 +26,14 @@ describe("mobile remote connection records", () => { label: "T3 Code Mobile", deviceType: "mobile", os: "iOS", + surface: "mobile", + }); + }); + + it("includes the mobile app version when the client provides it", () => { + expect(authClientMetadata("1.2.3")).toMatchObject({ + surface: "mobile", + appVersion: "1.2.3", }); }); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 0a1972c2827c..aa47a78238bb 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -287,6 +287,44 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues an exact capability for a saved favicon outside the workspace", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-workspace-", + }); + const pictures = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-pictures-", + }); + const externalPath = path.join(pictures, "custom.png"); + const siblingPath = path.join(pictures, "sibling.png"); + yield* fileSystem.writeFile(externalPath, new Uint8Array([1, 2, 3])); + yield* fileSystem.writeFile(siblingPath, new Uint8Array([4, 5, 6])); + const canonicalPath = yield* fileSystem.realPath(externalPath); + const canonicalSiblingPath = yield* fileSystem.realPath(siblingPath); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + projectFaviconPath: externalPath, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect(result.sourcePath).toBe(externalPath); + expect(result.relativeUrl).toMatch(/\/v[0-9a-f]{64}-custom\.png$/); + expect( + yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), + ).toEqual({ kind: "file", path: canonicalPath }); + const tamperedSuffixResult = yield* resolveAsset( + suffix.slice(0, separatorIndex), + "sibling.png", + ); + expect(tamperedSuffixResult).toEqual({ kind: "file", path: canonicalPath }); + expect(tamperedSuffixResult).not.toEqual({ kind: "file", path: canonicalSiblingPath }); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("ignores a client favicon path hint", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 7157513b14d3..232a41e5a9c8 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -88,6 +88,12 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.NullOr(Schema.String), expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("project-favicon-external"), + filePath: Schema.String, + expiresAt: Schema.Number, + }), ]); type AssetClaims = typeof AssetClaimsSchema.Type; @@ -124,6 +130,17 @@ const optionOnNotFound = ( }), ); +const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* ( + filePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const canonicalFile = yield* optionOnNotFound(fileSystem.realPath(filePath)); + if (Option.isNone(canonicalFile)) return null; + + const info = yield* optionOnNotFound(fileSystem.stat(canonicalFile.value)); + return Option.isSome(info) && info.value.type === "File" ? canonicalFile.value : null; +}); + const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; @@ -300,13 +317,24 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - if (relativePath && !isWorkspaceImagePreviewPath(relativePath)) { + const isExternalOverride = + faviconPath !== null && + input.projectFaviconPath !== undefined && + path.isAbsolute(input.projectFaviconPath) && + path.normalize(faviconPath) === path.normalize(input.projectFaviconPath); + const relativePath = + faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null; + const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath; + if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } - sourcePath = relativePath ?? undefined; - const canonicalFaviconPath = relativePath - ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + sourcePath = sourceFaviconPath ?? undefined; + const canonicalFaviconPath = sourceFaviconPath + ? yield* ( + isExternalOverride + ? resolveCanonicalFile(sourceFaviconPath) + : resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath: sourceFaviconPath }) + ).pipe( Effect.mapError( (cause) => new AssetProjectFaviconInspectionError({ @@ -316,27 +344,35 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ) : null; - if (relativePath && !canonicalFaviconPath) { + if (sourceFaviconPath && !canonicalFaviconPath) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, }); } - claims = { - version: 1, - kind: "project-favicon", - workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( - Effect.mapError( - (cause) => - new AssetWorkspaceResolutionError({ - resource: input.resource, - cause, - }), - ), - ), - relativePath, - expiresAt, - }; - if (relativePath && canonicalFaviconPath) { + claims = + isExternalOverride && canonicalFaviconPath + ? { + version: 1, + kind: "project-favicon-external", + filePath: canonicalFaviconPath, + expiresAt, + } + : { + version: 1, + kind: "project-favicon", + workspaceRoot: yield* fileSystem.realPath(workspaceRoot).pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceResolutionError({ + resource: input.resource, + cause, + }), + ), + ), + relativePath, + expiresAt, + }; + if (sourceFaviconPath && canonicalFaviconPath) { const crypto = yield* Crypto.Crypto; const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( Effect.mapError( @@ -357,7 +393,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; + fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(sourceFaviconPath)}`; } else { fileName = PROJECT_FAVICON_FALLBACK_MARKER; } @@ -375,7 +411,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - if (claims.kind === "project-favicon") { + if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") { const issuedAt = yield* Clock.currentTimeMillis; expiresAt = (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * @@ -441,6 +477,21 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; } + if (claims.kind === "project-favicon-external") { + const faviconPath = yield* resolveCanonicalFile(claims.filePath).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical asset path.", { + filePath: claims.filePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + return faviconPath === claims.filePath + ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) + : null; + } + const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 334c24ef52fd..1fb01c1f0002 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; @@ -47,6 +48,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi revoke: () => Effect.fail(repositoryFailure), revokeAllExcept: () => Effect.fail(repositoryFailure), setLastConnectedAt: () => Effect.void, + setClientConnection: () => Effect.void, }); const failingSessionLookupCredentialLayer = Layer.effect( @@ -315,4 +317,35 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); + it.effect("records client connection metadata without clearing prior values", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const issued = yield* sessions.issue({ + subject: "client-connection-test", + method: "bearer-access-token", + }); + const readRow = sql<{ + readonly surface: string | null; + readonly appVersion: string | null; + }>` + SELECT client_surface AS "surface", client_app_version AS "appVersion" + FROM auth_sessions + WHERE session_id = ${issued.sessionId} + `; + + yield* sessions.recordClientConnection(issued.sessionId, { + surface: "mobile", + appVersion: "1.2.0", + }); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.2.0" }); + + // A partial report (old or minimal client) must not null out stored data. + yield* sessions.recordClientConnection(issued.sessionId, { appVersion: "1.3.0" }); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); + + yield* sessions.recordClientConnection(issued.sessionId, {}); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); + }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), + ); }); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 40a1c43e0be7..cdcd4a1ac198 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -5,6 +5,7 @@ import { type AuthClientMetadata, type AuthClientSession, type AuthEnvironmentScope, + type ClientSurface, type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -396,6 +397,13 @@ export class SessionStore extends Context.Service< ) => Effect.Effect; readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly recordClientConnection: ( + sessionId: AuthSessionId, + client: { + readonly surface?: ClientSurface | undefined; + readonly appVersion?: string | undefined; + }, + ) => Effect.Effect; } >()("t3/auth/SessionStore") {} @@ -544,6 +552,28 @@ export const make = Effect.gen(function* () { Effect.withSpan("SessionStore.markConnected"), ); + // Best-effort: connection metadata must never block or fail a connect. + const recordClientConnection: SessionStore["Service"]["recordClientConnection"] = ( + sessionId, + client, + ) => + client.surface === undefined && client.appVersion === undefined + ? Effect.void + : authSessions + .setClientConnection({ + sessionId, + surface: client.surface ?? null, + appVersion: client.appVersion ?? null, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to record session client connection metadata.").pipe( + Effect.annotateLogs({ sessionId, cause }), + ), + ), + Effect.withSpan("SessionStore.recordClientConnection"), + ); + const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); @@ -912,6 +942,7 @@ export const make = Effect.gen(function* () { revokeAllExcept, markConnected, markDisconnected, + recordClientConnection, }); }); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 103b267d2954..32f249c251d5 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -342,11 +342,17 @@ export function projectActivityPayload( return activity; } + const itemStatus = asRecord(data.item)?.status; + const projectedPayload = + payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") + ? { ...payload, status: itemStatus } + : payload; + if (payload.itemType === "mcp_tool_call") { return { ...activity, payload: { - ...payload, + ...projectedPayload, data: projectMcpToolCallData(data), }, }; @@ -384,7 +390,7 @@ export function projectActivityPayload( return { ...activity, payload: { - ...payload, + ...projectedPayload, data: projectedData, }, }; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 1b89d6d4d8a8..382c253fe60b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1379,4 +1379,55 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + + it("stamps the dispatching client's origin onto persisted event metadata", async () => { + const createdAt = now(); + const system = await createOrchestrationSystem(); + const { engine } = system; + + await system.run( + engine.dispatch( + { + type: "project.create", + commandId: CommandId.make("cmd-origin-project-create"), + projectId: asProjectId("project-origin"), + title: "Origin Project", + workspaceRoot: "/tmp/project-origin", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }, + { origin: { surface: "mobile", appVersion: "1.2.3" } }, + ), + ); + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-no-origin-project-create"), + projectId: asProjectId("project-no-origin"), + title: "No Origin Project", + workspaceRoot: "/tmp/project-no-origin", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + + const events = await system.run( + Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), + ); + const withOrigin = events.find((event) => event.commandId === "cmd-origin-project-create"); + const withoutOrigin = events.find( + (event) => event.commandId === "cmd-no-origin-project-create", + ); + + expect(withOrigin?.metadata.origin).toEqual({ surface: "mobile", appVersion: "1.2.3" }); + expect(withoutOrigin?.metadata.origin).toBeUndefined(); + + await system.dispose(); + }); }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index da79b4395acb..423a44a6ff15 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,4 +1,5 @@ import type { + OrchestrationClientOrigin, OrchestrationEvent, OrchestrationReadModel, ProjectId, @@ -54,6 +55,7 @@ const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvar interface CommandEnvelope { command: OrchestrationCommand; + origin: OrchestrationClientOrigin | undefined; result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; startedAtMs: number; } @@ -182,7 +184,16 @@ const makeOrchestrationEngine = Effect.gen(function* () { }), ), ); - const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; + const plannedEvents = Array.isArray(eventBase) ? eventBase : [eventBase]; + // Stamp the dispatching client's origin onto every event the command + // produced. The decider stays pure; attribution is an engine concern. + const eventBases = + envelope.origin === undefined + ? plannedEvents + : plannedEvents.map((planned) => ({ + ...planned, + metadata: { ...planned.metadata, origin: envelope.origin }, + })); const committedCommand = yield* sql .withTransaction( Effect.gen(function* () { @@ -329,11 +340,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => eventStore.readFromSequence(fromSequenceExclusive, limit); - const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + const dispatch: OrchestrationEngineShape["dispatch"] = (command, options) => Effect.gen(function* () { const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); yield* Queue.offer(commandQueue, { command, + origin: options?.origin, result, startedAtMs: yield* Clock.currentTimeMillis, }); diff --git a/apps/server/src/orchestration/Layers/ThreadBootstrap.ts b/apps/server/src/orchestration/Layers/ThreadBootstrap.ts index c941cc53ef2f..525542a18c83 100644 --- a/apps/server/src/orchestration/Layers/ThreadBootstrap.ts +++ b/apps/server/src/orchestration/Layers/ThreadBootstrap.ts @@ -19,6 +19,13 @@ import { ThreadBootstrapService, type ThreadBootstrapShape } from "../Services/T const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); +/** + * What the caller knows about who asked. A bootstrap is several commands, and every one of them + * was caused by the same request, so the origin travels with all of them rather than only the + * turn start. + */ +type DispatchOptions = Parameters[1]; + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function unexpectedCompatibilityError(error: never): never { @@ -79,34 +86,40 @@ const makeThreadBootstrap = Effect.gen(function* () { .refreshStatus(cwd) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); - const appendSetupScriptActivity = (input: { - readonly threadId: ThreadId; - readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; - readonly summary: string; - readonly createdAt: string; - readonly payload: Record; - readonly tone: "info" | "error"; - }) => + const appendSetupScriptActivity = ( + input: { + readonly threadId: ThreadId; + readonly kind: "setup-script.requested" | "setup-script.started" | "setup-script.failed"; + readonly summary: string; + readonly createdAt: string; + readonly payload: Record; + readonly tone: "info" | "error"; + }, + options: DispatchOptions, + ) => Effect.all({ commandId: serverCommandId("setup-script-activity"), activityId: serverEventId, }).pipe( Effect.flatMap(({ commandId, activityId }) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId, - threadId: input.threadId, - activity: { - id: activityId, - tone: input.tone, - kind: input.kind, - summary: input.summary, - payload: input.payload, - turnId: null, + orchestrationEngine.dispatch( + { + type: "thread.activity.append", + commandId, + threadId: input.threadId, + activity: { + id: activityId, + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, createdAt: input.createdAt, }, - createdAt: input.createdAt, - }), + options, + ), ), ); @@ -123,6 +136,7 @@ const makeThreadBootstrap = Effect.gen(function* () { const dispatchBootstrapTurnStart: ThreadBootstrapShape["dispatchBootstrapTurnStart"] = ( command, + options, ) => Effect.gen(function* () { const bootstrap = command.bootstrap; @@ -136,11 +150,14 @@ const makeThreadBootstrap = Effect.gen(function* () { createdThread ? serverCommandId("bootstrap-thread-delete").pipe( Effect.flatMap((commandId) => - orchestrationEngine.dispatch({ - type: "thread.delete", - commandId, - threadId: command.threadId, - }), + orchestrationEngine.dispatch( + { + type: "thread.delete", + commandId, + threadId: command.threadId, + }, + options, + ), ), Effect.as(true), ) @@ -152,17 +169,20 @@ const makeThreadBootstrap = Effect.gen(function* () { readonly worktreePath: string; }) => { const detail = projectSetupScriptCompatibilityDetail(input.error); - return appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.failed", - summary: "Setup script failed to start", - createdAt: input.requestedAt, - payload: { - detail, - worktreePath: input.worktreePath, + return appendSetupScriptActivity( + { + threadId: command.threadId, + kind: "setup-script.failed", + summary: "Setup script failed to start", + createdAt: input.requestedAt, + payload: { + detail, + worktreePath: input.worktreePath, + }, + tone: "error", }, - tone: "error", - }).pipe( + options, + ).pipe( Effect.ignoreCause({ log: false }), Effect.flatMap(() => Effect.logWarning("bootstrap turn start failed to launch setup script", { @@ -190,22 +210,28 @@ const makeThreadBootstrap = Effect.gen(function* () { worktreePath: input.worktreePath, }; yield* Effect.all([ - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.requested", - summary: "Starting setup script", - createdAt: input.requestedAt, - payload, - tone: "info", - }), - appendSetupScriptActivity({ - threadId: command.threadId, - kind: "setup-script.started", - summary: "Setup script started", - createdAt: startedAt, - payload, - tone: "info", - }), + appendSetupScriptActivity( + { + threadId: command.threadId, + kind: "setup-script.requested", + summary: "Starting setup script", + createdAt: input.requestedAt, + payload, + tone: "info", + }, + options, + ), + appendSetupScriptActivity( + { + threadId: command.threadId, + kind: "setup-script.started", + summary: "Setup script started", + createdAt: startedAt, + payload, + tone: "info", + }, + options, + ), ]).pipe( Effect.asVoid, Effect.catch((error) => @@ -263,20 +289,23 @@ const makeThreadBootstrap = Effect.gen(function* () { const bootstrapProgram = Effect.gen(function* () { if (bootstrap?.createThread) { - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: yield* serverCommandId("bootstrap-thread-create"), - threadId: command.threadId, - projectId: bootstrap.createThread.projectId, - title: bootstrap.createThread.title, - modelSelection: bootstrap.createThread.modelSelection, - runtimeMode: bootstrap.createThread.runtimeMode, - interactionMode: bootstrap.createThread.interactionMode, - branch: bootstrap.createThread.branch, - worktreePath: bootstrap.createThread.worktreePath, - parentThreadId: bootstrap.createThread.parentThreadId ?? null, - createdAt: bootstrap.createThread.createdAt, - }); + yield* orchestrationEngine.dispatch( + { + type: "thread.create", + commandId: yield* serverCommandId("bootstrap-thread-create"), + threadId: command.threadId, + projectId: bootstrap.createThread.projectId, + title: bootstrap.createThread.title, + modelSelection: bootstrap.createThread.modelSelection, + runtimeMode: bootstrap.createThread.runtimeMode, + interactionMode: bootstrap.createThread.interactionMode, + branch: bootstrap.createThread.branch, + worktreePath: bootstrap.createThread.worktreePath, + parentThreadId: bootstrap.createThread.parentThreadId ?? null, + createdAt: bootstrap.createThread.createdAt, + }, + options, + ); createdThread = true; } @@ -311,19 +340,22 @@ const makeThreadBootstrap = Effect.gen(function* () { path: null, }); targetWorktreePath = worktree.worktree.path; - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* serverCommandId("bootstrap-thread-meta-update"), - threadId: command.threadId, - branch: worktree.worktree.refName, - worktreePath: targetWorktreePath, - }); + yield* orchestrationEngine.dispatch( + { + type: "thread.meta.update", + commandId: yield* serverCommandId("bootstrap-thread-meta-update"), + threadId: command.threadId, + branch: worktree.worktree.refName, + worktreePath: targetWorktreePath, + }, + options, + ); yield* refreshGitStatus(targetWorktreePath); } yield* runSetupProgram(); - return yield* orchestrationEngine.dispatch(finalTurnStartCommand); + return yield* orchestrationEngine.dispatch(finalTurnStartCommand, options); }); return yield* bootstrapProgram.pipe( diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index f8bcfd76ac06..a32a45684014 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -10,7 +10,11 @@ * * @module OrchestrationEngineService */ -import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts"; +import type { + OrchestrationClientOrigin, + OrchestrationCommand, + OrchestrationEvent, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; @@ -41,6 +45,8 @@ export interface OrchestrationEngineShape { * Dispatch a validated orchestration command. * * @param command - Valid orchestration command. + * @param options - Optional client origin (surface/app version) stamped into + * the metadata of every event the command produces. * @returns Effect containing the sequence of the persisted event. * * Dispatch is serialized through an internal queue and deduplicated via @@ -48,6 +54,7 @@ export interface OrchestrationEngineShape { */ readonly dispatch: ( command: OrchestrationCommand, + options?: { readonly origin?: OrchestrationClientOrigin }, ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; /** diff --git a/apps/server/src/orchestration/Services/ThreadBootstrap.ts b/apps/server/src/orchestration/Services/ThreadBootstrap.ts index be3bf43bc1fe..2a2d16f171b5 100644 --- a/apps/server/src/orchestration/Services/ThreadBootstrap.ts +++ b/apps/server/src/orchestration/Services/ThreadBootstrap.ts @@ -12,7 +12,11 @@ * * @module ThreadBootstrapService */ -import type { OrchestrationCommand, OrchestrationDispatchCommandError } from "@t3tools/contracts"; +import type { + OrchestrationClientOrigin, + OrchestrationCommand, + OrchestrationDispatchCommandError, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; @@ -26,6 +30,9 @@ export interface ThreadBootstrapShape { * running the project's setup script before starting the first turn. * * @param command - `thread.turn.start` command with an optional bootstrap payload. + * @param options - Optional client origin, stamped into every event the + * bootstrap produces. A caller with no client behind it, such as the MCP + * toolkit, leaves it out. * @returns Effect containing the sequence of the persisted `thread.turn.start` event. * * On failure, rolls back a thread it created (`thread.delete`) before @@ -33,6 +40,7 @@ export interface ThreadBootstrapShape { */ readonly dispatchBootstrapTurnStart: ( command: Extract, + options?: { readonly origin?: OrchestrationClientOrigin }, ) => Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError>; } diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 545688e38228..579d3a608190 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -10,6 +10,7 @@ import { AuthClientMetadataDeviceType, AuthEnvironmentScopes, AuthSessionId, + ClientSurface, ServerAuthSessionMethod, } from "@t3tools/contracts"; @@ -82,6 +83,13 @@ export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ }); export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; +export const SetAuthSessionClientConnectionInput = Schema.Struct({ + sessionId: AuthSessionId, + surface: Schema.NullOr(ClientSurface), + appVersion: Schema.NullOr(Schema.String), +}); +export type SetAuthSessionClientConnectionInput = typeof SetAuthSessionClientConnectionInput.Type; + export class AuthSessionRepository extends Context.Service< AuthSessionRepository, { @@ -103,6 +111,9 @@ export class AuthSessionRepository extends Context.Service< readonly setLastConnectedAt: ( input: SetAuthSessionLastConnectedAtInput, ) => Effect.Effect; + readonly setClientConnection: ( + input: SetAuthSessionClientConnectionInput, + ) => Effect.Effect; } >()("t3/persistence/AuthSessions/AuthSessionRepository") {} @@ -281,6 +292,20 @@ export const make = Effect.gen(function* () { `, }); + // COALESCE keeps the previous value when a client reports only one field, so + // a partial report never nulls out data a fuller client stored earlier. + const setClientConnectionRow = SqlSchema.void({ + Request: SetAuthSessionClientConnectionInput, + execute: ({ sessionId, surface, appVersion }) => + sql` + UPDATE auth_sessions + SET client_surface = COALESCE(${surface}, client_surface), + client_app_version = COALESCE(${appVersion}, client_app_version) + WHERE session_id = ${sessionId} + AND revoked_at IS NULL + `, + }); + const revokeSessionRows = SqlSchema.findAll({ Request: RevokeAuthSessionInput, Result: Schema.Struct({ sessionId: AuthSessionId }), @@ -404,6 +429,17 @@ export const make = Effect.gen(function* () { ), ); + const setClientConnection: AuthSessionRepository["Service"]["setClientConnection"] = (input) => + setClientConnectionRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.setClientConnection:query", + "AuthSessionRepository.setClientConnection:encodeRequest", + { sessionId: input.sessionId }, + ), + ), + ); + return { create, getById, @@ -411,6 +447,7 @@ export const make = Effect.gen(function* () { revoke, revokeAllExcept, setLastConnectedAt, + setClientConnection, } satisfies AuthSessionRepository["Service"]; }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..170cb3992279 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "AuthSessionClientConnection", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts new file mode 100644 index 000000000000..178338b78318 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts @@ -0,0 +1,31 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("041_AuthSessionClientConnection", (it) => { + it.effect("adds nullable client surface and app version columns to auth sessions", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + yield* runMigrations({ toMigrationInclusive: 41 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(auth_sessions) + `; + const surface = columns.find((column) => column.name === "client_surface"); + const appVersion = columns.find((column) => column.name === "client_app_version"); + + assert.equal(surface?.name, "client_surface"); + assert.equal(surface?.notnull, 0); + assert.equal(appVersion?.name, "client_app_version"); + assert.equal(appVersion?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts new file mode 100644 index 000000000000..2194c3cd0f14 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// Client-declared surface (web/desktop/mobile) and app version, refreshed on +// every WebSocket connect so the row tracks the client's current build instead +// of freezing at session issuance. Nullable: old clients never report them. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + + if (!columns.some((column) => column.name === "client_surface")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_surface TEXT + `; + } + + if (!columns.some((column) => column.name === "client_app_version")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_app_version TEXT + `; + } +}); diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 7448ced247b5..c610781ea9be 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -91,6 +91,21 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { }), ); + it.effect("uses a saved project favicon outside the workspace", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + const pictures = yield* makeTempDir; + yield* writeTextFile(pictures, "custom.png", "image"); + const externalPath = path.join(pictures, "custom.png"); + + const resolved = yield* resolver.resolvePath(cwd, externalPath); + + expect(resolved).toBe(externalPath); + }), + ); + it.effect("falls back when a saved override is missing from a checkout", () => Effect.gen(function* () { const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 458954daed4b..9d9a5bddc791 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -137,22 +137,25 @@ export const make = Effect.gen(function* () { const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray, + candidateScope: "workspace" | "filesystem", ): Effect.fn.Return { for (const relativePath of relativeCandidates) { - const candidate = yield* workspacePaths - .resolveRelativePathWithinRoot({ - workspaceRoot: projectCwd, - relativePath, - }) - .pipe( - Effect.map(Option.some), - Effect.catchTags({ - WorkspacePathOutsideRootError: () => - Effect.succeed( - Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), - ), - }), - ); + const candidate = yield* ( + candidateScope === "filesystem" && path.isAbsolute(relativePath) + ? Effect.succeed({ absolutePath: relativePath, relativePath }) + : workspacePaths.resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + ).pipe( + Effect.map(Option.some), + Effect.catchTags({ + WorkspacePathOutsideRootError: () => + Effect.succeed( + Option.none<{ readonly absolutePath: string; readonly relativePath: string }>(), + ), + }), + ); if (Option.isNone(candidate)) { continue; } @@ -191,7 +194,7 @@ export const make = Effect.gen(function* () { // A grouped project's saved path can be absent from one checkout. Use it // where it exists and retain automatic discovery for the other checkouts. if (faviconPath !== undefined) { - const existing = yield* findExistingFile(projectCwd, [faviconPath]); + const existing = yield* findExistingFile(projectCwd, [faviconPath], "filesystem"); if (existing) { return existing; } @@ -200,14 +203,18 @@ export const make = Effect.gen(function* () { // A t3.json iconPath takes precedence over the well-known locations. const projectFile = yield* projectFileLoader.load(projectCwd); if (Option.isSome(projectFile) && projectFile.value.iconPath !== undefined) { - const existing = yield* findExistingFile(projectCwd, [projectFile.value.iconPath]); + const existing = yield* findExistingFile( + projectCwd, + [projectFile.value.iconPath], + "workspace", + ); if (existing) { return existing; } } for (const candidate of FAVICON_CANDIDATES) { - const existing = yield* findExistingFile(projectCwd, [candidate]); + const existing = yield* findExistingFile(projectCwd, [candidate], "workspace"); if (existing) { return existing; } @@ -251,7 +258,7 @@ export const make = Effect.gen(function* () { if (!href) { continue; } - const existing = yield* findExistingFile(projectCwd, resolveIconHref(href)); + const existing = yield* findExistingFile(projectCwd, resolveIconHref(href), "workspace"); if (existing) { return existing; } diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index df226b38a697..a37110ef7126 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -931,7 +931,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( status: "error", auth: { status: "unknown" }, message: isCommandMissingCause(error) - ? "Claude Agent CLI (`claude`) is not installed or not on PATH." + ? "Claude Agent CLI (`claude`) was not found on PATH." : "Failed to execute Claude Agent CLI health check.", }, }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 5358716aabe4..da7f6fb1576a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -614,6 +614,66 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }), ); + it.effect("preserves failed and declined outcomes on completed tool items", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const items = [ + { + type: "commandExecution", + id: "failed-command", + command: "vp test run", + commandActions: [], + cwd: "/tmp", + exitCode: 1, + status: "failed", + }, + { + type: "mcpToolCall", + id: "failed-mcp", + server: "simulator", + tool: "build", + arguments: {}, + error: { message: "Build failed" }, + status: "failed", + }, + { + type: "fileChange", + id: "declined-change", + changes: [], + status: "declined", + }, + ] as const; + + for (const item of items) { + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit({ + id: asEventId(`evt-${item.id}`), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId(item.id), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-1", + turnId: "turn-1", + item, + }, + }); + + const firstEvent = yield* Fiber.join(firstEventFiber); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some" || firstEvent.value.type !== "item.completed") { + return; + } + NodeAssert.equal(firstEvent.value.payload.status, item.status); + } + }), + ); + it.effect("maps completed plan items to canonical proposed-plan completion 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 065156d36473..cf82ffd40dff 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -480,7 +480,9 @@ function mapItemLifecycle( lifecycle === "item.started" ? "inProgress" : lifecycle === "item.completed" - ? "completed" + ? "status" in item && (item.status === "failed" || item.status === "declined") + ? item.status + : "completed" : undefined; return { diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1d58ca6b18d9..6e485dd0a287 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -576,7 +576,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: installed ? `Codex app-server provider probe failed: ${error.message}.` - : "Codex CLI (`codex`) is not installed or not on PATH.", + : "Codex CLI (`codex`) was not found on PATH.", }, }); } diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 31f06e505f3a..c36deb6fc2b1 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -507,10 +507,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI (`codex`) is not installed or not on PATH.", - ); + assert.strictEqual(status.message, "Codex CLI (`codex`) was not found on PATH."); }), ); @@ -1763,7 +1760,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(codexPersonal?.installed, false); assert.strictEqual( codexPersonal?.message, - "Codex CLI (`codex`) is not installed or not on PATH.", + "Codex CLI (`codex`) was not found on PATH.", ); }).pipe(Effect.provide(runtimeServices)); }), @@ -2877,10 +2874,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual( - status.message, - "Claude Agent CLI (`claude`) is not installed or not on PATH.", - ); + assert.strictEqual(status.message, "Claude Agent CLI (`claude`) was not found on PATH."); }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), ); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c29da8575b8d..40e96f595ff4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -154,6 +154,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -406,6 +407,7 @@ const buildAppUnderTest = (options?: { >; terminalManager?: Partial; orchestrationEngine?: Partial; + analyticsService?: Partial; projectionSnapshotQuery?: Partial; providerAdapterRegistry?: Partial; checkpointDiffQuery?: Partial; @@ -872,6 +874,13 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), Layer.provide(UsageService.layerTest), + Layer.provide( + Layer.mock(AnalyticsService.AnalyticsService)({ + record: () => Effect.void, + flush: Effect.void, + ...options?.layers?.analyticsService, + }), + ), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -5112,6 +5121,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("records thread analytics only after a client command succeeds", () => + Effect.gen(function* () { + const effects: string[] = []; + const analyticsProperties: Array> | undefined> = []; + const failedCommandId = CommandId.make("cmd-thread-create-failed"); + + yield* buildAppUnderTest({ + layers: { + analyticsService: { + record: (event, properties) => + Effect.sync(() => { + effects.push(`analytics:${event}`); + analyticsProperties.push(properties); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => effects.push(`dispatch:${command.commandId}`)).pipe( + Effect.flatMap(() => + command.commandId === failedCommandId + ? Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "thread creation failed", + }), + ) + : Effect.succeed({ sequence: 1 }), + ), + ), + }, + }, + }); + + const createThreadCommand = (commandId: CommandId, threadId: ThreadId) => + ({ + type: "thread.create", + commandId, + threadId, + projectId: defaultProjectId, + title: "Analytics test", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }) as const; + + const wsUrl = yield* getWsServerUrl("/ws?clientSurface=mobile&clientAppVersion=1.2.3"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const failed = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + createThreadCommand(failedCommandId, ThreadId.make("thread-create-failed")), + ).pipe(Effect.result); + + assert.equal(failed._tag, "Failure"); + assert.deepEqual(effects, [ + "analytics:client.connected", + "dispatch:cmd-thread-create-failed", + ]); + + const succeeded = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + createThreadCommand( + CommandId.make("cmd-thread-create-succeeded"), + ThreadId.make("thread-create-succeeded"), + ), + ); + + assert.equal(succeeded.sequence, 1); + }), + ), + ); + + assert.deepEqual(effects, [ + "analytics:client.connected", + "dispatch:cmd-thread-create-failed", + "dispatch:cmd-thread-create-succeeded", + "analytics:client.thread.started", + ]); + assert.deepEqual(analyticsProperties, [ + { surface: "mobile", appVersion: "1.2.3" }, + { surface: "mobile", appVersion: "1.2.3" }, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index df745de61d73..8869ba3e522d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -13,8 +13,10 @@ import { type AuthAccessStreamEvent, type AuthEnvironmentScope, AuthSessionId, + ClientSurface, CommandId, type DiscoveredLocalServerList, + type OrchestrationClientOrigin, type OrchestrationCommand, type GitActionProgressEvent, type GitManagerServiceError, @@ -103,6 +105,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -320,8 +323,37 @@ function toAuthAccessStreamEvent( } } +const isClientSurface = Schema.is(ClientSurface); +const MAX_CLIENT_APP_VERSION_LENGTH = 64; + +// Optional client identity announced on the /ws upgrade URL next to wsTicket. +// Lenient by design: absent or malformed values degrade to {} so a connection +// never fails over attribution metadata. +function readClientConnectionOrigin( + request: HttpServerRequest.HttpServerRequest, +): OrchestrationClientOrigin { + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return {}; + } + const surface = url.value.searchParams.get("clientSurface"); + const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; + return { + ...(isClientSurface(surface) ? { surface } : {}), + ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH + ? { appVersion } + : {}), + }; +} + +const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ + ...(origin.surface !== undefined ? { surface: origin.surface } : {}), + ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), +}); + const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, + clientOrigin: OrchestrationClientOrigin, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], ) => WsRpcGroup.toLayer( @@ -330,6 +362,35 @@ const makeWsRpcLayer = ( const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const threadBootstrap = yield* ThreadBootstrap.ThreadBootstrapService; + const analytics = yield* AnalyticsService.AnalyticsService; + // Every command dispatched on this connection carries the connecting + // client's origin, including server-generated bootstrap sub-commands: + // the client's request caused them. + const hasClientOrigin = + clientOrigin.surface !== undefined || clientOrigin.appVersion !== undefined; + const dispatchFromClient: OrchestrationEngine.OrchestrationEngineShape["dispatch"] = ( + command, + ) => + orchestrationEngine.dispatch( + command, + hasClientOrigin ? { origin: clientOrigin } : undefined, + ); + const originProps = clientOriginAnalyticsProps(clientOrigin); + const recordClientCommandAnalytics = (command: OrchestrationCommand) => { + switch (command.type) { + case "thread.create": + return analytics.record("client.thread.started", originProps); + case "thread.turn.start": + return command.bootstrap?.createThread + ? Effect.andThen( + analytics.record("client.thread.started", originProps), + analytics.record("client.turn.requested", originProps), + ) + : analytics.record("client.turn.requested", originProps); + default: + return Effect.void; + } + }; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; @@ -679,14 +740,15 @@ const makeWsRpcLayer = ( ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => { const dispatchEffect = normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap - ? threadBootstrap.dispatchBootstrapTurnStart(normalizedCommand) - : orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), - ), - ); + ? threadBootstrap.dispatchBootstrapTurnStart( + normalizedCommand, + hasClientOrigin ? { origin: clientOrigin } : undefined, + ) + : dispatchFromClient(normalizedCommand).pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); return startup .enqueueCommand(dispatchEffect) @@ -785,6 +847,7 @@ const makeWsRpcLayer = ( ) : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); + yield* recordClientCommandAnalytics(normalizedCommand); if (parkingCommand) { const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; if (shouldStopSessionAfterCommand) { @@ -2027,6 +2090,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const analytics = yield* AnalyticsService.AnalyticsService; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), @@ -2035,11 +2099,14 @@ export const websocketRpcRouteLayer = Layer.unwrap( failEnvironmentInternal("internal_error", error), ), ); + const clientOrigin = readClientConnectionOrigin(request); + yield* sessions.recordClientConnection(session.sessionId, clientOrigin); + yield* analytics.record("client.connected", clientOriginAnalyticsProps(clientOrigin)); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, previewAutomationBroker).pipe( + makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 49f1b532a53a..72e2cd9e98eb 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -220,6 +220,40 @@ describe("projectActivityPayload", () => { } }); + it("preserves failed stored tool outcomes for web and mobile clients", () => { + const activities = [ + makeActivity("failed-command", "command_execution", { + item: { + command: "vp test run", + exitCode: 1, + status: "failed", + }, + }), + makeActivity("failed-mcp", "mcp_tool_call", { + item: { + server: "simulator", + tool: "build", + arguments: {}, + status: "failed", + }, + }), + ]; + + for (const activity of activities) { + const projected = projectActivityPayload(activity); + expect(projected.payload).toMatchObject({ status: "failed" }); + + const [webEntry] = deriveWorkLogEntries([projected]); + expect(webEntry?.toolLifecycleStatus).toBe("failed"); + + const [mobileGroup] = buildThreadFeed(makeThread([projected])); + expect(mobileGroup).toMatchObject({ type: "activity-group" }); + if (mobileGroup?.type === "activity-group") { + expect(mobileGroup.activities[0]?.status).toBe("failure"); + } + } + }); + it("projects snapshot and event transports without mutating their sources", () => { const activity = fixtures[0]!; const thread = makeThread([activity]); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 9499ee5a6915..a8c82552f9bb 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -24,13 +24,21 @@ describe("orderedListGutterStyle", () => { it("accounts for a non-default start attribute", () => { // start=95 + 9 items => last marker is "103", three digits. expect(orderedListGutterStyle(9, 95)).toEqual({ "--list-gutter": "4ch" }); + expect(orderedListGutterStyle(5, "999995")).toEqual({ "--list-gutter": "7ch" }); }); it("scales further for four-digit markers", () => { expect(orderedListGutterStyle(1000, undefined)).toEqual({ "--list-gutter": "5ch" }); }); + it("uses the widest marker and includes a negative start's minus sign", () => { + expect(orderedListGutterStyle(1001, -1000)).toEqual({ "--list-gutter": "6ch" }); + expect(orderedListGutterStyle(3, -15)).toEqual({ "--list-gutter": "4ch" }); + expect(orderedListGutterStyle(3, -5)).toBeUndefined(); + }); + it("treats a missing/zero item count as a single item", () => { expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); + expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" }); }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c3e1c5288da7..81f901d7f015 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -74,10 +74,12 @@ import { } from "../markdown-clipboard"; import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; import { + extractMarkdownLinkHrefs, normalizeMarkdownLinkDestination, resolveInlineCodeFileLinkMeta, resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, + shouldOpenMarkdownFileLinkInEditor, type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; @@ -157,22 +159,24 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb } /** - * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits two-digit - * decimal markers. Once a list's last item reaches three digits (item 100+), - * `list-style-position: outside` paints the marker wider than that gutter and - * the leading digit gets clipped by the item's own overflow. Rather than - * widening the gutter for every list, only lists whose last marker is 3+ - * digits get a wider `--list-gutter`, sized to that marker's digit count. + * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits markers up to + * two characters wide. Once a marker reaches three characters (item 100+), + * `list-style-position: outside` paints it wider than that gutter and clips + * the leading character against the item's own overflow. Rather than widening + * the gutter for every list, only lists whose widest marker is 3+ characters + * get a wider `--list-gutter`. The width includes a negative marker's minus + * sign. */ export function orderedListGutterStyle( itemCount: number, - start: number | undefined, + start: unknown, ): { "--list-gutter": string } | undefined { - const firstNumber = typeof start === "number" && Number.isFinite(start) ? start : 1; + const parsedStart = Number.parseInt(String(start ?? 1), 10); + const firstNumber = Number.isNaN(parsedStart) ? 1 : parsedStart; const lastNumber = firstNumber + Math.max(itemCount - 1, 0); - const digits = String(Math.abs(lastNumber)).length; - if (digits <= 2) return undefined; - return { "--list-gutter": `${digits + 1}ch` }; + const markerWidth = Math.max(String(firstNumber).length, String(lastNumber).length); + if (markerWidth <= 2) return undefined; + return { "--list-gutter": `${markerWidth + 1}ch` }; } const CHAT_MARKDOWN_SANITIZE_SCHEMA = { @@ -825,7 +829,6 @@ interface MarkdownFileLinkProps { className?: string | undefined; } -const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g; const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70"; @@ -904,16 +907,6 @@ function extractInlineCodeSpans(text: string): string[] { return spans; } -function extractMarkdownLinkHrefs(text: string): string[] { - const hrefs: string[] = []; - for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) { - const href = match[1]?.trim(); - if (!href) continue; - hrefs.push(href); - } - return hrefs; -} - function normalizeMarkdownLinkHrefKey(href: string): string { const normalizedHref = normalizeMarkdownLinkDestination(href); return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref; @@ -1307,6 +1300,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); + if (shouldOpenMarkdownFileLinkInEditor(event)) { + handleOpenInEditor(); + return; + } if (onOpenInBrowser) { handleOpenInBrowser(); return; @@ -1629,7 +1626,10 @@ function ChatMarkdown({ }, a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; - const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; + const fileLinkMeta = normalizedHref + ? (markdownFileLinkMetaByHref.get(normalizedHref) ?? + resolveMarkdownFileLinkMeta(normalizedHref, cwd)) + : null; if (!fileLinkMeta) { const faviconHost = resolveExternalWebLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bc87f08ba294..3a85f1441059 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -27,10 +27,14 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveBackgroundDraftWorkspaceOptions, + resolveDraftPromotionNavigationTarget, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, + shouldDockDraftHeroForSubmission, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -40,6 +44,40 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("draft hero submission transition", () => { + it("does not dock the composer before a background submission", () => { + expect( + shouldDockDraftHeroForSubmission({ + isDraftHeroState: true, + activeThreadKey: "environment-local:thread-1", + submissionIntent: "background", + }), + ).toBe(false); + }); + + it("keeps the composer in the hero layout until navigation after server promotion", () => { + expect( + resolveDraftHeroState({ + isLocalDraftThread: false, + hasTimelineEntries: true, + isWorking: true, + draftHeroDockRequested: false, + backgroundSubmissionPending: true, + }), + ).toBe(true); + }); + + it("does not auto-navigate a background submission after server promotion", () => { + expect( + resolveDraftPromotionNavigationTarget({ + serverThreadRef: { environmentId, threadId }, + serverThreadStarted: true, + backgroundSubmissionPending: true, + }), + ).toBeNull(); + }); +}); + describe("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); @@ -456,6 +494,23 @@ describe("resolveSendEnvMode", () => { }); }); +describe("resolveBackgroundDraftWorkspaceOptions", () => { + it("keeps New worktree selected without reusing the launched worktree", () => { + expect( + resolveBackgroundDraftWorkspaceOptions({ + envMode: "worktree", + branch: "main", + startFromOrigin: true, + }), + ).toEqual({ + envMode: "worktree", + branch: "main", + worktreePath: null, + startFromOrigin: true, + }); + }); +}); + describe("branchMismatchKey", () => { it("builds a key from thread id and both branches", () => { expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( @@ -657,6 +712,29 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(false); }); + it("keeps a follow-up active while its provider session is starting", () => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestTurn: completedTurn, session: readySession }), + ); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "connecting", + latestTurn: completedTurn, + latestUserMessageId: MessageId.make("message-followup"), + session: { + ...readySession, + status: "starting", + updatedAt: "2026-03-29T00:01:00.000Z", + }, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + }); + it("acknowledges a settled newer turn", () => { const localDispatch = createLocalDispatchSnapshot( makeThread({ latestTurn: completedTurn, session: readySession }), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index c88a2670003e..7c20a1cbb240 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -21,6 +21,7 @@ import { type TerminalContextDraft, } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; +import type { ComposerSubmissionIntent } from "../composer-logic"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -29,6 +30,47 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function shouldDockDraftHeroForSubmission(input: { + isDraftHeroState: boolean; + activeThreadKey: string | null; + submissionIntent: ComposerSubmissionIntent; +}): boolean { + return ( + input.submissionIntent === "foreground" && + input.isDraftHeroState && + input.activeThreadKey !== null + ); +} + +export function resolveDraftHeroState(input: { + isLocalDraftThread: boolean; + hasTimelineEntries: boolean; + isWorking: boolean; + draftHeroDockRequested: boolean; + backgroundSubmissionPending: boolean; +}): boolean { + if (input.backgroundSubmissionPending) { + return true; + } + return ( + input.isLocalDraftThread && + !input.hasTimelineEntries && + !input.isWorking && + !input.draftHeroDockRequested + ); +} + +export function resolveDraftPromotionNavigationTarget(input: { + serverThreadRef: ScopedThreadRef | null; + serverThreadStarted: boolean; + backgroundSubmissionPending: boolean; +}): ScopedThreadRef | null { + if (input.backgroundSubmissionPending) { + return null; + } + return input.serverThreadStarted ? input.serverThreadRef : null; +} + export function scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { const timeoutId = globalThis.setTimeout(showWarning, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); return () => globalThis.clearTimeout(timeoutId); @@ -323,6 +365,24 @@ export function resolveSendEnvMode(input: { return input.isGitRepo ? input.requestedEnvMode : "local"; } +export function resolveBackgroundDraftWorkspaceOptions(input: { + envMode: DraftThreadEnvMode; + branch: string | null; + startFromOrigin: boolean; +}): { + envMode: DraftThreadEnvMode; + branch: string | null; + worktreePath: null; + startFromOrigin: boolean; +} { + return { + envMode: input.envMode, + branch: input.branch, + worktreePath: null, + startFromOrigin: input.envMode === "worktree" && input.startFromOrigin, + }; +} + export function cloneComposerImageForRetry( image: ComposerImageAttachment, ): ComposerImageAttachment { @@ -557,6 +617,7 @@ export async function waitForStartedServerThread( export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; + submissionIntent: ComposerSubmissionIntent; latestUserMessageId: ChatMessage["id"] | null; latestTurnTurnId: TurnId | null; latestTurnRequestedAt: string | null; @@ -568,7 +629,10 @@ export interface LocalDispatchSnapshot { export function createLocalDispatchSnapshot( activeThread: Thread | undefined, - options?: { preparingWorktree?: boolean }, + options?: { + preparingWorktree?: boolean; + submissionIntent?: ComposerSubmissionIntent; + }, ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; const session = activeThread?.session ?? null; @@ -576,6 +640,7 @@ export function createLocalDispatchSnapshot( return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), + submissionIntent: options?.submissionIntent ?? "foreground", latestUserMessageId: latestUserMessage?.id ?? null, latestTurnTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, @@ -602,6 +667,9 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } + if (input.phase === "connecting") { + return false; + } const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e72f9939af68..f3789b6c567d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -82,6 +82,7 @@ import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { collapseExpandedComposerCursor, + type ComposerSubmissionIntent, parseStandaloneComposerSlashCommand, } from "../composer-logic"; import { @@ -214,10 +215,14 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; -import { buildDraftThreadRouteParams } from "../threadRoutes"; +import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { + beginBackgroundDraftSubmissionByRef, + clearBackgroundDraftSubmissionByRef, type ComposerImageAttachment, type DraftThreadEnvMode, + finalizePromotedDraftThreadByRef, + markPromotedDraftThreadByRef, useComposerDraftStore, type DraftId, } from "../composerDraftStore"; @@ -320,6 +325,7 @@ import { scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, + shouldDockDraftHeroForSubmission, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -330,6 +336,8 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveBackgroundDraftWorkspaceOptions, + resolveDraftHeroState, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -603,14 +611,16 @@ function useLocalDispatchState(input: { ); const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; const beginLocalDispatch = useCallback( - (options?: { preparingWorktree?: boolean }) => { + (options?: { preparingWorktree?: boolean; submissionIntent?: ComposerSubmissionIntent }) => { const preparingWorktree = Boolean(options?.preparingWorktree); setLocalDispatch((current) => { const active = serverAcknowledgedLocalDispatch ? null : current; if (active) { - return active.preparingWorktree === preparingWorktree + const submissionIntent = options?.submissionIntent ?? active.submissionIntent; + return active.preparingWorktree === preparingWorktree && + active.submissionIntent === submissionIntent ? active - : { ...active, preparingWorktree }; + : { ...active, preparingWorktree, submissionIntent }; } return createLocalDispatchSnapshot(input.activeThread, options); }); @@ -625,6 +635,7 @@ function useLocalDispatchState(input: { latestUserMessageAt: latestUserMessage?.createdAt ?? null, isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false, isSendBusy: activeLocalDispatch !== null, + backgroundSubmissionPending: localDispatch?.submissionIntent === "background", }; } @@ -2353,6 +2364,7 @@ function ChatViewContent(props: ChatViewProps) { latestUserMessageAt, isPreparingWorktree, isSendBusy, + backgroundSubmissionPending, } = useLocalDispatchState({ activeThread, activeLatestTurn, @@ -2621,8 +2633,13 @@ function ChatViewContent(props: ChatViewProps) { const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; - const isDraftHeroState = - isLocalDraftThread && timelineEntries.length === 0 && !isWorking && !draftHeroDockRequested; + const isDraftHeroState = resolveDraftHeroState({ + isLocalDraftThread, + hasTimelineEntries: timelineEntries.length > 0, + isWorking, + draftHeroDockRequested, + backgroundSubmissionPending, + }); const [ attachDraftHeroTransitionGroupRef, attachDraftHeroComposerAnchorRef, @@ -5057,6 +5074,7 @@ function ChatViewContent(props: ChatViewProps) { const onSend = async ( e?: { preventDefault: () => void }, + submissionIntent: ComposerSubmissionIntent = "foreground", directAnnotation?: { annotation: PreviewAnnotationPayload; image: ComposerImageAttachment | null; @@ -5265,8 +5283,17 @@ function ChatViewContent(props: ChatViewProps) { return; } + const resolvedSubmissionIntent = + submissionIntent === "background" && isLocalDraftThread ? "background" : "foreground"; sendInFlightRef.current = true; - if (isDraftHeroState && activeThreadKey) { + if ( + shouldDockDraftHeroForSubmission({ + isDraftHeroState, + activeThreadKey, + submissionIntent: resolvedSubmissionIntent, + }) && + activeThreadKey + ) { let resolveDockStarted: (() => void) | undefined; const dockStarted = new Promise((resolve) => { resolveDockStarted = resolve; @@ -5281,7 +5308,10 @@ function ChatViewContent(props: ChatViewProps) { void dockTransition.catch(() => resolveDockStarted?.()); await dockStarted; } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + beginLocalDispatch({ + preparingWorktree: Boolean(baseBranchForWorktree), + submissionIntent: resolvedSubmissionIntent, + }); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); @@ -5302,21 +5332,25 @@ function ChatViewContent(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); - // Sending always returns to the live edge. The new row becomes the - // anchored end-space target so it lands near the top while the response - // streams into the reserved space below it. - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); + const shouldAnchorFirstMessage = + activeThread.latestTurn === null && + !timelineMessages.some((message) => message.role === "user"); + if (shouldAnchorFirstMessage) { + isAtEndRef.current = true; + timelineScrollModeRef.current = "anchoring-new-turn"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + setTimelineLiveFollowEnabled(true); + pendingTimelineAnchorRef.current = messageIdForSend; + activeTimelineAnchorIndexRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + setTimelineAnchor({ + threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), + messageId: messageIdForSend, + }); + } else { + scrollToEnd(); + } setOptimisticUserMessages((existing) => [ ...existing, { @@ -5443,6 +5477,13 @@ function ChatViewContent(props: ChatViewProps) { } : undefined; beginLocalDispatch({ preparingWorktree: false }); + const backgroundThreadRef = + resolvedSubmissionIntent === "background" + ? scopeThreadRef(activeThread.environmentId, threadIdForSend) + : null; + if (backgroundThreadRef) { + beginBackgroundDraftSubmissionByRef(backgroundThreadRef); + } const startResult = await startThreadTurn({ environmentId, input: { @@ -5462,10 +5503,60 @@ function ChatViewContent(props: ChatViewProps) { }, }); if (startResult._tag === "Failure") { + if (backgroundThreadRef) { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + } failure = startResult; } else { turnStartSucceeded = true; acknowledgeActiveThreadWoke(); + if (backgroundThreadRef) { + markPromotedDraftThreadByRef(backgroundThreadRef); + try { + const nextDraft = await handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + resolveBackgroundDraftWorkspaceOptions({ + envMode: sendEnvMode, + branch: activeThreadBranch, + startFromOrigin, + }), + ); + if (nextDraft) { + finalizePromotedDraftThreadByRef(backgroundThreadRef); + toastManager.add( + stackedThreadToast({ + type: "success", + title: "Started in background", + timeout: 5_000, + actionProps: { + children: "Open", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(backgroundThreadRef), + }); + }, + }, + }), + ); + } else { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + } + } catch (error) { + clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + resetLocalDispatch(); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Task started in the background", + description: + error instanceof Error + ? `Could not open a fresh composer: ${error.message}` + : "Could not open a fresh composer.", + }), + ); + } + } } } @@ -5763,19 +5854,7 @@ function ChatViewContent(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - // Position this sent row once LegendList has measured the anchored tail. - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); + scrollToEnd(); setOptimisticUserMessages((existing) => [ ...existing, @@ -5870,6 +5949,7 @@ function ChatViewContent(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, + scrollToEnd, setComposerDraftInteractionMode, setThreadError, startThreadTurn, @@ -6255,7 +6335,7 @@ function ChatViewContent(props: ChatViewProps) { configuredUrls={configuredPreviewUrls} visible onSendAnnotation={(annotation, image) => { - void onSend(undefined, { annotation, image }); + void onSend(undefined, "foreground", { annotation, image }); }} /> diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 6512b7b9d830..cc57706d2379 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -95,7 +95,13 @@ import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; -import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; +import { + cn, + getLocalFileManagerName, + isMacPlatform, + isWindowsPlatform, + newProjectId, +} from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { @@ -147,7 +153,7 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; -import { CommandDialog, CommandDialogPopup } from "./ui/command"; +import { CommandDialog, CommandDialogPopup, CommandFooterAction } from "./ui/command"; import { Button } from "./ui/button"; import { Kbd, KbdGroup } from "./ui/kbd"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -175,16 +181,6 @@ function projectFavicon(project: Project) { ); } -function getLocalFileManagerName(platform: string): string { - if (isMacPlatform(platform)) { - return "Finder"; - } - if (isWindowsPlatform(platform)) { - return "Explorer"; - } - return "Files"; -} - function getEnvironmentBrowsePlatform(os: string | null | undefined): string { if (os === "windows") { return "Win32"; @@ -2489,17 +2485,14 @@ function OpenCommandPaletteDialog(props: { : undefined; const footerTrailing = canOpenProjectFromFileManager ? ( - + ) : null; return ( diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 1812aa10260b..7b0ae9b4c201 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -2,7 +2,12 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { RightPanelTabs, surfaceShortcutActionForKey, tabMuteMenuItem } from "./RightPanelTabs"; +import { + RightPanelTabs, + surfaceShortcutActionForKey, + surfaceShortcutTargetsTypingContext, + tabMuteMenuItem, +} from "./RightPanelTabs"; function shortcutEvent( key: string, @@ -166,6 +171,33 @@ describe("surface shortcuts", () => { }); }); +describe("surface shortcut typing contexts", () => { + // Selector-aware stub: closest() answers only tokens the combined selector + // would actually match, mirroring how the browser resolves it. + const makeTarget = (matches: string | null) => ({ + closest(selectors: string) { + if (matches === null || !selectors.includes(matches)) return null; + return {}; + }, + }); + + it("treats form fields and every editable region as typing contexts", () => { + expect(surfaceShortcutTargetsTypingContext(makeTarget("input"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(makeTarget("textarea"))).toBe(true); + expect(surfaceShortcutTargetsTypingContext(makeTarget("select"))).toBe(true); + // The chat composer is a contenteditable that sits empty until a draft + // exists; launcher letters claimed from it redirected prompts into shells. + // The :not clause sees past contenteditable="false" islands to an editable + // host around them, so nested editors stay protected too. + expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]"))).toBe(true); + }); + + it("claims letters when focus sits outside any editable region", () => { + expect(surfaceShortcutTargetsTypingContext(null)).toBe(false); + expect(surfaceShortcutTargetsTypingContext(makeTarget(null))).toBe(false); + }); +}); + describe("RightPanelTabs audio indicator", () => { // A muted tab only shows the indicator while it is actually making sound: // arming mute on a quiet tab is deliberate and stays invisible until there diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f48c9ca07e4c..5cc421db3542 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -190,6 +190,23 @@ export function surfaceShortcutActionForKey< ); } +/** + * A focused editable is a typing context whether or not it has text yet: an + * empty chat composer at rest is still where the user's next keystrokes are + * meant to land, and claiming launcher letters from it would redirect prompts + * into whatever surface opens. The `:not` clause lets `closest` see past + * non-editable islands (`contenteditable="false"`) to an editable host around + * them, matching ComposerPendingUserInputPanel's typing guard. + */ +export function surfaceShortcutTargetsTypingContext( + target: { closest(selectors: string): unknown } | null, +): boolean { + return ( + target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != + null + ); +} + function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { return ( @@ -329,13 +346,7 @@ function RightPanelEmptyState(props: { if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; const target = event.target; - if (target instanceof HTMLElement) { - if (target.closest("input, textarea, select")) return; - // An empty contenteditable (the chat composer at rest) does not - // count as typing; letters only become text once a draft exists. - const editable = target.isContentEditable ? target : target.closest("[contenteditable]"); - if (editable && (editable.textContent ?? "").trim().length > 0) return; - } + if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; event.preventDefault(); event.stopPropagation(); action.onClick(); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 93361bbb220e..7801258f6635 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1305,17 +1305,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - + + + } + > + + + Un-settle thread + ) : (