Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 7 additions & 30 deletions apps/web/src/domains/chat/chat-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Outlet, useLocation, useNavigate } from "react-router";
import { useQueryClient } from "@tanstack/react-query";

import { haptic } from "@/utils/haptics";
import { getLocalBool, setLocalBool, getLocalNumber, setLocalNumber } from "@/utils/local-settings";
import { routes } from "@/utils/routes";
import { MOBILE_MEDIA_QUERY, useIsMobile } from "@/hooks/use-is-mobile";
import { useRootOutletContext } from "@/root-layout";
Expand Down Expand Up @@ -66,29 +67,16 @@ const FOCUSABLE_SELECTOR = [
].join(",");

export function readPersistedCollapsed(): boolean {
try {
return (
window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "true"
);
} catch {
return false;
}
return getLocalBool(SIDEBAR_COLLAPSED_STORAGE_KEY, false);
}

const MIN_SIDEBAR_WIDTH = 220;
const MAX_SIDEBAR_WIDTH = 400;

export function readPersistedWidth(): number {
try {
const stored = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
if (stored != null) {
const parsed = Number(stored);
if (Number.isFinite(parsed) && parsed > 0) {
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, parsed));
}
}
} catch {
// Storage unavailable
const raw = getLocalNumber(SIDEBAR_WIDTH_STORAGE_KEY, DEFAULT_SIDEBAR_WIDTH);
if (raw > 0) {
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, raw));
}
return DEFAULT_SIDEBAR_WIDTH;
}
Expand Down Expand Up @@ -314,23 +302,12 @@ export function ChatLayout() {
const [sidebarWidth, setSidebarWidth] = useState<number>(readPersistedWidth);

useEffect(() => {
try {
window.localStorage.setItem(
SIDEBAR_COLLAPSED_STORAGE_KEY,
String(collapsed),
);
} catch {
// Storage unavailable (private mode, quota, etc.)
}
setLocalBool(SIDEBAR_COLLAPSED_STORAGE_KEY, collapsed);
}, [collapsed]);

const handleSidebarWidthChange = useCallback((width: number) => {
setSidebarWidth(width);
try {
window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(width)));
} catch {
// Storage unavailable
}
setLocalNumber(SIDEBAR_WIDTH_STORAGE_KEY, Math.round(width));
}, []);

const isMobile = useIsMobile();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
useState,
} from "react";

import { getLocalSetting, setLocalSetting } from "@/utils/local-settings";

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
Expand All @@ -35,10 +37,9 @@ function storageKey(assistantId: string): string {
// ---------------------------------------------------------------------------

function loadDrafts(assistantId: string): Map<string, string> {
if (typeof window === "undefined") return new Map();
const raw = getLocalSetting(storageKey(assistantId), "");
if (!raw) return new Map();
try {
const raw = window.localStorage.getItem(storageKey(assistantId));
if (!raw) return new Map();
const parsed: unknown = JSON.parse(raw);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return new Map();
Expand All @@ -57,15 +58,10 @@ function persistDrafts(
assistantId: string,
drafts: Map<string, string>,
): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
storageKey(assistantId),
JSON.stringify(Object.fromEntries(drafts)),
);
} catch {
// Storage can fail in private browsing / quota-exceeded.
}
setLocalSetting(
storageKey(assistantId),
JSON.stringify(Object.fromEntries(drafts)),
);
}

