Skip to content

Commit 993bb84

Browse files
Show transcription language warning as toast
Replace the transcription settings banner with a persistent warning toast that can be dismissed and clears when leaving the view.
1 parent fc78fc1 commit 993bb84

9 files changed

Lines changed: 199 additions & 23 deletions

File tree

apps/desktop/src/settings/ai/stt/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@ import { ConfigureProviders } from "./configure";
44
import { SttSettingsProvider } from "./context";
55
import {
66
SelectProviderAndModel,
7-
TranscriptionLanguageWarningBanner,
7+
TranscriptionLanguageWarningToast,
88
} from "./select";
99

1010
import { SettingsPageTitle } from "~/settings/page-title";
1111

1212
export function STT() {
1313
return (
1414
<SttSettingsProvider>
15-
<TranscriptionLanguageWarningBanner />
15+
<TranscriptionLanguageWarningToast />
1616
<div className="flex flex-col gap-6">
1717
<SettingsPageTitle title={<Trans>Transcription</Trans>} />
1818
<SelectProviderAndModel />

apps/desktop/src/settings/ai/stt/select.tsx

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ import {
5252
requiresEntitlement,
5353
} from "~/settings/ai/shared/eligibility";
5454
import { useConfigValues } from "~/shared/config";
55+
import { useMountEffect } from "~/shared/hooks/useMountEffect";
5556
import { SettingsAlert } from "~/shared/ui/settings-alert";
57+
import {
58+
showTransientToast,
59+
useTransientToast,
60+
} from "~/sidebar/toast/transient";
5661
import * as settings from "~/store/tinybase/store/settings";
5762
import {
5863
isConfiguredSttModel,
@@ -284,21 +289,51 @@ export function SelectProviderAndModel() {
284289
);
285290
}
286291

287-
export function TranscriptionLanguageWarningBanner() {
292+
const TRANSCRIPTION_LANGUAGE_WARNING_TOAST_ID =
293+
"transcription-language-warning";
294+
295+
export function TranscriptionLanguageWarningToast() {
288296
const hasLanguageWarning = useHasLanguageWarning();
289297

290298
if (!hasLanguageWarning) {
291299
return null;
292300
}
293301

294-
return (
295-
<div className="-mx-6 -mt-6 mb-6 border-b border-amber-200 bg-amber-50 px-6 py-3 dark:border-amber-900/60 dark:bg-amber-950/30">
296-
<span className="flex items-center justify-center gap-2 text-center text-sm text-amber-600 dark:text-amber-200">
297-
<AlertTriangle className="size-4 shrink-0" />
298-
Selected model may not support all your spoken languages.
299-
</span>
300-
</div>
301-
);
302+
return <TranscriptionLanguageWarningToastLifecycle />;
303+
}
304+
305+
function TranscriptionLanguageWarningToastLifecycle() {
306+
useMountEffect(() => {
307+
showTransientToast(
308+
{
309+
id: TRANSCRIPTION_LANGUAGE_WARNING_TOAST_ID,
310+
icon: <AlertTriangle className="size-4 shrink-0 text-amber-500" />,
311+
description: "Model doesn't support all languages.",
312+
anchor: "main-content-panel",
313+
actions: [
314+
{
315+
label: "Dismiss",
316+
onClick: clearTranscriptionLanguageWarningToast,
317+
},
318+
],
319+
dismissible: false,
320+
variant: "warning",
321+
},
322+
{ durationMs: null },
323+
);
324+
325+
return clearTranscriptionLanguageWarningToast;
326+
});
327+
328+
return null;
329+
}
330+
331+
function clearTranscriptionLanguageWarningToast() {
332+
const { toast, clearToast } = useTransientToast.getState();
333+
334+
if (toast?.id === TRANSCRIPTION_LANGUAGE_WARNING_TOAST_ID) {
335+
clearToast(toast.key);
336+
}
302337
}
303338

304339
function useHasLanguageWarning() {

apps/desktop/src/sidebar/toast/component.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,33 @@ describe("Toast", () => {
6666
expect(action.className).toContain("bg-destructive");
6767
expect(action.className).toContain("text-destructive-foreground");
6868
});
69+
70+
it("does not truncate warning toast text", () => {
71+
render(
72+
<Toast
73+
toast={{
74+
id: "transcription-language-warning",
75+
description: "Model doesn't support all languages.",
76+
dismissible: false,
77+
variant: "warning",
78+
actions: [
79+
{
80+
label: "Dismiss",
81+
onClick: vi.fn(),
82+
},
83+
],
84+
}}
85+
/>,
86+
);
87+
88+
const description = screen.getByText(
89+
"Model doesn't support all languages.",
90+
);
91+
92+
expect(description.className).not.toContain("truncate");
93+
expect(description.className).toContain("whitespace-nowrap");
94+
expect(
95+
screen.getByRole("button", { name: "Dismiss" }).parentElement?.className,
96+
).toContain("pl-2");
97+
});
6998
});

apps/desktop/src/sidebar/toast/component.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,21 @@ export function Toast({
2121
"border shadow-lg backdrop-blur-none",
2222
toast.variant === "error"
2323
? "border-alert-border shadow-red-100 dark:shadow-red-950/30"
24-
: "border-border",
24+
: toast.variant === "warning"
25+
? "border-amber-200 bg-amber-50 text-amber-950 shadow-amber-100 dark:border-amber-800/60 dark:bg-amber-950 dark:text-amber-100 dark:shadow-amber-950/30"
26+
: "border-border",
2527
])}
2628
>
2729
{toast.icon ? <span className="shrink-0">{toast.icon}</span> : null}
2830

2931
<div
3032
className={cn([
31-
"max-w-50 truncate text-sm",
33+
"min-w-0 text-sm",
3234
toast.variant === "error"
33-
? "text-alert-foreground"
34-
: "text-muted-foreground",
35+
? "text-alert-foreground max-w-50 truncate"
36+
: toast.variant === "warning"
37+
? "max-w-[min(720px,calc(100vw-16rem))] whitespace-nowrap text-amber-950 dark:text-amber-100"
38+
: "text-muted-foreground max-w-50 truncate",
3539
])}
3640
>
3741
{toast.description}
@@ -40,7 +44,12 @@ export function Toast({
4044
{progress !== null ? <ProgressPill progress={progress} /> : null}
4145

4246
{actions.length > 0 ? (
43-
<div className="flex items-center gap-1">
47+
<div
48+
className={cn([
49+
"flex items-center gap-1",
50+
toast.variant === "warning" && "pl-2",
51+
])}
52+
>
4453
{actions.map((action, index) => (
4554
<button
4655
key={action.label}
@@ -92,6 +101,10 @@ function getActionClassName(toast: ToastType, index: number) {
92101
return "bg-destructive text-destructive-foreground hover:bg-destructive/90";
93102
}
94103

104+
if (toast.variant === "warning" && index === 0) {
105+
return "bg-amber-950 text-amber-50 hover:bg-amber-900 dark:bg-amber-100 dark:text-amber-950 dark:hover:bg-amber-200";
106+
}
107+
95108
if (index === 0) {
96109
return "bg-foreground text-background hover:bg-foreground/90";
97110
}

apps/desktop/src/sidebar/toast/index.test.tsx

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ vi.mock("./useDismissedToasts", () => ({
7676
}));
7777

7878
import { ToastArea } from "./index";
79+
import { showTransientToast, useTransientToast } from "./transient";
7980

8081
describe("ToastArea", () => {
8182
beforeEach(() => {
@@ -86,9 +87,11 @@ describe("ToastArea", () => {
8687
mocks.updateSettingsTabState.mockClear();
8788
mocks.clearDevtoolsPreview.mockClear();
8889
mocks.setToastActionTarget.mockClear();
90+
useTransientToast.getState().clearToast();
8991
});
9092

9193
afterEach(() => {
94+
useTransientToast.getState().clearToast();
9295
cleanup();
9396
document.body.innerHTML = "";
9497
vi.useRealTimers();
@@ -139,7 +142,7 @@ describe("ToastArea", () => {
139142
expect(toastContainer?.style.top).toBe("88px");
140143
});
141144

142-
it("positions the left sidebar toast relative to the main content panel", () => {
145+
it("centers the left sidebar toast on the main content panel", () => {
143146
const mainContentPanel = document.createElement("div");
144147
mainContentPanel.setAttribute("data-main-content-panel", "");
145148
vi.spyOn(mainContentPanel, "getBoundingClientRect").mockReturnValue({
@@ -161,9 +164,9 @@ describe("ToastArea", () => {
161164
bottom: 520,
162165
height: 500,
163166
left: 300,
164-
right: 900,
167+
right: 700,
165168
top: 20,
166-
width: 600,
169+
width: 400,
167170
x: 300,
168171
y: 20,
169172
toJSON: () => ({}),
@@ -184,6 +187,60 @@ describe("ToastArea", () => {
184187
expect(toastContainer?.style.top).toBe("56px");
185188
});
186189

190+
it("centers anchored transient toasts on the main content panel", () => {
191+
const mainContentPanel = document.createElement("div");
192+
mainContentPanel.setAttribute("data-main-content-panel", "");
193+
vi.spyOn(mainContentPanel, "getBoundingClientRect").mockReturnValue({
194+
bottom: 520,
195+
height: 500,
196+
left: 200,
197+
right: 1_000,
198+
top: 20,
199+
width: 800,
200+
x: 200,
201+
y: 20,
202+
toJSON: () => ({}),
203+
});
204+
document.body.appendChild(mainContentPanel);
205+
206+
const mainSurface = document.createElement("div");
207+
mainSurface.setAttribute("data-chat-floating-anchor", "");
208+
vi.spyOn(mainSurface, "getBoundingClientRect").mockReturnValue({
209+
bottom: 520,
210+
height: 500,
211+
left: 300,
212+
right: 700,
213+
top: 20,
214+
width: 400,
215+
x: 300,
216+
y: 20,
217+
toJSON: () => ({}),
218+
});
219+
document.body.appendChild(mainSurface);
220+
221+
showTransientToast(
222+
{
223+
id: "transcription-language-warning",
224+
description: "Model doesn't support all languages.",
225+
anchor: "main-content-panel",
226+
},
227+
{ durationMs: null },
228+
);
229+
230+
render(<ToastArea />);
231+
232+
act(() => {
233+
vi.advanceTimersByTime(500);
234+
});
235+
236+
const toastContainer = screen
237+
.getByText("Model doesn't support all languages.")
238+
.closest(".fixed") as HTMLElement | null;
239+
240+
expect(toastContainer?.style.left).toBe("600px");
241+
expect(toastContainer?.style.top).toBe("56px");
242+
});
243+
187244
it("repositions the left sidebar toast when the main surface scrolls", () => {
188245
const mainSurface = document.createElement("div");
189246
mainSurface.setAttribute("data-chat-floating-anchor", "");

apps/desktop/src/sidebar/toast/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ export function ToastArea({
226226
const dismissAction = displayToast?.dismissible ? handleDismiss : undefined;
227227
const position =
228228
getMainSurfacePosition({
229+
anchor: displayToast?.anchor,
229230
contentOffset,
230231
mainContentPanelRect,
231232
mainSurfaceRect,
@@ -377,11 +378,13 @@ function useElementRect(selector: string) {
377378
}
378379

379380
function getMainSurfacePosition({
381+
anchor,
380382
contentOffset,
381383
mainContentPanelRect,
382384
mainSurfaceRect,
383385
placement,
384386
}: {
387+
anchor?: "main-content-panel";
385388
contentOffset: number;
386389
mainContentPanelRect: ElementRect | null;
387390
mainSurfaceRect: ElementRect | null;
@@ -395,7 +398,7 @@ function getMainSurfacePosition({
395398

396399
return {
397400
left:
398-
placement === "left-sidebar"
401+
anchor === "main-content-panel" || placement === "left-sidebar"
399402
? horizontalAnchorRect.left + horizontalAnchorRect.width / 2
400403
: `calc(50% + ${contentOffset}px)`,
401404
top: verticalAnchorRect.top + LEFT_SIDEBAR_TOP_OFFSET_PX,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { act } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { showTransientToast, useTransientToast } from "./transient";
5+
6+
describe("transient toast store", () => {
7+
beforeEach(() => {
8+
vi.useFakeTimers();
9+
useTransientToast.getState().clearToast();
10+
});
11+
12+
afterEach(() => {
13+
useTransientToast.getState().clearToast();
14+
vi.useRealTimers();
15+
});
16+
17+
it("keeps a toast visible when duration is disabled", () => {
18+
showTransientToast(
19+
{
20+
id: "persistent-toast",
21+
description: "Persistent warning",
22+
},
23+
{ durationMs: null },
24+
);
25+
26+
act(() => {
27+
vi.advanceTimersByTime(10_000);
28+
});
29+
30+
expect(useTransientToast.getState().toast?.id).toBe("persistent-toast");
31+
});
32+
});

apps/desktop/src/sidebar/toast/transient.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type TransientToastState = {
1717
toast: TransientToast | null;
1818
showToast: (
1919
toast: TransientToastInput,
20-
options?: { durationMs?: number },
20+
options?: { durationMs?: number | null },
2121
) => void;
2222
clearToast: (key?: string) => void;
2323
};
@@ -43,6 +43,10 @@ export const useTransientToast = create<TransientToastState>((set, get) => ({
4343

4444
set({ toast: nextToast });
4545

46+
if (options?.durationMs === null) {
47+
return;
48+
}
49+
4650
dismissTimer = setTimeout(() => {
4751
if (get().toast?.key === key) {
4852
set({ toast: null });
@@ -66,7 +70,7 @@ export const useTransientToast = create<TransientToastState>((set, get) => ({
6670

6771
export function showTransientToast(
6872
toast: TransientToastInput,
69-
options?: { durationMs?: number },
73+
options?: { durationMs?: number | null },
7074
) {
7175
useTransientToast.getState().showToast(toast, options);
7276
}

apps/desktop/src/sidebar/toast/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export type DownloadProgress = {
1212
progress: number;
1313
};
1414

15+
export type ToastAnchor = "main-content-panel";
16+
1517
export type ToastType = {
1618
id: string;
1719
icon?: ReactNode;
@@ -23,7 +25,8 @@ export type ToastType = {
2325
dismissible: boolean;
2426
progress?: number;
2527
downloads?: DownloadProgress[];
26-
variant?: "default" | "error";
28+
variant?: "default" | "error" | "warning";
29+
anchor?: ToastAnchor;
2730
gradient?: string;
2831
};
2932

0 commit comments

Comments
 (0)