diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 3584d6a21e42..ea7ec6e1512c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,7 +18,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, - diffWordWrap: true, favorites: [], providerModelPreferences: {}, sidebarProjectGroupingMode: "repository_path", @@ -29,6 +28,7 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", + wordWrap: true, }; const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 92c13c20070f..ce24198f5e22 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -30,7 +30,9 @@ import { resolveProviderOptionDescriptors, } from "../../lib/providerOptions"; import { buildThreadRoutePath } from "../../lib/routes"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; +import { getComposerDraftSnapshot } from "../../state/use-composer-drafts"; import { useProjects } from "../../state/entities"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { useCreateProjectThread } from "./use-project-actions"; @@ -63,6 +65,7 @@ export function NewTaskDraftScreen(props: { const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; const promptInputRef = useRef(null); + const loadedBranchesProjectKeyRef = useRef(null); const borderColor = useThemeColor("--color-border"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; @@ -78,6 +81,12 @@ export function NewTaskDraftScreen(props: { ) ?? null; if (directProject) { + if ( + selectedProject?.environmentId === directProject.environmentId && + selectedProject.id === directProject.id + ) { + return; + } setProject(directProject); return; } @@ -105,10 +114,16 @@ export function NewTaskDraftScreen(props: { useEffect(() => { if (!selectedProject) { + loadedBranchesProjectKeyRef.current = null; + return; + } + const projectKey = `${selectedProject.environmentId}:${selectedProject.id}`; + if (loadedBranchesProjectKeyRef.current === projectKey) { return; } + loadedBranchesProjectKeyRef.current = projectKey; void flow.loadBranches(); - }, [flow, selectedProject]); + }, [flow.loadBranches, selectedProject]); useEffect(() => { if (!selectedProject) { @@ -292,11 +307,7 @@ export function NewTaskDraftScreen(props: { if (!event.startsWith("model:")) { return; } - // Defer state update so the native menu dismiss animation completes - // before re-rendering the menu actions (prevents submenu jump). - setTimeout(() => { - flow.setSelectedModelKey(event.slice("model:".length)); - }, 150); + flow.setSelectedModelKey(event.slice("model:".length)); } function handleEnvironmentMenuAction(event: string) { @@ -366,27 +377,42 @@ export function NewTaskDraftScreen(props: { ); async function handleStart(): Promise { + const selectedProject = flow.selectedProject; + if (!selectedProject) { + return; + } + const draft = getComposerDraftSnapshot( + `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}`, + ); + const modelSelection = draft.modelSelection ?? flow.selectedModel; + const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; + const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; + const selectedWorktreePath = + draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath; + const runtimeMode = draft.runtimeMode ?? flow.runtimeMode; + const interactionMode = draft.interactionMode ?? flow.interactionMode; + const initialMessageText = draft.text.trim(); + if ( - !flow.selectedProject || - !flow.selectedModel || - flow.prompt.trim().length === 0 || + !modelSelection || + initialMessageText.length === 0 || flow.submitting || - (flow.workspaceMode === "worktree" && !flow.selectedBranchName) + (workspaceMode === "worktree" && !selectedBranchName) ) { return; } flow.setSubmitting(true); const result = await createProjectThread({ - project: flow.selectedProject, - modelSelection: flow.selectedModel, - envMode: flow.workspaceMode, - branch: flow.selectedBranchName, - worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath, - runtimeMode: flow.runtimeMode, - interactionMode: flow.interactionMode, - initialMessageText: flow.prompt.trim(), - initialAttachments: flow.attachments, + project: selectedProject, + modelSelection, + envMode: workspaceMode, + branch: selectedBranchName, + worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, + runtimeMode, + interactionMode, + initialMessageText, + initialAttachments: draft.attachments, }); flow.setSubmitting(false); diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 7bb74ae88ffd..f8c916974e54 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,13 +1,7 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useMemo, useState } from "react"; import * as Option from "effect/Option"; -import { - EnvironmentId, - type ModelSelection, - type ProjectScript, - type ProviderInteractionMode, - type RuntimeMode, -} from "@t3tools/contracts"; +import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { Pressable, ScrollView, Text as RNText, View } from "react-native"; import { useWorkspaceState } from "../../state/workspace"; @@ -79,18 +73,6 @@ export function ThreadRouteScreen() { const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); - const updateThreadMetadata = useAtomCommand( - threadEnvironment.updateMetadata, - "thread metadata update", - ); - const setThreadRuntimeMode = useAtomCommand( - threadEnvironment.setRuntimeMode, - "thread runtime mode", - ); - const setThreadInteractionMode = useAtomCommand( - threadEnvironment.setInteractionMode, - "thread interaction mode", - ); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const router = useRouter(); const params = useLocalSearchParams<{ @@ -105,6 +87,18 @@ export function ThreadRouteScreen() { const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null; + const selectedThreadWithDraftSettings = useMemo( + () => + selectedThread + ? { + ...selectedThread, + modelSelection: composer.modelSelection ?? selectedThread.modelSelection, + runtimeMode: composer.runtimeMode ?? selectedThread.runtimeMode, + interactionMode: composer.interactionMode ?? selectedThread.interactionMode, + } + : null, + [composer.interactionMode, composer.modelSelection, composer.runtimeMode, selectedThread], + ); /* ─── Native header theming ──────────────────────────────────────── */ const iconColor = String(useThemeColor("--color-icon")); @@ -157,51 +151,6 @@ export function ThreadRouteScreen() { const handleOpenConnectionEditor = useCallback(() => { void router.push("/connections"); }, [router]); - const handleUpdateThreadModelSelection = useCallback( - (modelSelection: ModelSelection) => { - if (!selectedThread) { - return; - } - return updateThreadMetadata({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - modelSelection, - }, - }); - }, - [selectedThread, updateThreadMetadata], - ); - const handleUpdateThreadRuntimeMode = useCallback( - (runtimeMode: RuntimeMode) => { - if (!selectedThread) { - return; - } - return setThreadRuntimeMode({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - runtimeMode, - }, - }); - }, - [selectedThread, setThreadRuntimeMode], - ); - const handleUpdateThreadInteractionMode = useCallback( - (interactionMode: ProviderInteractionMode) => { - if (!selectedThread) { - return; - } - return setThreadInteractionMode({ - environmentId: selectedThread.environmentId, - input: { - threadId: selectedThread.id, - interactionMode, - }, - }); - }, - [selectedThread, setThreadInteractionMode], - ); const handleStopThread = useCallback(() => { if ( !selectedThread || @@ -435,7 +384,7 @@ export function ThreadRouteScreen() { (null); - const [selectedModelKey, setSelectedModelKey] = useState(null); - const [workspaceMode, setWorkspaceMode] = useState("local"); - const [selectedBranchName, setSelectedBranchName] = useState(null); - const [selectedWorktreePath, setSelectedWorktreePath] = useState(null); - const branchLoadVersionRef = useRef(0); const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); - const [runtimeMode, setRuntimeMode] = useState(DEFAULT_RUNTIME_MODE); - const [interactionMode, setInteractionMode] = useState( - DEFAULT_PROVIDER_INTERACTION_MODE, - ); - const [modelSelectionOverrides, setModelSelectionOverrides] = useState< - Record - >({}); const [expandedProvider, setExpandedProvider] = useState(null); const reset = useCallback(() => { setSelectedEnvironmentId(null); setSelectedProjectKey(null); - setSelectedModelKey(null); - setWorkspaceMode("local"); - setSelectedBranchName(null); - setSelectedWorktreePath(null); setSubmitting(false); setBranchQuery(""); - setRuntimeMode(DEFAULT_RUNTIME_MODE); - setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); - setModelSelectionOverrides({}); setExpandedProvider(null); }, []); @@ -247,33 +229,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; + const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; + const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; + const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; + const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; + const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? null, + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null, ), - [selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection], + [ + selectedEnvironmentServerConfig, + selectedProject?.defaultModelSelection, + selectedProjectDraft.modelSelection, + ], ); - const defaultModelKey = selectedProject?.defaultModelSelection - ? `${selectedProject.defaultModelSelection.instanceId}:${selectedProject.defaultModelSelection.model}` - : null; - const baseSelectedModel = - modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? - (defaultModelKey - ? modelOptions.find((option) => option.key === defaultModelKey)?.selection - : null) ?? + const selectedModel = + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? modelOptions[0]?.selection ?? null; - const selectedModelIdentity = baseSelectedModel - ? `${baseSelectedModel.instanceId}:${baseSelectedModel.model}` + const selectedModelKey = selectedModel + ? `${selectedModel.instanceId}:${selectedModel.model}` : null; - const selectedModel = - (selectedModelIdentity ? modelSelectionOverrides[selectedModelIdentity] : null) ?? - baseSelectedModel; const selectedModelOption = modelOptions.find( @@ -282,13 +264,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = - selectedEnvironmentServerConfig?.providers.find( - (provider) => provider.instanceId === selectedModel?.instanceId, - )?.skills ?? []; + const selectedProviderSkills = useMemo( + () => + selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === selectedModel?.instanceId, + )?.skills ?? [], + [selectedEnvironmentServerConfig, selectedModel?.instanceId], + ); + const setSelectedModelKey = useCallback( + (key: string | null) => { + if (!key || !selectedProjectDraftKey) { + return; + } + const option = modelOptions.find((candidate) => candidate.key === key); + if (!option) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: option.selection, + }); + }, + [modelOptions, selectedProjectDraftKey], + ); const setSelectedModelOptions = useCallback( (options: ReadonlyArray | undefined) => { - if (!selectedModel || !selectedModelIdentity) { + if (!selectedModel || !selectedProjectDraftKey) { return; } const nextSelection: ModelSelection = options @@ -297,12 +297,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { instanceId: selectedModel.instanceId, model: selectedModel.model, }; - setModelSelectionOverrides((current) => ({ - ...current, - [selectedModelIdentity]: nextSelection, - })); + updateComposerDraftSettings(selectedProjectDraftKey, { + modelSelection: nextSelection, + }); }, - [selectedModel, selectedModelIdentity], + [selectedModel, selectedProjectDraftKey], ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); @@ -381,62 +380,85 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const setProject = useCallback((project: EnvironmentProject) => { const nextProjectKey = scopedProjectKey(project.environmentId, project.id); - branchLoadVersionRef.current += 1; setSelectedEnvironmentId(project.environmentId); setSelectedProjectKey(nextProjectKey); - setSelectedBranchName(null); - setSelectedWorktreePath(null); - setModelSelectionOverrides({}); }, []); const selectEnvironment = useCallback((environmentId: EnvironmentId) => { - branchLoadVersionRef.current += 1; setSelectedEnvironmentId(environmentId); setSelectedProjectKey(null); - setSelectedBranchName(null); - setSelectedWorktreePath(null); - setModelSelectionOverrides({}); }, []); + const setWorkspaceMode = useCallback( + (mode: WorkspaceMode) => { + if (!selectedProjectDraftKey) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode, + branch: selectedBranchName, + worktreePath: selectedWorktreePath, + }, + }); + }, + [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath], + ); + const selectBranch = useCallback( (branch: VcsRef) => { - setSelectedBranchName(branch.name); - setSelectedWorktreePath( - selectedProject ? normalizeSelectedWorktreePath(selectedProject, branch) : null, - ); + if (!selectedProject || !selectedProjectDraftKey) { + return; + } + updateComposerDraftSettings(selectedProjectDraftKey, { + workspaceSelection: { + mode: workspaceMode, + branch: branch.name, + worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), + }, + }); }, - [selectedProject], + [selectedProject, selectedProjectDraftKey, workspaceMode], ); + const refreshBranches = branchState.refresh; const loadBranches = useCallback(async () => { if (!selectedProject) { return; } + setPendingConnectionError(null); + refreshBranches(); + }, [refreshBranches, selectedProject]); - const loadVersion = ++branchLoadVersionRef.current; - const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id); - branchState.refresh(); - if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { + useEffect(() => { + if (workspaceMode !== "worktree" || selectedBranchName !== null) { return; } - setPendingConnectionError(null); - if (workspaceMode === "worktree" && !selectedBranchName) { - const preferredBranch = - availableBranches.find((branch) => branch.current)?.name ?? - availableBranches.find((branch) => branch.isDefault)?.name ?? - null; - if (preferredBranch) { - setSelectedBranchName(preferredBranch); - } + const preferredBranch = + availableBranches.find((branch) => branch.current) ?? + availableBranches.find((branch) => branch.isDefault) ?? + null; + if (preferredBranch) { + selectBranch(preferredBranch); } - }, [ - availableBranches, - branchState, - selectedBranchName, - selectedProject, - selectedProjectKey, - workspaceMode, - ]); + }, [availableBranches, selectBranch, selectedBranchName, workspaceMode]); + + const setRuntimeMode = useCallback( + (value: RuntimeMode) => { + if (selectedProjectDraftKey) { + updateComposerDraftSettings(selectedProjectDraftKey, { runtimeMode: value }); + } + }, + [selectedProjectDraftKey], + ); + const setInteractionMode = useCallback( + (value: ProviderInteractionMode) => { + if (selectedProjectDraftKey) { + updateComposerDraftSettings(selectedProjectDraftKey, { interactionMode: value }); + } + }, + [selectedProjectDraftKey], + ); const value = useMemo( () => ({ @@ -513,6 +535,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setProject, selectBranch, selectEnvironment, + setInteractionMode, + setPrompt, + setRuntimeMode, + setSelectedModelKey, + setWorkspaceMode, submitting, workspaceMode, appendAttachments, diff --git a/apps/mobile/src/lib/composer-image-schema.ts b/apps/mobile/src/lib/composer-image-schema.ts new file mode 100644 index 000000000000..a121b70ddb5a --- /dev/null +++ b/apps/mobile/src/lib/composer-image-schema.ts @@ -0,0 +1,11 @@ +import * as Schema from "effect/Schema"; + +export const DraftComposerImageAttachmentSchema = Schema.Struct({ + id: Schema.String, + previewUri: Schema.String, + type: Schema.Literal("image"), + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + dataUrl: Schema.String, +}); diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index aa7a1055136a..ed0c06ba38b9 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -1,32 +1,38 @@ import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; -import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + IsoDateTime, + MessageId, + ModelSelection, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + type ModelSelection as ModelSelectionType, + type ProviderInteractionMode as ProviderInteractionModeType, + type RuntimeMode as RuntimeModeType, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -const THREAD_OUTBOX_SCHEMA_VERSION = 1; +const THREAD_OUTBOX_SCHEMA_VERSION = 2; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; -const DraftComposerImageAttachmentSchema = Schema.Struct({ - id: Schema.String, - previewUri: Schema.String, - type: Schema.Literal("image"), - name: Schema.String, - mimeType: Schema.String, - sizeBytes: Schema.Number, - dataUrl: Schema.String, -}); - export const QueuedThreadMessageSchema = Schema.Struct({ - schemaVersion: Schema.Literal(THREAD_OUTBOX_SCHEMA_VERSION), + schemaVersion: Schema.Literals([1, THREAD_OUTBOX_SCHEMA_VERSION]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, commandId: CommandId, text: Schema.String, attachments: Schema.Array(DraftComposerImageAttachmentSchema), + modelSelection: Schema.optional(ModelSelection), + runtimeMode: Schema.optional(RuntimeMode), + interactionMode: Schema.optional(ProviderInteractionMode), createdAt: IsoDateTime, }); @@ -40,9 +46,37 @@ export interface QueuedThreadMessage { readonly commandId: CommandId; readonly text: string; readonly attachments: ReadonlyArray; + readonly modelSelection?: ModelSelectionType; + readonly runtimeMode?: RuntimeModeType; + readonly interactionMode?: ProviderInteractionModeType; readonly createdAt: string; } +export interface ThreadSettingsSnapshot { + readonly modelSelection: ModelSelectionType; + readonly runtimeMode: RuntimeModeType; + readonly interactionMode: ProviderInteractionModeType; +} + +export function resolveQueuedThreadSettings( + message: QueuedThreadMessage, + thread: ThreadSettingsSnapshot, +): ThreadSettingsSnapshot { + return { + modelSelection: message.modelSelection ?? thread.modelSelection, + runtimeMode: message.runtimeMode ?? thread.runtimeMode, + interactionMode: message.interactionMode ?? thread.interactionMode, + }; +} + +export function modelSelectionsEqual(left: ModelSelectionType, right: ModelSelectionType): boolean { + return ( + left.instanceId === right.instanceId && + left.model === right.model && + JSON.stringify(left.options ?? null) === JSON.stringify(right.options ?? null) + ); +} + export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown { return encodeStoredQueuedThreadMessage({ schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, @@ -119,3 +153,21 @@ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { } return isTransportConnectionErrorMessage(errorMessage(error)); } + +export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; +export type ThreadOutboxFailureAction = "retry" | "discard"; + +export function resolveThreadOutboxFailureAction(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; +}): ThreadOutboxFailureAction { + if ( + input.stage === "settings-sync" || + input.interrupted || + shouldRetryThreadOutboxDelivery(input.error) + ) { + return "retry"; + } + return "discard"; +} diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index d6b91c1c4f60..68d06d2e424b 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -1,11 +1,21 @@ import { describe, expect, it } from "@effect/vitest"; -import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + MessageId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { AtomRegistry } from "effect/unstable/reactivity"; import { decodeQueuedThreadMessage, + encodeQueuedThreadMessage, groupQueuedThreadMessages, + modelSelectionsEqual, resolveThreadOutboxDeliveryAction, + resolveThreadOutboxFailureAction, + resolveQueuedThreadSettings, shouldRetryThreadOutboxDelivery, threadOutboxRetryDelayMs, type QueuedThreadMessage, @@ -66,6 +76,54 @@ describe("thread outbox", () => { ).toThrow(); }); + it("persists the exact selector snapshot while remaining compatible with v1 messages", () => { + const legacyMessage = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + const selectedMessage = { + ...legacyMessage, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + } satisfies QueuedThreadMessage; + + expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual( + selectedMessage, + ); + expect( + resolveQueuedThreadSettings(legacyMessage, { + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }), + ).toEqual({ + modelSelection: selectedMessage.modelSelection, + runtimeMode: selectedMessage.runtimeMode, + interactionMode: selectedMessage.interactionMode, + }); + }); + + it("compares model options as part of the queued settings change", () => { + const base = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "medium" }], + } as const; + + expect(modelSelectionsEqual(base, base)).toBe(true); + expect( + modelSelectionsEqual(base, { + ...base, + options: [{ id: "reasoningEffort", value: "xhigh" }], + }), + ).toBe(false); + }); + it("backs off queued delivery retries and caps them at sixteen seconds", () => { expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([ 1_000, 2_000, 4_000, 8_000, 16_000, 16_000, @@ -271,4 +329,23 @@ describe("thread outbox", () => { ).toBe(true); expect(shouldRetryThreadOutboxDelivery(new Error("Thread no longer exists"))).toBe(false); }); + + it("retains queued messages when settings synchronization fails before startTurn", () => { + const deterministicFailure = new Error("Thread no longer exists"); + + expect( + resolveThreadOutboxFailureAction({ + stage: "settings-sync", + error: deterministicFailure, + interrupted: false, + }), + ).toBe("retry"); + expect( + resolveThreadOutboxFailureAction({ + stage: "start-turn", + error: deterministicFailure, + interrupted: false, + }), + ).toBe("discard"); + }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 48e4e8703f06..d02abb6a2656 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -1,14 +1,136 @@ -import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; -import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts"; +import { appAtomRegistry } from "./atom-registry"; +import { + clearComposerDraftContentState, + composerDraftsAtom, + decodePersistedComposerDrafts, + type ComposerDraft, + getComposerDraftSnapshot, + removeComposerDraftsForEnvironment, +} from "./use-composer-drafts"; const DRAFT: ComposerDraft = { text: "hello", attachments: [], }; +afterEach(() => { + appAtomRegistry.set(composerDraftsAtom, {}); +}); + describe("mobile composer drafts", () => { + it("hydrates selector state even when the message content is empty", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "new-task:environment-1:project-1": { + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }, + }, + }), + ).toEqual({ + "new-task:environment-1:project-1": { + text: "", + attachments: [], + modelSelection: { + instanceId: "codex", + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + runtimeMode: "approval-required", + interactionMode: "plan", + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }, + }); + }); + + it("keeps legacy content-only drafts and rejects invalid selector state", () => { + expect( + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": DRAFT, + }, + }), + ).toEqual({ + "environment-1:thread-1": DRAFT, + }); + + expect(() => + decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + "environment-1:thread-1": { + ...DRAFT, + runtimeMode: "sometimes-safe", + }, + }, + }), + ).toThrow(); + }); + + it("clears sent content without clearing the selected model or workspace", () => { + const draftKey = "environment-1:thread-1"; + const draft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + workspaceSelection: { + mode: "worktree", + branch: "main", + worktreePath: null, + }, + }; + + expect(clearComposerDraftContentState({ [draftKey]: draft }, draftKey)).toEqual({ + [draftKey]: { + ...draft, + text: "", + attachments: [], + }, + }); + }); + + it("reads the latest selector state synchronously for send", () => { + const draftKey = "environment-1:thread-1"; + const selectedDraft: ComposerDraft = { + text: "send this", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "xhigh" }], + }, + }; + appAtomRegistry.set(composerDraftsAtom, { [draftKey]: selectedDraft }); + + expect(getComposerDraftSnapshot(draftKey)).toEqual(selectedDraft); + }); + it("removes only drafts owned by the selected environment", () => { const environmentId = EnvironmentId.make("environment-cloud"); const retainedEnvironmentId = EnvironmentId.make("environment-local"); @@ -17,12 +139,15 @@ describe("mobile composer drafts", () => { removeComposerDraftsForEnvironment( { [`${environmentId}:thread-cloud`]: DRAFT, + [`new-task:${environmentId}:project-cloud`]: DRAFT, [`${retainedEnvironmentId}:thread-local`]: DRAFT, + [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, }, environmentId, ), ).toEqual({ [`${retainedEnvironmentId}:thread-local`]: DRAFT, + [`new-task:${retainedEnvironmentId}:project-local`]: DRAFT, }); }); }); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index d0329ad25981..9e2c1566190f 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,9 +1,18 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { + ModelSelection as ModelSelectionSchema, + ProviderInteractionMode as ProviderInteractionModeSchema, + RuntimeMode as RuntimeModeSchema, + type EnvironmentId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; +import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; @@ -29,13 +38,47 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass; + readonly modelSelection?: ModelSelection; + readonly runtimeMode?: RuntimeMode; + readonly interactionMode?: ProviderInteractionMode; + readonly workspaceSelection?: ComposerDraftWorkspaceSelection; } -interface PersistedComposerDrafts { - readonly schemaVersion: typeof COMPOSER_DRAFTS_SCHEMA_VERSION; - readonly drafts: Record; +export interface ComposerDraftWorkspaceSelection { + readonly mode: "local" | "worktree"; + readonly branch: string | null; + readonly worktreePath: string | null; } +export type ComposerDraftSettingsUpdate = Pick< + ComposerDraft, + "modelSelection" | "runtimeMode" | "interactionMode" | "workspaceSelection" +>; + +const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ + mode: Schema.Literals(["local", "worktree"]), + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), +}); + +const ComposerDraftSchema = Schema.Struct({ + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + modelSelection: Schema.optional(ModelSelectionSchema), + runtimeMode: Schema.optional(RuntimeModeSchema), + interactionMode: Schema.optional(ProviderInteractionModeSchema), + workspaceSelection: Schema.optional(ComposerDraftWorkspaceSelectionSchema), +}); + +const PersistedComposerDraftsSchema = Schema.Struct({ + schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION), + drafts: Schema.Record(Schema.String, ComposerDraftSchema), +}); + +const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync( + PersistedComposerDraftsSchema, +); + const EMPTY_DRAFT: ComposerDraft = { text: "", attachments: [], @@ -54,13 +97,32 @@ function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { return EMPTY_DRAFT; } return { + ...draft, text: draft.text, attachments: draft.attachments, }; } +export function getComposerDraftSnapshot(draftKey: string): ComposerDraft { + return normalizeDraft(appAtomRegistry.get(composerDraftsAtom)[draftKey]); +} + function isEmptyDraft(draft: ComposerDraft): boolean { - return draft.text.length === 0 && draft.attachments.length === 0; + return ( + draft.text.length === 0 && + draft.attachments.length === 0 && + draft.modelSelection === undefined && + draft.runtimeMode === undefined && + draft.interactionMode === undefined && + draft.workspaceSelection === undefined + ); +} + +export function decodePersistedComposerDrafts(value: unknown): Record { + const parsed = decodePersistedComposerDraftsDocument(value); + return Object.fromEntries( + Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)), + ); } async function getComposerDraftsFile() { @@ -80,20 +142,7 @@ async function loadPersistedComposerDrafts(): Promise; - if (parsed.schemaVersion !== COMPOSER_DRAFTS_SCHEMA_VERSION || !parsed.drafts) { - return {}; - } - return Object.fromEntries( - Object.entries(parsed.drafts).filter((entry): entry is [string, ComposerDraft] => { - const draft = entry[1]; - return ( - typeof draft?.text === "string" && - Array.isArray(draft.attachments) && - !isEmptyDraft(draft) - ); - }), - ); + return decodePersistedComposerDrafts(JSON.parse(raw) as unknown); } catch (cause) { console.warn( "[composer-drafts] ignored persisted draft failure", @@ -116,10 +165,10 @@ async function writePersistedComposerDrafts(drafts: Record !isEmptyDraft(draft)), ); - const document: PersistedComposerDrafts = { + const document = { schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, drafts: nonEmptyDrafts, - }; + } as const; const encoded = JSON.stringify(document); operation = "write"; if (!file.exists) { @@ -282,6 +331,55 @@ export function removeComposerDraftAttachment(draftKey: string, imageId: string) }); } +export function updateComposerDraftSettings( + draftKey: string, + settings: Partial, +): void { + updateComposerDrafts((current) => { + const draft = { + ...normalizeDraft(current[draftKey]), + ...settings, + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; + }); +} + +export function clearComposerDraftContentState( + current: Record, + draftKey: string, +): Record { + const existing = current[draftKey]; + if (!existing) { + return current; + } + const draft = { + ...existing, + text: "", + attachments: [], + }; + if (isEmptyDraft(draft)) { + const next = { ...current }; + delete next[draftKey]; + return next; + } + return { + ...current, + [draftKey]: draft, + }; +} + +export function clearComposerDraftContent(draftKey: string): void { + updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); +} + export function clearComposerDraft(draftKey: string): void { updateComposerDrafts((current) => { if (!current[draftKey]) { @@ -298,8 +396,12 @@ export function removeComposerDraftsForEnvironment( environmentId: EnvironmentId, ): Record { const environmentPrefix = `${environmentId}:`; + const newTaskPrefix = `new-task:${environmentId}:`; return Object.fromEntries( - Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)), + Object.entries(drafts).filter( + ([draftKey]) => + !draftKey.startsWith(environmentPrefix) && !draftKey.startsWith(newTaskPrefix), + ), ); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 60970b32a4d4..0b8cba16e165 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,7 +1,15 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; -import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { + CommandId, + MessageId, + type EnvironmentId, + type ModelSelection, + type ProviderInteractionMode, + type RuntimeMode, + type ThreadId, +} from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; @@ -18,11 +26,13 @@ import { appAtomRegistry } from "../state/atom-registry"; import { appendComposerDraftAttachments, appendComposerDraftText, - clearComposerDraft, + clearComposerDraftContent, composerDraftsAtom, ensureComposerDraftsLoaded, + getComposerDraftSnapshot, removeComposerDraftAttachment, setComposerDraftText, + updateComposerDraftSettings, useComposerDraft, } from "./use-composer-drafts"; import { setPendingConnectionError } from "../state/use-remote-environment-registry"; @@ -98,6 +108,10 @@ export function useThreadComposerState() { const draftMessage = selectedDraft?.text ?? ""; const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; + const selectedThread = selectedThreadDetail ?? selectedThreadShell; + const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null; + const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null; + const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null; const selectedThreadSessionActivity = useMemo(() => { const selectedThread = selectedThreadDetail ?? selectedThreadShell; @@ -130,7 +144,6 @@ export function useThreadComposerState() { selectedThreadShell, ]); - const selectedThread = selectedThreadDetail ?? selectedThreadShell; const activeThreadBusy = !!selectedThread && (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); @@ -141,9 +154,10 @@ export function useThreadComposerState() { } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const draft = composerDrafts[threadKey]; - const text = (draft?.text ?? "").trim(); - const attachments = draft?.attachments ?? []; + const draft = getComposerDraftSnapshot(threadKey); + const thread = selectedThreadDetail ?? selectedThreadShell; + const text = draft.text.trim(); + const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) { return; } @@ -157,15 +171,18 @@ export function useThreadComposerState() { commandId: CommandId.make(metadata.commandId), text, attachments, + modelSelection: draft.modelSelection ?? thread.modelSelection, + runtimeMode: draft.runtimeMode ?? thread.runtimeMode, + interactionMode: draft.interactionMode ?? thread.interactionMode, createdAt: metadata.createdAt, }); - clearComposerDraft(threadKey); + clearComposerDraftContent(threadKey); } catch (error) { setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", ); } - }, [composerDrafts, selectedThreadShell]); + }, [selectedThreadDetail, selectedThreadShell]); const onChangeDraftMessage = useCallback( (value: string) => { @@ -255,12 +272,45 @@ export function useThreadComposerState() { [selectedThreadShell], ); + const onUpdateModelSelection = useCallback( + (value: ModelSelection) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { modelSelection: value }); + }, + [selectedThreadKey], + ); + + const onUpdateRuntimeMode = useCallback( + (value: RuntimeMode) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { runtimeMode: value }); + }, + [selectedThreadKey], + ); + + const onUpdateInteractionMode = useCallback( + (value: ProviderInteractionMode) => { + if (!selectedThreadKey) { + return; + } + updateComposerDraftSettings(selectedThreadKey, { interactionMode: value }); + }, + [selectedThreadKey], + ); + return { selectedThreadFeed, selectedThreadQueueCount, activeWorkStartedAt, draftMessage, draftAttachments, + modelSelection, + runtimeMode, + interactionMode, activeThreadBusy, onChangeDraftMessage, onPickDraftImages, @@ -268,5 +318,8 @@ export function useThreadComposerState() { onNativePasteImages, onRemoveDraftImage, onSendMessage, + onUpdateModelSelection, + onUpdateRuntimeMode, + onUpdateInteractionMode, }; } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 840456e2d1c4..e912d6366b47 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { type MessageId } from "@t3tools/contracts"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { CommandId, type MessageId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -10,10 +11,13 @@ import { appAtomRegistry } from "./atom-registry"; import { useThreadShells } from "./entities"; import { ensureThreadOutboxLoaded, removeThreadOutboxMessage } from "./thread-outbox"; import { + modelSelectionsEqual, resolveThreadOutboxDeliveryAction, - shouldRetryThreadOutboxDelivery, + resolveThreadOutboxFailureAction, + resolveQueuedThreadSettings, threadOutboxRetryDelayMs, type QueuedThreadMessage, + type ThreadOutboxCommandStage, } from "./thread-outbox-model"; import { threadEnvironment } from "./threads"; import { useAtomCommand } from "./use-atom-command"; @@ -44,8 +48,21 @@ function findThread( ); } +function settingsCommandId(message: QueuedThreadMessage, setting: string): CommandId { + return CommandId.make(`${message.commandId}:${setting}`); +} + export function useThreadOutboxDrain(): void { const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { + reportFailure: false, + }); + const setThreadRuntimeMode = useAtomCommand(threadEnvironment.setRuntimeMode, { + reportFailure: false, + }); + const setThreadInteractionMode = useAtomCommand(threadEnvironment.setInteractionMode, { + reportFailure: false, + }); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); const queuedMessagesByThreadKey = useThreadOutboxMessages(); const shellStatuses = useThreadOutboxShellStatuses(); @@ -68,6 +85,98 @@ export function useThreadOutboxDrain(): void { const sendQueuedMessage = useCallback( async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => { + const settings = resolveQueuedThreadSettings(queuedMessage, thread); + const reportFailure = ( + commandResult: AtomCommandResult, + stage: ThreadOutboxCommandStage, + ): boolean => { + if (!AsyncResult.isFailure(commandResult)) { + return false; + } + const action = resolveThreadOutboxFailureAction({ + stage, + error: Cause.squash(commandResult.cause), + interrupted: Cause.hasInterruptsOnly(commandResult.cause), + }); + const retry = action === "retry"; + console.warn("[thread-outbox] queued message delivery failed", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + stage, + cause: commandResult.cause, + retry, + }); + return retry; + }; + const completeDelivery = async ( + deliveryResult: AtomCommandResult, + ): Promise => { + if (reportFailure(deliveryResult, "start-turn")) { + return false; + } + + try { + await removeThreadOutboxMessage(queuedMessage); + return true; + } catch (error) { + console.warn("[thread-outbox] failed to remove delivered queued message", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + error, + }); + return false; + } + }; + + if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) { + const updateResult = await updateThreadMetadata({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "model-selection"), + threadId: queuedMessage.threadId, + modelSelection: settings.modelSelection, + }, + }); + if (AsyncResult.isFailure(updateResult)) { + reportFailure(updateResult, "settings-sync"); + return false; + } + } + + if (settings.runtimeMode !== thread.runtimeMode) { + const runtimeResult = await setThreadRuntimeMode({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "runtime-mode"), + threadId: queuedMessage.threadId, + runtimeMode: settings.runtimeMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(runtimeResult)) { + reportFailure(runtimeResult, "settings-sync"); + return false; + } + } + + if (settings.interactionMode !== thread.interactionMode) { + const interactionResult = await setThreadInteractionMode({ + environmentId: queuedMessage.environmentId, + input: { + commandId: settingsCommandId(queuedMessage, "interaction-mode"), + threadId: queuedMessage.threadId, + interactionMode: settings.interactionMode, + createdAt: queuedMessage.createdAt, + }, + }); + if (AsyncResult.isFailure(interactionResult)) { + reportFailure(interactionResult, "settings-sync"); + return false; + } + } + const deliveryResult = await startTurn({ environmentId: queuedMessage.environmentId, input: { @@ -79,41 +188,15 @@ export function useThreadOutboxDrain(): void { text: queuedMessage.text, attachments: queuedMessage.attachments, }, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, + modelSelection: settings.modelSelection, + runtimeMode: settings.runtimeMode, + interactionMode: settings.interactionMode, createdAt: queuedMessage.createdAt, }, }); - if (AsyncResult.isFailure(deliveryResult)) { - const error = Cause.squash(deliveryResult.cause); - const retry = - Cause.hasInterruptsOnly(deliveryResult.cause) || shouldRetryThreadOutboxDelivery(error); - console.warn("[thread-outbox] queued message delivery failed", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - cause: deliveryResult.cause, - retry, - }); - if (retry) { - return false; - } - } - - try { - await removeThreadOutboxMessage(queuedMessage); - return true; - } catch (error) { - console.warn("[thread-outbox] failed to remove delivered queued message", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - error, - }); - return false; - } + return completeDelivery(deliveryResult); }, - [startTurn], + [setThreadInteractionMode, setThreadRuntimeMode, startTurn, updateThreadMetadata], ); useEffect(() => { diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 87dc0c263e28..f19984eb3690 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -3,7 +3,8 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; -import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as Option from "effect/Option"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { ServerAuthDpopReplayKeyCalculationError, @@ -13,22 +14,6 @@ import { } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; -function firstHeaderValue(value: string | undefined): string | undefined { - const first = value?.split(",")[0]?.trim(); - return first && first.length > 0 ? first : undefined; -} - -export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest): string { - try { - return new URL(request.originalUrl).href; - } catch { - const host = firstHeaderValue(request.headers.host) ?? "127.0.0.1"; - const forwardedProto = firstHeaderValue(request.headers["x-forwarded-proto"]); - const proto = forwardedProto === "https" || forwardedProto === "http" ? forwardedProto : "http"; - return new URL(request.originalUrl, `${proto}://${host}`).href; - } -} - export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError => @@ -48,11 +33,17 @@ export const verifyRequestDpopProof = (input: { }) => Effect.gen(function* () { const proof = input.request.headers.dpop; + const url = HttpServerRequest.toURL(input.request); + if (Option.isNone(url)) { + return yield* new ServerAuthInvalidCredentialError({ + diagnostic: "Invalid DPoP request URL.", + }); + } const now = yield* DateTime.now; const result = verifyDpopProof({ proof, method: input.request.method, - url: requestAbsoluteUrl(input.request), + url: url.value.href, nowEpochSeconds: Math.floor(now.epochMilliseconds / 1_000), ...(input.expectedThumbprint ? { expectedThumbprint: input.expectedThumbprint } : {}), ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index ba95422735cd..a51ad20afbe8 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -198,6 +198,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); + assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 173e74264a28..6d72f0b112bb 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -260,13 +260,13 @@ function withUnifiedCompatSettings( UnifiedSettings, | "confirmThreadDelete" | "defaultThreadEnvMode" - | "diffWordWrap" | "diffIgnoreWhitespace" | "enableAssistantStreaming" | "providers" | "sidebarProjectSortOrder" | "sidebarThreadSortOrder" | "timestampFormat" + | "wordWrap" >, ): AppSettings { return normalizeAppSettings({ @@ -278,7 +278,7 @@ function withUnifiedCompatSettings( copilotConfigDir: unifiedSettings.providers.copilot.configDir, defaultThreadEnvMode: unifiedSettings.defaultThreadEnvMode, confirmThreadDelete: unifiedSettings.confirmThreadDelete, - diffWordWrap: unifiedSettings.diffWordWrap, + diffWordWrap: unifiedSettings.wordWrap, diffIgnoreWhitespace: unifiedSettings.diffIgnoreWhitespace, enableAssistantStreaming: unifiedSettings.enableAssistantStreaming, sidebarProjectSortOrder: unifiedSettings.sidebarProjectSortOrder, @@ -341,7 +341,7 @@ function toUnifiedPatch(patch: Partial): Partial { ...(patch.confirmThreadDelete !== undefined ? { confirmThreadDelete: patch.confirmThreadDelete } : {}), - ...(patch.diffWordWrap !== undefined ? { diffWordWrap: patch.diffWordWrap } : {}), + ...(patch.diffWordWrap !== undefined ? { wordWrap: patch.diffWordWrap } : {}), ...(patch.diffIgnoreWhitespace !== undefined ? { diffIgnoreWhitespace: patch.diffIgnoreWhitespace } : {}), @@ -398,7 +398,7 @@ export function useAppSettings() { () => ({ confirmThreadDelete: unifiedSettings.confirmThreadDelete, defaultThreadEnvMode: unifiedSettings.defaultThreadEnvMode, - diffWordWrap: unifiedSettings.diffWordWrap, + wordWrap: unifiedSettings.wordWrap, diffIgnoreWhitespace: unifiedSettings.diffIgnoreWhitespace, enableAssistantStreaming: unifiedSettings.enableAssistantStreaming, providers: unifiedSettings.providers, diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 2305812784f4..d3c7f6a8daba 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -47,6 +47,14 @@ describe("browser target resolver", () => { ).toBe("http://localhost:3000/app"); }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:5173/app?x=1#top"), + ).toBe("http://localhost:5173/app?x=1#top"); + }); + it("normalizes public URLs without treating them as environment ports", async () => { const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); expect(resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "example.com/app")).toBe( diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 0a6dc3aa7c2a..9142cce1e720 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -24,6 +24,11 @@ const isPrivateNetworkHost = (host: string): boolean => { ); }; +const isLocalLoopbackHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +}; + export function resolveBrowserNavigationTarget( environmentId: EnvironmentId, target: BrowserNavigationTarget, @@ -68,6 +73,12 @@ export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: const normalizedUrl = normalizePreviewUrl(rawUrl); const parsed = new URL(normalizedUrl); if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + const connection = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + const environmentUrl = new URL(connection.httpBaseUrl); + if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { + return normalizedUrl; + } const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); return resolveBrowserNavigationTarget(environmentId, { kind: "environment-port", diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index ec335892bea8..8f849a6e7b39 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -69,4 +69,25 @@ describe("clientPersistenceStorage", () => { }), ); }); + + it("defaults word wrap on and discards obsolete wrapping preferences", async () => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem( + "t3code:client-settings:v1", + JSON.stringify({ + chatWordWrap: false, + diffWordWrap: false, + }), + ); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + const settings = readBrowserClientSettings(); + + expect(settings).toEqual( + expect.objectContaining({ + wordWrap: true, + }), + ); + expect(settings).not.toHaveProperty("chatWordWrap"); + expect(settings).not.toHaveProperty("diffWordWrap"); + }); }); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index f823016ddf0d..7e6f2365e50b 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -33,6 +33,7 @@ import { readPrimaryCloudLinkState, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "./linkEnvironment"; const TARGET: CloudLinkTarget = { @@ -252,6 +253,38 @@ describe("web cloud link environment client", () => { }), ); + it.effect("updates agent activity publishing for the explicit primary target", () => + Effect.gen(function* () { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + linked: true, + cloudUserId: "user-1", + relayUrl: "https://relay.example.test", + relayIssuer: "https://relay.example.test", + publishAgentActivity: true, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const state = yield* withServices( + updatePrimaryCloudPreferences({ + target: TARGET, + publishAgentActivity: true, + }), + ); + + expect(state.publishAgentActivity).toBe(true); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/preferences", + ); + expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(bodyText(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + publishAgentActivity: true, + }); + }), + ); + it.effect("links an available primary environment without invoking installation", () => Effect.gen(function* () { const fetchMock = vi diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts index ea924cae2343..4cb62271a480 100644 --- a/apps/web/src/cloud/linkEnvironmentAtoms.ts +++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts @@ -8,6 +8,7 @@ import { linkPrimaryEnvironmentToCloud, type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, + updatePrimaryCloudPreferences, } from "./linkEnvironment"; const cloudLinkScheduler = createAtomCommandScheduler(); @@ -31,3 +32,11 @@ export const unlinkPrimaryEnvironment = createRuntimeCommand(connectionAtomRunti execute: (input: { readonly target: CloudLinkTarget; readonly clerkToken: string | null }) => unlinkPrimaryEnvironmentFromCloud(input), }); + +export const updatePrimaryEnvironmentPreferences = createRuntimeCommand(connectionAtomRuntime, { + label: "web:cloud:update-primary-environment-preferences", + scheduler: cloudLinkScheduler, + concurrency: cloudLinkConcurrency, + execute: (input: { readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean }) => + updatePrimaryCloudPreferences(input), +}); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index cbfce7b43d01..0f1a8f9d4297 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,14 +1,64 @@ -import { useEffect, type ReactNode } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { useEffect, type CSSProperties, type ReactNode } from "react"; import { useNavigate } from "@tanstack/react-router"; +import { isElectron } from "../env"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { isMacPlatform } from "../lib/utils"; +import { primaryServerKeybindingsAtom } from "../state/server"; import ThreadSidebar from "./Sidebar"; -import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar"; +import { Sidebar, SidebarProvider, SidebarRail, SidebarTrigger, useSidebar } from "./ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; +const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; + +function SidebarControl() { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { toggleSidebar } = useSidebar(); + const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle"); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (resolveShortcutCommand(event, keybindings) !== "sidebar.toggle") return; + + event.preventDefault(); + event.stopPropagation(); + toggleSidebar(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings, toggleSidebar]); + + return ( +
+ + + } + /> + + Toggle main sidebar{shortcutLabel ? ` (${shortcutLabel})` : ""} + + +
+ ); +} + export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const macosWindowControlsStyle = + isElectron && isMacPlatform(navigator.platform) + ? ({ "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } as CSSProperties) + : undefined; useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; @@ -28,7 +78,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }, [navigate]); return ( - + {children} + ); } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 43fb9cf11f51..033818e4a422 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -54,6 +54,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; +import { getClientSettings } from "../hooks/useSettings"; import { chatMarkdownClipboardPayload, serializeTableElementToCsv, @@ -293,10 +294,14 @@ function getHighlighterPromise(language: string): Promise { return promise; } +function readInitialWordWrapSetting(): boolean { + return getClientSettings().wordWrap; +} + function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { const containerRef = useRef(null); const tableRef = useRef(null); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState(readInitialWordWrapSetting); const [copied, setCopied] = useState(false); const copiedTimerRef = useRef | null>(null); const expandLabel = expanded ? "Collapse table cells" : "Expand table cells"; @@ -526,10 +531,11 @@ function MarkdownCodeBlock({ children: ReactNode; }) { const [copied, setCopied] = useState(false); - const [wrapped, setWrapped] = useState(false); + const [wrapped, setWrapped] = useState(readInitialWordWrapSetting); const copiedTimerRef = useRef | null>(null); const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; const copyLabel = copied ? "Copied" : "Copy code"; + const handleCopy = useCallback(() => { if (typeof navigator === "undefined" || navigator.clipboard == null) { return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cf5bb9de5e93..44429614b446 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -128,6 +128,7 @@ import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -4677,7 +4678,7 @@ function ChatViewContent(props: ChatViewProps) {
{!rightPanelOpen ? panelLayoutControls : null} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index f39af581d5a2..cbcd36ce05e5 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -38,7 +38,7 @@ import { resolveThreadRouteRef } from "../threadRoutes"; import { useClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; -import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableFileDiff"; +import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; import { Switch } from "./ui/switch"; import { @@ -186,7 +186,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff const { resolvedTheme } = useTheme(); const settings = useClientSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); - const [diffWordWrap, setDiffWordWrap] = useState(settings.diffWordWrap); + const [wordWrap, setWordWrap] = useState(settings.wordWrap); const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace); const [baseRefQuery, setBaseRefQuery] = useState(""); const [collapsedDiffFiles, setCollapsedDiffFiles] = useState(() => ({ @@ -194,6 +194,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, })); const codeViewRef = useRef(null); + const routeThreadRef = useParams({ strict: false, select: (params) => resolveThreadRouteRef(params), @@ -695,14 +696,12 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff { - setDiffWordWrap(Boolean(pressed)); + setWordWrap(Boolean(pressed)); }} /> } @@ -710,7 +709,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff - {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"} + {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} @@ -844,7 +843,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff options={{ diffStyle: diffRenderMode === "split" ? "split" : "unified", lineDiffType: "none", - overflow: diffWordWrap ? "wrap" : "scroll", + overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme as DiffThemeType, unsafeCSS: DIFF_PANEL_UNSAFE_CSS, @@ -860,7 +859,7 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
         
{isElectron ? ( @@ -19,7 +21,6 @@ export function NoActiveThreadState() { ) : (
- No active thread diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 515c0013eddc..5cd7844cccdc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -71,7 +71,7 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { isElectron } from "../env"; -import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; +import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; @@ -189,12 +189,12 @@ import { resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, + resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - resolveSidebarStageBadgeLabel, useThreadJumpHintVisibility, ThreadStatusPill, } from "./Sidebar.logic"; @@ -2456,22 +2456,6 @@ const SidebarProjectListRow = memo(function SidebarProjectListRow(props: Sidebar ); }); -function T3Wordmark() { - return ( - - - - ); -} - type SortableProjectHandleProps = Pick< ReturnType, "attributes" | "listeners" | "setActivatorNodeRef" @@ -2668,48 +2652,64 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { + return isElectron ? ( + + + + + ) : ( + + + + + ); +}); + +function SidebarBrand() { + const stageLabel = useSidebarStageLabel(); + + return ( + + + + Code + + + {stageLabel} + + + ); +} + +function useSidebarStageLabel() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - const stageBadgeLabel = resolveSidebarStageBadgeLabel({ + + return resolveSidebarStageBadgeLabel({ primaryServerVersion, fallbackStageLabel: APP_STAGE_LABEL, }); - const wordmark = ( -
- - - - - - Code - - - {stageBadgeLabel} - - - } - /> - - Version {APP_VERSION} - - -
- ); +} - return isElectron ? ( - - {wordmark} - - ) : ( - {wordmark} +function T3Wordmark() { + return ( + + + ); -}); +} const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 826739a7a63f..eb4d0762324f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3,7 +3,6 @@ import type { EnvironmentId, ModelSelection, PreviewAnnotationPayload, - ProjectEntry, ProviderApprovalDecision, ProviderInteractionMode, ResolvedKeybindingsConfig, @@ -79,6 +78,7 @@ import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { + getComposerPromptInjectionState, getComposerProviderState, renderProviderTraitsMenuContent, renderProviderTraitsPicker, @@ -119,8 +119,6 @@ import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; -const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; -const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-slot="popover-popup"]', '[data-slot="menu-popup"]', @@ -769,18 +767,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [selectedProviderEntry], ); + const composerPromptInjectionState = useMemo( + () => getComposerPromptInjectionState(prompt), + [prompt], + ); const composerProviderState = useMemo( () => getComposerProviderState({ provider: selectedProvider, model: selectedModel, models: selectedProviderModels, - prompt, + promptInjectionState: composerPromptInjectionState, modelOptions: composerModelOptions?.[selectedInstanceId], }), [ composerModelOptions, - prompt, + composerPromptInjectionState, selectedInstanceId, selectedModel, selectedProvider, diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index efc160b0bd15..ef3ec863d0b8 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -14,7 +14,6 @@ import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { cn } from "~/lib/utils"; @@ -80,7 +79,6 @@ export const ChatHeader = memo(function ChatHeader({ return (
- ); return ( -
{ - if (isResponding) return; handleOptionSelection(activeQuestion.id, option.label); }} className={className} > {content} -
+ ); })}
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 58bc7cb3d967..19d700a5cd38 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -195,6 +195,33 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Show full message"); }, 20_000); + it("renders chips for standalone element-pick context messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ", + "- (Button.tsx:12):", + " url: https://example.com/dashboard", + " selector: button.submit", + " source: /repo/src/Button.tsx:12:5", + " html:", + ' ', + "", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("SubmitButton"); + expect(markup).not.toContain("<element_context"); + expect(markup).not.toContain(" { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a176f8fec50b..38c95668b00a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -454,6 +454,10 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; @@ -501,9 +505,9 @@ function UserTimelineRow({ row }: { row: Extract ))} - {elementContextState.contexts.length > 0 ? ( + {elementContexts.length > 0 ? (
- {elementContextState.contexts.map((context) => ( + {elementContexts.map((context) => ( , + promptInjectedValues?: ReadonlyArray, +): Extract { + const defaultId = options.find((option) => option.isDefault)?.id; + return { + id, + label: id, + type: "select", + options: [...options], + ...(defaultId ? { currentValue: defaultId } : {}), + ...(promptInjectedValues && promptInjectedValues.length > 0 + ? { promptInjectedValues: [...promptInjectedValues] } + : {}), + }; +} + +function booleanDescriptor(id: string): Extract { + return { id, label: id, type: "boolean" }; +} + +function modelWith( + descriptors: ReadonlyArray, +): ReadonlyArray { + return [ + { slug: MODEL, name: MODEL, isCustom: false, capabilities: { optionDescriptors: descriptors } }, + ]; +} + +function selections( + ...entries: Array<[string, string | boolean]> +): ReadonlyArray { + return entries.map(([id, value]) => ({ id, value })); +} + +const ULTRATHINK_FRAME_CLASSES = { + composerFrameClassName: "ultrathink-frame", + composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.07)_inset]", + modelPickerIconClassName: "ultrathink-chroma", +} as const; + +describe("getComposerProviderState", () => { + it("derives a stable prompt injection state for ordinary prompt edits", () => { + expect(getComposerPromptInjectionState("Investigate this failure")).toBe("none"); + expect(getComposerPromptInjectionState("Ultrathink:\nInvestigate this failure")).toBe( + "ultrathink", + ); + }); + + it("returns descriptor defaults when no selections are provided", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ]), + ]), + modelOptions: undefined, + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: "high", + modelOptionsForDispatch: selections(["effort", "high"]), + }); + }); + + it("lets selections override defaults and propagates them through dispatch", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ]), + booleanDescriptor("fastMode"), + ]), + modelOptions: selections(["effort", "low"], ["fastMode", true]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: "low", + modelOptionsForDispatch: selections(["effort", "low"], ["fastMode", true]), + }); + }); + + it("preserves selections that match defaults so deepMerge can overwrite prior state", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + booleanDescriptor("fastMode"), + ]), + modelOptions: selections(["effort", "high"], ["fastMode", false]), + }); + + expect(state.modelOptionsForDispatch).toEqual( + selections(["effort", "high"], ["fastMode", false]), + ); + }); + + it("drops selections for descriptors the model does not declare", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([booleanDescriptor("thinking")]), + modelOptions: selections(["effort", "max"], ["thinking", false]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: selections(["thinking", false]), + }); + }); + + it("derives promptEffort from the first select descriptor and preserves all others for dispatch", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + selectDescriptor("contextWindow", [ + { id: "200k", label: "200k", isDefault: true }, + { id: "1m", label: "1M" }, + ]), + selectDescriptor("agent", [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ]), + ]), + modelOptions: selections(["agent", "plan"]), + }); + + expect(state.promptEffort).toBe("high"); + expect(state.modelOptionsForDispatch).toEqual( + selections(["effort", "high"], ["contextWindow", "200k"], ["agent", "plan"]), + ); + }); + + it("returns undefined dispatch options when the model declares no descriptors", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([]), + modelOptions: selections(["anything", "value"]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: null, + modelOptionsForDispatch: undefined, + }); + }); + + it("adds ultrathink class names when the prompt triggers a promptInjectedValues descriptor", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor( + "effort", + [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + ["ultrathink"], + ), + ]), + promptInjectionState: getComposerPromptInjectionState( + "Ultrathink:\nInvestigate this failure", + ), + modelOptions: selections(["effort", "medium"]), + }); + + expect(state).toEqual({ + provider: PROVIDER, + promptEffort: "medium", + modelOptionsForDispatch: selections(["effort", "medium"]), + ...ULTRATHINK_FRAME_CLASSES, + }); + }); + + it("does not add ultrathink class names when the descriptor has no promptInjectedValues", () => { + const state = getComposerProviderState({ + provider: PROVIDER, + model: MODEL, + models: modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + ]), + promptInjectionState: getComposerPromptInjectionState( + "Ultrathink:\nInvestigate this failure", + ), + modelOptions: undefined, + }); + + expect(state).not.toHaveProperty("composerFrameClassName"); + expect(state).not.toHaveProperty("composerSurfaceClassName"); + expect(state).not.toHaveProperty("modelPickerIconClassName"); + }); +}); + +describe("provider traits render guards", () => { + it("returns null when no thread target is provided", () => { + const models = modelWith([ + selectDescriptor("effort", [{ id: "high", label: "High", isDefault: true }]), + ]); + const args = { + provider: PROVIDER, + model: MODEL, + models, + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }; + + expect(renderProviderTraitsPicker(args)).toBeNull(); + expect(renderProviderTraitsMenuContent(args)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/composerProviderState.tsx b/apps/web/src/components/chat/composerProviderState.tsx index b5cc790538d2..1349e2509b7b 100644 --- a/apps/web/src/components/chat/composerProviderState.tsx +++ b/apps/web/src/components/chat/composerProviderState.tsx @@ -21,10 +21,12 @@ export type ComposerProviderStateInput = { provider: ProviderDriverKind; model: string; models: ReadonlyArray; - prompt: string; + promptInjectionState?: ComposerPromptInjectionState; modelOptions: ReadonlyArray | null | undefined; }; +export type ComposerPromptInjectionState = "none" | "ultrathink"; + export type ComposerProviderState = { provider: ProviderDriverKind; promptEffort: string | null; @@ -46,8 +48,12 @@ type TraitsRenderInput = { onPromptChange: (prompt: string) => void; }; +export function getComposerPromptInjectionState(prompt: string): ComposerPromptInjectionState { + return isClaudeUltrathinkPrompt(prompt) ? "ultrathink" : "none"; +} + export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { - const { provider, model, models, prompt, modelOptions } = input; + const { provider, model, models, modelOptions, promptInjectionState = "none" } = input; const caps = getProviderModelCapabilities(models, model, provider); const descriptors = getProviderOptionDescriptors({ caps, selections: modelOptions }); const primarySelectDescriptor = descriptors.find( @@ -58,7 +64,7 @@ export function getComposerProviderState(input: ComposerProviderStateInput): Com const promptEffort = typeof primaryValue === "string" ? primaryValue : null; const ultrathinkActive = (primarySelectDescriptor?.promptInjectedValues?.length ?? 0) > 0 && - isClaudeUltrathinkPrompt(prompt); + promptInjectionState === "ultrathink"; return { provider, diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts new file mode 100644 index 000000000000..fcc660e8305a --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.test.ts @@ -0,0 +1,65 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; + +import { + mobileClientNotificationDetail, + mobileClientPlatformLabel, + mobileClientUpdatedAtLabel, +} from "./MobileClientsUserProfilePage.logic"; + +function device(overrides: Partial = {}): RelayClientDeviceRecord { + return { + deviceId: "device-1", + label: "Julius’s iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.2.3", + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: false, + notifyOnCompletion: true, + notifyOnFailure: false, + }, + liveActivities: { enabled: true }, + updatedAt: "2026-06-21T12:00:00.000Z", + ...overrides, + }; +} + +describe("mobile client presentation", () => { + it("describes the client platform and enabled notification events", () => { + const client = device(); + + expect(mobileClientPlatformLabel(client)).toBe("iOS 18 · T3 Code 1.2.3"); + expect(mobileClientNotificationDetail(client)).toBe( + "Alerts enabled for approvals, completions.", + ); + }); + + it("distinguishes disabled notifications from an empty event selection", () => { + expect( + mobileClientNotificationDetail( + device({ notifications: { ...device().notifications, enabled: false } }), + ), + ).toBe("Push notifications are disabled on this device."); + expect( + mobileClientNotificationDetail( + device({ + notifications: { + enabled: true, + notifyOnApproval: false, + notifyOnInput: false, + notifyOnCompletion: false, + notifyOnFailure: false, + }, + }), + ), + ).toBe("Push notifications are enabled, but no alert types are selected."); + }); + + it("handles missing app versions and invalid update timestamps", () => { + expect(mobileClientPlatformLabel(device({ appVersion: null }))).toBe("iOS 18"); + expect(mobileClientUpdatedAtLabel("not-a-date")).toBe("Update time unavailable"); + }); +}); diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts new file mode 100644 index 000000000000..5ca9595bef4d --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.logic.ts @@ -0,0 +1,39 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; + +const mobileClientUpdatedAtFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +const NOTIFICATION_PREFERENCES = [ + ["notifyOnApproval", "approvals"], + ["notifyOnInput", "input requests"], + ["notifyOnCompletion", "completions"], + ["notifyOnFailure", "failures"], +] as const satisfies ReadonlyArray< + readonly [keyof RelayClientDeviceRecord["notifications"], string] +>; + +export function mobileClientPlatformLabel(device: RelayClientDeviceRecord): string { + return `iOS ${device.iosMajorVersion}${device.appVersion ? ` · T3 Code ${device.appVersion}` : ""}`; +} + +export function mobileClientNotificationDetail(device: RelayClientDeviceRecord): string { + if (!device.notifications.enabled) { + return "Push notifications are disabled on this device."; + } + + const enabledPreferences = NOTIFICATION_PREFERENCES.flatMap(([preference, label]) => + device.notifications[preference] ? [label] : [], + ); + return enabledPreferences.length > 0 + ? `Alerts enabled for ${enabledPreferences.join(", ")}.` + : "Push notifications are enabled, but no alert types are selected."; +} + +export function mobileClientUpdatedAtLabel(updatedAt: string): string { + const date = new Date(updatedAt); + return Number.isNaN(date.getTime()) + ? "Update time unavailable" + : `Updated ${mobileClientUpdatedAtFormatter.format(date)}`; +} diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx new file mode 100644 index 000000000000..26af10ba5b83 --- /dev/null +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx @@ -0,0 +1,166 @@ +import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; +import { RefreshCwIcon, SmartphoneIcon } from "lucide-react"; + +import { useManagedRelayDevices } from "../../cloud/managedRelayState"; +import { cn } from "../../lib/utils"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { Skeleton } from "../ui/skeleton"; +import { + mobileClientNotificationDetail, + mobileClientPlatformLabel, + mobileClientUpdatedAtLabel, +} from "./MobileClientsUserProfilePage.logic"; + +const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; + +function MobileClientStatusBadge({ + enabled, + label, +}: { + readonly enabled: boolean; + readonly label: string; +}) { + return ( + + {label}: {enabled ? "On" : "Off"} + + ); +} + +function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { + return ( +
  • +
    +
    + +
    +
    +
    +
    +

    {device.label}

    +

    {mobileClientPlatformLabel(device)}

    +
    +

    + {mobileClientUpdatedAtLabel(device.updatedAt)} +

    +
    +
    + + +
    +

    + {mobileClientNotificationDetail(device)} +

    +
    +
    +
  • + ); +} + +function MobileClientsSkeleton() { + return ( +
    + {MOBILE_CLIENT_SKELETON_ROWS.map((row) => ( +
    +
    + +
    + + +
    + + +
    +
    +
    +
    + ))} +
    + ); +} + +function EmptyMobileClients() { + return ( + + + + + + No mobile clients + + Sign in to T3 Code on your iPhone to register it for push notifications and Live + Activities. + + + + ); +} + +export function MobileClientsUserProfilePage() { + const devicesState = useManagedRelayDevices(); + const devices = devicesState.data ?? []; + const isInitialLoad = + !devicesState.accountId || (devicesState.data === null && !devicesState.error); + const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null; + + return ( +
    +
    +
    +

    Mobile clients

    +

    + Devices registered to receive T3 Connect activity from your environments. +

    +
    + +
    + +
    + {devicesState.error ? ( +
    +
    +

    + Could not load mobile clients +

    +

    {devicesState.error}

    +
    + +
    + ) : null} + + {isInitialLoad ? ( + + ) : hasErrorWithoutData ? null : devices.length > 0 ? ( +
      + {devices.map((device) => ( + + ))} +
    + ) : ( + + )} +
    +
    + ); +} diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index d3f906ef414b..45477ee1b7e7 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,8 +1,9 @@ import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon } from "lucide-react"; +import { LogInIcon, SmartphoneIcon } from "lucide-react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; +import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; export function T3ConnectSidebarSignIn() { @@ -30,7 +31,15 @@ function ConfiguredT3ConnectSidebarAvatar() { userButtonTrigger: "rounded-lg p-1 hover:bg-sidebar-accent", }, }} - /> + > + } + url="mobile-clients" + > + + + ); } diff --git a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx similarity index 60% rename from apps/web/src/components/diffs/AnnotatableFileDiff.tsx rename to apps/web/src/components/diffs/AnnotatableCodeView.tsx index f74b1e59aa38..6cea64fb5702 100644 --- a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -6,13 +6,7 @@ import type { FileDiffMetadata, SelectedLineRange, } from "@pierre/diffs"; -import { - CodeView, - type CodeViewHandle, - type CodeViewProps, - FileDiff, - type FileDiffProps, -} from "@pierre/diffs/react"; +import { CodeView, type CodeViewHandle, type CodeViewProps } from "@pierre/diffs/react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { useCallback, useMemo, useState, type ReactNode, type Ref } from "react"; @@ -76,178 +70,6 @@ function appendAnnotationEntry( ); } -interface AnnotatableFileDiffProps { - fileDiff: FileDiffMetadata; - filePath: string; - sectionId: string; - sectionTitle: string; - composerDraftTarget: ScopedThreadRef | DraftId; - options: FileDiffProps["options"]; - renderHeaderPrefix: (fileDiff: FileDiffMetadata) => ReactNode; -} - -export function AnnotatableFileDiff({ - fileDiff, - filePath, - sectionId, - sectionTitle, - composerDraftTarget, - options, - renderHeaderPrefix, -}: AnnotatableFileDiffProps) { - const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); - const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); - const reviewComments = useComposerDraftStore( - (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, - ); - const [selectedRange, setSelectedRange] = useState(null); - const [draftAnnotation, setDraftAnnotation] = useState(null); - const persistedAnnotations = useMemo( - () => - reviewComments - .filter( - (comment) => - comment.sectionId === sectionId && - comment.filePath === filePath && - (comment.fenceLanguage ?? "diff") === "diff", - ) - .reduce((annotations, comment) => { - const range = restoreDiffReviewCommentRange(fileDiff, comment); - if (!range) return annotations; - return appendAnnotationEntry(annotations, range, { - id: comment.id, - kind: "comment", - range, - rangeLabel: comment.rangeLabel, - text: comment.text, - }); - }, []), - [fileDiff, filePath, reviewComments, sectionId], - ); - const lineAnnotations = useMemo( - () => (draftAnnotation ? [...persistedAnnotations, draftAnnotation] : persistedAnnotations), - [draftAnnotation, persistedAnnotations], - ); - - const removeAnnotationEntry = useCallback( - (entryId: string) => { - setSelectedRange(null); - if ( - draftAnnotation?.metadata.entries.some( - (entry) => entry.id === entryId && entry.kind === "draft", - ) - ) { - setDraftAnnotation(null); - return; - } - removeReviewComment(composerDraftTarget, entryId); - }, - [composerDraftTarget, draftAnnotation, removeReviewComment], - ); - - const submitAnnotationEntry = useCallback( - (entryId: string, text: string) => { - const entry = draftAnnotation?.metadata.entries.find((candidate) => candidate.id === entryId); - if (!entry) return; - - const comment = buildDiffReviewComment({ - id: entry.id, - sectionId, - sectionTitle, - filePath, - fileDiff, - range: entry.range, - text, - }); - if (comment) { - addReviewComment(composerDraftTarget, comment); - } - setSelectedRange(null); - setDraftAnnotation(null); - }, - [ - addReviewComment, - composerDraftTarget, - fileDiff, - filePath, - draftAnnotation, - sectionId, - sectionTitle, - ], - ); - - const beginComment = useCallback( - (range: SelectedLineRange) => { - const id = nextFileCommentId(); - const comment = buildDiffReviewComment({ - id, - sectionId, - sectionTitle, - filePath, - fileDiff, - range, - text: "", - }); - if (!comment) return; - - const draftEntry: DiffCommentAnnotationEntry = { - id, - kind: "draft", - range, - rangeLabel: comment.rangeLabel, - text: "", - }; - setDraftAnnotation({ - side: annotationSide(range), - lineNumber: range.end, - metadata: { entries: [draftEntry] }, - }); - }, - [fileDiff, filePath, sectionId, sectionTitle], - ); - - const hasOpenCommentForm = draftAnnotation !== null; - const handleLineSelectionEnd = useCallback( - (range: SelectedLineRange | null) => { - setSelectedRange(range); - if (range) beginComment(range); - }, - [beginComment], - ); - - return ( - - fileDiff={fileDiff} - renderHeaderPrefix={renderHeaderPrefix} - options={{ - ...options, - enableGutterUtility: !hasOpenCommentForm, - enableLineSelection: !hasOpenCommentForm, - onGutterUtilityClick: setSelectedRange, - onLineSelectionChange: setSelectedRange, - onLineSelectionEnd: handleLineSelectionEnd, - }} - selectedLines={selectedRange} - lineAnnotations={lineAnnotations} - renderAnnotation={(annotation) => ( -
    - {annotation.metadata.entries.map((entry) => ( - removeAnnotationEntry(entry.id)} - onComment={(text) => submitAnnotationEntry(entry.id, text)} - onDelete={() => removeAnnotationEntry(entry.id)} - /> - ))} -
    - )} - /> - ); -} - interface AnnotatableCodeViewProps { files: ReadonlyArray<{ fileDiff: FileDiffMetadata; diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 89176cd45254..8c430d5d2ab8 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -18,6 +18,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; +import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; @@ -248,6 +249,7 @@ interface EditableFileSurfaceProps { contents: string; resolvedTheme: "light" | "dark"; revealRequestId: number; + wordWrap: boolean; onPostRender: FilePostRender; onPendingChange: (relativePath: string, pending: boolean) => void; } @@ -296,6 +298,7 @@ function EditableFileSurface({ contents, resolvedTheme, revealRequestId, + wordWrap, onPostRender, onPendingChange, }: EditableFileSurfaceProps) { @@ -516,7 +519,7 @@ function EditableFileSurface({ onGutterUtilityClick: setSelectedRange, onLineSelectionChange: setSelectedRange, onLineSelectionEnd: handleLineSelectionEnd, - overflow: "scroll", + overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme, unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, @@ -557,7 +560,12 @@ function RenderedMarkdownSurface({ onPendingChange, }: Omit< EditableFileSurfaceProps, - "resolvedTheme" | "composerDraftTarget" | "revealLine" | "revealRequestId" | "onPostRender" + | "resolvedTheme" + | "composerDraftTarget" + | "revealLine" + | "revealRequestId" + | "wordWrap" + | "onPostRender" > & { threadRef: ScopedThreadRef; }) { @@ -613,6 +621,7 @@ export default function FilePreviewPanel({ onPendingChange, }: FilePreviewPanelProps) { const { resolvedTheme } = useTheme(); + const wordWrap = useClientSettings((settings) => settings.wordWrap); const primaryEnvironmentId = usePrimaryEnvironmentId(); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -844,7 +853,7 @@ export default function FilePreviewPanel({ }} options={{ disableFileHeader: true, - overflow: "scroll", + overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), themeType: resolvedTheme, unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, @@ -863,6 +872,7 @@ export default function FilePreviewPanel({ contents={file.data.contents} resolvedTheme={resolvedTheme} revealRequestId={revealRequestId} + wordWrap={wordWrap} onPostRender={onFilePostRender} onPendingChange={onPendingChange} /> diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 96d9dd4510fd..5012986ff458 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -118,6 +118,7 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, + updatePrimaryEnvironmentPreferences as updatePrimaryEnvironmentPreferencesAtom, } from "~/cloud/linkEnvironmentAtoms"; import { authEnvironment } from "~/state/auth"; import { environmentCatalog } from "~/connection/catalog"; @@ -1423,6 +1424,31 @@ function SavedBackendListRow({ : "bg-muted-foreground/40"; const statusTooltip = connectionStatusText(environment.connection); const errorTraceId = environment.connection.traceId; + const { copyToClipboard: copyTraceIdToClipboard } = useCopyToClipboard<{ traceId: string }>({ + target: "trace ID", + onCopy: ({ traceId }) => { + toastManager.add({ + type: "success", + title: "Trace ID copied", + description: traceId, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy trace ID", + description: error.message, + }), + ); + }, + }); + const copyTraceId = useCallback( + (traceId: string) => { + copyTraceIdToClipboard(traceId, { traceId }); + }, + [copyTraceIdToClipboard], + ); const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); const sshTarget = environment.entry.target._tag === "SshConnectionTarget" && @@ -1432,7 +1458,7 @@ function SavedBackendListRow({ : null; const metadataBits = [ sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, - environment.relayManaged ? "T3 Cloud" : null, + environment.relayManaged ? "T3 Connect" : null, ].filter((value): value is string => value !== null); return ( @@ -1468,7 +1494,7 @@ function SavedBackendListRow({ @@ -1550,7 +1576,7 @@ function CloudLinkSwitch({ }) { const control = ( (null); const [isUpdating, setIsUpdating] = useState(false); + const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); const reportUpdateFailure = (cause: unknown) => { - const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; + const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; const traceId = findErrorTraceId(cause); - console.error("[t3-cloud] Could not update T3 Cloud", { message, traceId, cause }); + console.error("[t3-connect] Could not update T3 Connect", { message, traceId, cause }); setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); toastManager.add({ type: "error", - title: "Could not update T3 Cloud", + title: "Could not update T3 Connect", description: message, data: traceId ? { @@ -1618,9 +1649,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b return; } if (enabled && !tokenResult.value) { - reportUpdateFailure( - new Error("Sign in from T3 Cloud settings before linking this environment."), - ); + reportUpdateFailure(new Error("Sign in to T3 Connect before linking this environment.")); setIsUpdating(false); return; } @@ -1655,38 +1684,95 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b toastManager.add({ type: "success", - title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", + title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", description: enabled - ? "This environment is available through T3 Cloud." - : "This environment is no longer available through T3 Cloud.", + ? "This environment is available through T3 Connect." + : "This environment is no longer available through T3 Connect.", }); setIsUpdating(false); }; + + const updatePublishAgentActivity = async (enabled: boolean) => { + const target = primaryCloudLinkState.target; + if (!target) { + reportUpdateFailure(new Error("Local environment is not ready yet.")); + return; + } + + setIsUpdatingPreference(true); + setOperationError(null); + const updateResult = await updatePrimaryEnvironmentPreferences({ + target, + publishAgentActivity: enabled, + }); + if (updateResult._tag === "Failure") { + if (!isAtomCommandInterrupted(updateResult)) { + reportUpdateFailure(squashAtomCommandFailure(updateResult)); + } + setIsUpdatingPreference(false); + return; + } + + primaryCloudLinkState.refresh(); + toastManager.add({ + type: "success", + title: enabled ? "Agent activity enabled" : "Agent activity disabled", + description: enabled + ? "This environment can publish agent activity to your mobile clients." + : "This environment will stop publishing agent activity.", + }); + setIsUpdatingPreference(false); + }; const disabledReason = !isSignedIn - ? "Sign in from T3 Cloud settings to manage this environment." + ? "Sign in to T3 Connect to manage this environment." : !canManageRelay - ? "Your session does not have permission to manage T3 Cloud access." + ? "Your session does not have permission to manage T3 Connect access." : null; const linked = primaryCloudLinkState.data?.linked ?? false; return ( - void updateLink(enabled)} + <> + void updateLink(enabled)} + /> + } + /> + {linked ? ( + void updatePublishAgentActivity(enabled)} + /> + } /> - } - /> + ) : null} + ); } @@ -1704,7 +1790,7 @@ function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnable No saved remote environments {cloudEnabled - ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." + ? "Click “Add environment” to pair another environment, or connect one from T3 Connect." : "Click “Add environment” to pair another environment."} @@ -1769,7 +1855,7 @@ function ConfiguredCloudRemoteEnvironmentRows({ toastManager.add({ type: "success", title: "Environment connected", - description: `${environment.label} is available through T3 Cloud.`, + description: `${environment.label} is available through T3 Connect.`, }); return; } @@ -1778,9 +1864,9 @@ function ConfiguredCloudRemoteEnvironmentRows({ } const cause = squashAtomCommandFailure(result); const message = - cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment."; + cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment."; const traceId = findErrorTraceId(cause); - console.error("[t3-cloud] Could not connect environment", { message, traceId, cause }); + console.error("[t3-connect] Could not connect environment", { message, traceId, cause }); toastManager.add({ type: "error", title: "Could not connect environment", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 994cbb08f234..40017d563143 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -391,9 +391,7 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), - ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap - ? ["Diff line wrapping"] - : []), + ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), @@ -434,11 +432,11 @@ export function useSettingsRestore(onRestored?: () => void) { settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, - settings.diffWordWrap, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.sidebarThreadPreviewCount, settings.timestampFormat, + settings.wordWrap, theme, ], ); @@ -456,7 +454,7 @@ export function useSettingsRestore(onRestored?: () => void) { setTheme("system"); updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, - diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, + wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, @@ -594,15 +592,15 @@ export function GeneralSettingsPanel() { /> updateSettings({ - diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, + wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, }) } /> @@ -610,9 +608,9 @@ export function GeneralSettingsPanel() { } control={ updateSettings({ diffWordWrap: Boolean(checked) })} - aria-label="Wrap diff lines by default" + checked={settings.wordWrap} + onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })} + aria-label="Wrap code, tables, diffs, and file previews by default" /> } /> diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index 1332bbe2517d..904f86647725 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -6,7 +6,9 @@ import { SidebarMenuButton, SidebarMenuSubButton, SidebarProvider, + SidebarTrigger, } from "./sidebar"; +import { resolveSidebarState } from "./sidebarState"; function renderSidebarButton(className?: string) { return renderToStaticMarkup( @@ -17,6 +19,37 @@ function renderSidebarButton(className?: string) { } describe("sidebar interactive cursors", () => { + it("uses mobile sheet visibility for the shared responsive state", () => { + expect(resolveSidebarState({ isMobile: true, open: true, openMobile: false })).toBe( + "collapsed", + ); + expect(resolveSidebarState({ isMobile: true, open: false, openMobile: true })).toBe("expanded"); + expect(resolveSidebarState({ isMobile: false, open: true, openMobile: false })).toBe( + "expanded", + ); + }); + + it("exposes collapsed state for shared titlebar inset styling", () => { + const html = renderToStaticMarkup( + +
    + , + ); + + expect(html).toContain('data-sidebar-state="collapsed"'); + }); + + it("keeps the sidebar trigger interactive inside Electron drag regions", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("[-webkit-app-region:no-drag]"); + expect(html).toContain("size-[var(--workspace-titlebar-control-size)]!"); + }); + it("uses a pointer cursor for menu buttons by default", () => { const html = renderSidebarButton(); diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 5534d0fc17e5..aeb8440127d8 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -19,6 +19,7 @@ import { Skeleton } from "~/components/ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; import * as Schema from "effect/Schema"; // CookieStore API type (not yet in TypeScript's lib.dom) @@ -34,7 +35,7 @@ const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_RESIZE_DEFAULT_MIN_WIDTH = 16 * 16; type SidebarContextProps = { - state: "expanded" | "collapsed"; + state: ResponsiveSidebarState; open: boolean; setOpen: (open: boolean) => void; openMobile: boolean; @@ -90,6 +91,11 @@ function useSidebar() { return context; } +function useSidebarVisibility() { + const { isMobile, open, openMobile } = useSidebar(); + return isMobile ? openMobile : open; +} + function SidebarProvider({ defaultOpen = true, open: openProp, @@ -139,7 +145,7 @@ function SidebarProvider({ // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed"; + const state = resolveSidebarState({ isMobile, open, openMobile }); const contextValue = React.useMemo( () => ({ @@ -161,6 +167,7 @@ function SidebarProvider({ "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar", className, )} + data-sidebar-state={state} data-slot="sidebar-wrapper" style={ { @@ -317,13 +324,18 @@ function Sidebar({ } function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { - const { toggleSidebar, openMobile } = useSidebar(); + const { toggleSidebar } = useSidebar(); + const isOpen = useSidebarVisibility(); return ( ); @@ -1011,4 +1023,5 @@ export { SidebarSeparator, SidebarTrigger, useSidebar, + useSidebarVisibility, }; diff --git a/apps/web/src/components/ui/sidebarState.ts b/apps/web/src/components/ui/sidebarState.ts new file mode 100644 index 000000000000..fcdfed105218 --- /dev/null +++ b/apps/web/src/components/ui/sidebarState.ts @@ -0,0 +1,9 @@ +export type ResponsiveSidebarState = "expanded" | "collapsed"; + +export function resolveSidebarState(input: { + isMobile: boolean; + open: boolean; + openMobile: boolean; +}): ResponsiveSidebarState { + return (input.isMobile ? input.openMobile : input.open) ? "expanded" : "collapsed"; +} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 17ae9a261c63..0a8cacb78f7c 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -176,7 +176,7 @@ export function buildLegacyClientSettingsMigrationPatch( ? { confirmThreadDelete: legacySettings.confirmThreadDelete } : {}), ...(typeof legacySettings.diffWordWrap === "boolean" - ? { diffWordWrap: legacySettings.diffWordWrap } + ? { wordWrap: legacySettings.diffWordWrap } : {}), ...(typeof legacySettings.diffIgnoreWhitespace === "boolean" ? { diffIgnoreWhitespace: legacySettings.diffIgnoreWhitespace } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d7d2eb57f22a..d5a04db9f7f7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -7,13 +7,24 @@ :root { --workspace-topbar-height: 52px; --workspace-controls-top: 0px; + --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); --workspace-controls-right: calc(env(safe-area-inset-right) + 0.75rem); --workspace-native-controls-inset: 0px; + --workspace-titlebar-control-size: 1.75rem; + --workspace-titlebar-control-gap: 0.75rem; +} + +[data-slot="sidebar-wrapper"] { + --workspace-titlebar-content-left: calc( + var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + + var(--workspace-titlebar-control-gap) + ); } .wco { --workspace-topbar-height: env(titlebar-area-height, 52px); --workspace-controls-top: env(titlebar-area-y, 0px); + --workspace-controls-left: calc(env(titlebar-area-x, 0px) + 0.75rem); --workspace-controls-right: calc( 100vw - env(titlebar-area-width, 100vw) - env(titlebar-area-x, 0px) + 0.75rem ); @@ -93,6 +104,28 @@ } @layer components { + .sidebar-brand { + display: none; + } + + .sidebar-brand-stage { + display: none; + } + + @media (min-width: 48rem) { + @container sidebar-header (min-width: 13.5rem) { + .sidebar-brand { + display: flex; + } + } + + @container sidebar-header (min-width: 15.75rem) { + .sidebar-brand-stage { + display: inline-flex; + } + } + } + .workspace-topbar { display: flex; height: var(--workspace-topbar-height); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 7e30c772d4cf..d277207b0698 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -86,6 +86,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { } const DEFAULT_BINDINGS = compile([ + { shortcut: modShortcut("b"), command: "sidebar.toggle" }, { shortcut: modShortcut("j"), command: "terminal.toggle" }, { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" }, { @@ -309,10 +310,10 @@ describe("shortcutLabelForCommand", () => { ); }); - it("returns labels for non-terminal commands", () => { + it("returns effective labels for non-terminal commands", () => { assert.strictEqual( - shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), - "⌘K", + shortcutLabelForCommand(DEFAULT_BINDINGS, "sidebar.toggle", "MacIntel"), + "⌘B", ); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 0fcf680b732a..7ede9665ac94 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -303,7 +303,6 @@ export function resolveAppModelSelectionState( provider, model, models: entry.models, - prompt: "", modelOptions: selectedEntry ? selection.options : undefined, }); @@ -321,7 +320,6 @@ export function resolveAppModelSelectionState( provider, model, models: getProviderModels(providers, provider), - prompt: "", modelOptions: keptSelectedProvider ? selection.options : undefined, }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 7be0f50414ec..94d49d00afe0 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -4,10 +4,12 @@ import { LinkIcon, PlusIcon } from "lucide-react"; import { NoActiveThreadState } from "../components/NoActiveThreadState"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; -import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { SidebarInset } from "../components/ui/sidebar"; import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); @@ -30,9 +32,13 @@ function HostedStaticOnboardingState() { return (
    -
    +
    - {APP_DISPLAY_NAME} diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index fa5c6a4201df..405073210662 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -11,8 +11,10 @@ import { useCallback, useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; -import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; +import { SidebarInset } from "../components/ui/sidebar"; import { isElectron } from "../env"; +import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -64,9 +66,13 @@ function SettingsContentLayout() {
    {!isElectron && ( -
    +
    - Settings {showRestoreDefaults ? (
    @@ -78,7 +84,12 @@ function SettingsContentLayout() { )} {isElectron && ( -
    +
    Settings diff --git a/apps/web/src/workspaceTitlebar.ts b/apps/web/src/workspaceTitlebar.ts new file mode 100644 index 000000000000..b481221e63aa --- /dev/null +++ b/apps/web/src/workspaceTitlebar.ts @@ -0,0 +1,2 @@ +export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS = + "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]"; diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 4689c1408f7e..a069460e63ce 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -44,6 +44,26 @@ const stubThread = { } as const; describe("applyShellStreamEvent", () => { + it("ignores stale project upserts without mutating the snapshot", () => { + const snapshotWithProject: OrchestrationShellSnapshot = { + ...baseSnapshot, + snapshotSequence: 4, + projects: [stubProject], + }; + + for (const sequence of [3, 4]) { + const next = applyShellStreamEvent(snapshotWithProject, { + kind: "project-upserted", + sequence, + project: { ...stubProject, title: "Stale Title" }, + }); + + expect(next).toBe(snapshotWithProject); + expect(next.snapshotSequence).toBe(4); + expect(next.projects[0]?.title).toBe("Test Project"); + } + }); + describe("project-upserted", () => { it("adds a new project", () => { const event: OrchestrationShellStreamEvent = { diff --git a/packages/client-runtime/src/state/shellReducer.ts b/packages/client-runtime/src/state/shellReducer.ts index 71c8a6b0eb30..3d3b22a1289f 100644 --- a/packages/client-runtime/src/state/shellReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -13,6 +13,8 @@ export function applyShellStreamEvent( snapshot: OrchestrationShellSnapshot, event: OrchestrationShellStreamEvent, ): OrchestrationShellSnapshot { + if (event.sequence <= snapshot.snapshotSequence) return snapshot; + switch (event.kind) { case "project-upserted": { const projects = snapshot.projects.some((p) => p.id === event.project.id) diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 54862f49f631..dca661911c2a 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -35,6 +35,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsed.command, "terminal.toggle"); + const parsedSidebarToggle = yield* decode(KeybindingRule, { + key: "mod+b", + command: "sidebar.toggle", + }); + assert.strictEqual(parsedSidebarToggle.command, "sidebar.toggle"); + const parsedRightPanelToggle = yield* decode(KeybindingRule, { key: "mod+alt+b", command: "rightPanel.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 8fc90bc0eec8..c7cff9943cdd 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -48,7 +48,7 @@ export const MODEL_PICKER_KEYBINDING_COMMANDS = [ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; const STATIC_KEYBINDING_COMMANDS = [ - "commandPalette.toggle", + "sidebar.toggle", "terminal.toggle", "terminal.split", "terminal.splitVertical", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index aba97cbe2056..ac2d47ca3365 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,12 +2,35 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; import { ProviderInstanceId } from "./providerInstance.ts"; -import { DEFAULT_SERVER_SETTINGS, ServerSettings, ServerSettingsPatch } from "./settings.ts"; - +import { + ClientSettingsSchema, + DEFAULT_SERVER_SETTINGS, + ServerSettings, + ServerSettingsPatch, +} from "./settings.ts"; + +const decodeClientSettings = Schema.decodeUnknownSync(ClientSettingsSchema); const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +describe("ClientSettings word wrap", () => { + it("defaults word wrap on", () => { + expect(decodeClientSettings({}).wordWrap).toBe(true); + }); + + it("ignores obsolete wrapping preferences", () => { + const decoded = decodeClientSettings({ + chatWordWrap: false, + diffWordWrap: false, + }); + + expect(decoded.wordWrap).toBe(true); + expect(decoded).not.toHaveProperty("chatWordWrap"); + expect(decoded).not.toHaveProperty("diffWordWrap"); + }); +}); + describe("ServerSettings.providerInstances (slice-2 invariant)", () => { it("defaults to an empty record so legacy configs without the key still decode", () => { expect(DEFAULT_SERVER_SETTINGS.providerInstances).toEqual({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index caa0cbb24dcb..694130a765de 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -47,7 +47,6 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), diffIgnoreWhitespace: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -92,6 +91,7 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + wordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }); export type ClientSettings = typeof ClientSettingsSchema.Type; @@ -735,7 +735,6 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), - diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({ @@ -765,5 +764,6 @@ export const ClientSettingsPatch = Schema.Struct({ sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), + wordWrap: Schema.optionalKey(Schema.Boolean), }); export type ClientSettingsPatch = typeof ClientSettingsPatch.Type; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 4abe53f20532..b6bdd7b4783e 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -19,6 +19,7 @@ type WhenToken = | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ + { key: "mod+b", command: "sidebar.toggle" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 54c789074212..24e787570099 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -72,6 +72,53 @@ describe("remote", () => { }); }); + it("rejects unsupported direct pairing URL protocols", () => { + let pairingUrlError: unknown; + try { + resolveRemotePairingTarget({ + pairingUrl: "ftp://remote.example.com/pair#token=pairing-token", + }); + } catch (cause) { + pairingUrlError = cause; + } + + expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError); + expect(pairingUrlError).toMatchObject({ protocol: "ftp:" }); + expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeUndefined(); + }); + + it("rejects unsupported hosted pairing backend protocols", () => { + let hostError: unknown; + try { + resolveRemotePairingTarget({ + pairingUrl: + "https://app.t3.codes/pair?host=ftp%3A%2F%2Fremote.example.com#token=pairing-token", + }); + } catch (cause) { + hostError = cause; + } + + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "hosted-pairing-host", protocol: "ftp:" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined(); + }); + + it("rejects unsupported direct host protocols", () => { + let hostError: unknown; + try { + resolveRemotePairingTarget({ + host: "ftp://remote.example.com", + pairingCode: "pairing-token", + }); + } catch (cause) { + hostError = cause; + } + + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "direct-host", protocol: "ftp:" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeUndefined(); + }); + it("uses distinct structural errors for missing pairing inputs", () => { expect(() => resolveRemotePairingTarget({})).toThrowError(RemoteBackendUrlMissingError); expect(() => diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 703811609b8e..7347dbc74a17 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; +const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); @@ -18,7 +19,10 @@ export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( "RemotePairingUrlInvalidError", - { cause: Schema.Defect() }, + { + cause: Schema.optional(Schema.Defect()), + protocol: Schema.optional(Schema.String), + }, ) { override get message(): string { return "Pairing URL is invalid."; @@ -29,7 +33,8 @@ export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass + SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol); + const normalizeRemoteBaseUrl = ( rawValue: string, source: RemoteBackendUrlInvalidError["source"], @@ -83,6 +91,12 @@ const normalizeRemoteBaseUrl = ( } catch (cause) { throw new RemoteBackendUrlInvalidError({ source, cause }); } + if (!hasSupportedRemoteBackendProtocol(url)) { + throw new RemoteBackendUrlInvalidError({ + source, + protocol: url.protocol, + }); + } url.pathname = "/"; url.search = ""; url.hash = ""; @@ -184,6 +198,11 @@ export const resolveRemotePairingTarget = (input: { } catch (cause) { throw new RemotePairingUrlInvalidError({ cause }); } + if (!hasSupportedRemoteBackendProtocol(url)) { + throw new RemotePairingUrlInvalidError({ + protocol: url.protocol, + }); + } const hostedPairingRequest = readHostedPairingRequest(url); if (hostedPairingRequest) { const hostedBackendUrl = normalizeRemoteBaseUrl( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40bbaa29c2e6..012d21ae7c76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,13 +35,13 @@ catalogs: version: 0.1.24 overrides: - '@clerk/backend': 3.8.2-snapshot.v20260619001138 - '@clerk/clerk-js': 6.18.2-snapshot.v20260619001138 - '@clerk/electron': 0.0.1-snapshot.v20260619001138 - '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 - '@clerk/expo': 3.4.8-snapshot.v20260619001138 - '@clerk/react': 6.10.4-snapshot.v20260619001138 - '@clerk/shared': 4.19.2-snapshot.v20260619001138 + '@clerk/backend': 3.8.3-snapshot.v20260622234151 + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151 + '@clerk/electron': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 + '@clerk/expo': 3.5.3-snapshot.v20260622234151 + '@clerk/react': 6.11.0-snapshot.v20260622234151 + '@clerk/shared': 4.21.0-snapshot.v20260622234151 '@clerk/clerk-js>@base-org/account': '-' '@clerk/clerk-js>@coinbase/wallet-sdk': '-' '@clerk/clerk-js>@solana/wallet-adapter-base': '-' @@ -113,11 +113,11 @@ importers: apps/desktop: dependencies: '@clerk/electron': - specifier: 0.0.1-snapshot.v20260619001138 - version: 0.0.1-snapshot.v20260619001138(@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron-passkeys': - specifier: 0.0.1-snapshot.v20260619001138 - version: 0.0.1-snapshot.v20260619001138 + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151 '@effect/platform-node': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) @@ -196,8 +196,8 @@ importers: specifier: ^0.7.1 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': - specifier: 3.4.8-snapshot.v20260619001138 - version: 3.4.8-snapshot.v20260619001138(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 3.5.3-snapshot.v20260622234151 + version: 3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -495,14 +495,14 @@ importers: specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/clerk-js': - specifier: 6.18.2-snapshot.v20260619001138 - version: 6.18.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.21.0-snapshot.v20260622234151 + version: 6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/electron': - specifier: 0.0.1-snapshot.v20260619001138 - version: 0.0.1-snapshot.v20260619001138(@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 0.0.2-snapshot.v20260622234151 + version: 0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: 6.10.4-snapshot.v20260619001138 - version: 6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 6.11.0-snapshot.v20260622234151 + version: 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -658,8 +658,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.8.2-snapshot.v20260619001138 - version: 3.8.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.8.3-snapshot.v20260622234151 + version: 3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) @@ -1556,43 +1556,43 @@ packages: resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.8.2-snapshot.v20260619001138': - resolution: {integrity: sha512-nT6M7rKTuvoDnSZwO3Th2NMjcWZy/0ZfXYyqd/o/lFpUFcQO0J4fM2e2wF9kNCl99EPvDQQUAR8APNNy/j40rg==} + '@clerk/backend@3.8.3-snapshot.v20260622234151': + resolution: {integrity: sha512-B5goX0n/5pibc4dMQOfMmn4mPq7eqtrbNV20tOqO9qMPzFA+TKAj9SnB0oJvFsZAXKO6+/n16uYPbqeM2PN6CA==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.18.2-snapshot.v20260619001138': - resolution: {integrity: sha512-BpRSi2QXdfR5nnzC7/YCCqK40m1M4A/rN5unau7QKHj6V7xChl2fOvxYjekpH+DEyw6NAe/2jdqQv35iv3T5oA==} + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151': + resolution: {integrity: sha512-mRNn6H8GbeEkcCIzZ03WZ9c1Uy8znf70okYmmeJKzK72gsdwnrxEfbu3DYE/5yRbX6lvL7ugWaNTT3DvPEbBzg==} engines: {node: '>=20.9.0'} - '@clerk/electron-passkeys-darwin-arm64@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-dbQ/0ZtfDQgYKCSzu3AMxTnGSrdZxulurZMT4Jpvin48Etc27PdcT7VvwOGIa7R+Ab8yMeaoLJbfwHTZv35F+Q==} + '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-NZjIVGitAf0yyQvs1WXIAYOle9RjGsExpfwje2U20POTnNueFy56M0g39UTFQXJ42saTZ0zauLD8sd0cHqpOjA==} cpu: [arm64] os: [darwin] - '@clerk/electron-passkeys-darwin-x64@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-g9tbni7yKIJ/Xpollm25Gf7YLyFP24VyqRHcGDnEsHC1tIffG41spjLr10NgsBoRvFLMlaQJMSrfpjVOpBhAjw==} + '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-gGAwinVoIxa9lefCJjhf5D6oA8uq1sXQ/JwqffkBPJrvczYyAv9FYuQ1/ihji3jTplC0/bpTuIrwBkBCzwj5ug==} cpu: [x64] os: [darwin] - '@clerk/electron-passkeys-win32-arm64-msvc@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-mFDQ1vQ9dLIUrnjNGhIGxqyK0iiym919gYDPO3orYEcICdEiE8xZyEbCtKSVaeX6RWGJAcqFzCxbLVG6V+1k4Q==} + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-anBFktMTAF7of37nLQsqQuW489w563J75l4QolWtblKbrBjlwFsEif95uj3vlQj0qWVVRWpAcxD21oD6wdJcwQ==} cpu: [arm64] os: [win32] - '@clerk/electron-passkeys-win32-x64-msvc@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-IwkLw+d73bd7YJPRR8LAkqP42VIIpbJXCCbs5eVKbmam9M0vSUyeLWEtSiWUEypmLK13xv+7316K4GIA2tmDGQ==} + '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-Pq4FOklTJNpyMTJpxY+/gq6CukvWHFJfGYjuz985cPHup1OSsVmuO/GYil36vh7Qbe8vDivT91wNe73DbEOcVw==} cpu: [x64] os: [win32] - '@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-diU5Q9Nx+30mesrLFOmr0OCnmxE0ogUHXktZBTQx2nQvZb/n2UGOpGNLbY1p9edKMcSc5LfJEsQ8NogfLBBvPg==} + '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-UvBweTg9+FCbygxARV7RvJ4/lcQgLN+gJC6Lj++cLmbxpgRwRAgR9xXRlKV6dVg1p5k81a4DcFNMu/oR6zBdlQ==} engines: {node: '>=20.9.0'} - '@clerk/electron@0.0.1-snapshot.v20260619001138': - resolution: {integrity: sha512-wrBEdMMRqhMF4a7aQpZaBXKJfONIpaYLcgBl0m1b2r+Xg4yuZ47YUoDxhI2Ksvbx2KQPd4i9HP0enL6gEcoqfA==} + '@clerk/electron@0.0.2-snapshot.v20260622234151': + resolution: {integrity: sha512-T0LUJeAPaAZxpk13Q14b9LSVKKio/X/zFo67hgdr35MaOChsn4T6lQ/rEL7laScQBduskcs1yyjr3e9ndy5ojg==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 electron: '>=28' electron-store: ^8.2.0 react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -1605,11 +1605,11 @@ packages: react-dom: optional: true - '@clerk/expo@3.4.8-snapshot.v20260619001138': - resolution: {integrity: sha512-E6q4p5ded45aO3y/+f7APfy5pFj2Y/BEpe/1gzdr6UGxj9kJxkiZQV29hcpjYEFMEJK60f7XbOHZTQEZRB5OwQ==} + '@clerk/expo@3.5.3-snapshot.v20260622234151': + resolution: {integrity: sha512-qeKTJYA7cTe5oCmRVCT4A2QEPRp2af0ox0VdXzIhWRqRVFNF5bnsZRNKE2e4QnsC16NYLsGHLR5uQGC15qBiug==} engines: {node: '>=20.9.0'} peerDependencies: - '@clerk/expo-passkeys': 1.1.8-snapshot.v20260619001138 + '@clerk/expo-passkeys': 1.1.9-snapshot.v20260622234151 expo: '>=53 <57' expo-apple-authentication: '>=7.0.0' expo-auth-session: '>=5' @@ -1620,7 +1620,7 @@ packages: expo-web-browser: '>=12.5.0' react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-native: '>=0.73' + react-native: '>=0.75' peerDependenciesMeta: '@clerk/expo-passkeys': optional: true @@ -1641,15 +1641,15 @@ packages: react-dom: optional: true - '@clerk/react@6.10.4-snapshot.v20260619001138': - resolution: {integrity: sha512-Z7Otjly14SoxadMmk8d9ZdbaXU0me9B1zGdCtnNIcQ7X2GCbeyKm/lOM27IzWYXZKO6o+sXlqsq9A9tcxet5nA==} + '@clerk/react@6.11.0-snapshot.v20260622234151': + resolution: {integrity: sha512-yF4jQFJqEHAqZCpOtjQ/Kg9yqZlEG6vW2vmVR5kVwgESkpOR1KPJIEMU8o3b6W86RKQai4lt3CWtY+o7CJsDyw==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.19.2-snapshot.v20260619001138': - resolution: {integrity: sha512-NAIz0L6+CaRrYn0DoXclUP1R0g6C2ljOzHogQ3rx9/SWUVpfaYX2lxPhURU89xvPoOGBZQb2xwk6xFh/7cjJfQ==} + '@clerk/shared@4.21.0-snapshot.v20260622234151': + resolution: {integrity: sha512-OYu+hO0GHiHwYTwSFe/GCHmuQlyTHyNa7fJ8vrmtUyML09mf+dayRFeUnxgkwOYhGriyq40t1zxdVx53Td73xg==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -10983,18 +10983,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.8.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.8.3-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.18.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -11009,9 +11009,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.18.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -11026,43 +11026,43 @@ snapshots: - react - react-dom - '@clerk/electron-passkeys-darwin-arm64@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-darwin-arm64@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys-darwin-x64@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-darwin-x64@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys-win32-arm64-msvc@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-win32-arm64-msvc@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys-win32-x64-msvc@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys-win32-x64-msvc@0.0.2-snapshot.v20260622234151': optional: true - '@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138': + '@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151': optionalDependencies: - '@clerk/electron-passkeys-darwin-arm64': 0.0.1-snapshot.v20260619001138 - '@clerk/electron-passkeys-darwin-x64': 0.0.1-snapshot.v20260619001138 - '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.1-snapshot.v20260619001138 - '@clerk/electron-passkeys-win32-x64-msvc': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys-darwin-arm64': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-darwin-x64': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-win32-arm64-msvc': 0.0.2-snapshot.v20260622234151 + '@clerk/electron-passkeys-win32-x64-msvc': 0.0.2-snapshot.v20260622234151 - '@clerk/electron@0.0.1-snapshot.v20260619001138(@clerk/electron-passkeys@0.0.1-snapshot.v20260619001138)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/electron@0.0.2-snapshot.v20260622234151(@clerk/electron-passkeys@0.0.2-snapshot.v20260622234151)(electron-store@8.2.0)(electron@41.5.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/clerk-js': 6.18.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/react': 6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) electron: 41.5.0 react: 19.2.6 tslib: 2.8.1 optionalDependencies: - '@clerk/electron-passkeys': 0.0.1-snapshot.v20260619001138 + '@clerk/electron-passkeys': 0.0.2-snapshot.v20260622234151 electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@3.4.8-snapshot.v20260619001138(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.5.3-snapshot.v20260622234151(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@clerk/clerk-js': 6.18.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.10.4-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 expo: 56.0.8(83ff3cadc430781e7f3dd60ccdbde588) react: 19.2.3 @@ -11077,21 +11077,21 @@ snapshots: expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) - '@clerk/react@6.10.4-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.10.4-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.11.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.19.2-snapshot.v20260619001138(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11101,7 +11101,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.19.2-snapshot.v20260619001138(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.21.0-snapshot.v20260622234151(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 728ae163e89d..5354f778b298 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,13 +16,13 @@ ignoredBuiltDependencies: - msw catalog: - "@clerk/backend": 3.8.2-snapshot.v20260619001138 - "@clerk/clerk-js": 6.18.2-snapshot.v20260619001138 - "@clerk/electron": 0.0.1-snapshot.v20260619001138 - "@clerk/electron-passkeys": 0.0.1-snapshot.v20260619001138 - "@clerk/expo": 3.4.8-snapshot.v20260619001138 - "@clerk/react": 6.10.4-snapshot.v20260619001138 - "@clerk/shared": 4.19.2-snapshot.v20260619001138 + "@clerk/backend": 3.8.3-snapshot.v20260622234151 + "@clerk/clerk-js": 6.21.0-snapshot.v20260622234151 + "@clerk/electron": 0.0.2-snapshot.v20260622234151 + "@clerk/electron-passkeys": 0.0.2-snapshot.v20260622234151 + "@clerk/expo": 3.5.3-snapshot.v20260622234151 + "@clerk/react": 6.11.0-snapshot.v20260622234151 + "@clerk/shared": 4.21.0-snapshot.v20260622234151 effect: 4.0.0-beta.78 "@effect/atom-react": 4.0.0-beta.78 "@effect/openapi-generator": 4.0.0-beta.78 diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index acb9a7d3aff8..b51e3b486545 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -1436,8 +1436,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const bundledCopilotVersion = serverDependencies["@github/copilot"]; if (typeof bundledCopilotVersion !== "string" || bundledCopilotVersion.trim().length === 0) { - return yield* new BuildScriptError({ - message: "Could not resolve bundled @github/copilot version from apps/server/package.json.", + return yield* new MissingServerProductionDependenciesError({ + manifestPath: "apps/server/package.json", }); } const bundledCopilotPlatformDependencies = Object.fromEntries(