// ---------------------------------------------------------------------------
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/domains/chat/components/chat-route-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { IOSAppBanner } from "@/components/nudges/ios-app-banner";
import { MacOSAppBanner } from "@/components/nudges/macos-app-banner";
import { Loader2 } from "lucide-react";
import { Button, Notice, ResizablePanel } from "@vellum/design-library";
import { getLocalBool, setLocalBool, removeLocalSetting } from "@/utils/local-settings";
import { ProviderBillingBanner } from "@/domains/chat/components/provider-billing-banner";
import { QueuedMessagesDrawer } from "@/domains/chat/components/queued-messages-drawer";
import { AppViewerContainer } from "@/components/app-viewer-container";
Expand Down Expand Up @@ -1013,12 +1014,12 @@ export function ChatRouteContent({

const [warningDismissed, setWarningDismissed] = useState(() => {
if (!assistantId) return false;
return localStorage.getItem(`vellum:diskPressureDismissed:${assistantId}`) === "true";
return getLocalBool(`vellum:diskPressureDismissed:${assistantId}`, false);
});

const dismissWarning = useCallback(() => {
if (!assistantId) return;
localStorage.setItem(`vellum:diskPressureDismissed:${assistantId}`, "true");
setLocalBool(`vellum:diskPressureDismissed:${assistantId}`, true);
setWarningDismissed(true);
}, [assistantId]);

Expand All @@ -1027,7 +1028,7 @@ export function ChatRouteContent({
const st = diskPressure.status?.state;
if (st && st !== "warning" && warningDismissed) {
if (assistantId) {
localStorage.removeItem(`vellum:diskPressureDismissed:${assistantId}`);
removeLocalSetting(`vellum:diskPressureDismissed:${assistantId}`);
}
setWarningDismissed(false);
}
Expand Down
16 changes: 3 additions & 13 deletions apps/web/src/domains/chat/components/mic-permission-primer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Mic } from "lucide-react";

import { Button } from "@vellum/design-library";
import { Modal } from "@vellum/design-library";
import { getLocalBool, setLocalBool } from "@/utils/local-settings";
import { isBatchSttSupported } from "@/domains/chat/components/voice-input-button";

const MIC_PRIMER_STORAGE_KEY = "vellum:voice:permissionPrimerSeen";
Expand All @@ -13,17 +14,10 @@ const MIC_PRIMER_STORAGE_KEY = "vellum:voice:permissionPrimerSeen";
* dismissed the primer dialog.
*/
export function shouldShowMicPrimer(): boolean {
if (typeof window === "undefined") {
return false;
}
if (!isBatchSttSupported()) {
return false;
}
try {
return localStorage.getItem(MIC_PRIMER_STORAGE_KEY) !== "true";
} catch {
return false;
}
return !getLocalBool(MIC_PRIMER_STORAGE_KEY, false);
Comment thread
ashleeradka marked this conversation as resolved.
}

export interface MicPermissionPrimerProps {
Expand Down Expand Up @@ -52,11 +46,7 @@ export function MicPermissionPrimer({
onCancel,
}: MicPermissionPrimerProps) {
const handleContinue = () => {
try {
localStorage.setItem(MIC_PRIMER_STORAGE_KEY, "true");
} catch {
// localStorage may be unavailable (e.g. private browsing quota exceeded).
}
setLocalBool(MIC_PRIMER_STORAGE_KEY, true);
onContinue();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from "react";

import { Button, Card, ConfirmDialog, Input, Popover } from "@vellum/design-library";
import { getLocalBool, setLocalBool } from "@/utils/local-settings";
import {
MobileSidebarDrawer,
MobileSidebarTrigger,
Expand Down Expand Up @@ -95,10 +96,9 @@ export function SkillsTab({ assistantId, initialSkillId }: SkillsTabProps) {
const [removingSkillId, setRemovingSkillId] = useState<string | null>(null);
const [skillPendingRemoval, setSkillPendingRemoval] = useState<SkillInfo | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [tipDismissed, setTipDismissed] = useState(() => {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(TIP_STORAGE_KEY) === "1";
});
const [tipDismissed, setTipDismissed] = useState(() =>
getLocalBool(TIP_STORAGE_KEY, false),
);
Comment thread
ashleeradka marked this conversation as resolved.

useEffect(() => {
const handle = setTimeout(() => {
Expand Down Expand Up @@ -185,9 +185,7 @@ export function SkillsTab({ assistantId, initialSkillId }: SkillsTabProps) {

const handleDismissTip = useCallback(() => {
setTipDismissed(true);
if (typeof window !== "undefined") {
window.localStorage.setItem(TIP_STORAGE_KEY, "1");
}
setLocalBool(TIP_STORAGE_KEY, true);
}, []);

const allSkills = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ import {
} from "@/domains/settings/api/oauth-apps";

import { IntegrationIcon } from "@/domains/settings/components/integration-icon";
import {
type OAuthCompletePayload,
oauthCompletionStorageKey,
getOAuthCompleteMessagePayload,
getOAuthCompleteStoragePayload,
} from "@/lib/auth/oauth-popup";

function extractErrorDetail(error: unknown, fallback: string): string {
if (typeof error === "object" && error !== null && "detail" in error) {
Expand All @@ -53,67 +59,6 @@ function extractErrorDetail(error: unknown, fallback: string): string {
return fallback;
}

export interface OAuthCompletePayload {
type: "vellum:oauth-complete";
requestId?: string | null;
oauthStatus?: string | null;
oauthProvider?: string | null;
oauthCode?: string | null;
}

export function oauthCompletionStorageKey(requestId: string): string {
return `vellum:oauth-complete:${requestId}`;
}

export function isOAuthCompletePayloadForRequest(
payload: unknown,
requestId: string,
): payload is OAuthCompletePayload {
return (
typeof payload === "object" &&
payload !== null &&
(payload as OAuthCompletePayload).type === "vellum:oauth-complete" &&
(payload as OAuthCompletePayload).requestId === requestId
);
}

export function getOAuthCompleteMessagePayload(
event: MessageEvent,
expectedOrigin: string,
requestId: string,
): OAuthCompletePayload | null {
if (event.origin !== expectedOrigin) {
return null;
}

if (!isOAuthCompletePayloadForRequest(event.data, requestId)) {
return null;
}

return event.data;
}

export function getOAuthCompleteStoragePayload(
event: StorageEvent,
requestId: string,
): OAuthCompletePayload | null {
if (
event.key !== oauthCompletionStorageKey(requestId) ||
event.newValue === null
) {
return null;
}

try {
const payload = JSON.parse(event.newValue);
return isOAuthCompletePayloadForRequest(payload, requestId)
? payload
: null;
} catch {
return null;
}
}

function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/domains/settings/pages/voice-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,10 @@ function SpeechServicesBanner() {
}

function PushToTalkCard() {
const [activator, setActivator] = useState<PTTActivator>(() =>
parseActivator(getLocalSetting(LS_PTT_ACTIVATION_KEY, "")),
);
const [activator, setActivator] = useState<PTTActivator>(() => {
const raw = getLocalSetting(LS_PTT_ACTIVATION_KEY, "");
return raw ? parseActivator(raw) : { kind: "off" };
});
const [isRecording, setIsRecording] = useState(false);
const [pendingModifiers, setPendingModifiers] = useState<PTTModifier[]>([]);
const recordingZoneRef = useRef<HTMLDivElement | null>(null);
Expand Down
10 changes: 3 additions & 7 deletions apps/web/src/domains/voice/use-push-to-talk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
parseActivator,
type PTTActivator,
} from "@/utils/ptt-activator";
import { getLocalSetting } from "@/utils/local-settings";

/**
* Imperative handle (subset of `VoiceInputButtonHandle`) that the hook drives.
Expand Down Expand Up @@ -119,13 +120,8 @@ export function usePushToTalk(
}

const readActivator = () => {
try {
activatorRef.current = parseActivator(
window.localStorage.getItem(LS_PTT_ACTIVATION_KEY),
);
} catch {
activatorRef.current = { kind: "off" };
}
const raw = getLocalSetting(LS_PTT_ACTIVATION_KEY, "");
activatorRef.current = raw ? parseActivator(raw) : { kind: "off" };
Comment thread
ashleeradka marked this conversation as resolved.
};
readActivator();

Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/lib/auth/session-cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ describe("clearUserScopedStorage", () => {
expect(localStorage.getItem("vellum:onboarding:completed")).toBeNull();
});

test("preserves active app. nudge keys on logout", () => {
localStorage.setItem("app.iosNudge.downloaded", "true");
localStorage.setItem("app.macOsNudge.bannerDismissed", "true");
localStorage.setItem("app.githubNudge.starred", "true");

clearUserScopedStorage();

// Active iOS/macOS nudge keys must survive logout — they are
// still read by use-ios-app-nudge.ts and use-macos-app-nudge.ts.
// Dead github/discord keys are removed at startup by removeKey()
// in storage-migration.ts, not by the logout sweep.
expect(localStorage.getItem("app.iosNudge.downloaded")).toBe("true");
expect(localStorage.getItem("app.macOsNudge.bannerDismissed")).toBe("true");
expect(localStorage.getItem("app.githubNudge.starred")).toBe("true");
});

test("clears legacy prefixed keys if startup migration failed", () => {
// eslint-disable-next-line no-restricted-syntax -- test: verifying cleanup of legacy auth token
localStorage.setItem("gw:token", "legacy-jwt-token");
Expand Down
Loading