-
-
{status?.model ?? "English transcription model"}
- {status?.modelId === "handy-computer/parakeet-unified-en-0.6b-gguf" ? (
-
- Recommended for English
-
+ {model ? (
+
+
+
{model.name}
+ {model?.recommended && props.language ? (
+
+ Recommended for {languageLabel(props.language)}
+
+ ) : null}
+
+
+ Download the {Math.round(model.size / 1024 / 1024)} MB model to{" "}
+ {environment?.label ?? "your transcription environment"}. Microphone audio is
+ sent there for transcription, as chosen in Voice settings.
+
+
+ Silero (1.2 MB), used for silence filtering and pause detection, downloads
+ alongside the speech model. An installed copy is reused.
+
+ {props.downloading ? (
+
+
+
+ {model?.state === "verifying"
+ ? "Verifying download…"
+ : progress === null
+ ? "Starting download…"
+ : `${progress}% downloaded`}
+
+
) : null}
-
- Download the {status ? Math.round(status.size / 1024 / 1024) : 697} MB model to{" "}
- {environment?.label ?? "your transcription environment"}. Microphone audio is sent
- there for transcription, as chosen in Voice settings.
+ ) : !props.error ? (
+
+ Loading transcription models…
-
- Silero (1.2 MB), used for silence filtering and pause detection, downloads
- alongside the speech model. An installed copy is reused.
-
- {props.downloading ? (
-
-
-
- {model?.state === "verifying"
- ? "Verifying download…"
- : progress === null
- ? "Starting download…"
- : `${progress}% downloaded`}
-
-
- ) : null}
-
+ ) : null}
{props.error ? (
{props.error}
@@ -208,25 +218,30 @@ export function VoiceInputSetup(props: {
{props.step === 0 ? (
<>
openSettings("speech-models")}>
- Choose another model or language
+ {model ? "Choose another model or language" : "Choose a model or language"}
+ {props.onRetryCatalog ? (
+ Retry loading models
+ ) : null}
{props.downloading ? (
Cancel download
) : null}
-
- {props.downloading
- ? "Downloading…"
- : props.error
- ? "Retry download"
- : canManage
- ? "Download model"
- : "Ask an environment admin to download"}
-
+ {model ? (
+
+ {props.downloading
+ ? "Downloading…"
+ : props.error
+ ? "Try again"
+ : canManage
+ ? "Download model"
+ : "Ask an environment admin to download"}
+
+ ) : null}
>
) : (
<>
diff --git a/apps/web/src/components/chat/useSideChat.ts b/apps/web/src/components/chat/useSideChat.ts
index 1587526528987..1083ca819c6dc 100644
--- a/apps/web/src/components/chat/useSideChat.ts
+++ b/apps/web/src/components/chat/useSideChat.ts
@@ -1,4 +1,4 @@
-import type { ScopedThreadRef } from "@t3tools/contracts";
+import type { ModelSelection, RuntimeMode, ScopedThreadRef } from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
import {
@@ -9,6 +9,7 @@ import {
import { useRef } from "react";
import { newMessageId, newThreadId } from "~/lib/utils";
+import { useComposerDraftStore } from "../../composerDraftStore";
import { useStableCallback } from "../../hooks/useStableCallback";
import { useRightPanelStore } from "../../rightPanelStore";
import { readThreadShell, waitForThreadShell } from "../../state/entities";
@@ -18,16 +19,24 @@ import { useOrchestrationCommand } from "../../state/use-orchestration-command";
/**
* Starts side chats from `threadRef`, or offers none when it is null. `open` forks the
- * thread's latest stable point, sends `prompt` there, and shows the side chat's tab. It
- * resolves false when the side chat did not start, or another one is still starting.
+ * thread's latest stable point, sends `prompt` there with `readSettings()`, the parent
+ * composer's settings, and shows the tab. It resolves false when the side chat did not
+ * start, or another one is still starting.
*/
export function useSideChat(input: {
readonly threadRef: ScopedThreadRef | null;
+ readonly readSettings: () => {
+ readonly runtimeMode: RuntimeMode;
+ readonly modelSelection: ModelSelection | undefined;
+ };
readonly onError: (message: string) => void;
}) {
const pendingRef = useRef(false);
const forkSideChat = useAtomCommand(threadEnvironment.forkSideChat, { reportFailure: false });
const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false });
+ const setThreadRuntimeMode = useOrchestrationCommand(threadEnvironment.setRuntimeMode, {
+ reportFailure: false,
+ });
const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, {
reportFailure: false,
});
@@ -40,6 +49,7 @@ export function useSideChat(input: {
input.onError(error instanceof Error ? error.message : fallback);
};
const start = async (threadRef: ScopedThreadRef, prompt: string | undefined) => {
+ const { runtimeMode, modelSelection } = input.readSettings();
const targetThreadId = newThreadId();
const fork = await forkSideChat({
environmentId: threadRef.environmentId,
@@ -67,6 +77,17 @@ export function useSideChat(input: {
discard();
return false;
}
+ if (runtimeMode !== sideChat.runtimeMode) {
+ const mode = await setThreadRuntimeMode({
+ environmentId: threadRef.environmentId,
+ input: { threadId: targetThreadId, runtimeMode, createdAt: new Date().toISOString() },
+ });
+ if (mode._tag === "Failure") {
+ reportFailure(mode, "Could not set the side chat's permission mode.");
+ discard();
+ return false;
+ }
+ }
const text = prompt?.trim();
// Send before showing the tab: closing a tab whose side chat has no run deletes it.
if (text) {
@@ -75,8 +96,9 @@ export function useSideChat(input: {
input: {
threadId: targetThreadId,
message: { messageId: newMessageId(), role: "user", text, attachments: [] },
+ ...(modelSelection === undefined ? {} : { modelSelection }),
titleSeed: deriveThreadTitleSeed({ text, attachments: [] }),
- runtimeMode: sideChat.runtimeMode,
+ runtimeMode,
interactionMode: "default",
createdAt: new Date().toISOString(),
},
@@ -86,6 +108,8 @@ export function useSideChat(input: {
discard();
return false;
}
+ } else if (modelSelection !== undefined) {
+ useComposerDraftStore.getState().setModelSelection(targetRef, modelSelection);
}
useRightPanelStore.getState().openSideChat(threadRef, targetThreadId);
return true;
diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx
index 9c8dde95f44a2..6faae7da2344f 100644
--- a/apps/web/src/components/media/MediaVideoPlayer.tsx
+++ b/apps/web/src/components/media/MediaVideoPlayer.tsx
@@ -132,7 +132,7 @@ export function MediaVideoPlayer({
diff --git a/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx b/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx
index 5bb06553b252d..8d52066cd5f7a 100644
--- a/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx
+++ b/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx
@@ -61,6 +61,7 @@ export function PreviewAnnotationSpeech({
projectName: project?.title,
ownerKey: JSON.stringify(["preview-annotation", threadRef, tabId, config.sessionId]),
draftText: text,
+ offerSetup: false,
readDraft: () => ({
text: draft.current.text,
selection: { start: draft.current.cursor, end: draft.current.selectionEnd },
@@ -183,9 +184,11 @@ export function PreviewAnnotationSpeech({
step={speech.setup.step}
status={speech.status}
model={speech.setup.model}
+ language={speech.setup.language}
downloading={speech.setup.downloading}
error={speech.setup.error}
onOpenChange={speech.setup.setOpen}
+ onRetryCatalog={speech.setup.retryCatalog}
onDownload={() => void speech.setup.download()}
onCancelDownload={() => void speech.setup.cancelDownload()}
onStartRecording={() => void speech.setup.startRecording()}
diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx
index 00f7a5d0bd118..449e901d0f0d3 100644
--- a/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestMarkdown.tsx
@@ -16,7 +16,7 @@ import { PULL_REQUESTS_PANEL_REF } from "~/rightPanelStore";
import ChatMarkdown from "../ChatMarkdown";
import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
-import { splitPullRequestBody } from "./pullRequestMarkdown.logic";
+import { splitPullRequestBody, type PullRequestBodySegment } from "./pullRequestMarkdown.logic";
export const PullRequestMarkdownContext = createContext<{
/** Stable per repository, so comments share one renderer and its parse cache. */
@@ -62,6 +62,123 @@ function PullRequestHostVideo({
);
}
+function PullRequestBodySegmentView({
+ segment,
+ cwd,
+ environmentId,
+ threadRef,
+ lineBreaks,
+ onTaskListChange,
+}: {
+ segment: PullRequestBodySegment;
+ cwd: string;
+ environmentId: EnvironmentId;
+ threadRef: ScopedThreadRef | undefined;
+ lineBreaks: boolean;
+ onTaskListChange: ((input: { markerOffset: number; checked: boolean }) => void) | undefined;
+}) {
+ const context = useContext(PullRequestMarkdownContext);
+ const repository = context?.repository ?? undefined;
+ if (segment.kind === "table") {
+ return (
+
+ {segment.rows.map((cells, rowIndex) => (
+
+ {cells.map((cell, cellIndex) => (
+
+ {cell.segments.map((cellSegment) => (
+
+ ))}
+
+ ))}
+
+ ))}
+
+ );
+ }
+ if (segment.kind === "markdown" || segment.media === "image") {
+ return (
+ )`}
+ cwd={cwd}
+ threadRef={threadRef}
+ pullRequestPanelRef={threadRef ?? PULL_REQUESTS_PANEL_REF}
+ environmentId={environmentId}
+ {...(context ? { extraRemarkPlugins: context.remarkPlugins } : {})}
+ lineBreaks={lineBreaks}
+ sourceControlMedia
+ sourceControlMediaRepository={repository}
+ onTaskListChange={
+ onTaskListChange && segment.kind === "markdown"
+ ? ({ markerOffset, checked }) =>
+ onTaskListChange({
+ markerOffset: segment.sourceOffset + markerOffset,
+ checked,
+ })
+ : undefined
+ }
+ />
+ );
+ }
+ const hostMedia = sourceControlClients.mediaUrl(segment.url);
+ if (segment.media === "video" && hostMedia !== null) {
+ return (
+
+ );
+ }
+ if (segment.media === "video") {
+ return (
+
+ );
+ }
+ return (
+ // Desktop converts blocked _blank navigation to openExternal.
+
+
+
+ Open attachment on {hostMedia?.definition.label ?? new URL(segment.url).hostname}
+
+
+
+ );
+}
+
export function PullRequestMarkdown({
text,
cwd,
@@ -82,7 +199,6 @@ export function PullRequestMarkdown({
}) {
const segments = splitPullRequestBody(text);
const context = useContext(PullRequestMarkdownContext);
- const repository = context?.repository ?? undefined;
const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined;
return (
- {segments.map((segment) => {
- if (segment.kind === "markdown" || segment.media === "image") {
- return (
-
)`}
- cwd={cwd}
- threadRef={resolvedThreadRef}
- pullRequestPanelRef={resolvedThreadRef ?? PULL_REQUESTS_PANEL_REF}
- environmentId={environmentId}
- {...(context ? { extraRemarkPlugins: context.remarkPlugins } : {})}
- lineBreaks={lineBreaks ?? context?.lineBreaks ?? false}
- sourceControlMedia
- sourceControlMediaRepository={repository}
- onTaskListChange={
- onTaskListChange && segment.kind === "markdown"
- ? ({ markerOffset, checked }) =>
- onTaskListChange({
- markerOffset: segment.sourceOffset + markerOffset,
- checked,
- })
- : undefined
- }
- />
- );
- }
- const hostMedia = sourceControlClients.mediaUrl(segment.url);
- if (segment.media === "video" && hostMedia !== null) {
- return (
-
- );
- }
- if (segment.media === "video") {
- return (
-
- );
- }
- return (
- // Desktop converts blocked _blank navigation to openExternal.
-
-
-
- Open attachment on {hostMedia?.definition.label ?? new URL(segment.url).hostname}
-
-
-
- );
- })}
+ {segments.map((segment) => (
+
+ ))}
);
}
diff --git a/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx b/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx
index a61814ae3efa6..824d4a28f99a1 100644
--- a/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx
+++ b/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx
@@ -7,6 +7,7 @@ import {
type ServerProvider,
type AcpRegistryConfigurableProvider,
} from "@t3tools/contracts";
+import type { ComponentProps } from "react";
import { expect, it, vi } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";
@@ -79,16 +80,28 @@ const provider = {
skills: [],
} satisfies ServerProvider;
-async function renderSection(readOnly = false) {
- const screen = await render(
+const project = { id: projectId, title: "Antigravity", workspaceRoot: session.cwd };
+const replacement = {
+ id: ProjectId.make("replacement"),
+ title: "Replacement",
+ workspaceRoot: "/replacement",
+};
+type Projects = ComponentProps["projects"];
+
+async function renderSection(options: { canList?: boolean; projects?: Projects } = {}) {
+ const section = (projects = options.projects ?? [project]) => (
,
+ provider={{
+ ...provider,
+ nativeSessions: { ...provider.nativeSessions, canList: options.canList ?? true },
+ }}
+ projects={projects}
+ readOnly={false}
+ />
);
+ const screen = await render(section());
const button = (label: string) => {
const found = [...screen.container.querySelectorAll("button")].find(
(element) => element.textContent === label,
@@ -99,6 +112,7 @@ async function renderSection(readOnly = false) {
return Object.assign(screen, {
button,
click: (label: string) => act(async () => button(label).click()),
+ setProjects: (projects: Projects) => screen.rerender(section(projects)),
});
}
@@ -224,3 +238,210 @@ it.each(["Save", "Disable"])("keeps other provider edits through a %s refetch",
expect(input("other base URL").value).toBe("https://api.example.test/v1");
expect(input("other write-only headers JSON").value).toBe("");
});
+
+it.each([true, false])(
+ "enables project actions after projects arrive (sessions: %s)",
+ async (canList) => {
+ setup();
+ await using screen = await renderSection({ canList, projects: [] });
+ await screen.setProjects([project]);
+ const action = screen.getByRole("button", {
+ name: canList ? "List sessions" : "List providers",
+ });
+ await expect.element(action).toBeEnabled();
+ if (!canList) {
+ await expect.element(screen.getByRole("combobox")).not.toBeInTheDocument();
+ }
+ await action.click();
+ if (canList) {
+ await expect.element(screen.getByText(session.title)).toBeVisible();
+ expect(commands.list).toHaveBeenCalledWith({
+ environmentId,
+ input: { instanceId, projectId },
+ });
+ } else {
+ await expect
+ .element(screen.getByRole("textbox", { name: "google base URL" }))
+ .toHaveValue("https://api.example.test/v1");
+ expect(commands.listProviders).toHaveBeenCalledWith({
+ environmentId,
+ input: { instanceId, projectId },
+ });
+ }
+ },
+);
+
+it.each([true, false])(
+ "preserves a valid selection and clears a removed project's data (sessions: %s)",
+ async (canList) => {
+ setup();
+ const second = { id: ProjectId.make("second"), title: "Second", workspaceRoot: "/second" };
+ commands.list.mockResolvedValue({
+ _tag: "Success",
+ value: { sessions: [session], nextCursor: "page-2" },
+ });
+ await using screen = await renderSection({ canList, projects: [project, second] });
+ await screen.getByRole("combobox").click();
+ if (canList) await userEvent.keyboard("{ArrowDown}{Enter}");
+ else await screen.getByRole("option", { name: "Second", exact: true }).click();
+ await expect
+ .element(
+ screen.getByRole("combobox", {
+ name: canList ? "Project for ACP sessions" : "Project for ACP providers",
+ }),
+ )
+ .toHaveFocus();
+ if (canList) await screen.getByRole("button", { name: "List sessions" }).click();
+ await screen.getByRole("button", { name: "List providers" }).click();
+ await userEvent.fill(
+ screen.getByRole("textbox", { name: "google base URL" }),
+ "https://draft.test",
+ );
+ await userEvent.fill(
+ screen.getByLabelText("google write-only headers JSON"),
+ '{"Secret":"draft"}',
+ );
+
+ await screen.setProjects([project, replacement, second]);
+ await expect
+ .element(
+ screen.getByRole("combobox", {
+ name: canList ? "Project for ACP sessions" : "Project for ACP providers",
+ }),
+ )
+ .toHaveTextContent("Second");
+ await expect
+ .element(screen.getByRole("textbox", { name: "google base URL" }))
+ .toHaveValue("https://draft.test");
+ await screen.getByRole("button", { name: "Save", exact: true }).click();
+ expect(commands.setProvider).toHaveBeenLastCalledWith({
+ environmentId,
+ input: {
+ instanceId,
+ projectId: second.id,
+ providerId: "google",
+ apiType: "openai",
+ baseUrl: "https://draft.test",
+ headers: { Secret: "draft" },
+ },
+ });
+
+ await screen.setProjects([project]);
+ await expect.element(screen.getByText(session.title)).not.toBeInTheDocument();
+ await expect.element(screen.getByRole("button", { name: "Load more" })).not.toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("textbox", { name: "google base URL" }))
+ .not.toBeInTheDocument();
+ await screen.getByRole("button", { name: "List providers" }).click();
+ expect(commands.listProviders).toHaveBeenLastCalledWith({
+ environmentId,
+ input: { instanceId, projectId },
+ });
+ await expect.element(screen.getByLabelText("google write-only headers JSON")).toHaveValue("");
+ await expect
+ .element(screen.getByRole("textbox", { name: "google base URL" }))
+ .toHaveValue("https://api.example.test/v1");
+
+ await screen.setProjects([second, project]);
+ await expect
+ .element(
+ screen.getByRole("combobox", {
+ name: canList ? "Project for ACP sessions" : "Project for ACP providers",
+ }),
+ )
+ .toHaveTextContent("Antigravity");
+ await expect
+ .element(screen.getByRole("textbox", { name: "google base URL" }))
+ .toHaveValue("https://api.example.test/v1");
+
+ await screen.setProjects([]);
+ await expect.element(screen.getByRole("button", { name: "List providers" })).toBeDisabled();
+ await expect
+ .element(screen.getByRole("textbox", { name: "google base URL" }))
+ .not.toBeInTheDocument();
+ },
+);
+
+it("ignores a late listing after project replacement", async () => {
+ setup();
+ let finish!: (value: unknown) => void;
+ commands.list.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ await using screen = await renderSection();
+ await screen.click("List sessions");
+ await screen.setProjects([replacement]);
+ await act(async () =>
+ finish({ _tag: "Success", value: { sessions: [session], nextCursor: "old-page" } }),
+ );
+ await expect.element(screen.getByText(session.title)).not.toBeInTheDocument();
+ await expect.element(screen.getByRole("button", { name: "Load more" })).not.toBeInTheDocument();
+ await expect.element(screen.getByRole("button", { name: "List sessions" })).toBeEnabled();
+});
+
+it.each(["Save", "Disable"])("does not refetch the old project after a late %s", async (action) => {
+ setup();
+ const command = action === "Save" ? commands.setProvider : commands.disableProvider;
+ let finish!: () => void;
+ command.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finish = resolve;
+ }).then(() => ({
+ _tag: "Success",
+ value: action === "Save" ? { configured: true } : { disabled: true },
+ })),
+ );
+ await using screen = await renderSection();
+ await screen.click("List providers");
+ await screen.click(action);
+ expect(command).toHaveBeenCalledOnce();
+ await screen.setProjects([replacement]);
+ await act(async () => finish());
+ expect(commands.listProviders).toHaveBeenCalledOnce();
+ await expect.element(screen.getByRole("button", { name: "List providers" })).toBeEnabled();
+});
+
+it.each(["Delete", "Disable"])(
+ "does not act on a replaced project after confirming %s",
+ async (action) => {
+ setup();
+ let finish!: (confirmed: boolean) => void;
+ dialogs.confirm.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ await using screen = await renderSection();
+ await screen.click(action === "Delete" ? "List sessions" : "List providers");
+ await screen.click(action);
+ await screen.setProjects([replacement]);
+ await act(async () => finish(true));
+ expect(action === "Delete" ? commands.delete : commands.disableProvider).not.toHaveBeenCalled();
+ },
+);
+
+it("keeps logout pending across project replacement and clears replacement sessions", async () => {
+ setup();
+ let finish!: () => void;
+ commands.logout.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finish = resolve;
+ }).then(() => ({
+ _tag: "Success",
+ value: { loggedOut: true },
+ })),
+ );
+ await using screen = await renderSection();
+ await screen.click("Log out");
+ await screen.setProjects([replacement]);
+ await screen.click("List sessions");
+ await expect.element(screen.getByText(session.title)).toBeVisible();
+ await expect.element(screen.getByRole("button", { name: "Logging out" })).toBeDisabled();
+ await screen.click("Logging out");
+ expect(commands.logout).toHaveBeenCalledOnce();
+ await act(async () => finish());
+ await expect.element(screen.getByText(session.title)).not.toBeInTheDocument();
+ await expect.element(screen.getByRole("button", { name: "Log out", exact: true })).toBeEnabled();
+});
diff --git a/apps/web/src/components/settings/AcpSessionManagementSection.tsx b/apps/web/src/components/settings/AcpSessionManagementSection.tsx
index 675294497b118..d187633aa3172 100644
--- a/apps/web/src/components/settings/AcpSessionManagementSection.tsx
+++ b/apps/web/src/components/settings/AcpSessionManagementSection.tsx
@@ -17,7 +17,7 @@ import {
squashAtomCommandFailure,
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
-import { useState } from "react";
+import { useLayoutEffect, useRef, useState } from "react";
import { ensureLocalApi } from "../../localApi";
import { serverEnvironment } from "../../state/server";
@@ -70,16 +70,49 @@ function parseProviderHeaders(
return { ok: true, headers: parsed as Record };
}
-export function AcpSessionManagementSection(props: {
+interface AcpSessionManagementProps {
readonly environmentId: EnvironmentId;
readonly instanceId: ProviderInstanceId;
readonly provider: ServerProvider;
readonly projects: ReadonlyArray;
readonly readOnly: boolean;
-}) {
+}
+
+export function AcpSessionManagementSection(props: AcpSessionManagementProps) {
+ const [selectedProjectId, setSelectedProjectId] = useState(null);
+ const projectId =
+ props.projects.find((project) => project.id === selectedProjectId)?.id ??
+ props.projects[0]?.id ??
+ null;
+ if (selectedProjectId !== projectId) setSelectedProjectId(projectId);
+
+ return (
+
+ );
+}
+
+function InstanceSessionManagement(
+ props: AcpSessionManagementProps & {
+ readonly projectId: ProjectId | null;
+ readonly onProjectChange: (projectId: ProjectId) => void;
+ },
+) {
const canOperate = useEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope);
const canRead = useEnvironmentScope(props.environmentId, AuthOrchestrationReadScope);
- const [projectId, setProjectId] = useState(props.projects[0]?.id ?? null);
+ const { projectId } = props;
+ const projectGeneration = useRef(0);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Late continuations must not act on a replaced project scope.
+ useLayoutEffect(() => {
+ return () => {
+ projectGeneration.current += 1;
+ };
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Retiring a project invalidates its pending operations even when the instance stays mounted.
+ }, [projectId]);
const [sessions, setSessions] = useState>([]);
const [nextCursor, setNextCursor] = useState(null);
const [loading, setLoading] = useState(false);
@@ -92,6 +125,19 @@ export function AcpSessionManagementSection(props: {
const [providerDrafts, setProviderDrafts] = useState<
Readonly>
>({});
+ const [previousProjectId, setPreviousProjectId] = useState(projectId);
+ if (previousProjectId !== projectId) {
+ setPreviousProjectId(projectId);
+ setSessions([]);
+ setNextCursor(null);
+ setLoading(false);
+ setImportingSessionId(null);
+ setDeletingSessionId(null);
+ setProviders([]);
+ setLoadingProviders(false);
+ setSavingProviderId(null);
+ setProviderDrafts({});
+ }
const listSessions = useAtomCommand(serverEnvironment.listAcpRegistrySessions, {
reportFailure: false,
});
@@ -129,6 +175,7 @@ export function AcpSessionManagementSection(props: {
if (!canList && !canLogout && !canConfigureProviders) return null;
const loadSessions = async (cursor?: string) => {
+ const generation = projectGeneration.current;
if (projectId === null || loading) return;
setLoading(true);
const result = await listSessions({
@@ -139,6 +186,7 @@ export function AcpSessionManagementSection(props: {
...(cursor === undefined ? {} : { cursor }),
},
});
+ if (generation !== projectGeneration.current) return;
setLoading(false);
if (result._tag === "Success") {
setSessions((current) =>
@@ -151,6 +199,7 @@ export function AcpSessionManagementSection(props: {
};
const importNativeSession = async (session: AcpRegistrySession) => {
+ const generation = projectGeneration.current;
if (
!readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope) ||
projectId === null ||
@@ -168,6 +217,7 @@ export function AcpSessionManagementSection(props: {
updatedAt: session.updatedAt,
},
});
+ if (generation !== projectGeneration.current) return;
setImportingSessionId(null);
if (result._tag === "Success") {
setSessions((current) =>
@@ -187,19 +237,25 @@ export function AcpSessionManagementSection(props: {
};
const deleteNativeSession = async (session: AcpRegistrySession) => {
+ const generation = projectGeneration.current;
if (projectId === null || deletingSessionId !== null || session.importedThreadId !== null)
return;
const confirmed = await ensureLocalApi().dialogs.confirm(
`Permanently delete native ACP session "${session.title ?? session.sessionId}"?`,
{ variant: "destructive" },
);
- if (!confirmed || !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope))
+ if (
+ generation !== projectGeneration.current ||
+ !confirmed ||
+ !readEnvironmentScope(props.environmentId, AuthOrchestrationOperateScope)
+ )
return;
setDeletingSessionId(session.sessionId);
const result = await deleteSession({
environmentId: props.environmentId,
input: { instanceId: props.instanceId, projectId, sessionId: session.sessionId },
});
+ if (generation !== projectGeneration.current) return;
setDeletingSessionId(null);
if (result._tag === "Success") {
setSessions((current) =>
@@ -212,12 +268,14 @@ export function AcpSessionManagementSection(props: {
};
const loadProviders = async (resetProviderId?: string) => {
+ const generation = projectGeneration.current;
if (projectId === null || loadingProviders) return;
setLoadingProviders(true);
const result = await listProviders({
environmentId: props.environmentId,
input: { instanceId: props.instanceId, projectId },
});
+ if (generation !== projectGeneration.current) return;
setLoadingProviders(false);
if (result._tag === "Success") {
setProviders(result.value.providers);
@@ -241,6 +299,7 @@ export function AcpSessionManagementSection(props: {
};
const saveProvider = async (provider: AcpRegistryConfigurableProvider) => {
+ const generation = projectGeneration.current;
if (
!readEnvironmentScope(props.environmentId, AuthProvidersManageScope) ||
projectId === null ||
@@ -272,6 +331,7 @@ export function AcpSessionManagementSection(props: {
...(headers === undefined ? {} : { headers }),
},
});
+ if (generation !== projectGeneration.current) return;
setSavingProviderId(null);
if (result._tag === "Success") {
toastManager.add({ type: "success", title: "ACP provider configured" });
@@ -282,18 +342,25 @@ export function AcpSessionManagementSection(props: {
};
const disableConfiguredProvider = async (provider: AcpRegistryConfigurableProvider) => {
+ const generation = projectGeneration.current;
if (projectId === null || savingProviderId !== null || loadingProviders || provider.required)
return;
const confirmed = await ensureLocalApi().dialogs.confirm(
`Disable ACP provider "${provider.providerId}"?`,
{ variant: "destructive" },
);
- if (!confirmed || !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)) return;
+ if (
+ generation !== projectGeneration.current ||
+ !confirmed ||
+ !readEnvironmentScope(props.environmentId, AuthProvidersManageScope)
+ )
+ return;
setSavingProviderId(provider.providerId);
const result = await disableProvider({
environmentId: props.environmentId,
input: { instanceId: props.instanceId, projectId, providerId: provider.providerId },
});
+ if (generation !== projectGeneration.current) return;
setSavingProviderId(null);
if (result._tag === "Success") {
toastManager.add({ type: "success", title: "ACP provider disabled" });
@@ -350,11 +417,7 @@ export function AcpSessionManagementSection(props: {
disabled={!canRead || projectOperationPending}
onValueChange={(value) => {
if (value === null) return;
- setProjectId(value as ProjectId);
- setSessions([]);
- setNextCursor(null);
- setProviders([]);
- setProviderDrafts({});
+ props.onProjectChange(value as ProjectId);
}}
>
@@ -485,9 +548,7 @@ export function AcpSessionManagementSection(props: {
disabled={!canRead || projectOperationPending}
onValueChange={(value) => {
if (value === null) return;
- setProjectId(value as ProjectId);
- setProviders([]);
- setProviderDrafts({});
+ props.onProjectChange(value as ProjectId);
}}
>
diff --git a/apps/web/src/components/settings/ArchivedThreadsPanel.tsx b/apps/web/src/components/settings/ArchivedThreadsPanel.tsx
index 8aacb39c50320..a694ba5750596 100644
--- a/apps/web/src/components/settings/ArchivedThreadsPanel.tsx
+++ b/apps/web/src/components/settings/ArchivedThreadsPanel.tsx
@@ -1,10 +1,10 @@
import { settingsScopeEnvironmentLabels } from "./settingsScopeAxis";
import { Spinner } from "~/components/ui/spinner";
-import { ArchiveIcon, ArchiveX } from "lucide-react";
-import { type ScopedThreadRef } from "@t3tools/contracts";
-import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { ArchiveIcon, ArchiveX, Trash2Icon } from "lucide-react";
+import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
import {
+ type AtomCommandResult,
isAtomCommandInterrupted,
settlePromise,
squashAtomCommandFailure,
@@ -20,9 +20,20 @@ import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsL
import { searchableSetting } from "./settingsSearch";
import { ProjectFavicon } from "../ProjectFavicon";
+import { useRef, useState } from "react";
+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
+
+const ARCHIVE_ACTIONS = [
+ { action: "unarchive", label: "Unarchive", Icon: ArchiveX },
+ { action: "delete", label: "Delete", Icon: Trash2Icon },
+] as const;
+type ArchiveAction = (typeof ARCHIVE_ACTIONS)[number]["action"];
+
export function ArchivedThreadsPanel() {
const { scope, groups: projectGroups, environments } = useSettingsScope();
const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
+ const pendingActionsRef = useRef(new Set());
+ const [pendingKeys, setPendingKeys] = useState>(() => new Set());
const {
snapshots: archivedSnapshots,
error: archiveError,
@@ -122,52 +133,57 @@ export function ArchivedThreadsPanel() {
return { groups: sortedGroups, hasMultipleEnvironments };
})();
- const handleArchivedThreadContextMenu = async (
- threadRef: ScopedThreadRef,
- position: { x: number; y: number },
- ) => {
- const api = readLocalApi();
- if (!api) return;
- const clicked = await api.contextMenu.show(
- [
- { id: "unarchive", label: "Unarchive" },
- { id: "delete", label: "Delete", destructive: true },
- ],
- position,
+ type ArchivedThread = (typeof archivedGroups)[number]["threads"][number];
+
+ const reportFailure = (result: AtomCommandResult | null, title: string) => {
+ if (result?._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title,
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
);
+ };
- if (clicked === "unarchive") {
- const result = await unarchiveThread(threadRef);
- if (result._tag === "Success") {
- refreshArchivedThreads();
- } else if (!isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Failed to unarchive thread",
- description: error instanceof Error ? error.message : "An error occurred.",
- }),
- );
- }
- return;
- }
+ const handleThreadAction = async (action: ArchiveAction, thread: ArchivedThread) => {
+ const target = scopeThreadRef(thread.environmentId, thread.id);
+ const key = scopedThreadKey(target);
+ // Context menus can outlive their render, so checks and cleanup use the live set.
+ const pending = pendingActionsRef.current;
+ if (pending.has(key)) return;
+ pending.add(key);
+ setPendingKeys(new Set(pending));
+ const result = await settlePromise(async () => {
+ const result = await (action === "unarchive"
+ ? unarchiveThread(target)
+ : confirmAndDeleteThread(target, thread.title));
+ if (result._tag === "Success") refreshArchivedThreads();
+ return result;
+ }).finally(() => {
+ pending.delete(key);
+ setPendingKeys(new Set(pending));
+ });
+ reportFailure(result._tag === "Failure" ? result : result.value, `Failed to ${action} thread`);
+ };
- if (clicked === "delete") {
- const result = await confirmAndDeleteThread(threadRef);
- if (result._tag === "Success") {
- refreshArchivedThreads();
- } else if (!isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Failed to delete thread",
- description: error instanceof Error ? error.message : "An error occurred.",
- }),
- );
- }
- }
+ const handleContextMenu = async (thread: ArchivedThread, position: { x: number; y: number }) => {
+ const pending = pendingActionsRef.current;
+ if (pending.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)))) return;
+ const result = await settlePromise(async () => {
+ const clicked = await readLocalApi()?.contextMenu.show(
+ ARCHIVE_ACTIONS.map(({ action, label }) => ({
+ id: action,
+ label,
+ destructive: action === "delete",
+ })),
+ position,
+ );
+ if (clicked === "unarchive" || clicked === "delete")
+ await handleThreadAction(clicked, thread);
+ });
+ reportFailure(result, "Archived thread action failed");
};
return (
@@ -207,78 +223,50 @@ export function ArchivedThreadsPanel() {
title={title}
icon={ }
>
- {projectThreads.map((thread) => (
- {
- event.preventDefault();
- void (async () => {
- const result = await settlePromise(() =>
- handleArchivedThreadContextMenu(
- scopeThreadRef(thread.environmentId, thread.id),
- {
- x: event.clientX,
- y: event.clientY,
- },
- ),
- );
- if (result._tag === "Failure") {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Archived thread action failed",
- description:
- error instanceof Error ? error.message : "An error occurred.",
- }),
- );
- }
- })();
- }}
- title={thread.title}
- description={
-
- {hasMultipleEnvironments ? `${thread.environmentLabel} · ` : null}
- Archived
- {" \u00b7 Created "}
-
-
- }
- control={
- {
- void (async () => {
- const result = await unarchiveThread(
- scopeThreadRef(thread.environmentId, thread.id),
- );
- if (result._tag === "Success") {
- refreshArchivedThreads();
- return;
- }
- if (!isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Failed to unarchive thread",
- description:
- error instanceof Error ? error.message : "An error occurred.",
- }),
- );
- }
- })();
- }}
- >
-
- Unarchive
-
- }
- />
- ))}
+ {projectThreads.map((thread) => {
+ const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ return (
+ {
+ event.preventDefault();
+ void handleContextMenu(thread, { x: event.clientX, y: event.clientY });
+ }}
+ title={thread.title}
+ description={
+
+ {hasMultipleEnvironments ? `${thread.environmentLabel} · ` : null}
+ Archived
+ {" \u00b7 Created "}
+
+
+ }
+ control={
+
+ {ARCHIVE_ACTIONS.map(({ action, label, Icon }) => (
+
+ void handleThreadAction(action, thread)}
+ />
+ }
+ >
+
+
+ {label}
+
+ ))}
+
+ }
+ />
+ );
+ })}
))
)}
diff --git a/apps/web/src/components/settings/ChatGptAccountPicker.tsx b/apps/web/src/components/settings/ChatGptAccountPicker.tsx
index f3eaa907765d1..f3f113a916039 100644
--- a/apps/web/src/components/settings/ChatGptAccountPicker.tsx
+++ b/apps/web/src/components/settings/ChatGptAccountPicker.tsx
@@ -9,6 +9,7 @@ import {
DialogDescription,
DialogFooter,
} from "../ui/dialog";
+import { Button } from "../ui/button";
import { ChatGptConnectionButton } from "./ChatGptConnectionButton";
export function ChatGptAccountPicker({
@@ -16,11 +17,19 @@ export function ChatGptAccountPicker({
methods,
onClose,
onSelect,
+ onRemove,
+ disabled,
+ canRemove,
+ error,
}: {
open: boolean;
methods: readonly ProviderAuthMethod[];
onClose: () => void;
onSelect: (methodId: string) => void;
+ onRemove: (methodId: string) => void;
+ disabled: boolean;
+ canRemove: boolean;
+ error: string | null;
}) {
const profiles = methods.filter((method) => method.id.startsWith("chatgpt-profile:"));
const [selection, setSelection] = useState(null);
@@ -47,6 +56,7 @@ export function ChatGptAccountPicker({
setSelection(value)}
>
@@ -65,8 +75,22 @@ export function ChatGptAccountPicker({
+ {error ? (
+
+ {error}
+
+ ) : null}
- onSelect(selectedMethodId)} />
+ {profiles.find((profile) => profile.id === selectedMethodId)?.canRemove ? (
+ onRemove(selectedMethodId)}
+ >
+ Remove connection
+
+ ) : null}
+ onSelect(selectedMethodId)} />
diff --git a/apps/web/src/components/settings/CloudVoiceConfiguration.browser.test.tsx b/apps/web/src/components/settings/CloudVoiceConfiguration.browser.test.tsx
new file mode 100644
index 0000000000000..0295603ed2038
--- /dev/null
+++ b/apps/web/src/components/settings/CloudVoiceConfiguration.browser.test.tsx
@@ -0,0 +1,227 @@
+import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection";
+import { EnvironmentId } from "@t3tools/contracts";
+import * as Option from "effect/Option";
+import { expect, it, vi } from "vite-plus/test";
+import { page, userEvent } from "vite-plus/test/browser";
+import { render } from "../../../test/render";
+import "~/index.css";
+import { chooseAudioDestination } from "../../speech/voiceConsent";
+import { useEnvironmentScope, usePreparedConnection } from "../../state/session";
+import { VoiceSettingsPanel } from "./VoiceSettingsPanel";
+import { SettingsScopeProvider } from "./SettingsScopeContext";
+
+vi.mock(import("../../state/session"), { spy: true });
+vi.mock("../../env", async (original) => ({
+ ...(await original()),
+ isSecretsFeatureEnabled: true,
+}));
+vi.mock("../../state/environments", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useEnvironments: () => ({
+ environments: [
+ {
+ environmentId: "voice-host",
+ label: "Laptop",
+ connection: { phase: "available" },
+ serverConfig: {
+ secretReferences: true,
+ settings: {
+ secretProviders: {
+ work: { kind: "one_password", label: "Work vault", config: { account: "work" } },
+ },
+ },
+ environment: {
+ platform: { os: "linux", arch: "x64" },
+ capabilities: { voiceTranscription: true, cloudVoiceTranscription: true },
+ },
+ },
+ },
+ ],
+ }),
+}));
+
+it("enables the chosen provider only after a successful setup and keeps its grant when editing the key", async () => {
+ const id = EnvironmentId.make("voice-host");
+ const target = new PrimaryConnectionTarget({
+ environmentId: id,
+ label: "Laptop",
+ httpBaseUrl: window.location.origin,
+ wsBaseUrl: "ws://localhost",
+ });
+ const prepared = Option.some({
+ ...target,
+ target,
+ socketUrl: target.wsBaseUrl,
+ httpAuthorization: null,
+ });
+ using cleanup = new DisposableStack();
+ cleanup.defer(() => chooseAudioDestination(null));
+ chooseAudioDestination({ kind: "environment", environmentId: id });
+ const writes: unknown[] = [];
+ let rejectNextWrite = true;
+ const fetch = globalThis.fetch;
+ using _network = vi.spyOn(globalThis, "fetch").mockImplementation((request, init) => {
+ const url = new URL(request instanceof Request ? request.url : request, window.location.href);
+ if (url.pathname === "/api/voice/cloud/keys")
+ return Promise.resolve(Response.json({ openai: writes.length > 0, xai: true }));
+ if (url.pathname === "/api/voice/cloud/openai/key") {
+ if (rejectNextWrite) {
+ rejectNextWrite = false;
+ return Promise.resolve(new Response(null, { status: 403 }));
+ }
+ return new Request(request, init).json().then((body) => {
+ writes.push(body);
+ return new Response(null, { status: 204 });
+ });
+ }
+ return fetch(request, init);
+ });
+ await vi.mocked(useEnvironmentScope).withImplementation(
+ () => true,
+ async () => {
+ await vi.mocked(usePreparedConnection).withImplementation(
+ () => prepared,
+ async () => {
+ await using screen = await render(
+ {}}>
+
+ ,
+ );
+ const destination = screen.getByRole("combobox", { name: "Transcribe with" });
+ await expect.element(destination).toHaveTextContent("Laptop");
+ await destination.click();
+ await page.getByRole("option", { name: "OpenAI via Laptop" }).click();
+ const dialog = page.getByRole("dialog");
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ await expect.element(destination).toHaveTextContent("Laptop");
+ expect(writes).toEqual([]);
+ await destination.click();
+ await page.getByRole("option", { name: "OpenAI via Laptop" }).click();
+ await dialog.getByRole("combobox", { name: "OpenAI API key source" }).click();
+ await page.getByRole("option", { name: "Work vault" }).click();
+ await dialog.getByLabelText("OpenAI API key reference").fill("op://Work/Voice/key");
+ await userEvent.keyboard("{Enter}");
+ await expect.element(dialog.getByRole("alert")).toBeVisible();
+ expect(writes).toEqual([]);
+ await expect
+ .element(dialog.getByRole("button", { name: "Save and enable" }))
+ .toBeEnabled();
+ await dialog.getByLabelText("OpenAI API key reference").click();
+ await userEvent.keyboard("{Enter}");
+ await expect.element(dialog).not.toBeInTheDocument();
+ await expect.element(destination).toHaveTextContent("OpenAI via Laptop");
+ expect(writes).toEqual([{ key: { provider: "work", reference: "op://Work/Voice/key" } }]);
+ const grant = window.localStorage.getItem("t3code:voice-audio-grant:v1");
+ await screen.getByRole("button", { name: "Manage key" }).click();
+ await dialog.getByLabelText("OpenAI API key", { exact: true }).fill("replacement");
+ await userEvent.keyboard("{Enter}");
+ await expect.element(dialog).not.toBeInTheDocument();
+ expect(writes.at(-1)).toEqual({ key: "replacement" });
+ expect(window.localStorage.getItem("t3code:voice-audio-grant:v1")).toBe(grant);
+ await destination.click();
+ await page.getByRole("option", { name: "Grok Voice Transcribe via Laptop" }).click();
+ await dialog.getByRole("button", { name: "Use Grok Voice Transcribe" }).click();
+ await expect.element(destination).toHaveTextContent("Grok Voice Transcribe via Laptop");
+ await destination.click();
+ await page.getByRole("option", { name: "Off", exact: true }).click();
+ await expect.element(destination).toHaveTextContent("Off");
+ },
+ );
+ },
+ );
+});
+
+it("OpenRouter needs an exact model, and changing it writes no credential", async () => {
+ const id = EnvironmentId.make("voice-host");
+ const target = new PrimaryConnectionTarget({
+ environmentId: id,
+ label: "Laptop",
+ httpBaseUrl: window.location.origin,
+ wsBaseUrl: "ws://localhost",
+ });
+ const prepared = Option.some({
+ ...target,
+ target,
+ socketUrl: target.wsBaseUrl,
+ httpAuthorization: null,
+ });
+ using cleanup = new DisposableStack();
+ cleanup.defer(() => chooseAudioDestination(null));
+ chooseAudioDestination(null);
+ const writes: unknown[] = [];
+ const fetch = globalThis.fetch;
+ using _network = vi.spyOn(globalThis, "fetch").mockImplementation((request, init) => {
+ const url = new URL(request instanceof Request ? request.url : request, window.location.href);
+ if (url.pathname === "/api/voice/cloud/keys")
+ return Promise.resolve(Response.json({ openai: false, xai: false, openrouter: true }));
+ if (url.pathname === "/api/voice/cloud/openrouter/models")
+ return Promise.resolve(
+ Response.json({
+ models: [
+ { id: "acme/transcribe-1", name: "Acme: Transcribe 1" },
+ { id: "other/listen-2", name: "Other: Listen 2" },
+ ],
+ }),
+ );
+ if (url.pathname === "/api/voice/cloud/openrouter/key")
+ return new Request(request, init).json().then((body) => {
+ writes.push(body);
+ return new Response(null, { status: 204 });
+ });
+ return fetch(request, init);
+ });
+ const grantKey = "t3code:voice-audio-grant:v1";
+ const grantModel = () => {
+ const raw = window.localStorage.getItem(grantKey);
+ const parsed = raw ? (JSON.parse(raw) as { destination?: { model?: string } }) : null;
+ return parsed?.destination?.model ?? null;
+ };
+ await vi.mocked(useEnvironmentScope).withImplementation(
+ () => true,
+ async () => {
+ await vi.mocked(usePreparedConnection).withImplementation(
+ () => prepared,
+ async () => {
+ await using screen = await render(
+ {}}>
+
+ ,
+ );
+ const destination = screen.getByRole("combobox", { name: "Transcribe with" });
+ await destination.click();
+ await page.getByRole("option", { name: "OpenRouter via Laptop" }).click();
+ const dialog = page.getByRole("dialog");
+ const save = dialog.getByRole("button", { name: "Save and enable" });
+ await expect.element(save).toBeDisabled();
+ const picker = dialog.getByRole("combobox", { name: "OpenRouter model" });
+ await expect.element(picker).toBeVisible();
+ await picker.click();
+ await userEvent.keyboard("listen");
+ await expect.element(page.getByRole("option", { name: /Other: Listen 2/ })).toBeVisible();
+ await page.getByRole("option", { name: /Other: Listen 2/ }).click();
+ await expect.element(save).toBeEnabled();
+ const key = dialog.getByLabelText("OpenRouter API key", { exact: true });
+ await key.fill("op://not-a-literal-key");
+ await expect.element(save).toBeDisabled();
+ await key.fill("");
+ await save.click();
+ await expect.element(dialog).not.toBeInTheDocument();
+ await expect.element(destination).toHaveTextContent("OpenRouter via Laptop");
+ expect(grantModel()).toBe("other/listen-2");
+ expect(writes).toEqual([]);
+ await screen.getByRole("button", { name: "Manage OpenRouter" }).click();
+ const current = dialog.getByRole("combobox", { name: "OpenRouter model" });
+ await expect.element(current).toHaveValue("Other: Listen 2");
+ await userEvent.clear(current);
+ await userEvent.type(current, "acme");
+ await page.getByRole("option", { name: /Acme: Transcribe 1/ }).click();
+ await dialog.getByRole("button", { name: "Save" }).click();
+ await expect.element(dialog).not.toBeInTheDocument();
+ await expect.element(destination).toHaveTextContent("OpenRouter via Laptop");
+ expect(grantModel()).toBe("acme/transcribe-1");
+ expect(writes).toEqual([]);
+ },
+ );
+ },
+ );
+});
diff --git a/apps/web/src/components/settings/CloudVoiceConfiguration.tsx b/apps/web/src/components/settings/CloudVoiceConfiguration.tsx
new file mode 100644
index 0000000000000..95dcee4cffb45
--- /dev/null
+++ b/apps/web/src/components/settings/CloudVoiceConfiguration.tsx
@@ -0,0 +1,472 @@
+import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
+import { cloudSpeechDisclosure } from "@t3tools/shared/speech";
+import type { PreparedConnection } from "@t3tools/client-runtime/connection";
+import {
+ configuredSecretSources,
+ secretRequestReference,
+} from "@t3tools/client-runtime/secret-request";
+import {
+ getCloudSpeechKeys,
+ getCloudSpeechModels,
+ setCloudSpeechKey,
+} from "@t3tools/client-runtime/voice-input";
+import {
+ AuthOrchestrationOperateScope,
+ AuthSettingsWriteScope,
+ CloudSpeechApiKey,
+ CloudSpeechModelId,
+ CLOUD_SPEECH_LABELS,
+ CLOUD_SPEECH_PROVIDER_NAMES,
+ isSecretReference,
+ type CloudSpeechModel,
+ type CloudSpeechProvider,
+ type SecretProviderId,
+ type SettingsSecretValue,
+ type VoiceTranscriptionDestination,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import type { HttpClient } from "effect/http";
+import { Atom, AsyncResult } from "effect/reactivity";
+import { useId, useState } from "react";
+import { isSecretsFeatureEnabled } from "../../env";
+import {
+ inEnvironmentSpeechRuntime,
+ runEnvironmentSpeech,
+} from "../../speech/runEnvironmentSpeech";
+import {
+ chooseAudioDestination,
+ pendingCloudAudioChoice,
+ useAudioChoice,
+} from "../../speech/voiceConsent";
+import type { EnvironmentPresentation } from "../../state/environments";
+import { useEnvironmentScope, usePreparedConnection } from "../../state/session";
+import { Button } from "../ui/button";
+import {
+ Combobox,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxPopup,
+ useComboboxFilter,
+} from "../ui/combobox";
+import {
+ Dialog,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogPanel,
+ DialogPopup,
+ DialogTitle,
+} from "../ui/dialog";
+import { Input } from "../ui/input";
+import { Label } from "../ui/label";
+import { SecretInput } from "./SecretInput";
+import { SecretSourceSelect } from "./SecretSourceInput";
+
+// Per dialog instance, not an Atom.family: key presence must be read fresh each time it opens.
+function useCloudRead(
+ prepared: PreparedConnection | null,
+ read: (prepared: PreparedConnection) => Effect.Effect ,
+) {
+ const make = (connection: PreparedConnection | null) => ({
+ prepared: connection,
+ atom: Atom.make(
+ connection ? inEnvironmentSpeechRuntime(read(connection)) : Effect.succeed(null),
+ ),
+ });
+ const [current, setCurrent] = useState(() => make(prepared));
+ if (current.prepared !== prepared) setCurrent(make(prepared));
+ return [useAtomValue(current.atom), useAtomRefresh(current.atom)] as const;
+}
+const isApiKey = Schema.is(CloudSpeechApiKey);
+const isModelId = Schema.is(CloudSpeechModelId);
+type CloudDestination = Extract;
+
+function ModelPicker({
+ models,
+ value,
+ onChange,
+ disabled,
+ current,
+}: {
+ models: ReadonlyArray;
+ value: string;
+ onChange: (next: string) => void;
+ disabled: boolean;
+ /** The grant's model, flagged when the catalog no longer lists it. */
+ current: string | undefined;
+}) {
+ const { contains } = useComboboxFilter();
+ const selected = models.find((model) => model.id === value) ?? null;
+ const delisted = current !== undefined && !models.some((model) => model.id === current);
+ return (
+
+
model.name}
+ itemToStringValue={(model: CloudSpeechModel) => model.id}
+ filter={(model: CloudSpeechModel, query) =>
+ contains(model.name, query) || contains(model.id, query)
+ }
+ value={selected}
+ onValueChange={(model) => onChange(model?.id ?? "")}
+ autoHighlight
+ disabled={disabled}
+ >
+
+
+ No transcription model matches.
+
+ {(model: CloudSpeechModel) => (
+
+
+ {model.name}
+ {model.id}
+
+
+ )}
+
+
+
+ {delisted ? (
+
+ OpenRouter no longer lists {current}. Dictation stays off until you choose another model.
+
+ ) : selected ? (
+
{selected.id}
+ ) : null}
+
+ );
+}
+
+export function CloudVoiceConfiguration({
+ environment,
+ provider,
+ active,
+ close,
+}: {
+ environment: Pick;
+ provider: CloudSpeechProvider;
+ active: boolean;
+ close: () => void;
+}) {
+ const id = environment.environmentId;
+ const name = CLOUD_SPEECH_LABELS[provider];
+ const company = CLOUD_SPEECH_PROVIDER_NAMES[provider];
+ const routed = provider === "openrouter";
+ const formId = useId();
+ const prepared = Option.getOrNull(usePreparedConnection(id));
+ const canUse = useEnvironmentScope(id, AuthOrchestrationOperateScope);
+ const canManage = useEnvironmentScope(id, AuthSettingsWriteScope);
+ const [presence, refresh] = useCloudRead(canUse ? prepared : null, getCloudSpeechKeys);
+ const [models, refreshModels] = useCloudRead(
+ routed && canUse ? prepared : null,
+ getCloudSpeechModels,
+ );
+ const choice = useAudioChoice();
+ const grant = choice === "off" ? null : choice;
+ const currentModel =
+ active && grant?.destination.kind === "cloud" && grant.destination.provider === "openrouter"
+ ? grant.destination.model
+ : undefined;
+ const [remove, setRemove] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const [model, setModel] = useState(currentModel ?? "");
+ const saved = AsyncResult.isSuccess(presence) ? presence.value?.[provider] : undefined;
+ const existing = environment.serverConfig?.settings?.cloudSpeechCredentials?.[provider];
+ const sources =
+ isSecretsFeatureEnabled && environment.serverConfig?.secretReferences
+ ? configuredSecretSources(environment.serverConfig.settings?.secretProviders ?? {})
+ : [];
+ const reference =
+ isSecretReference(existing) && sources.some((s) => s.id === existing.provider)
+ ? existing
+ : null;
+ const [sourceId, setSourceId] = useState(reference?.provider ?? null);
+ const [draft, setDraft] = useState(reference?.reference ?? "");
+ const source = sources.find((s) => s.id === sourceId);
+ const credential =
+ sourceId !== null
+ ? source && secretRequestReference(source, draft)
+ : isApiKey(draft.trim())
+ ? draft.trim()
+ : undefined;
+ const keyChanged =
+ credential !== undefined &&
+ !(
+ isSecretReference(credential) &&
+ reference?.provider === credential.provider &&
+ reference.reference === credential.reference
+ );
+ const connected = canUse && prepared !== null;
+ // Fixed providers edit the key only when nothing is saved or the device already uses them.
+ const editingKey = canManage && saved !== undefined && (routed || active || !saved);
+ const listedModels = AsyncResult.isSuccess(models) ? (models.value?.models ?? null) : null;
+ const modelChosen = isModelId(model) && (listedModels?.some((m) => m.id === model) ?? false);
+ const keyReady = draft.trim() ? credential !== undefined : Boolean(saved);
+ const modelChanged = model !== (currentModel ?? "");
+ const destination = (): CloudDestination =>
+ routed && isModelId(model)
+ ? { kind: "cloud", provider: "openrouter", environmentId: id, model }
+ : { kind: "cloud", provider: provider as "openai" | "xai", environmentId: id };
+ const enable = () => chooseAudioDestination(destination());
+ const canSubmit = routed
+ ? modelChosen && keyReady && (!active || modelChanged || keyChanged)
+ : credential !== undefined;
+
+ const saveKey = async (value: SettingsSecretValue | null, thenEnable: boolean) => {
+ if (!connected || !canManage || busy) return;
+ setError("");
+ setBusy(true);
+ try {
+ const commit = thenEnable ? pendingCloudAudioChoice(destination()) : null;
+ await runEnvironmentSpeech(setCloudSpeechKey(prepared, provider, value));
+ refresh();
+ commit?.();
+ close();
+ } catch {
+ setError("Couldn’t save this change. Check your connection and try again.");
+ }
+ setBusy(false);
+ };
+ const submit = () => {
+ if (!canSubmit || busy) return;
+ const grantChanges = !active || modelChanged;
+ if (credential !== undefined && (!routed || keyChanged))
+ return void saveKey(credential, grantChanges);
+ // Model-only changes never write to the environment.
+ if (grantChanges) enable();
+ close();
+ };
+
+ const keyField = (
+ <>
+ {routed && saved ? "Replace API key" : "API key"}
+
+
+ {draft.trim() && !credential
+ ? (source?.invalidReferenceMessage ?? "Enter an API key, not a URL or secret reference.")
+ : active && !routed
+ ? "Updating the key keeps dictation enabled."
+ : routed && saved
+ ? "Leave blank to keep the saved key."
+ : `Anyone with dictation access to ${environment.label} can bill this key.`}
+
+ >
+ );
+
+ return (
+ {
+ if (!open && !busy) close();
+ }}
+ >
+
+
+
+ {remove
+ ? `Remove the ${company} key?`
+ : active
+ ? routed
+ ? "Manage OpenRouter dictation"
+ : `Manage ${company} key`
+ : `Use ${name} for dictation?`}
+
+
+ {remove
+ ? `Everyone using ${name} for dictation on ${environment.label} will lose access.`
+ : active && !routed
+ ? `Anyone with dictation access to ${environment.label} can use this API key.`
+ : cloudSpeechDisclosure(provider, environment.label)}
+
+
+ {!remove && (
+
+ {!connected ? (
+ Connect to {environment.label} with dictation access to continue.
+ ) : AsyncResult.isFailure(presence) && saved === undefined ? (
+
+ Retry loading API key
+
+ ) : routed &&
+ AsyncResult.isSuccess(presence) &&
+ presence.value &&
+ saved === undefined ? (
+ Update {environment.label} to use OpenRouter dictation.
+ ) : saved === undefined ? (
+ Checking for an API key…
+ ) : routed ? (
+
+ ) : editingKey ? (
+
+ ) : !saved ? (
+ Ask an administrator on {environment.label} to add an API key.
+ ) : (
+
+ This choice applies only to this device. Choose Off to stop sharing.
+
+ )}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ {connected && canManage && saved && (active || routed) && !remove && (
+
+ setRemove(true)}
+ >
+ Remove key…
+
+
+ )}
+
+ Cancel
+
+ {connected &&
+ (remove ? (
+ void saveKey(null, false)}
+ >
+ Remove key
+
+ ) : routed ? (
+ saved !== undefined && (saved || canManage) ? (
+
+ {active ? "Save" : "Save and enable"}
+
+ ) : null
+ ) : editingKey ? (
+
+ {active ? "Save key" : "Save and enable"}
+
+ ) : saved && !active ? (
+ {
+ enable();
+ close();
+ }}
+ >
+ Use {name}
+
+ ) : null)}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx
index 0dfd68dcdc378..f4dd3da002865 100644
--- a/apps/web/src/components/settings/CodexSetupSection.tsx
+++ b/apps/web/src/components/settings/CodexSetupSection.tsx
@@ -1,3 +1,4 @@
+import { useAtomValue } from "@effect/atom-react";
import { AuthProvidersManageScope, ProviderDriverKind } from "@t3tools/contracts";
import { readPreparedConnection, useEnvironmentScope } from "../../state/session";
import {
@@ -293,6 +294,10 @@ function ManagedCodexSetup({
const completeAuth = useAtomCommand(serverEnvironment.completeProviderAuth, options);
const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, options);
const logoutAuth = useAtomCommand(serverEnvironment.logoutProviderAuth, options);
+ const removeAuthMethod = useAtomCommand(serverEnvironment.removeProviderAuthMethod, options);
+ const canRemoveAuthMethod = useAtomValue(
+ serverEnvironment.removeProviderAuthMethod.permissionAtom(environmentId),
+ );
const startInstall = useAtomCommand(serverEnvironment.startProviderInstall, options);
const cancelInstall = useAtomCommand(serverEnvironment.cancelProviderInstall, options);
const reconnectProfile = useAtomCommand(serverEnvironment.chatGptReconnectProfile, options);
@@ -771,6 +776,12 @@ function ManagedCodexSetup({
open={accountPickerOpen}
methods={auth?.methods ?? []}
onClose={() => setAccountPickerOpen(false)}
+ disabled={unavailable || busy}
+ canRemove={canRemoveAuthMethod}
+ error={error}
+ onRemove={(methodId) => {
+ void run(() => removeAuthMethod({ environmentId, input: { instanceId, methodId } }));
+ }}
onSelect={(methodId) => {
setAccountPickerOpen(false);
void setup(
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
index fca8b88925e96..6012b31d4dc33 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
@@ -351,25 +351,9 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
hasServerConfig: environment.serverConfig !== null,
}),
)?.environmentId;
- const searchableCursorEnvironmentId = options.find(
- (environment) =>
- environment.serverConfig?.environment.platform.os === "darwin" &&
- isProviderSettingsEnvironmentAvailable({
- connectionPhase: environment.connection.phase,
- hasServerConfig: true,
- }),
- )?.environmentId;
let searchRedirectEnvironmentId: EnvironmentId | undefined;
{
if (
- !target.scoped &&
- searchTargetId === searchableSetting("cursor-keychain-usage").id &&
- (!selectedEnvironmentCanRenderSettings ||
- selectedEnvironment?.serverConfig?.environment.platform.os !== "darwin") &&
- searchableCursorEnvironmentId !== undefined
- ) {
- searchRedirectEnvironmentId = searchableCursorEnvironmentId;
- } else if (
!target.scoped &&
(searchTargetId === searchableSetting("provider-health-check-interval").id ||
searchTargetId === searchableSetting("usage-providers").id) &&
@@ -1105,7 +1089,6 @@ export function EnvironmentProviderSettings({
environmentId={environmentId}
environmentLabel={environmentLabel}
sources={settings.usageLimitSources}
- cursorKeychainUsageEnabled={settings.cursorKeychainUsageEnabled}
readOnly={readOnly}
/>
diff --git a/apps/web/src/components/settings/SecretsSettings.logic.test.ts b/apps/web/src/components/settings/SecretsSettings.logic.test.ts
index 1a0ff29e11cfb..1480844f58baf 100644
--- a/apps/web/src/components/settings/SecretsSettings.logic.test.ts
+++ b/apps/web/src/components/settings/SecretsSettings.logic.test.ts
@@ -31,6 +31,7 @@ const settings = Schema.decodeSync(ServerSettings)({
easDeviceHosts: [
{ kind: "eas", id: "eas", label: "EAS Simulator", accessToken: personal("op://expo") },
],
+ cloudSpeechCredentials: { openai: personal("op://voice"), xai: "" },
});
it.each([
@@ -42,6 +43,7 @@ it.each([
"GitHub · github.com",
"https://hub.test · Management Key",
"EAS Simulator · Access Token",
+ "Voice · OpenAI",
],
],
["work", ["Antigravity Work · OTHER_TOKEN", "GitHub · ghe.acme.test"]],
diff --git a/apps/web/src/components/settings/SecretsSettings.logic.ts b/apps/web/src/components/settings/SecretsSettings.logic.ts
index 3ae528aed162a..a85bc9e330143 100644
--- a/apps/web/src/components/settings/SecretsSettings.logic.ts
+++ b/apps/web/src/components/settings/SecretsSettings.logic.ts
@@ -1,4 +1,6 @@
import {
+ CLOUD_SPEECH_LABELS,
+ CloudSpeechProvider,
EasDeviceHostConfig,
isSecretReference,
secretReferencesInSettings,
@@ -12,7 +14,11 @@ import { deriveProviderSettingsFields, type SettingsFormDefinition } from "./Pro
type SecretUseSettings = Pick<
ServerSettings,
- "providerInstances" | "sourceControlHosts" | "usageLimitSources" | "easDeviceHosts"
+ | "providerInstances"
+ | "sourceControlHosts"
+ | "usageLimitSources"
+ | "easDeviceHosts"
+ | "cloudSpeechCredentials"
>;
interface SecretProviderUse {
@@ -86,5 +92,11 @@ export function secretProviderUses(
...settings.easDeviceHosts.flatMap((host) =>
uses({ settingsSchema: EasDeviceHostConfig }, `eas:${host.id}`, host.label, host),
),
+ ...CloudSpeechProvider.literals.flatMap((provider) => {
+ const credential = settings.cloudSpeechCredentials[provider];
+ return isSecretReference(credential) && credential.provider === providerId
+ ? [{ id: `voice:${provider}`, label: `Voice · ${CLOUD_SPEECH_LABELS[provider]}` }]
+ : [];
+ }),
];
}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index d408d6c8d5d94..c53ca8a2dc71a 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -705,7 +705,7 @@ export function useSettingsRestore(onRestored?: () => void) {
};
}
-const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
+const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 7;
function AutoSettleDaysInput({
value,
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx
index e273d0c672e76..e175fbc439f22 100644
--- a/apps/web/src/components/settings/SettingsSidebarNav.tsx
+++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx
@@ -57,7 +57,6 @@ import {
type SettingsSearchItem,
} from "./settingsSearch";
import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
-import { validateSettingsScopeSearch } from "./settingsScope";
import { useSettingsSectionNavigation } from "./useSettingsSectionNavigation";
import { workspaceAreaProps } from "../../lib/workspaceFocus";
@@ -132,8 +131,6 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
const navArea = workspaceAreaProps("settings-nav", 0, "Settings sections");
const navigate = useNavigate();
const currentHash = useLocation({ select: (location) => location.hash });
- const currentSearch = useLocation({ select: (location) => location.search });
- const scopeSearch = validateSettingsScopeSearch(currentSearch);
const activePath = SETTINGS_DETAIL_PAGE_PARENTS[pathname] ?? pathname;
const navPaths = SETTINGS_NAV_GROUPS.flatMap((group) => group.paths);
const sectionNavigation = useSettingsSectionNavigation(
@@ -146,7 +143,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
// Below md the nav is an icon rail; searching expands it over the page.
const [compactSearchOpen, setCompactSearchOpen] = useState(false);
const [activeResultIndex, setActiveResultIndex] = useState(0);
- const searchableItems = useAvailableSettingsSearchItems(scopeSearch);
+ const searchableItems = useAvailableSettingsSearchItems();
const results = searchSettings(query, searchableItems);
const isSearching = query.trim().length > 0;
const hasResults = results.length > 0;
diff --git a/apps/web/src/components/settings/UsageProviderSettings.tsx b/apps/web/src/components/settings/UsageProviderSettings.tsx
index f238850ae04d0..93f2fd6ddec51 100644
--- a/apps/web/src/components/settings/UsageProviderSettings.tsx
+++ b/apps/web/src/components/settings/UsageProviderSettings.tsx
@@ -1,11 +1,8 @@
import type { EnvironmentId, UnifiedSettings } from "@t3tools/contracts";
-import { useAtomValue } from "@effect/atom-react";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useUpdateEnvironmentSettings } from "../../hooks/useSettings";
-import { serverEnvironment } from "../../state/server";
-import { useAtomCommand } from "../../state/use-atom-command";
import {
AlertDialog,
AlertDialogClose,
@@ -16,7 +13,6 @@ import {
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Button } from "../ui/button";
-import { Switch } from "../ui/switch";
import { AddUsageLimitSourceDialog } from "./AddUsageLimitSourceDialog";
import { searchableSetting } from "./settingsSearch";
import { SettingsRow, SettingsSection } from "./settingsLayout";
@@ -25,40 +21,17 @@ export function UsageProviderSettings({
environmentId,
environmentLabel,
sources,
- cursorKeychainUsageEnabled,
readOnly,
}: {
readonly environmentId: EnvironmentId;
readonly environmentLabel: string;
readonly sources: UnifiedSettings["usageLimitSources"];
- readonly cursorKeychainUsageEnabled: boolean;
readonly readOnly: boolean;
}) {
const updateSettings = useUpdateEnvironmentSettings(environmentId);
- const updateCursorSettings = useAtomCommand(serverEnvironment.updateSettings, {
- label: "update Cursor account usage",
- });
- const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
- reportFailure: false,
- });
- const platform = useAtomValue(serverEnvironment.configValueAtom(environmentId))?.environment
- .platform;
const [adding, setAdding] = useState(false);
- const [updatingCursor, setUpdatingCursor] = useState(false);
const entries = Object.entries(sources);
- const setCursorUsageEnabled = async (enabled: boolean) => {
- setUpdatingCursor(true);
- const result = await updateCursorSettings({
- environmentId,
- input: { patch: { cursorKeychainUsageEnabled: enabled } },
- });
- if (result._tag === "Success") {
- await refreshProviders({ environmentId, input: {} });
- }
- setUpdatingCursor(false);
- };
-
return (
<>
- {platform?.os === "darwin" ? (
- void setCursorUsageEnabled(enabled)}
- />
- }
- />
- ) : null}
{entries.length === 0 ? (
) : (
diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx
index ab1c0187c2806..969420ff2e0fc 100644
--- a/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx
+++ b/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx
@@ -1,4 +1,5 @@
-import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection";
+import { PrimaryConnectionTarget, RelayConnectionTarget } from "@t3tools/client-runtime/connection";
+import type * as Effect from "effect/Effect";
import {
EnvironmentId,
type EnvironmentSpeechModel,
@@ -13,14 +14,64 @@ import {
useVoiceTranscriptionDestination,
} from "../../speech/useEnvironmentSpeechInput";
import { chooseAudioDestination, chooseTranscriptCleanup } from "../../speech/voiceConsent";
+import { useEnvironmentScope } from "../../state/session";
import { toastManager } from "../ui/toast";
import { SettingsScopeProvider } from "./SettingsScopeContext";
import { SpeechLanguageSelect } from "./SpeechLanguageSelect";
import { VoiceSettingsPanel } from "./VoiceSettingsPanel";
vi.mock(import("../../speech/useEnvironmentSpeechInput"), { spy: true });
+vi.mock(import("../../state/session"), { spy: true });
-it("preserves the model test transcript when its model is filtered out", async () => {
+// The real connection runtime authorizes relay requests with a signed-in cloud session.
+vi.mock("../../connection/runtime", async () => {
+ const [{ ManagedRelay }, rpc, { Atom }, Context, Effect, Layer] = await Promise.all([
+ import("@t3tools/client-runtime/relay"),
+ import("@t3tools/client-runtime/rpc"),
+ import("effect/reactivity"),
+ import("effect/Context"),
+ import("effect/Effect"),
+ import("effect/Layer"),
+ ]);
+ type AuthorizedHttp =
+ import("@t3tools/client-runtime/authorization").AuthorizedRemoteHttpEnvironment;
+ // Services resolve by key, and client-runtime does not export this one.
+ class RemoteEnvironmentAuthorization extends Context.Service<
+ RemoteEnvironmentAuthorization,
+ {
+ readonly authorizeDpopHttp: (input: {
+ readonly expectedEnvironmentId: AuthorizedHttp["environmentId"];
+ }) => Effect.Effect;
+ }
+ // @effect-diagnostics-next-line deterministicKeys:off
+ >()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {}
+ return {
+ connectionAtomRuntime: Atom.runtime(
+ Layer.mergeAll(
+ rpc.layerRemoteHttpClient((input, init) => globalThis.fetch(input, init)),
+ Layer.succeed(ManagedRelay.ManagedRelayDpopSigner, {
+ thumbprint: Effect.succeed("thumbprint"),
+ createProof: () => Effect.succeed("proof"),
+ }),
+ Layer.succeed(RemoteEnvironmentAuthorization, {
+ authorizeDpopHttp: ({ expectedEnvironmentId }) =>
+ Effect.succeed({
+ environmentId: expectedEnvironmentId,
+ label: "MacBook Pro",
+ httpBaseUrl: window.location.origin,
+ httpAuthorization: {
+ _tag: "Dpop",
+ accessToken: "relay-access-token",
+ expiresAtEpochMs: 4_102_444_800_000,
+ },
+ }),
+ }),
+ ),
+ ),
+ };
+});
+
+it("tests the selected model after download and preserves its transcript across filters", async () => {
const target = new PrimaryConnectionTarget({
environmentId: EnvironmentId.make("voice-filter"),
label: "Test environment",
@@ -55,15 +106,23 @@ it("preserves the model test transcript when its model is filtered out", async (
modelUnloadTimeout: "never",
gpuDevices: [],
} satisfies EnvironmentSpeechStatus;
+ let installed = false;
const fetch = globalThis.fetch;
using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
const url = new URL(input instanceof Request ? input.url : input, window.location.href);
- if (url.pathname === "/api/voice/status") return Promise.resolve(Response.json(status));
+ if (url.pathname === "/api/voice/status")
+ return Promise.resolve(
+ Response.json(installed ? status : { ...status, state: "missing-model" }),
+ );
+ if (url.pathname === "/api/voice/models/download") {
+ installed = true;
+ return Promise.resolve(Response.json({ models: [model] }));
+ }
if (url.pathname === "/api/voice/models")
return Promise.resolve(
Response.json({
models: [
- model,
+ installed ? model : { ...model, state: "downloadable", active: false },
{
...model,
id: "both",
@@ -88,6 +147,7 @@ it("preserves the model test transcript when its model is filtered out", async (
return fetch(input, init);
});
const transcript = "Keep this transcript.";
+ using _scope = vi.mocked(useEnvironmentScope).mockReturnValue(true);
await vi.mocked(useVoiceTranscriptionDestination).withImplementation(
() => ({ environmentId: target.environmentId, label: target.label, grantId: "test", prepared }),
async () => {
@@ -101,7 +161,10 @@ it("preserves the model test transcript when its model is filtered out", async (
open: false,
step: 0,
model: null,
+ language: null,
downloading: false,
+ catalogFailed: false,
+ retryCatalog: null,
error: null,
setOpen: () => {},
download: async () => {},
@@ -129,7 +192,13 @@ it("preserves the model test transcript when its model is filtered out", async (
,
);
const testModel = screen.getByRole("button", { name: "Test model", exact: true });
+ const download = screen.getByRole("button", { name: "Download", exact: true });
+ await expect.element(download).toBeVisible();
+ expect(testModel.query()).toBeNull();
+ expect(screen.getByRole("button", { name: "Set up voice input" }).query()).toBeNull();
+ await userEvent.click(download);
await expect.element(testModel).toBeVisible();
+ expect(screen.getByRole("button", { name: "Set up voice input" }).query()).toBeNull();
await userEvent.click(testModel);
const result = screen.getByRole("status", { name: "Test transcript" });
await expect.element(result).toHaveTextContent(transcript);
@@ -157,6 +226,81 @@ it("preserves the model test transcript when its model is filtered out", async (
);
});
+it("loads the models of a relay-connected destination", async () => {
+ const target = new RelayConnectionTarget({
+ environmentId: EnvironmentId.make("voice-relay"),
+ label: "MacBook Pro",
+ });
+ const prepared = {
+ environmentId: target.environmentId,
+ label: target.label,
+ httpBaseUrl: window.location.origin,
+ socketUrl: "ws://localhost",
+ httpAuthorization: {
+ _tag: "Dpop" as const,
+ accessToken: "stale-access-token",
+ expiresAtEpochMs: 4_102_444_800_000,
+ },
+ target,
+ };
+ const model: EnvironmentSpeechModel = {
+ id: "tiny",
+ name: "Tiny",
+ description: "",
+ size: 100,
+ languages: ["en"],
+ accuracy: 1,
+ speed: 1,
+ recommended: false,
+ supportsStreaming: false,
+ supportsTranslation: false,
+ supportsLanguageDetection: false,
+ active: false,
+ state: "downloadable",
+ };
+ const status = {
+ supported: true,
+ state: "missing-model",
+ modelId: model.id,
+ model: model.name,
+ size: model.size,
+ supportsStreaming: false,
+ supportsTranslation: false,
+ acceleration: "cpu",
+ modelUnloadTimeout: "never",
+ gpuDevices: [],
+ } satisfies EnvironmentSpeechStatus;
+ const authorizations: Array = [];
+ const fetch = globalThis.fetch;
+ using _network = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
+ const url = new URL(input instanceof Request ? input.url : input, window.location.href);
+ if (url.pathname === "/api/voice/status") {
+ authorizations.push(new Headers(init?.headers).get("authorization"));
+ return Promise.resolve(Response.json(status));
+ }
+ if (url.pathname === "/api/voice/models")
+ return Promise.resolve(Response.json({ models: [model] }));
+ return fetch(input, init);
+ });
+ using notify = vi.spyOn(toastManager, "add").mockReturnValue("test");
+ // Restored even when an assertion fails, unlike `withImplementation`.
+ using _destination = vi.mocked(useVoiceTranscriptionDestination).mockImplementation(() => ({
+ environmentId: target.environmentId,
+ label: target.label,
+ grantId: "test",
+ prepared,
+ }));
+ await using screen = await render(
+ {}}>
+
+ ,
+ );
+ await vi.waitFor(() => expect(authorizations.length + notify.mock.calls.length).not.toBe(0));
+ expect(notify).not.toHaveBeenCalled();
+ expect(authorizations).toEqual(["DPoP relay-access-token"]);
+ await expect.element(screen.getByText("Tiny", { exact: true })).toBeVisible();
+});
+
it("recovers from a failed download progress poll without notifying, but reports a failed initial load", async () => {
const target = new PrimaryConnectionTarget({
environmentId: EnvironmentId.make("voice-progress"),
@@ -252,7 +396,7 @@ it.each([
);
await vi.waitFor(() => expect(document.activeElement?.id).toBe(prerequisite));
await expect
- .element(screen.getByRole("combobox", { name: "Send microphone audio to" }))
+ .element(screen.getByRole("combobox", { name: "Transcribe with" }))
.toHaveTextContent("Off");
await expect
.element(screen.getByRole("switch", { name: "Clean up transcripts with a provider" }))
@@ -271,7 +415,7 @@ it("keeps a missing destination selected without claiming it cannot transcribe",
,
);
await expect
- .element(screen.getByRole("combobox", { name: "Send microphone audio to" }))
+ .element(screen.getByRole("combobox", { name: "Transcribe with" }))
.toHaveTextContent("Chosen environment (unavailable)");
await expect.element(screen.getByText(/can’t transcribe audio/)).not.toBeInTheDocument();
});
diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx
index c08844bafe09b..4a3df08f40f14 100644
--- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx
+++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx
@@ -9,13 +9,17 @@ import {
updateEnvironmentSpeechModelUnloadTimeout,
} from "@t3tools/client-runtime/voice-input";
import type { PreparedConnection } from "@t3tools/client-runtime/connection";
+import { openRouterIcon } from "@t3tools/client-runtime/provider-clients";
import {
AuthSettingsWriteScope,
+ CLOUD_SPEECH_LABELS,
+ CloudSpeechProvider,
DEFAULT_SPEECH_POST_PROCESSING_PROMPT,
type EnvironmentId,
type EnvironmentSpeechModel,
type EnvironmentSpeechStatus,
type ProjectId,
+ ProviderDriverKind,
type SpeechAcceleration,
type SpeechCustomWords,
type SpeechModelUnloadTimeout,
@@ -23,14 +27,19 @@ import {
} from "@t3tools/contracts";
import { createModelSelection } from "@t3tools/shared/model";
import { resolveSpeechPostProcessingModelSelection } from "@t3tools/shared/serverSettings";
+import { cloudSpeechLanguages } from "@t3tools/shared/speech";
import {
AudioLinesIcon,
CheckIcon,
DownloadIcon,
LanguagesIcon,
+ KeyRoundIcon,
+ MonitorIcon,
+ MicOffIcon,
MicIcon,
RefreshCwIcon,
StarIcon,
+ SlidersHorizontalIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
@@ -38,13 +47,14 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import { useStableCallback } from "~/hooks/useStableCallback";
import { listBrowserMicrophones, type BrowserMicrophone } from "../../speech/browserMicrophones";
+import { preferredSpeechLanguages } from "../../speech/preferredSpeechLanguages";
import {
chooseAudioDestination,
chooseTranscriptCleanup,
useCleanupGrant,
} from "../../speech/voiceConsent";
import { updateClientSettings, useClientSettings } from "../../hooks/useSettings";
-import { runtime } from "../../lib/runtime";
+import { runEnvironmentSpeech } from "../../speech/runEnvironmentSpeech";
import {
getCustomModelOptionsByInstance,
resolveAppModelSelectionState,
@@ -59,19 +69,44 @@ import {
} from "../../speech/useEnvironmentSpeechInput";
import { ComposerSpeechRecordingPill } from "../chat/ComposerSpeechButton";
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
+import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
+import { ProviderPackageIcon } from "../chat/ProviderPackageIcon";
import { TraitsPicker } from "../chat/TraitsPicker";
import { VoiceInputSetup } from "../chat/VoiceInputSetup";
import { Badge } from "../ui/badge";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
-import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
+import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
+import {
+ Menu,
+ MenuCheckboxItem,
+ MenuGroup,
+ MenuGroupLabel,
+ MenuPopup,
+ MenuRadioGroup,
+ MenuRadioItem,
+ MenuRadioItemIndicator,
+ MenuSeparator,
+ MenuTrigger,
+} from "../ui/menu";
+import {
+ Select,
+ SelectGroup,
+ SelectGroupLabel,
+ SelectItem,
+ SelectPopup,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
import { Switch } from "../ui/switch";
import { Textarea } from "../ui/textarea";
import { toastManager } from "../ui/toast";
import { MicrophoneTest } from "./MicrophoneTest";
+import { CloudVoiceConfiguration } from "./CloudVoiceConfiguration";
import { LanguageRow, ModelBenchmarks, orderSpeechLanguages } from "./SpeechModelComparison";
import { languageLabel, SpeechLanguageSelect } from "./SpeechLanguageSelect";
+import { filterSpeechModels, type SpeechModelSort } from "./speechModelBrowser";
import { AdvancedSettings } from "./AdvancedSettings";
import {
SETTINGS_PICKER_TRIGGER_CLASSNAME,
@@ -87,6 +122,17 @@ import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";
const OFF = "off";
+function CloudSpeechLogo({ provider }: { provider: CloudSpeechProvider }) {
+ if (provider === "openrouter")
+ return ;
+ return (
+
+ );
+}
const SYSTEM_DEFAULT = "system-default";
const formatSize = (bytes: number) => `${Math.round(bytes / 1024 / 1024)} MB`;
const SHORTCUT_MODE_LABELS: Record = {
@@ -110,12 +156,6 @@ const spellingsOf = (words: SpeechCustomWords) =>
[term, ...aliases].map((spelling) => spelling.toLocaleLowerCase()),
),
);
-const modelSortOrder = (model: EnvironmentSpeechModel) => {
- if (model.active) return 0;
- if (model.state === "installed") return 1;
- if (model.state === "downloading" || model.state === "verifying") return 2;
- return model.recommended ? 3 : 4;
-};
type ModelCardProps = {
readonly model: EnvironmentSpeechModel;
@@ -533,8 +573,8 @@ function useVoiceModels(prepared: PreparedConnection | null) {
const revision = ++refreshRevision.current;
try {
const [nextStatus, nextModels] = await Promise.all([
- runtime.runPromise(getEnvironmentSpeechStatus(connection)),
- runtime.runPromise(getEnvironmentSpeechModels(connection, mode === "foreground")),
+ runEnvironmentSpeech(getEnvironmentSpeechStatus(connection)),
+ runEnvironmentSpeech(getEnvironmentSpeechModels(connection, mode === "foreground")),
]);
if (current !== epoch.current || revision !== refreshRevision.current) return;
setStatus(nextStatus);
@@ -640,7 +680,7 @@ function AdvancedSpeechSettings({
onValueChange={(value) => {
if (value)
void run("Could not change transcription hardware", () =>
- runtime.runPromise(
+ runEnvironmentSpeech(
updateEnvironmentSpeechAcceleration(prepared, value as SpeechAcceleration),
),
);
@@ -679,7 +719,7 @@ function AdvancedSpeechSettings({
onValueChange={(value) => {
if (value)
void run("Could not change when the model unloads", () =>
- runtime.runPromise(
+ runEnvironmentSpeech(
updateEnvironmentSpeechModelUnloadTimeout(
prepared,
value as SpeechModelUnloadTimeout,
@@ -717,9 +757,11 @@ function SpeechModelBrowser(props: {
catalog: { status, models, run, controlsDisabled, canCancel, requestedDownload },
} = props;
const [search, setSearch] = useState("");
+ const [sort, setSort] = useState("recommended");
+ const [downloadedOnly, setDownloadedOnly] = useState(false);
const [languageSearch, setLanguageSearch] = useState("");
const [selection, setSelection] = useState(null);
- const preferred = navigator.languages.map((language) => language.toLowerCase().split("-")[0]!);
+ const [preferred] = useState(preferredSpeechLanguages);
const supportedLanguages = [...new Set(models.flatMap((model) => model.languages))];
const defaultLanguage = preferred.find((code) => supportedLanguages.includes(code));
const selectedLanguages = selection ?? (defaultLanguage ? [defaultLanguage] : []);
@@ -729,14 +771,20 @@ function SpeechModelBrowser(props: {
(code) =>
!needle || code.includes(needle) || languageLabel(code).toLocaleLowerCase().includes(needle),
);
- const query = search.trim().toLocaleLowerCase();
- const sortedModels = models
- .filter(
- (model) =>
- selectedLanguages.every((language) => model.languages.includes(language)) &&
- (!query || (model.name + " " + model.description).toLocaleLowerCase().includes(query)),
- )
- .toSorted((left, right) => modelSortOrder(left) - modelSortOrder(right));
+ const accuracyLanguage = selectedLanguages[0] ?? "en";
+ const sortLabels: Record = {
+ recommended: "Recommended",
+ size: "Smallest download",
+ accuracy: `Best ${languageLabel(accuracyLanguage)} accuracy`,
+ speed: "Fastest transcription",
+ };
+ const sortedModels = filterSpeechModels(models, {
+ search,
+ languages: selectedLanguages,
+ downloadedOnly,
+ sort,
+ accuracyLanguage,
+ });
const activeModel = models.find((model) => model.active);
const renderedModels =
activeModel && !sortedModels.includes(activeModel)
@@ -808,13 +856,54 @@ function SpeechModelBrowser(props: {
? "Models for " + selectedLanguages.map(languageLabel).join(", ")
: "All models"}
-
setSearch(e.target.value)}
- />
+
+ setSearch(e.target.value)}
+ />
+
+
+ }
+ aria-label={`Sort and filter models: ${sortLabels[sort]}${downloadedOnly ? ", downloaded only" : ""}`}
+ >
+
+
+ {sortLabels[sort]}
+
+ {downloadedOnly && 1 }
+
+
+
+ Sort by
+ setSort(value as SpeechModelSort)}
+ >
+ {(Object.keys(sortLabels) as SpeechModelSort[]).map((value) => (
+
+
+ {sortLabels[value]}
+
+
+
+ ))}
+
+
+
+
+ Downloaded only
+
+
+
+
+