Merge remote-tracking branch 'openchamber/main' into requests-in-flight

# Conflicts:
#	packages/ui/src/components/ui/MemoryDebugPanel.tsx
This commit is contained in:
Tom Rochette
2026-07-12 05:07:04 +00:00
1083 changed files with 77081 additions and 33166 deletions
+3 -3
View File
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { devtools, persist } from "zustand/middleware";
import type { EditPermissionMode } from "./types/sessionTypes";
import { getAgentDefaultEditPermission } from "./utils/permissionUtils";
import { extractTokensFromMessage } from "./utils/tokenUtils";
import { calculateContextUsage } from "./utils/contextUtils";
import { getSafeStorage } from "./utils/safeStorage";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
interface ContextUsage {
totalTokens: number;
@@ -449,7 +449,7 @@ export const useContextStore = create<ContextStore>()(
}),
{
name: "context-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
sessionModelSelections: Array.from(state.sessionModelSelections.entries()),
sessionAgentSelections: Array.from(state.sessionAgentSelections.entries()),
-272
View File
@@ -1,272 +0,0 @@
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import type { AttachedFile } from "./types/sessionTypes";
import { getSafeStorage } from "./utils/safeStorage";
import { runtimeFetch } from "@/lib/runtime-fetch";
interface FileState {
attachedFiles: AttachedFile[];
}
interface FileActions {
addAttachedFile: (file: File) => Promise<void>;
addServerFile: (path: string, name: string, content?: string) => Promise<void>;
removeAttachedFile: (id: string) => void;
clearAttachedFiles: () => void;
}
type FileStore = FileState & FileActions;
const MAX_ATTACHMENT_SIZE = 50 * 1024 * 1024;
const guessMimeTypeFromName = (filename: string): string => {
const name = (filename || "").toLowerCase();
const ext = name.includes(".") ? name.split(".").pop() || "" : "";
switch (ext) {
case "png":
return "image/png";
case "jpg":
case "jpeg":
return "image/jpeg";
case "gif":
return "image/gif";
case "webp":
return "image/webp";
case "svg":
return "image/svg+xml";
case "bmp":
return "image/bmp";
case "ico":
return "image/x-icon";
case "pdf":
return "application/pdf";
default:
return "application/octet-stream";
}
};
const guessMimeType = (file: File): string => {
if (file.type && file.type.trim().length > 0) {
return file.type;
}
const name = (file.name || "").toLowerCase();
const ext = name.includes(".") ? name.split(".").pop() || "" : "";
const noExtNames = new Set([
"license",
"readme",
"changelog",
"notice",
"authors",
"copying",
]);
if (noExtNames.has(name)) return "text/plain";
switch (ext) {
case "md":
case "markdown":
return "text/markdown";
case "txt":
return "text/plain";
case "json":
return "application/json";
case "yaml":
case "yml":
return "application/x-yaml";
case "ts":
case "tsx":
case "js":
case "jsx":
case "mjs":
case "cjs":
case "py":
case "rb":
case "sh":
case "bash":
case "zsh":
return "text/plain";
default:
return "application/octet-stream";
}
};
const normalizeServerPath = (inputPath: string): string => inputPath.replace(/\\/g, "/").trim();
const toFileUrl = (inputPath: string): string => {
const normalized = normalizeServerPath(inputPath);
if (normalized.startsWith("file://")) {
return normalized;
}
const withLeadingSlash = normalized.startsWith("/") ? normalized : `/${normalized}`;
return `file://${encodeURI(withLeadingSlash)}`;
};
const readRawFileAsDataUrl = async (absolutePath: string): Promise<string> => {
const response = await runtimeFetch("/api/fs/raw", { query: { path: absolutePath } });
if (!response.ok) {
throw new Error(`Failed to read raw file: ${response.status}`);
}
const blob = await response.blob();
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
export const useFileStore = create<FileStore>()(
devtools(
persist(
(set, get) => ({
attachedFiles: [],
addAttachedFile: async (file: File) => {
const { attachedFiles } = get();
const isDuplicate = attachedFiles.some((f) => f.filename === file.name && f.size === file.size);
if (isDuplicate) {
console.log(`File "${file.name}" is already attached`);
return;
}
const maxSize = MAX_ATTACHMENT_SIZE;
if (file.size > maxSize) {
throw new Error(`File "${file.name}" is too large. Maximum size is 50MB.`);
}
const allowedTypes = [
"text/",
"application/json",
"application/xml",
"application/pdf",
"image/",
"video/",
"audio/",
"application/javascript",
"application/typescript",
"application/x-python",
"application/x-ruby",
"application/x-sh",
"application/yaml",
"application/octet-stream",
];
const mimeType = guessMimeType(file);
const isAllowed = allowedTypes.some((type) => mimeType.startsWith(type) || mimeType === type || mimeType === "");
if (!isAllowed && mimeType !== "") {
console.warn(`File type "${mimeType}" might not be supported`);
}
const reader = new FileReader();
const rawDataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const dataUrl = rawDataUrl.startsWith("data:")
? rawDataUrl.replace(/^data:[^;]*/, `data:${mimeType}`)
: rawDataUrl;
const extractFilename = (fullPath: string) => {
const parts = fullPath.replace(/\\/g, "/").split("/");
return parts[parts.length - 1] || fullPath;
};
const attachedFile: AttachedFile = {
id: `file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
file,
dataUrl,
mimeType,
filename: extractFilename(file.name),
size: file.size,
source: "local",
};
set((state) => ({
attachedFiles: [...state.attachedFiles, attachedFile],
}));
},
addServerFile: async (path: string, name: string, content?: string) => {
const normalizedPath = normalizeServerPath(path);
const { attachedFiles } = get();
const isDuplicate = attachedFiles.some((f) => normalizeServerPath(f.serverPath || "") === normalizedPath && f.source === "server");
if (isDuplicate) {
console.log(`Server file "${name}" is already attached`);
return;
}
const inferredMime = guessMimeTypeFromName(name);
const safeMimeType = inferredMime && inferredMime.trim().length > 0 ? inferredMime : "application/octet-stream";
const shouldInlineBinary = safeMimeType !== "text/plain" && safeMimeType !== "application/x-directory";
let dataUrl = toFileUrl(normalizedPath);
if (shouldInlineBinary) {
try {
dataUrl = await readRawFileAsDataUrl(normalizedPath);
} catch (error) {
console.warn("Failed to inline binary server file, falling back to file://", error);
}
}
const sizeBytes = typeof content === "string"
? new TextEncoder().encode(content).length
: 0;
const file = new File([], name, { type: safeMimeType });
const attachedFile: AttachedFile = {
id: `server-file-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
file,
dataUrl,
mimeType: safeMimeType,
filename: name,
size: sizeBytes,
source: "server",
serverPath: normalizedPath,
};
set((state) => ({
attachedFiles: [...state.attachedFiles, attachedFile],
}));
},
removeAttachedFile: (id: string) => {
set((state) => ({
attachedFiles: state.attachedFiles.filter((f) => f.id !== id),
}));
},
clearAttachedFiles: () => {
set({ attachedFiles: [] });
},
}),
{
name: "file-store",
storage: createJSONStorage(() => getSafeStorage()),
version: 3,
migrate: (persistedState) => {
const state = persistedState as { attachedFiles?: AttachedFile[] } | undefined;
return { attachedFiles: Array.isArray(state?.attachedFiles) ? state.attachedFiles : [] };
},
// Keep unsent draft attachments across restarts.
partialize: (state) => ({
attachedFiles: state.attachedFiles,
}),
}
),
{
name: "file-store",
}
)
);
@@ -55,4 +55,44 @@ describe('listGlobalSessionPages', () => {
expect(session.revert).toEqual({ messageID: 'msg_1' })
expect(session.summary).toEqual({ additions: 5, deletions: 3, files: 2 })
})
test('paginates through all session-list pages', async () => {
const calls: Array<Record<string, unknown>> = []
const apiClient = {
experimental: {
session: {
list: async (options: Record<string, unknown>) => {
calls.push(options)
if (options.cursor === undefined) {
return {
data: [
{ id: 'ses_root', time: { updated: 20 } },
{ id: 'ses_child_1', time: { updated: 10 } },
],
response: { headers: new Headers({ 'x-next-cursor': '10' }) },
}
}
return {
data: [
{ id: 'ses_child_2', time: { updated: 5 } },
],
response: { headers: new Headers() },
}
},
},
},
} as unknown as OpencodeClient
const sessions = await listGlobalSessionPages(apiClient, {
directory: '/repo',
archived: false,
roots: false,
pageSize: 2,
})
expect(calls).toHaveLength(2)
expect(calls[0]).toEqual({ directory: '/repo', archived: false, roots: false, limit: 2 })
expect(calls[1]).toEqual({ directory: '/repo', archived: false, roots: false, limit: 2, cursor: 10 })
expect(sessions.map((session) => session.id)).toEqual(['ses_root', 'ses_child_1', 'ses_child_2'])
})
})
-23
View File
@@ -73,29 +73,6 @@ const unwrapSessionList = (
return result.data as GlobalSessionRecord[];
};
export const readNextCursor = (response: unknown): number | null => {
return toNumber(readResponseHeader(response, "x-next-cursor"));
};
export const isMissingGlobalSessionsEndpointError = (error: unknown): boolean => {
if (!error || typeof error !== "object") {
return false;
}
const value = error as {
status?: number;
response?: { status?: number };
cause?: { status?: number; response?: { status?: number } };
};
const status = value.status
?? value.response?.status
?? value.cause?.status
?? value.cause?.response?.status;
return status === 404;
};
export async function listGlobalSessionPages(
apiClient: OpencodeClient,
options: {
+81 -11
View File
@@ -1,9 +1,43 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
import { devtools, persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import type { AttachedFile } from './types/sessionTypes';
import { updateDesktopSettings } from '@/lib/persistence';
export type FollowUpBehavior = 'steer' | 'queue';
export const DEFAULT_FOLLOW_UP_BEHAVIOR: FollowUpBehavior = 'queue';
export const isFollowUpBehavior = (value: unknown): value is FollowUpBehavior => (
value === 'steer' || value === 'queue'
);
export const normalizeFollowUpBehavior = (
value: unknown,
legacyQueueModeEnabled?: boolean | null,
): FollowUpBehavior => {
// "immediate" was removed: on a busy session it was wire-identical to
// "steer" (OpenCode only supports delivery "steer" | "queue", defaulting
// to "steer"), so collapse any persisted/legacy "immediate" onto "steer".
if (value === 'immediate') {
return 'steer';
}
if (isFollowUpBehavior(value)) {
return value;
}
if (legacyQueueModeEnabled === false) {
return 'steer';
}
if (legacyQueueModeEnabled === true) {
return 'queue';
}
return DEFAULT_FOLLOW_UP_BEHAVIOR;
};
export interface QueuedMessage {
id: string;
content: string;
@@ -20,27 +54,34 @@ export interface QueuedMessage {
interface MessageQueueState {
queuedMessages: Record<string, QueuedMessage[]>; // sessionId → queue
queueModeEnabled: boolean; // global toggle
followUpBehavior: FollowUpBehavior;
}
interface MessageQueueActions {
addToQueue: (sessionId: string, message: Omit<QueuedMessage, 'id' | 'createdAt'>) => void;
removeFromQueue: (sessionId: string, messageId: string) => void;
reorderQueue: (sessionId: string, fromId: string, toId: string) => void;
popToInput: (sessionId: string, messageId: string) => QueuedMessage | null;
clearQueue: (sessionId: string) => void;
clearAllQueues: () => void;
setQueueMode: (enabled: boolean) => void;
setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
getQueueForSession: (sessionId: string) => QueuedMessage[];
}
type MessageQueueStore = MessageQueueState & MessageQueueActions;
type PersistedMessageQueueState = {
queuedMessages?: Record<string, QueuedMessage[]>;
followUpBehavior?: FollowUpBehavior;
queueModeEnabled?: boolean;
};
export const useMessageQueueStore = create<MessageQueueStore>()(
devtools(
persist(
(set, get) => ({
queuedMessages: {},
queueModeEnabled: true,
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
addToQueue: (sessionId, message) => {
const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
@@ -83,6 +124,28 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
});
},
reorderQueue: (sessionId, fromId, toId) => {
if (fromId === toId) return;
set((state) => {
const currentQueue = state.queuedMessages[sessionId];
if (!currentQueue) return state;
const fromIndex = currentQueue.findIndex((m) => m.id === fromId);
const toIndex = currentQueue.findIndex((m) => m.id === toId);
if (fromIndex === -1 || toIndex === -1) return state;
const newQueue = currentQueue.slice();
const [moved] = newQueue.splice(fromIndex, 1);
newQueue.splice(toIndex, 0, moved);
return {
queuedMessages: {
...state.queuedMessages,
[sessionId]: newQueue,
},
};
});
},
popToInput: (sessionId, messageId) => {
const state = get();
const currentQueue = state.queuedMessages[sessionId] ?? [];
@@ -126,10 +189,9 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
set({ queuedMessages: {} });
},
setQueueMode: (enabled) => {
set({ queueModeEnabled: enabled });
// Persist to settings.json (async, fire-and-forget)
void updateDesktopSettings({ queueModeEnabled: enabled });
setFollowUpBehavior: (behavior) => {
set({ followUpBehavior: behavior });
void updateDesktopSettings({ followUpBehavior: behavior });
},
getQueueForSession: (sessionId) => {
@@ -138,11 +200,19 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
}),
{
name: 'message-queue-store',
storage: createJSONStorage(() => getSafeStorage()),
version: 1,
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
queuedMessages: state.queuedMessages,
queueModeEnabled: state.queueModeEnabled,
followUpBehavior: state.followUpBehavior,
}),
migrate: (persistedState) => {
const state = (persistedState ?? {}) as PersistedMessageQueueState;
return {
queuedMessages: state.queuedMessages ?? {},
followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null),
};
},
}
),
{
+3 -3
View File
@@ -1,11 +1,11 @@
import { create } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { devtools, persist } from "zustand/middleware";
import type { Session } from "@opencode-ai/sdk/v2/client";
import {
autoRespondsPermission,
type PermissionAutoAcceptMap,
} from "./utils/permissionAutoAccept";
import { getSafeStorage } from "./utils/safeStorage";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { getAllSyncSessions, getSyncChildStores } from "@/sync/sync-refs";
import { opencodeClient } from "@/lib/opencode/client";
import { respondToPermission } from "@/sync/session-actions";
@@ -201,7 +201,7 @@ const autoRespondsPermissionBySession = (
});
};
const getStorage = () => createJSONStorage(() => getSafeStorage());
const getStorage = () => createDeferredSafeJSONStorage();
export const usePermissionStore = create<PermissionStore>()(
devtools(
+4 -249
View File
@@ -1,7 +1,3 @@
import type { Session, Message, Part } from "@opencode-ai/sdk/v2";
import type { PermissionRequest, PermissionResponse } from "@/types/permission";
import type { QuestionRequest } from "@/types/question";
export type SessionWorktreeAttachment = {
worktreeRoot: string | null;
cwd: string | null;
@@ -31,31 +27,6 @@ export type EditPermissionMode = 'allow' | 'ask' | 'deny' | 'full';
export type MessageStreamPhase = 'streaming' | 'cooldown' | 'completed';
export interface MessageStreamLifecycle {
phase: MessageStreamPhase;
startedAt: number;
lastUpdateAt: number;
completedAt?: number;
}
export interface SessionMemoryState {
viewportAnchor: number;
isStreaming: boolean;
streamStartTime?: number;
lastAccessedAt: number;
backgroundMessageCount: number;
isZombie?: boolean;
totalAvailableMessages?: number;
loadedTurnCount?: number;
hasMoreAbove?: boolean;
hasMoreTurnsAbove?: boolean;
historyLoading?: boolean;
historyComplete?: boolean;
historyLimit?: number;
streamingCooldownUntil?: number;
lastUserMessageAt?: number; // Timestamp when user last sent a message
}
export interface SessionHistoryMeta {
limit: number;
complete: boolean;
@@ -75,18 +46,14 @@ export interface SessionContextUsage {
// Default message limit (can be overridden via settings).
// Single value controls: fetch from server, active session ceiling, Load More chunk.
// Background trim is derived automatically as Math.round(limit * 0.6).
export const DEFAULT_MESSAGE_LIMIT = 200;
/** Timeout after which a session stuck in 'busy' or 'retry' with no SSE events is force-reset to idle. */
export const STUCK_SESSION_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export const MEMORY_CONSTANTS = {
const DEFAULT_MESSAGE_LIMIT = 200;
const MEMORY_CONSTANTS = {
MAX_SESSIONS: 3,
ZOMBIE_TIMEOUT: 10 * 60 * 1000,
} as const;
/** OpenCode parity: fixed page/window size for message history. */
export const getMessageLimit = (): number => {
const getMessageLimit = (): number => {
return DEFAULT_MESSAGE_LIMIT;
};
@@ -95,7 +62,7 @@ export const getBackgroundTrimLimit = (): number =>
Math.round(getMessageLimit() * 0.6);
// --- Backward-compat shims (avoid mass refactor of non-critical callers) ---
export const DEFAULT_MEMORY_LIMITS = {
const DEFAULT_MEMORY_LIMITS = {
MAX_SESSIONS: MEMORY_CONSTANTS.MAX_SESSIONS,
VIEWPORT_MESSAGES: Math.round(DEFAULT_MESSAGE_LIMIT * 0.6),
HISTORICAL_MESSAGES: DEFAULT_MESSAGE_LIMIT,
@@ -116,216 +83,4 @@ export const getMemoryLimits = () => {
};
};
export const getActiveSessionWindow = () => getMessageLimit();
export const DEFAULT_ACTIVE_SESSION_WINDOW = DEFAULT_MESSAGE_LIMIT;
export const MEMORY_LIMITS = DEFAULT_MEMORY_LIMITS;
export const ACTIVE_SESSION_WINDOW = DEFAULT_ACTIVE_SESSION_WINDOW;
/** Synthetic context parts to attach when sending initial message */
export interface SyntheticContextPart {
text: string;
synthetic: true;
}
export type NewSessionDraftState = {
open: boolean;
selectedProjectId?: string | null;
directoryOverride: string | null;
pendingWorktreeRequestId?: string | null;
bootstrapPendingDirectory?: string | null;
preserveDirectoryOverride?: boolean;
parentID: string | null;
title?: string;
initialPrompt?: string;
/** Synthetic context parts to include with the initial message */
syntheticParts?: SyntheticContextPart[];
targetFolderId?: string;
};
// Voice state types
export type VoiceStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
export type VoiceMode = 'idle' | 'speaking' | 'listening';
export interface VoiceState {
status: VoiceStatus;
mode: VoiceMode;
}
export interface SessionStore {
sessions: Session[];
archivedSessions: Session[];
sessionsByDirectory: Map<string, Session[]>;
currentSessionId: string | null;
lastLoadedDirectory: string | null;
messages: Map<string, { info: Message; parts: Part[] }[]>;
sessionMemoryState: Map<string, SessionMemoryState>;
sessionHistoryMeta: Map<string, SessionHistoryMeta>;
messageStreamStates: Map<string, MessageStreamLifecycle>;
sessionCompactionUntil: Map<string, number>;
permissions: Map<string, PermissionRequest[]>;
questions: Map<string, QuestionRequest[]>;
sessionAbortFlags: Map<string, { timestamp: number; acknowledged: boolean }>;
attachedFiles: AttachedFile[];
abortPromptSessionId: string | null;
abortPromptExpiresAt: number | null;
isLoading: boolean;
error: string | null;
streamingMessageIds: Map<string, string | null>;
abortControllers: Map<string, AbortController>;
lastUsedProvider: { providerID: string; modelID: string } | null;
isSyncing: boolean;
sessionModelSelections: Map<string, { providerId: string; modelId: string }>;
sessionAgentSelections: Map<string, string>;
sessionAgentModelSelections: Map<string, Map<string, { providerId: string; modelId: string }>>;
webUICreatedSessions: Set<string>;
worktreeMetadata: Map<string, import('@/types/worktree').WorktreeMetadata>;
availableWorktrees: import('@/types/worktree').WorktreeMetadata[];
availableWorktreesByProject: Map<string, import('@/types/worktree').WorktreeMetadata[]>;
currentAgentContext: Map<string, string>;
sessionContextUsage: Map<string, SessionContextUsage>;
sessionAgentEditModes: Map<string, Map<string, EditPermissionMode>>;
// Server-owned session status (mirrors OpenCode SessionStatus: busy|retry|idle).
// Use as the single source of truth for "assistant working" UI.
// confirmedAt: timestamp when idle was confirmed locally (prevents race with server polling)
sessionStatus?: Map<
string,
{ type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number; confirmedAt?: number }
>;
// sessionAttentionStates removed — replaced by notification-store
userSummaryTitles: Map<string, { title: string; createdAt: number | null }>;
pendingInputText: string | null;
pendingInputMode: 'replace' | 'append' | 'append-inline';
/** Synthetic context parts to include with the next message sent */
pendingSyntheticParts: SyntheticContextPart[] | null;
newSessionDraft: NewSessionDraftState;
// Voice state
voiceStatus: VoiceStatus;
voiceMode: VoiceMode;
// Voice actions
setVoiceStatus: (status: VoiceStatus) => void;
setVoiceMode: (mode: VoiceMode) => void;
getSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => EditPermissionMode;
toggleSessionAgentEditMode: (sessionId: string, agentName: string | undefined, defaultMode?: EditPermissionMode) => void;
setSessionAgentEditMode: (sessionId: string, agentName: string | undefined, mode: EditPermissionMode, defaultMode?: EditPermissionMode) => void;
loadSessions: () => Promise<void>;
openNewSessionDraft: (options?: { projectId?: string | null; directoryOverride?: string | null; pendingWorktreeRequestId?: string | null; bootstrapPendingDirectory?: string | null; preserveDirectoryOverride?: boolean; parentID?: string | null; title?: string; initialPrompt?: string; syntheticParts?: SyntheticContextPart[]; targetFolderId?: string }) => void;
overrideNewSessionDraftTarget: (options: { projectId?: string | null; directoryOverride?: string | null; pendingWorktreeRequestId?: string | null; bootstrapPendingDirectory?: string | null; preserveDirectoryOverride?: boolean; title?: string; initialPrompt?: string }) => void;
setNewSessionDraftTarget: (target: { projectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void;
setPendingDraftWorktreeRequest: (requestId: string | null) => void;
resolvePendingDraftWorktreeTarget: (requestId: string, directory: string | null, options?: { projectId?: string | null; bootstrapPendingDirectory?: string | null; preserveDirectoryOverride?: boolean }) => void;
setDraftBootstrapPendingDirectory: (directory: string | null) => void;
setDraftPreserveDirectoryOverride: (value: boolean) => void;
closeNewSessionDraft: () => void;
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
createSessionFromAssistantMessage: (sourceMessageId: string, execution: { providerID: string; modelID: string; variant: string; agent: string; instructions: string; createWorktree?: boolean }) => Promise<void>;
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise<boolean>;
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
archiveSession: (id: string) => Promise<boolean>;
archiveSessions: (ids: string[], options?: { silent?: boolean }) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
updateSessionTitle: (id: string, title: string) => Promise<void>;
shareSession: (id: string) => Promise<Session | null>;
unshareSession: (id: string) => Promise<Session | null>;
setCurrentSession: (id: string | null) => void;
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell', options?: { sessionId?: string }) => Promise<void>;
abortCurrentOperation: (sessionIdOverride?: string) => Promise<void>;
acknowledgeSessionAbort: (sessionId: string) => void;
armAbortPrompt: (durationMs?: number) => number | null;
clearAbortPrompt: () => void;
addStreamingPart: (sessionId: string, messageId: string, part: Part, role?: string) => void;
applyPartDelta: (sessionId: string, messageId: string, partId: string, field: string, delta: string, role?: string) => void;
completeStreamingMessage: (sessionId: string, messageId: string) => void;
markMessageStreamSettled: (messageId: string) => void;
updateMessageInfo: (sessionId: string, messageId: string, messageInfo: Message) => void;
updateSessionCompaction: (sessionId: string, compactingTimestamp?: number | null) => void;
addPermission: (permission: PermissionRequest) => void;
respondToPermission: (sessionId: string, requestId: string, response: PermissionResponse) => Promise<void>;
dismissPermission: (sessionId: string, requestId: string) => void;
addQuestion: (question: QuestionRequest) => void;
dismissQuestion: (sessionId: string, requestId: string) => void;
respondToQuestion: (sessionId: string, requestId: string, answers: string[] | string[][]) => Promise<void>;
rejectQuestion: (sessionId: string, requestId: string) => Promise<void>;
clearError: () => void;
getSessionsByDirectory: (directory: string) => Session[];
getDirectoryForSession: (sessionId: string) => string | null;
getLastUserChoice: (sessionId: string) => { agent?: string; providerID?: string; modelID?: string; variant?: string } | null;
getCurrentAgent: (sessionId: string) => string | undefined;
syncMessages: (
sessionId: string,
messages: { info: Message; parts: Part[] }[],
options?: { replace?: boolean }
) => void;
applySessionMetadata: (sessionId: string, metadata: Partial<Session>) => void;
setSessionDirectory: (sessionId: string, directory: string | null) => void;
addAttachedFile: (file: File) => Promise<void>;
addServerFile: (path: string, name: string, content?: string) => Promise<void>;
removeAttachedFile: (id: string) => void;
clearAttachedFiles: () => void;
updateViewportAnchor: (sessionId: string, anchor: number) => void;
loadMoreMessages: (sessionId: string, direction: "up" | "down") => Promise<void>;
saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => void;
getSessionModelSelection: (sessionId: string) => { providerId: string; modelId: string } | null;
saveSessionAgentSelection: (sessionId: string, agentName: string) => void;
getSessionAgentSelection: (sessionId: string) => string | null;
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null;
saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void;
getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined;
isOpenChamberCreatedSession: (sessionId: string) => boolean;
markSessionAsOpenChamberCreated: (sessionId: string) => void;
initializeNewOpenChamberSession: (sessionId: string, agents: Array<{ name: string; [key: string]: unknown }>) => void;
setWorktreeMetadata: (sessionId: string, metadata: import('@/types/worktree').WorktreeMetadata | null) => void;
getWorktreeMetadata: (sessionId: string) => import('@/types/worktree').WorktreeMetadata | undefined;
getContextUsage: (contextLimit: number, outputLimit: number) => SessionContextUsage | null;
updateSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => void;
initializeSessionContextUsage: (sessionId: string, contextLimit: number, outputLimit: number) => void;
debugSessionMessages: (sessionId: string) => Promise<void>;
pollForTokenUpdates: (sessionId: string, messageId: string, maxAttempts?: number) => void;
updateSession: (session: Session) => void;
removeSessionFromStore: (sessionId: string) => void;
revertToMessage: (sessionId: string, messageId: string) => Promise<void>;
handleSlashUndo: (sessionId: string) => Promise<void>;
handleSlashRedo: (sessionId: string) => Promise<void>;
forkFromMessage: (sessionId: string, messageId: string) => Promise<void>;
setPendingInputText: (text: string | null, mode?: 'replace' | 'append' | 'append-inline') => void;
consumePendingInputText: () => { text: string; mode: 'replace' | 'append' | 'append-inline' } | null;
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void;
consumePendingSyntheticParts: () => SyntheticContextPart[] | null;
}
@@ -41,7 +41,7 @@ export interface AgentGroup {
sessionCount: number;
}
export interface DeleteAgentGroupResult {
interface DeleteAgentGroupResult {
failedIds: string[];
failedWorktreePaths: string[];
}
@@ -50,7 +50,7 @@ export interface DeleteAgentGroupResult {
// parseSessionTitle
// ---------------------------------------------------------------------------
export function parseSessionTitle(title: string | undefined): {
function parseSessionTitle(title: string | undefined): {
groupSlug: string;
provider: string;
model: string;
+67 -27
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { devtools, persist } from "zustand/middleware";
import type { Agent, PermissionConfig } from "@opencode-ai/sdk/v2";
import { opencodeClient } from "@/lib/opencode/client";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges, type ConfigChangeScope } from "@/lib/configSync";
@@ -9,7 +9,7 @@ import {
finishConfigUpdate,
updateConfigUpdateMessage,
} from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { useConfigStore } from "@/stores/useConfigStore";
import { invalidateCommandsLoadCache, useCommandsStore } from "@/stores/useCommandsStore";
import { useProjectsStore } from "@/stores/useProjectsStore";
@@ -104,8 +104,9 @@ export interface AgentConfig {
name: string;
description?: string;
model?: string | null;
temperature?: number;
top_p?: number;
variant?: string | null;
temperature?: number | null;
top_p?: number | null;
prompt?: string | null;
mode?: "primary" | "subagent" | "all";
permission?: PermissionConfig | null;
@@ -114,6 +115,17 @@ export interface AgentConfig {
scope?: AgentScope;
}
/**
* Result of an agent config mutation.
* `requiresManualRestart` is true when the change was persisted to disk but the
* connected (external) OpenCode server could not be reloaded by OpenChamber, so
* the user must restart that server before the change takes effect.
*/
export interface AgentMutationResult {
ok: boolean;
requiresManualRestart?: boolean;
}
// Extended Agent type for API properties not in SDK types
export type AgentWithExtras = Agent & {
native?: boolean;
@@ -165,13 +177,16 @@ const SLOW_HEALTH_POLL_BASE_MS = 800;
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
const SLOW_HEALTH_POLL_MAX_MS = 2000;
const hasValue = <T>(value: T | null | undefined): value is T => value !== null && value !== undefined;
export interface AgentDraft {
name: string;
scope: AgentScope;
description?: string;
model?: string | null;
temperature?: number;
top_p?: number;
variant?: string;
temperature?: number | null;
top_p?: number | null;
prompt?: string;
mode?: "primary" | "subagent" | "all";
permission?: PermissionConfig;
@@ -188,9 +203,9 @@ interface AgentsStore {
setSelectedAgent: (name: string | null) => void;
setAgentDraft: (draft: AgentDraft | null) => void;
loadAgents: () => Promise<boolean>;
createAgent: (config: AgentConfig) => Promise<boolean>;
updateAgent: (name: string, config: Partial<AgentConfig>) => Promise<boolean>;
deleteAgent: (name: string, scope?: AgentScope) => Promise<boolean>;
createAgent: (config: AgentConfig) => Promise<AgentMutationResult>;
updateAgent: (name: string, config: Partial<AgentConfig>) => Promise<AgentMutationResult>;
deleteAgent: (name: string, scope?: AgentScope) => Promise<AgentMutationResult>;
getAgentByName: (name: string) => Agent | undefined;
// Returns only visible agents (excludes hidden internal agents)
getVisibleAgents: () => Agent[];
@@ -331,8 +346,9 @@ export const useAgentsStore = create<AgentsStore>()(
if (config.description) agentConfig.description = config.description;
if (config.model) agentConfig.model = config.model;
if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
if (config.variant) agentConfig.variant = config.variant;
if (hasValue(config.temperature)) agentConfig.temperature = config.temperature;
if (hasValue(config.top_p)) agentConfig.top_p = config.top_p;
if (config.prompt) agentConfig.prompt = config.prompt;
if (config.permission) agentConfig.permission = config.permission;
if (config.disable !== undefined) agentConfig.disable = config.disable;
@@ -358,8 +374,16 @@ export const useAgentsStore = create<AgentsStore>()(
throw new Error(message);
}
const needsReload = payload?.requiresReload ?? true;
invalidateAgentsLoadCache(configDirectory);
// External OpenCode server: persisted to disk but not reloaded.
// Skip the reload so the form keeps the just-saved values instead of
// reverting to the server's stale, startup-cached config.
if (payload?.requiresManualRestart) {
return { ok: true, requiresManualRestart: true };
}
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await refreshAfterOpenCodeRestart({
@@ -368,17 +392,17 @@ export const useAgentsStore = create<AgentsStore>()(
scopes: ["agents"],
mode: "projects",
});
return true;
return { ok: true };
}
const loaded = await get().loadAgents();
if (loaded) {
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
return { ok: loaded };
} catch (error) {
console.error('Failed to create agent:', error);
return false;
return { ok: false };
} finally {
if (!requiresReload) {
finishConfigUpdate();
@@ -395,8 +419,9 @@ export const useAgentsStore = create<AgentsStore>()(
if (config.mode !== undefined) agentConfig.mode = config.mode;
if (config.description !== undefined) agentConfig.description = config.description;
if (config.model !== undefined) agentConfig.model = config.model;
if (config.temperature !== undefined) agentConfig.temperature = config.temperature;
if (config.top_p !== undefined) agentConfig.top_p = config.top_p;
if ('variant' in config) agentConfig.variant = config.variant ?? null;
if ('temperature' in config) agentConfig.temperature = config.temperature ?? null;
if ('top_p' in config) agentConfig.top_p = config.top_p ?? null;
if (config.prompt !== undefined) agentConfig.prompt = config.prompt;
if (config.permission !== undefined) agentConfig.permission = config.permission;
if (config.disable !== undefined) agentConfig.disable = config.disable;
@@ -420,8 +445,16 @@ export const useAgentsStore = create<AgentsStore>()(
throw new Error(message);
}
const needsReload = payload?.requiresReload ?? true;
invalidateAgentsLoadCache(configDirectory);
// External OpenCode server: persisted to disk but not reloaded.
// Skip the reload so the form keeps the just-saved values instead of
// reverting to the server's stale, startup-cached config.
if (payload?.requiresManualRestart) {
return { ok: true, requiresManualRestart: true };
}
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await refreshAfterOpenCodeRestart({
@@ -430,14 +463,14 @@ export const useAgentsStore = create<AgentsStore>()(
scopes: ["agents"],
mode: "projects",
});
return true;
return { ok: true };
}
const loaded = await get().loadAgents();
if (loaded) {
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
return loaded;
return { ok: loaded };
} catch (error) {
console.error('Failed to update agent:', error);
throw error;
@@ -471,8 +504,18 @@ export const useAgentsStore = create<AgentsStore>()(
throw new Error(message);
}
const needsReload = payload?.requiresReload ?? true;
invalidateAgentsLoadCache(configDirectory);
if (get().selectedAgentName === name) {
set({ selectedAgentName: null });
}
// External OpenCode server: persisted to disk but not reloaded.
if (payload?.requiresManualRestart) {
return { ok: true, requiresManualRestart: true };
}
const needsReload = payload?.requiresReload ?? true;
if (needsReload) {
requiresReload = true;
await refreshAfterOpenCodeRestart({
@@ -481,7 +524,7 @@ export const useAgentsStore = create<AgentsStore>()(
scopes: ["agents"],
mode: "projects",
});
return true;
return { ok: true };
}
const loaded = await get().loadAgents();
@@ -489,10 +532,7 @@ export const useAgentsStore = create<AgentsStore>()(
emitConfigChange("agents", { source: CONFIG_EVENT_SOURCE });
}
if (get().selectedAgentName === name) {
set({ selectedAgentName: null });
}
return loaded;
return { ok: loaded };
} catch (error) {
console.error('Failed to delete agent:', error);
throw error;
@@ -516,7 +556,7 @@ export const useAgentsStore = create<AgentsStore>()(
}),
{
name: "agents-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedAgentName: state.selectedAgentName,
}),
@@ -0,0 +1,95 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from '@/stores/utils/safeStorage';
type AutoReviewPhase = 'waiting_for_reviewer' | 'waiting_for_implementer';
type AutoReviewStatus = 'running' | 'completed' | 'stopped' | 'error';
export type AutoReviewRun = {
originalSessionID: string;
reviewSessionID: string;
directory: string;
runtimeKey: string;
status: AutoReviewStatus;
phase: AutoReviewPhase;
iteration: number;
maxIterations: number;
lastForwardedMessageID?: string;
expectedAssistantParentID?: string;
waitAfterCreatedAt?: number;
error?: string;
};
type AutoReviewState = {
runsByOriginalSessionID: Record<string, AutoReviewRun>;
upsertRun: (run: AutoReviewRun) => void;
updateRun: (originalSessionID: string, updater: (run: AutoReviewRun) => AutoReviewRun) => void;
stopRun: (originalSessionID: string) => void;
completeRun: (originalSessionID: string) => void;
stopRunningRunsForRuntime: (runtimeKey: string) => void;
isRunningForSession: (sessionID: string) => boolean;
};
export const useAutoReviewStore = create<AutoReviewState>()(
persist(
(set, get) => ({
runsByOriginalSessionID: {},
upsertRun: (run) => set((state) => ({
runsByOriginalSessionID: {
...state.runsByOriginalSessionID,
[run.originalSessionID]: run,
},
})),
updateRun: (originalSessionID, updater) => set((state) => {
const current = state.runsByOriginalSessionID[originalSessionID];
if (!current) return state;
return {
runsByOriginalSessionID: {
...state.runsByOriginalSessionID,
[originalSessionID]: updater(current),
},
};
}),
stopRun: (originalSessionID) => set((state) => {
const current = state.runsByOriginalSessionID[originalSessionID];
if (!current) return state;
return {
runsByOriginalSessionID: {
...state.runsByOriginalSessionID,
[originalSessionID]: { ...current, status: 'stopped' },
},
};
}),
completeRun: (originalSessionID) => set((state) => {
const current = state.runsByOriginalSessionID[originalSessionID];
if (!current) return state;
return {
runsByOriginalSessionID: {
...state.runsByOriginalSessionID,
[originalSessionID]: { ...current, status: 'completed' },
},
};
}),
stopRunningRunsForRuntime: (runtimeKey) => set((state) => {
let changed = false;
const next = { ...state.runsByOriginalSessionID };
for (const [sessionID, run] of Object.entries(next)) {
if (run.runtimeKey === runtimeKey && run.status === 'running') {
next[sessionID] = { ...run, status: 'stopped' };
changed = true;
}
}
return changed ? { runsByOriginalSessionID: next } : state;
}),
isRunningForSession: (sessionID) => {
const run = get().runsByOriginalSessionID[sessionID];
return run?.status === 'running';
},
}),
{
name: 'auto-review-store',
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ runsByOriginalSessionID: state.runsByOriginalSessionID }),
},
),
);
+3 -36
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { devtools, persist } from "zustand/middleware";
import { opencodeClient } from "@/lib/opencode/client";
import {
startConfigUpdate,
@@ -8,7 +8,7 @@ import {
updateConfigUpdateMessage,
} from "@/lib/configUpdate";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import { getSafeStorage } from "./utils/safeStorage";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { useProjectsStore } from "@/stores/useProjectsStore";
import { runtimeFetch } from "@/lib/runtime-fetch";
@@ -427,7 +427,7 @@ export const useCommandsStore = create<CommandsStore>()(
}),
{
name: "commands-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedCommandName: state.selectedCommandName,
}),
@@ -516,39 +516,6 @@ async function performFullConfigRefresh(options: { message?: string; delayMs?: n
}
}
export async function reloadOpenCodeConfiguration(options?: { message?: string; delayMs?: number }) {
startConfigUpdate(options?.message || "Reloading OpenCode configuration…");
try {
const response = await runtimeFetch('/api/config/reload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = payload?.error || 'Failed to reload configuration';
throw new Error(message);
}
if (payload?.requiresReload) {
await performFullConfigRefresh({
message: payload.message,
delayMs: payload.reloadDelayMs,
});
} else {
await performFullConfigRefresh(options);
}
} catch (error) {
console.error('[reloadOpenCodeConfiguration] Failed:', error);
updateConfigUpdateMessage('Failed to reload configuration. Please try again.');
await sleep(2000);
finishConfigUpdate();
throw error;
}
}
let unsubscribeCommandsConfigChanges: (() => void) | null = null;
if (!unsubscribeCommandsConfigChanges) {
@@ -129,7 +129,23 @@ const deferred = <T,>() => {
};
mock.module('@/stores/utils/safeStorage', () => ({
getDeferredSafeStorage: () => makeStorage(),
getSafeStorage: () => makeStorage(),
createDeferredSafeJSONStorage: () => {
const testStorage = makeStorage();
return {
getItem: (name: string) => {
const value = testStorage.getItem(name);
return value === null ? null : JSON.parse(value);
},
setItem: (name: string, value: unknown) => {
testStorage.setItem(name, JSON.stringify(value));
},
removeItem: (name: string) => {
testStorage.removeItem(name);
},
};
},
}));
mock.module('@/stores/useProjectsStore', () => ({
@@ -216,6 +232,8 @@ mock.module('@/lib/configSync', () => ({
const { useConfigStore } = await import('./useConfigStore');
const { emitSyncConfigChanged, setSyncRefs } = await import('@/sync/sync-refs');
const { useSelectionStore } = await import('@/sync/selection-store');
const { useSessionUIStore } = await import('@/sync/session-ui-store');
describe('useConfigStore provider persistence', () => {
beforeEach(() => {
@@ -235,6 +253,13 @@ describe('useConfigStore provider persistence', () => {
withDirectoryCalls = [];
currentFetchDirectory = DIRECTORY;
setSyncRefs({} as never, { children: new Map(), getState: () => undefined } as never, DIRECTORY);
useSelectionStore.setState({
sessionModelSelections: new Map(),
sessionAgentSelections: new Map(),
sessionAgentModelSelections: new Map(),
lastUsedProvider: null,
});
useSessionUIStore.setState({ currentSessionId: null });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
directoryScoped: {},
@@ -380,6 +405,123 @@ describe('useConfigStore provider persistence', () => {
expect(state.currentVariant).toBe('fast');
});
test('provider reload preserves the add-provider sentinel selection', async () => {
// The user has opened the "Add provider" form, which sets selectedProviderId
// to the sentinel. A background provider refresh must not navigate them away
// (and discard their unsaved input) just because the sentinel is not a real
// provider id. See issue #1765.
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
currentProviderId: 'live',
currentModelId: 'live-model',
selectedProviderId: '__add_provider__',
directoryScoped: {},
});
liveProviderId = 'live';
await useConfigStore.getState().loadProviders({ source: 'test:add-provider' });
expect(useConfigStore.getState().selectedProviderId).toBe('__add_provider__');
});
test('add-provider sentinel is not persisted as a stable provider selection', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
currentProviderId: 'live',
currentModelId: 'live-model',
selectedProviderId: '__add_provider__',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('live')],
agents: [],
currentProviderId: 'live',
currentModelId: 'live-model',
currentAgentName: undefined,
selectedProviderId: '__add_provider__',
agentModelSelections: {},
defaultProviders: { default: 'live' },
},
},
});
const persisted = JSON.parse(storage.get(STORAGE_KEY) ?? '{}');
expect(persisted.state.selectedProviderId).toBe('');
expect(persisted.state.directoryScoped[DIRECTORY].selectedProviderId).toBe('');
});
test('setAgent applies settings default variant for an agent configured model', () => {
useSessionUIStore.setState({ currentSessionId: 'ses_agent_default_variant' });
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })],
agents: [testAgent('plan', { model: { providerID: 'openai', modelID: 'gpt-5.5' } })],
settingsDefaultVariant: 'high',
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('openai');
expect(state.currentModelId).toBe('gpt-5.5');
expect(state.currentVariant).toBe('high');
expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe('high');
});
test('setAgent prefers saved and agent variants before settings default', () => {
const sessionId = 'ses_agent_saved_variant';
useSessionUIStore.setState({ currentSessionId: sessionId });
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, 'plan', 'openai', 'gpt-5.5', 'low');
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, medium: {}, high: {} })],
agents: [testAgent('plan', {
model: { providerID: 'openai', modelID: 'gpt-5.5' },
variant: 'medium',
})],
settingsDefaultVariant: 'high',
currentProviderId: 'openai',
currentModelId: 'gpt-5.5',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
expect(useConfigStore.getState().currentVariant).toBe('low');
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, 'plan', 'openai', 'gpt-5.5', undefined);
useConfigStore.setState({ currentVariant: undefined, directoryScoped: {} });
useConfigStore.getState().setAgent('plan');
expect(useConfigStore.getState().currentVariant).toBe('medium');
});
test('setAgent applies settings default variant for a saved session agent model', () => {
const sessionId = 'ses_existing_agent_model_default_variant';
useSessionUIStore.setState({ currentSessionId: sessionId });
useSelectionStore.getState().saveAgentModelForSession(sessionId, 'plan', 'openai', 'gpt-5.5');
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })],
agents: [testAgent('plan')],
settingsDefaultVariant: 'high',
currentProviderId: 'other',
currentModelId: 'other-model',
currentVariant: undefined,
directoryScoped: {},
});
useConfigStore.getState().setAgent('plan');
const state = useConfigStore.getState();
expect(state.currentProviderId).toBe('openai');
expect(state.currentModelId).toBe('gpt-5.5');
expect(state.currentVariant).toBe('high');
});
test('loadAgents does not fetch OpenCode config directly', async () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
@@ -502,6 +644,47 @@ describe('useConfigStore provider persistence', () => {
expect(state.currentAgentName).toBe('review');
});
test('sync config defaults do not close the add-provider settings flow', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
providers: [provider('openai', 'gpt-5.5'), provider('anthropic', 'claude')],
agents: [
testAgent('build', { model: { providerID: 'anthropic', modelID: 'claude' } }),
testAgent('review', { model: { providerID: 'openai', modelID: 'gpt-5.5' } }),
],
currentProviderId: 'anthropic',
currentModelId: 'claude',
currentAgentName: 'build',
selectedProviderId: '__add_provider__',
selectionSource: 'auto',
directoryScoped: {
[DIRECTORY]: {
providers: [provider('openai', 'gpt-5.5'), provider('anthropic', 'claude')],
agents: [
testAgent('build', { model: { providerID: 'anthropic', modelID: 'claude' } }),
testAgent('review', { model: { providerID: 'openai', modelID: 'gpt-5.5' } }),
],
currentProviderId: 'anthropic',
currentModelId: 'claude',
currentAgentName: 'build',
selectedProviderId: '__add_provider__',
agentModelSelections: {},
defaultProviders: {},
selectionSource: 'auto',
},
},
});
emitSyncConfigChanged(DIRECTORY, { default_agent: 'review', model: 'openai/gpt-5.5' });
const state = useConfigStore.getState();
expect(state.currentAgentName).toBe('review');
expect(state.currentProviderId).toBe('openai');
expect(state.currentModelId).toBe('gpt-5.5');
expect(state.selectedProviderId).toBe('__add_provider__');
expect(state.directoryScoped[DIRECTORY]?.selectedProviderId).toBe('__add_provider__');
});
test('duplicate sync config event is a no-op when defaults and selection are unchanged', () => {
useConfigStore.setState({
activeDirectoryKey: DIRECTORY,
+169 -172
View File
@@ -1,12 +1,13 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { devtools, persist } from "zustand/middleware";
import type { Provider, Agent, Config } from "@opencode-ai/sdk/v2";
import { opencodeClient } from "@/lib/opencode/client";
import { scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import type { ModelMetadata } from "@/types";
import { getSafeStorage } from "./utils/safeStorage";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { filterVisibleAgents } from "./useAgentsStore";
import { isPrimaryMode } from "@/components/chat/mobileControlsUtils";
import { useSessionUIStore } from "@/sync/session-ui-store";
import { useSelectionStore } from "@/sync/selection-store";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
@@ -22,29 +23,30 @@ import { getSyncConfig, subscribeToSyncConfigChanges } from "@/sync/sync-refs";
const MODELS_DEV_API_URL = "https://models.dev/api.json";
const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata";
const STT_SILENCE_THRESHOLD_DB_MIN = -100;
const STT_SILENCE_THRESHOLD_DB_MAX = 0;
const STT_SILENCE_HOLD_MS_MIN = 250;
const STT_SILENCE_HOLD_MS_MAX = 10000;
const FALLBACK_PROVIDER_ID = "opencode";
const FALLBACK_MODEL_ID = "big-pickle";
// Sentinel selectedProviderId used by the providers UI while the "Add provider"
// form is open. It is intentionally not a real provider id and must not be
// persisted as a stable provider selection.
const ADD_PROVIDER_SENTINEL = "__add_provider__";
const GIT_UTILITY_PROVIDER_ID = "zen";
const GIT_UTILITY_PREFERRED_MODEL_ID = "big-pickle";
const PROVIDER_CONFIG_REFRESH_CONCURRENCY = 4;
const normalizeSttSilenceThresholdDb = (value: unknown): number | undefined => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined;
const normalizeSttProvider = (value: unknown): 'local' | 'openai-compatible' | undefined => {
if (value === 'local' || value === 'openai-compatible') {
return value;
}
return Math.max(STT_SILENCE_THRESHOLD_DB_MIN, Math.min(STT_SILENCE_THRESHOLD_DB_MAX, value));
};
const normalizeSttSilenceHoldMs = (value: unknown): number | undefined => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return undefined;
// Legacy providers: 'server' used an OpenAI-compatible endpoint;
// 'browser' and 'wasm' map to the local default.
if (value === 'server') {
return 'openai-compatible';
}
return Math.max(STT_SILENCE_HOLD_MS_MIN, Math.min(STT_SILENCE_HOLD_MS_MAX, Math.round(value)));
if (value === 'browser' || value === 'wasm') {
return 'local';
}
return undefined;
};
interface OpenChamberDefaults {
@@ -56,13 +58,11 @@ interface OpenChamberDefaults {
defaultFileViewerPreview?: boolean;
zenModel?: string;
messageStreamTransport?: 'auto' | 'ws' | 'sse';
sttProvider?: 'browser' | 'server' | 'wasm';
sttProvider?: 'local' | 'openai-compatible';
sttServerUrl?: string;
wasmSttModel?: string;
sttModel?: string;
sttLocalModel?: string;
sttLanguage?: string;
sttSilenceThresholdDb?: number;
sttSilenceHoldMs?: number;
}
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
@@ -96,12 +96,11 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto'
? data.messageStreamTransport
: undefined;
const sttProvider = data?.sttProvider === 'browser' || data?.sttProvider === 'server' || data?.sttProvider === 'wasm' ? data.sttProvider : undefined;
const sttProvider = normalizeSttProvider(data?.sttProvider);
const sttServerUrl = typeof data?.sttServerUrl === 'string' ? data.sttServerUrl.trim() : undefined;
const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined;
const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined;
const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined;
const sttSilenceThresholdDb = normalizeSttSilenceThresholdDb(data?.sttSilenceThresholdDb);
const sttSilenceHoldMs = normalizeSttSilenceHoldMs(data?.sttSilenceHoldMs);
return finish('runtime-settings', {
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
@@ -115,9 +114,8 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
sttProvider,
sttServerUrl,
sttModel,
sttLocalModel,
sttLanguage,
sttSilenceThresholdDb,
sttSilenceHoldMs,
});
}
} catch {
@@ -144,12 +142,11 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
data?.messageStreamTransport === 'ws' || data?.messageStreamTransport === 'sse' || data?.messageStreamTransport === 'auto'
? data.messageStreamTransport
: undefined;
const sttProvider = data?.sttProvider === 'browser' || data?.sttProvider === 'server' ? data.sttProvider : undefined;
const sttProvider = normalizeSttProvider(data?.sttProvider);
const sttServerUrl = typeof data?.sttServerUrl === 'string' ? data.sttServerUrl.trim() : undefined;
const sttModel = typeof data?.sttModel === 'string' ? data.sttModel.trim() : undefined;
const sttLocalModel = typeof data?.sttLocalModel === 'string' ? data.sttLocalModel.trim() : undefined;
const sttLanguage = typeof data?.sttLanguage === 'string' ? data.sttLanguage.trim() : undefined;
const sttSilenceThresholdDb = normalizeSttSilenceThresholdDb(data?.sttSilenceThresholdDb);
const sttSilenceHoldMs = normalizeSttSilenceHoldMs(data?.sttSilenceHoldMs);
return finish('settings-route', {
defaultModel: defaultModel.length > 0 ? defaultModel : undefined,
@@ -163,9 +160,8 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
sttProvider,
sttServerUrl,
sttModel,
sttLocalModel,
sttLanguage,
sttSilenceThresholdDb,
sttSilenceHoldMs,
});
} catch (error) {
markStartupTrace('config.defaults:error', { error: error instanceof Error ? error.message : String(error) });
@@ -179,14 +175,20 @@ const parseModelString = (modelString: string): { providerId: string; modelId: s
const normalizeProviderId = (value: string) => value?.toLowerCase?.() ?? '';
const isPrimaryMode = (mode?: string) => mode === "primary" || mode === "all" || mode === undefined || mode === null;
type ProviderModel = Provider["models"][string];
type ProviderWithModelList = Omit<Provider, "models"> & { models: ProviderModel[] };
type GitModelSelection = { providerId: string; modelId: string };
type ProviderModelSelection = { providerId: string; modelId: string; variant?: string } | null;
const sanitizePersistedSelectedProviderId = (providerId: string | undefined): string => (
providerId === ADD_PROVIDER_SENTINEL ? "" : (providerId ?? "")
);
const preserveAddProviderSelection = (currentSelectedProviderId: string | undefined, nextProviderId: string): string => (
currentSelectedProviderId === ADD_PROVIDER_SENTINEL ? ADD_PROVIDER_SENTINEL : nextProviderId
);
const normalizeOptionalString = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
@@ -279,7 +281,7 @@ type DefaultAgentModelSelection = {
// fresh draft (applyDefaultModelAgentSelection), so the two paths stay identical.
//
// Agent: settings.defaultAgent → opencode default_agent → build → first primary → first
// Model: settings.defaultModel → resolved agent's pinned model+variant → opencode config.model
// Model: project.defaultModel → settings.defaultModel → resolved agent's pinned model+variant → opencode config.model
// → opencode/big-pickle → first
//
// The opencode default_agent / default model (config fields on the OpenCode server) are honored
@@ -290,6 +292,7 @@ type DefaultAgentModelSelection = {
const resolveDefaultAgentModelSelection = ({
agents,
providers,
projectDefaultModel,
settingsDefaultAgent,
settingsDefaultModel,
settingsDefaultVariant,
@@ -298,6 +301,7 @@ const resolveDefaultAgentModelSelection = ({
}: {
agents: Agent[];
providers: ProviderWithModelList[];
projectDefaultModel?: string;
settingsDefaultAgent?: string;
settingsDefaultModel?: string;
settingsDefaultVariant?: string;
@@ -346,12 +350,14 @@ const resolveDefaultAgentModelSelection = ({
let modelId: string | undefined;
let variant: string | undefined;
if (settingsDefaultModel) {
const parsed = parseModelString(settingsDefaultModel);
const effectiveDefaultModel = projectDefaultModel || settingsDefaultModel;
if (effectiveDefaultModel) {
const parsed = parseModelString(effectiveDefaultModel);
if (parsed && hasProviderModel(providers, parsed.providerId, parsed.modelId)) {
providerId = parsed.providerId;
modelId = parsed.modelId;
variant = resolveVariant(providerId, modelId, settingsDefaultVariant);
variant = resolveVariant(providerId, modelId, projectDefaultModel ? undefined : settingsDefaultVariant);
}
}
@@ -978,33 +984,31 @@ interface ConfigStore {
settingsZenModel: string | undefined;
settingsMessageStreamTransport: 'auto' | 'ws' | 'sse';
// Voice provider preference ('browser', 'openai', 'openai-compatible', or 'say' for macOS)
voiceProvider: 'browser' | 'openai' | 'openai-compatible' | 'say';
setVoiceProvider: (provider: 'browser' | 'openai' | 'openai-compatible' | 'say') => void;
voiceProvider: 'browser' | 'local' | 'openai' | 'openai-compatible' | 'say';
setVoiceProvider: (provider: 'browser' | 'local' | 'openai' | 'openai-compatible' | 'say') => void;
// TTS settings
speechRate: number;
speechPitch: number;
speechVolume: number;
sayVoice: string;
browserVoice: string;
localTtsVoiceId: number;
openaiVoice: string;
openaiApiKey: string;
openaiCompatibleUrl: string;
openaiCompatibleApiKey: string;
openaiCompatibleVoice: string;
openaiCompatibleTtsModel: string;
// STT (speech-to-text) settings
sttProvider: 'browser' | 'server' | 'wasm';
// STT (dictation) settings
dictationEnabled: boolean;
sttProvider: 'local' | 'openai-compatible';
sttServerUrl: string;
sttApiKey: string;
sttModel: string;
wasmSttModel: string;
sttLocalModel: string;
sttLanguage: string;
sttSilenceThresholdDb: number;
sttSilenceHoldMs: number;
sttTranscribeOnStop: boolean;
showMessageTTSButtons: boolean;
ttsInputMode: 'sanitized' | 'raw';
voiceModeEnabled: boolean;
ttsInputMode: 'sanitized' | 'raw' | 'summarized';
// Summarization settings
summarizeMessageTTS: boolean;
summarizeVoiceConversation: boolean;
@@ -1015,24 +1019,22 @@ interface ConfigStore {
setSpeechVolume: (volume: number) => void;
setSayVoice: (voice: string) => void;
setBrowserVoice: (voice: string) => void;
setLocalTtsVoiceId: (voiceId: number) => void;
setOpenaiVoice: (voice: string) => void;
setOpenaiApiKey: (apiKey: string) => void;
setOpenaiCompatibleUrl: (url: string) => void;
setOpenaiCompatibleApiKey: (apiKey: string) => void;
setOpenaiCompatibleVoice: (voice: string) => void;
setOpenaiCompatibleTtsModel: (model: string) => void;
setSttProvider: (provider: 'browser' | 'server' | 'wasm') => void;
setDictationEnabled: (enabled: boolean) => void;
setSttProvider: (provider: 'local' | 'openai-compatible') => void;
setSttServerUrl: (url: string) => void;
setSttApiKey: (apiKey: string) => void;
setSttModel: (model: string) => void;
setWasmSttModel: (model: string) => void;
setSttLocalModel: (model: string) => void;
setSttLanguage: (lang: string) => void;
setSttSilenceThresholdDb: (db: number) => void;
setSttSilenceHoldMs: (ms: number) => void;
setSttTranscribeOnStop: (enabled: boolean) => void;
setShowMessageTTSButtons: (show: boolean) => void;
setTtsInputMode: (mode: 'sanitized' | 'raw') => void;
setVoiceModeEnabled: (enabled: boolean) => void;
setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => void;
setSummarizeMessageTTS: (enabled: boolean) => void;
setSummarizeVoiceConversation: (enabled: boolean) => void;
setSummarizeCharacterThreshold: (threshold: number) => void;
@@ -1050,7 +1052,7 @@ interface ConfigStore {
cycleCurrentVariant: () => void;
getCurrentModelVariants: () => string[];
setAgent: (agentName: string | undefined) => void;
applyDefaultModelAgentSelection: () => void;
applyDefaultModelAgentSelection: (options?: { projectDefaultModel?: string }) => void;
applyOpenCodeConfigDefaults: (directory?: string | null, source?: string, config?: Config) => void;
setSelectedProvider: (providerId: string) => void;
setSettingsDefaultModel: (model: string | undefined) => void;
@@ -1125,7 +1127,7 @@ export const useConfigStore = create<ConfigStore>()(
voiceProvider: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('voiceProvider');
if (saved === 'openai' || saved === 'browser' || saved === 'say' || saved === 'openai-compatible') return saved;
if (saved === 'openai' || saved === 'browser' || saved === 'local' || saved === 'say' || saved === 'openai-compatible') return saved;
}
return 'browser';
})(),
@@ -1168,6 +1170,17 @@ export const useConfigStore = create<ConfigStore>()(
}
return 'Samantha';
})(),
// Local (Kokoro) TTS speaker id - load from localStorage or default to 0
localTtsVoiceId: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('localTtsVoiceId');
if (saved !== null) {
const parsed = Number.parseInt(saved, 10);
if (Number.isInteger(parsed) && parsed >= 0) return parsed;
}
}
return 0;
})(),
// Browser voice - load from localStorage or default to empty (auto-select)
browserVoice: (() => {
if (typeof window !== 'undefined') {
@@ -1224,17 +1237,24 @@ export const useConfigStore = create<ConfigStore>()(
}
return 'kokoro';
})(),
// STT provider: 'browser' (Web Speech API), 'server' (OpenAI-compat), 'wasm' (local Whisper)
// Voice input (dictation) master toggle - default enabled
dictationEnabled: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('dictationEnabled');
if (saved === 'false') return false;
}
return true;
})(),
// STT provider: 'local' (server-side sherpa-onnx) or 'openai-compatible'
sttProvider: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('sttProvider');
if (saved === 'browser' || saved === 'server' || saved === 'wasm') return saved;
// Electron/Chromium's Web Speech API requires Google API keys
// not available in Electron, so default to WASM local Whisper.
const electron = (window as unknown as { __OPENCHAMBER_ELECTRON__?: { runtime?: string } }).__OPENCHAMBER_ELECTRON__;
if (electron?.runtime === 'electron') return 'wasm' as const;
if (saved === 'local' || saved === 'openai-compatible') return saved;
// Migrate legacy providers: 'server' used an OpenAI-compatible
// endpoint; 'browser' and 'wasm' map to the local default.
if (saved === 'server') return 'openai-compatible' as const;
}
return 'browser' as const;
return 'local' as const;
})(),
sttServerUrl: (() => {
if (typeof window !== 'undefined') {
@@ -1257,12 +1277,12 @@ export const useConfigStore = create<ConfigStore>()(
}
return 'deepdml/faster-whisper-large-v3-turbo-ct2';
})(),
wasmSttModel: (() => {
sttLocalModel: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('wasmSttModel');
const saved = localStorage.getItem('sttLocalModel');
if (saved) return saved;
}
return 'Xenova/whisper-base.en';
return 'parakeet-tdt-0.6b-v2-int8';
})(),
sttLanguage: (() => {
if (typeof window !== 'undefined') {
@@ -1271,33 +1291,6 @@ export const useConfigStore = create<ConfigStore>()(
}
return '';
})(),
sttSilenceThresholdDb: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('sttSilenceThresholdDb');
if (saved) {
const parsed = parseFloat(saved);
if (!isNaN(parsed)) return parsed;
}
}
return -45;
})(),
sttSilenceHoldMs: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('sttSilenceHoldMs');
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed)) return parsed;
}
}
return 1500;
})(),
sttTranscribeOnStop: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('sttTranscribeOnStop');
if (saved === 'true') return true;
}
return false;
})(),
// Show TTS buttons on messages - disabled by default until user enables it
showMessageTTSButtons: (() => {
if (typeof window !== 'undefined') {
@@ -1310,17 +1303,10 @@ export const useConfigStore = create<ConfigStore>()(
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('ttsInputMode');
if (saved === 'raw') return 'raw' as const;
if (saved === 'summarized') return 'summarized' as const;
}
return 'sanitized' as const;
})(),
// Voice mode enabled - load from localStorage or default to false
voiceModeEnabled: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('voiceModeEnabled');
if (saved === 'true') return true;
}
return false;
})(),
// Summarization settings
summarizeMessageTTS: (() => {
if (typeof window !== 'undefined') {
@@ -1574,7 +1560,10 @@ export const useConfigStore = create<ConfigStore>()(
const currentSelectedProviderId = state.activeDirectoryKey === directoryKey
? state.selectedProviderId
: baseSnapshot.selectedProviderId;
const selectedProviderId = processedProviders.some((provider) => provider.id === currentSelectedProviderId)
// Preserve the add-provider sentinel so a background refresh does not
// navigate the user out of the in-progress add-provider form (issue #1765).
const selectedProviderId = currentSelectedProviderId === ADD_PROVIDER_SENTINEL
|| processedProviders.some((provider) => provider.id === currentSelectedProviderId)
? currentSelectedProviderId
: (resolvedModel?.providerId ?? processedProviders[0]?.id ?? "");
@@ -2046,9 +2035,8 @@ export const useConfigStore = create<ConfigStore>()(
sttProvider: openChamberDefaults.sttProvider ?? state.sttProvider,
sttServerUrl: openChamberDefaults.sttServerUrl ?? state.sttServerUrl,
sttModel: openChamberDefaults.sttModel ?? state.sttModel,
sttLocalModel: openChamberDefaults.sttLocalModel ?? state.sttLocalModel,
sttLanguage: openChamberDefaults.sttLanguage ?? state.sttLanguage,
sttSilenceThresholdDb: openChamberDefaults.sttSilenceThresholdDb ?? state.sttSilenceThresholdDb,
sttSilenceHoldMs: openChamberDefaults.sttSilenceHoldMs ?? state.sttSilenceHoldMs,
directoryScoped: {
...state.directoryScoped,
[directoryKey]: nextSnapshot,
@@ -2419,7 +2407,7 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: providerId,
currentModelId: modelId,
currentVariant: variant,
selectedProviderId: providerId,
selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, providerId),
selectionSource: "manual",
};
@@ -2427,7 +2415,7 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: providerId,
currentModelId: modelId,
currentVariant: variant,
selectedProviderId: providerId,
selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, providerId),
selectionSource: "manual",
directoryScoped: {
...state.directoryScoped,
@@ -2437,6 +2425,35 @@ export const useConfigStore = create<ConfigStore>()(
});
};
const resolveVariantForModel = (
providerId: string,
modelId: string,
agentVariant?: string,
): string | undefined => {
const model = providers
.find((provider) => provider.id === providerId)
?.models.find((candidate) => candidate.id === modelId) as { variants?: Record<string, unknown> } | undefined;
const variants = model?.variants;
if (!variants) return undefined;
const savedVariant = currentSessionId
? useSelectionStore.getState().getAgentModelVariantForSession(
currentSessionId,
agentName,
providerId,
modelId,
)
: undefined;
for (const candidate of [savedVariant, agentVariant, settingsDefaultVariant]) {
if (candidate && Object.prototype.hasOwnProperty.call(variants, candidate)) {
return candidate;
}
}
return undefined;
};
// Prefer the selected agent's configured model when switching agents.
const agent = agents.find((candidate) => candidate.name === agentName);
const agentModelSelection = agent?.model;
@@ -2446,7 +2463,7 @@ export const useConfigStore = create<ConfigStore>()(
const agentModel = agentProvider?.models.find((model) => model.id === modelID);
if (agentModel) {
applyResolvedModelSelection(providerID, modelID, undefined);
applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant));
return;
}
}
@@ -2454,18 +2471,13 @@ export const useConfigStore = create<ConfigStore>()(
if (currentSessionId) {
const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName);
if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) {
const savedVariant = useSelectionStore.getState().getAgentModelVariantForSession(
currentSessionId,
agentName,
existingAgentModel.providerId,
existingAgentModel.modelId,
);
const resolvedVariant = resolveVariantForModel(existingAgentModel.providerId, existingAgentModel.modelId, agent?.variant);
if (
currentProviderId !== existingAgentModel.providerId
|| currentModelId !== existingAgentModel.modelId
|| get().currentVariant !== savedVariant
|| get().currentVariant !== resolvedVariant
) {
applyResolvedModelSelection(existingAgentModel.providerId, existingAgentModel.modelId, savedVariant);
applyResolvedModelSelection(existingAgentModel.providerId, existingAgentModel.modelId, resolvedVariant);
}
return;
}
@@ -2477,16 +2489,7 @@ export const useConfigStore = create<ConfigStore>()(
if (parsed) {
const settingsProvider = providers.find((p) => p.id === parsed.providerId);
if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) {
let nextVariant: string | undefined;
if (settingsDefaultVariant) {
const model = settingsProvider.models.find((m) => m.id === parsed.modelId) as { variants?: Record<string, unknown> } | undefined;
const variants = model?.variants;
if (variants && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
nextVariant = settingsDefaultVariant;
}
}
applyResolvedModelSelection(parsed.providerId, parsed.modelId, nextVariant);
applyResolvedModelSelection(parsed.providerId, parsed.modelId, resolveVariantForModel(parsed.providerId, parsed.modelId, agent?.variant));
return;
}
}
@@ -2498,10 +2501,10 @@ export const useConfigStore = create<ConfigStore>()(
// Re-applies the same priority cascade used at app startup (see loadAgents):
// agent: settings.defaultAgent → build → first primary → first agent
// model: settings.defaultModel → agent's preferred model → opencode/big-pickle → first
// model: project.defaultModel → settings.defaultModel → agent's preferred model → opencode/big-pickle → first
// Used when entering a fresh draft session so model/agent reset to defaults
// instead of sticking to the previously open session's selection.
applyDefaultModelAgentSelection: () => {
applyDefaultModelAgentSelection: (options) => {
const {
agents,
providers,
@@ -2524,6 +2527,7 @@ export const useConfigStore = create<ConfigStore>()(
} = resolveDefaultAgentModelSelection({
agents,
providers,
projectDefaultModel: options?.projectDefaultModel,
settingsDefaultAgent,
settingsDefaultModel,
settingsDefaultVariant,
@@ -2557,7 +2561,7 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: resolvedProviderId,
currentModelId: resolvedModelId,
currentVariant: resolvedVariant,
selectedProviderId: resolvedProviderId,
selectedProviderId: preserveAddProviderSelection(state.selectedProviderId, resolvedProviderId),
}
: {}),
selectionSource: "auto",
@@ -2576,7 +2580,7 @@ export const useConfigStore = create<ConfigStore>()(
nextState.currentProviderId = resolvedProviderId;
nextState.currentModelId = resolvedModelId;
nextState.currentVariant = resolvedVariant;
nextState.selectedProviderId = resolvedProviderId;
nextState.selectedProviderId = preserveAddProviderSelection(state.selectedProviderId, resolvedProviderId);
}
return nextState;
@@ -2658,6 +2662,7 @@ export const useConfigStore = create<ConfigStore>()(
const currentProviderId = isActive ? state.currentProviderId : baseSnapshot.currentProviderId;
const currentModelId = isActive ? state.currentModelId : baseSnapshot.currentModelId;
const currentVariant = isActive ? state.currentVariant : baseSnapshot.currentVariant;
const currentSelectedProviderId = isActive ? state.selectedProviderId : baseSnapshot.selectedProviderId;
const nextSelection = resolveSelectionWithManualGuard({
agents,
providers,
@@ -2682,7 +2687,7 @@ export const useConfigStore = create<ConfigStore>()(
currentProviderId: nextSelection.providerId,
currentModelId: nextSelection.modelId,
currentVariant: nextSelection.variant,
selectedProviderId: nextSelection.providerId,
selectedProviderId: preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId),
}
: {}),
selectionSource: nextSelection.selectionSource,
@@ -2701,7 +2706,7 @@ export const useConfigStore = create<ConfigStore>()(
state.currentProviderId !== nextSelection.providerId
|| state.currentModelId !== nextSelection.modelId
|| state.currentVariant !== nextSelection.variant
|| state.selectedProviderId !== nextSelection.providerId
|| state.selectedProviderId !== preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId)
))
));
@@ -2721,7 +2726,7 @@ export const useConfigStore = create<ConfigStore>()(
nextState.currentProviderId = nextSelection.providerId;
nextState.currentModelId = nextSelection.modelId;
nextState.currentVariant = nextSelection.variant;
nextState.selectedProviderId = nextSelection.providerId;
nextState.selectedProviderId = preserveAddProviderSelection(currentSelectedProviderId, nextSelection.providerId);
}
}
@@ -2770,7 +2775,7 @@ export const useConfigStore = create<ConfigStore>()(
});
},
setVoiceProvider: (provider: 'browser' | 'openai' | 'openai-compatible' | 'say') => {
setVoiceProvider: (provider: 'browser' | 'local' | 'openai' | 'openai-compatible' | 'say') => {
set({ voiceProvider: provider });
if (typeof window !== 'undefined') {
localStorage.setItem('voiceProvider', provider);
@@ -2808,6 +2813,13 @@ export const useConfigStore = create<ConfigStore>()(
}
},
setLocalTtsVoiceId: (voiceId: number) => {
set({ localTtsVoiceId: voiceId });
if (typeof window !== 'undefined') {
localStorage.setItem('localTtsVoiceId', String(voiceId));
}
},
setBrowserVoice: (voice: string) => {
set({ browserVoice: voice });
if (typeof window !== 'undefined') {
@@ -2857,7 +2869,15 @@ export const useConfigStore = create<ConfigStore>()(
}
},
setSttProvider: (provider: 'browser' | 'server' | 'wasm') => {
setDictationEnabled: (enabled: boolean) => {
set({ dictationEnabled: enabled });
if (typeof window !== 'undefined') {
localStorage.setItem('dictationEnabled', String(enabled));
}
updateDesktopSettings({ dictationEnabled: enabled }).catch(() => {});
},
setSttProvider: (provider: 'local' | 'openai-compatible') => {
set({ sttProvider: provider });
if (typeof window !== 'undefined') {
localStorage.setItem('sttProvider', provider);
@@ -2888,12 +2908,12 @@ export const useConfigStore = create<ConfigStore>()(
updateDesktopSettings({ sttModel: model }).catch(() => {});
},
setWasmSttModel: (model: string) => {
set({ wasmSttModel: model });
setSttLocalModel: (model: string) => {
set({ sttLocalModel: model });
if (typeof window !== 'undefined') {
localStorage.setItem('wasmSttModel', model);
localStorage.setItem('sttLocalModel', model);
}
updateDesktopSettings({ wasmSttModel: model }).catch(() => {});
updateDesktopSettings({ sttLocalModel: model }).catch(() => {});
},
setSttLanguage: (lang: string) => {
@@ -2904,30 +2924,6 @@ export const useConfigStore = create<ConfigStore>()(
updateDesktopSettings({ sttLanguage: lang }).catch(() => {});
},
setSttSilenceThresholdDb: (db: number) => {
set({ sttSilenceThresholdDb: db });
if (typeof window !== 'undefined') {
localStorage.setItem('sttSilenceThresholdDb', String(db));
}
updateDesktopSettings({ sttSilenceThresholdDb: db }).catch(() => {});
},
setSttSilenceHoldMs: (ms: number) => {
set({ sttSilenceHoldMs: ms });
if (typeof window !== 'undefined') {
localStorage.setItem('sttSilenceHoldMs', String(ms));
}
updateDesktopSettings({ sttSilenceHoldMs: ms }).catch(() => {});
},
setSttTranscribeOnStop: (enabled: boolean) => {
set({ sttTranscribeOnStop: enabled });
if (typeof window !== 'undefined') {
localStorage.setItem('sttTranscribeOnStop', String(enabled));
}
updateDesktopSettings({ sttTranscribeOnStop: enabled }).catch(() => {});
},
setShowMessageTTSButtons: (show: boolean) => {
set({ showMessageTTSButtons: show });
if (typeof window !== 'undefined') {
@@ -2935,20 +2931,13 @@ export const useConfigStore = create<ConfigStore>()(
}
},
setTtsInputMode: (mode: 'sanitized' | 'raw') => {
setTtsInputMode: (mode: 'sanitized' | 'raw' | 'summarized') => {
set({ ttsInputMode: mode });
if (typeof window !== 'undefined') {
localStorage.setItem('ttsInputMode', mode);
}
},
setVoiceModeEnabled: (enabled: boolean) => {
set({ voiceModeEnabled: enabled });
if (typeof window !== 'undefined') {
localStorage.setItem('voiceModeEnabled', String(enabled));
}
},
setSummarizeMessageTTS: (enabled: boolean) => {
set({ summarizeMessageTTS: enabled });
if (typeof window !== 'undefined') {
@@ -3253,7 +3242,7 @@ export const useConfigStore = create<ConfigStore>()(
}),
{
name: "config-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
merge: (persistedState, currentState) =>
hydrateActiveDirectorySnapshot({
...currentState,
@@ -3268,14 +3257,22 @@ export const useConfigStore = create<ConfigStore>()(
// success) and by the provider/agent config-change subscriptions.
partialize: (state) => ({
activeDirectoryKey: state.activeDirectoryKey,
directoryScoped: state.directoryScoped,
directoryScoped: Object.fromEntries(
Object.entries(state.directoryScoped).map(([directoryKey, snapshot]) => [
directoryKey,
{
...snapshot,
selectedProviderId: sanitizePersistedSelectedProviderId(snapshot.selectedProviderId),
},
]),
),
providers: state.providers,
agents: state.agents,
currentProviderId: state.currentProviderId,
currentModelId: state.currentModelId,
currentVariant: state.currentVariant,
currentAgentName: state.currentAgentName,
selectedProviderId: state.selectedProviderId,
selectedProviderId: sanitizePersistedSelectedProviderId(state.selectedProviderId),
agentModelSelections: state.agentModelSelections,
defaultProviders: state.defaultProviders,
settingsDefaultModel: state.settingsDefaultModel,
+2 -2
View File
@@ -6,7 +6,7 @@ import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { updateDesktopSettings } from '@/lib/persistence';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { getSafeStorage } from './utils/safeStorage';
import { getDeferredSafeStorage } from './utils/safeStorage';
interface DirectoryStore {
@@ -28,7 +28,7 @@ interface DirectoryStore {
let cachedHomeDirectory: string | null = null;
let homeResolveGeneration = 0;
const safeStorage = getSafeStorage();
const safeStorage = getDeferredSafeStorage();
const persistedLastDirectory = safeStorage.getItem('lastDirectory');
const initialHasPersistedDirectory =
typeof persistedLastDirectory === 'string' && persistedLastDirectory.length > 0;
@@ -28,4 +28,23 @@ describe('useFilesViewTabsStore', () => {
expect(useFilesViewTabsStore.getState().byRoot[root]?.expandedPaths).toEqual(['/repo/src']);
});
test('removes stale expanded paths by prefix without closing files', () => {
const root = '/repo';
const store = useFilesViewTabsStore.getState();
store.addOpenPath(root, '/repo/src/index.ts');
store.expandPaths(root, [
'/repo/src',
'/repo/bun test packages',
'/repo/bun test packages/web',
'/repo/other',
]);
store.removeExpandedPathsByPrefix(root, '/repo/bun test packages');
const state = useFilesViewTabsStore.getState().byRoot[root];
expect(state?.openPaths).toEqual(['/repo/src/index.ts']);
expect(state?.expandedPaths).toEqual(['/repo/src', '/repo/other']);
});
});
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { createJSONStorage, devtools, persist } from 'zustand/middleware';
import { devtools, persist } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
type RootTabsState = {
openPaths: string[];
@@ -18,6 +18,7 @@ type FilesViewTabsActions = {
addOpenPath: (root: string, path: string, options?: { allowOutsideRoot?: boolean }) => void;
removeOpenPath: (root: string, path: string) => void;
removeOpenPathsByPrefix: (root: string, prefixPath: string) => void;
removeExpandedPathsByPrefix: (root: string, prefixPath: string) => void;
setSelectedPath: (root: string, path: string | null, options?: { allowOutsideRoot?: boolean }) => void;
ensureSelectedPath: (root: string) => void;
toggleExpandedPath: (root: string, path: string) => void;
@@ -270,6 +271,43 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
});
},
removeExpandedPathsByPrefix: (root, prefixPath) => {
const normalizedRoot = normalizePath((root || '').trim());
const normalizedPrefix = normalizePath((prefixPath || '').trim());
if (!normalizedRoot || !normalizedPrefix) {
return;
}
set((state) => {
const current = state.byRoot[normalizedRoot];
if (!current) {
return state;
}
const comparablePrefix = toComparablePath(normalizedPrefix);
const comparablePrefixWithSlash = comparablePrefix.endsWith('/') ? comparablePrefix : `${comparablePrefix}/`;
const expandedPaths = current.expandedPaths.filter((candidate) => {
const comparablePath = toComparablePath(candidate);
return comparablePath !== comparablePrefix && !comparablePath.startsWith(comparablePrefixWithSlash);
});
if (expandedPaths.length === current.expandedPaths.length) {
return state;
}
const byRoot = {
...state.byRoot,
[normalizedRoot]: {
...current,
expandedPaths,
touchedAt: Date.now(),
},
};
return { byRoot: clampRoots(byRoot, 20) };
});
},
setSelectedPath: (root, path, options) => {
const normalizedRoot = normalizePath((root || '').trim());
const normalizedPath = path ? normalizePath(path.trim()) : null;
@@ -413,7 +451,7 @@ export const useFilesViewTabsStore = create<FilesViewTabsStore>()(
{
name: 'files-view-tabs-store',
version: 2,
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return { byRoot: {} };
@@ -1,8 +1,8 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { persist } from 'zustand/middleware';
import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types';
import { mapWithConcurrency } from '@/lib/concurrency';
import { getSafeStorage } from './utils/safeStorage';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
const PR_REVALIDATE_TTL_MS = 90_000;
const PR_REVALIDATE_INTERVAL_MS = 15_000;
@@ -93,6 +93,43 @@ const timers = new Map<string, number>();
const bootstrapTimers = new Map<string, number[]>();
const inFlightBySignature = new Set<string>();
const lastRefreshBySignature = new Map<string, number>();
// Global concurrency gate for PR-status network requests.
//
// PR status is non-critical chrome, but each request can be slow (the server
// makes many serial GitHub API calls and GitHub secondary-rate-limits bursts,
// so a single request can take 20s+). The browser allows only ~6 concurrent
// HTTP/1.1 connections per origin. Without this cap, watching N worktrees fires
// N PR-status requests at once (each startWatching() calls refresh() directly,
// bypassing refreshTargets' batch limiter), which saturates the connection pool
// and starves the critical path (bootstrap session.status, diffs, sending
// messages) for the full duration — the whole UI appears frozen on startup.
//
// Capping concurrency low guarantees free sockets remain for critical traffic.
const PR_STATUS_NETWORK_CONCURRENCY = 2;
let prStatusNetworkActive = 0;
const prStatusNetworkWaiters: Array<() => void> = [];
const acquirePrStatusNetworkSlot = (): Promise<void> => {
if (prStatusNetworkActive < PR_STATUS_NETWORK_CONCURRENCY) {
prStatusNetworkActive += 1;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
prStatusNetworkWaiters.push(resolve);
});
};
const releasePrStatusNetworkSlot = (): void => {
const next = prStatusNetworkWaiters.shift();
if (next) {
// Hand the slot directly to the next waiter — keep the active count steady.
next();
return;
}
prStatusNetworkActive = Math.max(0, prStatusNetworkActive - 1);
};
const createEntry = (): PrStatusEntry => ({
status: null,
isLoading: false,
@@ -488,7 +525,13 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
activeRequestCount: prev.activeRequestCount + 1,
totalRequestCount: prev.totalRequestCount + 1,
}));
const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined, { force: options?.force });
await acquirePrStatusNetworkSlot();
let next: GitHubPullRequestStatus;
try {
next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined, { force: options?.force });
} finally {
releasePrStatusNetworkSlot();
}
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {
@@ -604,7 +647,7 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
}),
{
name: PR_STATUS_STORAGE_KEY,
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
entries: Object.fromEntries(
Object.entries(state.entries)
@@ -632,15 +675,6 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
},
),
);
export const usePrStatusForDirectoryBranch = (directory: string | null, branch: string | null) => {
return useGitHubPrStatusStore((state) => {
if (!directory || !branch) return null;
const key = getGitHubPrStatusKey(directory, branch);
return state.entries[key] ?? null;
});
};
export type PrVisualSummary = {
number: number;
visualState: string;
@@ -1,7 +1,7 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { getSafeStorage } from "./utils/safeStorage";
import { devtools, persist } from "zustand/middleware";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import {
getGitIdentities,
createGitIdentity,
@@ -23,6 +23,8 @@ export interface GitIdentityProfile {
userEmail: string;
authType?: GitIdentityAuthType;
sshKey?: string | null;
signCommits?: boolean;
signingKey?: string | null;
host?: string | null;
color?: string | null;
icon?: string | null;
@@ -269,7 +271,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
}),
{
name: "git-identities-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedProfileId: state.selectedProfileId,
}),
+3 -37
View File
@@ -7,7 +7,7 @@ import type {
GitLogResponse,
GitIdentitySummary,
} from '@/lib/api/types';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
const LOG_STALE_THRESHOLD = 10000;
const REPO_CHECK_STALE_THRESHOLD = 60_000;
@@ -155,7 +155,7 @@ const GIT_BRANCH_CACHE_KEY = 'oc.gitBranchCache';
const readBranchCache = (): Record<string, GitBranch> => {
try {
const raw = getSafeStorage().getItem(GIT_BRANCH_CACHE_KEY);
const raw = getDeferredSafeStorage().getItem(GIT_BRANCH_CACHE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, GitBranch>;
return parsed && typeof parsed === 'object' ? parsed : {};
@@ -169,7 +169,7 @@ const writeCachedBranches = (directory: string, branches: GitBranch): void => {
try {
const cache = readBranchCache();
cache[directory] = branches;
getSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache));
getDeferredSafeStorage().setItem(GIT_BRANCH_CACHE_KEY, JSON.stringify(cache));
} catch {
// quota / serialization — ignore; live fetch still refreshes the store
}
@@ -1048,13 +1048,6 @@ export const useIsGitRepo = (directory: string | null) => {
});
};
export const useGitFileCount = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return 0;
return state.directories.get(directory)?.status?.files?.length ?? 0;
});
};
export const useGitBranchLabel = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return null;
@@ -1083,26 +1076,6 @@ export const useGitAllBranches = () => {
});
};
export const useGitBranchMap = (directories: string[]) => {
const cacheRef = React.useRef<Map<string, string | null>>(new Map());
return useGitStore((state) => {
const prev = cacheRef.current;
let same = prev.size === directories.length;
if (same) {
for (const dir of directories) {
if (prev.get(dir) !== (state.directories.get(dir)?.status?.current ?? null)) { same = false; break; }
}
}
if (same) return prev;
const result = new Map<string, string | null>();
for (const dir of directories) {
result.set(dir, state.directories.get(dir)?.status?.current ?? null);
}
cacheRef.current = result;
return result;
});
};
export const useGitRepoStatusMap = (directories: string[]) => {
const cacheRef = React.useRef<Map<string, { isGitRepo: boolean | null; branch: string | null }>>(new Map());
return useGitStore((state) => {
@@ -1146,10 +1119,3 @@ export const useGitLoadingBranches = (directory: string | null) => {
return state.directories.get(directory)?.isLoadingBranches ?? false;
});
};
export const useGitLoadingIdentity = (directory: string | null) => {
return useGitStore((state) => {
if (!directory) return false;
return state.directories.get(directory)?.isLoadingIdentity ?? false;
});
};
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from './useGlobalSessionsStore';
import { resolveGlobalSessionDirectory, mergeLiveSessionWithGlobalSession, useGlobalSessionsStore } from './useGlobalSessionsStore';
type SessionExtra = Partial<Session> & {
directory?: string | null;
@@ -79,3 +79,30 @@ describe('useGlobalSessionsStore', () => {
expect(resolveGlobalSessionDirectory(useGlobalSessionsStore.getState().archivedSessions[0])).toBe('/repo/app');
});
});
describe('mergeLiveSessionWithGlobalSession', () => {
test('preserves global share over live share', () => {
const live = buildSession('https://live.example/s', { time: { created: 1, updated: 5 } });
const global = buildSession('https://global.example/s', { time: { created: 1, updated: 3 } });
const merged = mergeLiveSessionWithGlobalSession(live, global);
expect(merged.share?.url).toBe('https://global.example/s');
expect(merged.time?.updated).toBe(5);
});
test('preserves directory from global when live omits it', () => {
const live = buildSession('https://live.example/s', { time: { created: 1, updated: 5 } });
const global = buildSession('https://global.example/s', { directory: '/repo/app' });
const merged = mergeLiveSessionWithGlobalSession(live, global);
expect(resolveGlobalSessionDirectory(merged)).toBe('/repo/app');
});
test('live directory takes precedence over global when present', () => {
const live = buildSession('https://live.example/s', { directory: '/repo/worktree' });
const global = buildSession('https://global.example/s', { directory: '/repo/app' });
const merged = mergeLiveSessionWithGlobalSession(live, global);
expect(resolveGlobalSessionDirectory(merged)).toBe('/repo/worktree');
});
});
@@ -25,11 +25,17 @@ type GlobalSessionsState = {
upsertSession: (session: Session) => void;
removeSessions: (ids: Iterable<string>) => void;
archiveSessions: (ids: Iterable<string>, archivedAt?: number) => void;
/** Drop every session from the previous runtime instance and go back to the
unloaded state, so a fresh load runs against the new endpoint. */
resetForRuntimeSwitch: () => void;
};
const PAGE_SIZE = 500;
let inflightLoad: Promise<LoadResult> | null = null;
// Bumped on runtime switch: an in-flight load from the previous instance must
// not apply its (stale) snapshot after the reset.
let loadGeneration = 0;
const normalizePath = (value?: string | null): string | null => {
if (typeof value !== 'string') {
@@ -100,6 +106,17 @@ export const mergeSessionDirectoryMetadata = (incoming: Session, existing?: Sess
return changed ? next : incoming;
};
export const mergeLiveSessionWithGlobalSession = (
liveSession: Session,
globalSession: Session,
): Session => {
const merged = mergeSessionDirectoryMetadata(liveSession, globalSession);
if (merged.share !== globalSession.share) {
return { ...merged, share: globalSession.share };
}
return merged;
};
const buildSessionsByDirectory = (sessions: Session[]): Map<string, Session[]> => {
const next = new Map<string, Session[]>();
for (const session of sessions) {
@@ -352,6 +369,19 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
set((state) => applySnapshot(state, activeSessions, archivedSessions, status));
},
resetForRuntimeSwitch: () => {
loadGeneration += 1;
inflightLoad = null;
set({
activeSessions: [],
archivedSessions: [],
sessionsByDirectory: new Map(),
reviewTransferBySessionId: new Map(),
hasLoaded: false,
status: 'idle',
});
},
loadSessions: async (fallbackActive) => {
if (inflightLoad) {
return inflightLoad;
@@ -359,6 +389,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
set((state) => (state.status === 'loading' ? state : { status: 'loading' }));
const generation = loadGeneration;
inflightLoad = (async () => {
const current = get();
@@ -384,9 +415,20 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
}
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'ready'));
if (generation !== loadGeneration) {
// Runtime switched mid-load: this snapshot belongs to the previous
// instance — drop it.
return { activeSessions: [], archivedSessions: [] };
}
const status = activeResult.status === 'fulfilled' && archivedResult.status === 'fulfilled'
? 'ready'
: 'error';
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, status));
return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions };
} catch (error) {
if (generation !== loadGeneration) {
return { activeSessions: [], archivedSessions: [] };
}
const nextActiveSessions = mergeSessionLists(current.activeSessions, fallbackActive);
const nextArchivedSessions = current.archivedSessions;
console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error);
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
import { devtools, persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-console' | 'preview-annotation';
@@ -207,7 +207,7 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
}),
{
name: 'openchamber-inline-comment-drafts',
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
version: 1,
migrate: (persistedState: unknown) => {
if (!persistedState || typeof persistedState !== 'object') {
@@ -224,5 +224,3 @@ export const useInlineCommentDraftStore = create<InlineCommentDraftStore>()(
{ name: 'inline-comment-draft-store' }
)
);
export default useInlineCommentDraftStore;
+8 -8
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
import { devtools, persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import {
startConfigUpdate,
finishConfigUpdate,
@@ -39,21 +39,21 @@ const getConfigDirectory = (): string | null => {
// ============== TYPES ==============
export interface McpLocalConfig {
interface McpLocalConfig {
type: 'local';
command: string[];
environment?: Record<string, string>;
enabled: boolean;
}
export interface McpOAuthConfig {
interface McpOAuthConfig {
clientId?: string;
clientSecret?: string;
scope?: string;
redirectUri?: string;
}
export interface McpRemoteConfig {
interface McpRemoteConfig {
type: 'remote';
url: string;
environment?: Record<string, string>;
@@ -64,7 +64,7 @@ export interface McpRemoteConfig {
}
export type McpServerConfig = (McpLocalConfig | McpRemoteConfig) & { name: string };
export type McpServerWithScope = McpServerConfig & { scope?: McpScope | null };
type McpServerWithScope = McpServerConfig & { scope?: McpScope | null };
export interface McpDraft {
name: string;
@@ -90,7 +90,7 @@ export const envRecordToArray = (env?: Record<string, string>): Array<{ key: str
return Object.entries(env).map(([key, value]) => ({ key, value }));
};
export const envArrayToRecord = (arr: Array<{ key: string; value: string }>): Record<string, string> | undefined => {
const envArrayToRecord = (arr: Array<{ key: string; value: string }>): Record<string, string> | undefined => {
const filtered = arr.filter((e) => e.key.trim());
if (filtered.length === 0) return undefined;
return Object.fromEntries(filtered.map((e) => [e.key.trim(), e.value]));
@@ -350,7 +350,7 @@ export const useMcpConfigStore = create<McpConfigStore>()(
}),
{
name: 'mcp-config-store',
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ selectedMcpName: state.selectedMcpName }),
},
),
+2 -2
View File
@@ -5,11 +5,11 @@ import { opencodeClient } from '@/lib/opencode/client';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
export type McpStatusMap = Record<string, McpStatus>;
export type McpRuntimeDiagnostic = {
type McpRuntimeDiagnostic = {
status: 'failed';
error: string;
};
export type McpRuntimeDiagnosticMap = Record<string, McpRuntimeDiagnostic>;
type McpRuntimeDiagnosticMap = Record<string, McpRuntimeDiagnostic>;
const EMPTY_STATUS: McpStatusMap = {};
const EMPTY_DIAGNOSTICS: McpRuntimeDiagnosticMap = {};
@@ -0,0 +1,36 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
/**
* Persisted collapsed state for the collapsible sections (accordions) in the
* model/provider picker (`ModelPickerList`): the `favorites` and `recent`
* sections plus each `provider:<id>` group. Section keys are stable and shared
* across every picker surface, so collapsing a provider in one picker is
* remembered everywhere and survives remounts and full page reloads.
*
* Only collapsed keys are stored (presence === collapsed); the default for any
* unknown key is expanded.
*/
type ModelPickerSectionsStore = {
collapsedSections: Record<string, boolean>;
toggleSection: (key: string) => void;
};
export const useModelPickerSectionsStore = create<ModelPickerSectionsStore>()(
persist(
(set) => ({
collapsedSections: {},
toggleSection: (key) =>
set((state) => {
const next = { ...state.collapsedSections };
if (next[key]) delete next[key];
else next[key] = true;
return { collapsedSections: next };
}),
}),
{
name: 'model-picker-collapsed-sections',
version: 1,
},
),
);
@@ -6,7 +6,10 @@ const registeredDirectories: Array<{ sessionID: string; directory: string }> = [
const ensureChildCalls: Array<{ directory: string; bootstrap?: boolean }> = [];
const worktreeMetadataCalls: Array<{ sessionId: string; path: string }> = [];
const worktreeCreateCalls: Array<{ project: { id?: string; path: string }; args: Record<string, unknown>; options: unknown }> = [];
const worktreeBootstrapWaitCalls: string[] = [];
const operationOrder: string[] = [];
let isGitRepository = false;
let waitForWorktreeSetup = false;
const createWorktreeWithDefaultsMock = mock((project: { id?: string; path: string }, args: Record<string, unknown>, options: unknown) => {
worktreeCreateCalls.push({ project, args, options });
return Promise.resolve({
@@ -52,12 +55,15 @@ mock.module('@/lib/opencode/client', () => ({
currentDirectory = previous;
}
},
createSession: async (params?: { title?: string }): Promise<Session> => ({
id: 'ses_multirun',
title: params?.title ?? '',
directory: currentDirectory,
time: { created: 1, updated: 1 },
} as Session),
createSession: async (params?: { title?: string }): Promise<Session> => {
operationOrder.push(`createSession:${currentDirectory}`);
return {
id: 'ses_multirun',
title: params?.title ?? '',
directory: currentDirectory,
time: { created: 1, updated: 1 },
} as Session;
},
},
}));
@@ -70,11 +76,20 @@ mock.module('@/lib/worktrees/worktreeCreate', () => ({
resolveRootTrackingRemote: mock(() => Promise.resolve(null)),
}));
mock.module('@/lib/worktrees/worktreeBootstrap', () => ({
waitForWorktreeBootstrap: (directory: string) => {
worktreeBootstrapWaitCalls.push(directory);
operationOrder.push(`wait:${directory}`);
return Promise.resolve();
},
}));
mock.module('@/lib/worktrees/worktreeStatus', () => ({
getRootBranch: mock(() => Promise.resolve('main')),
}));
mock.module('@/lib/openchamberConfig', () => ({
getWorktreeSetupWaitEnabled: mock(() => Promise.resolve(waitForWorktreeSetup)),
saveWorktreeSetupCommands: mock(() => Promise.resolve()),
}));
@@ -139,7 +154,10 @@ describe('useMultiRunStore', () => {
ensureChildCalls.length = 0;
worktreeMetadataCalls.length = 0;
worktreeCreateCalls.length = 0;
worktreeBootstrapWaitCalls.length = 0;
operationOrder.length = 0;
isGitRepository = false;
waitForWorktreeSetup = false;
childState.session = [];
childState.sessionTotal = 0;
childState.limit = 5;
@@ -181,7 +199,30 @@ describe('useMultiRunStore', () => {
expect(worktreeCreateCalls[0]?.project).toEqual({ id: 'project-1', path: '/repo' });
expect(worktreeCreateCalls[0]?.args.returnAfterDirectoryCreated).toBe(true);
expect(worktreeCreateCalls[0]?.options).toEqual({ resolvedRootTrackingRemote: null });
expect(worktreeBootstrapWaitCalls).toEqual([]);
expect(operationOrder).toEqual(['createSession:/repo-worktrees/fix-thing']);
expect(registeredDirectories).toEqual([{ sessionID: 'ses_multirun', directory: '/repo-worktrees/fix-thing' }]);
expect(worktreeMetadataCalls).toEqual([{ sessionId: 'ses_multirun', path: '/repo-worktrees/fix-thing' }]);
});
test('waits for isolated worktree bootstrap when setup wait is enabled', async () => {
isGitRepository = true;
waitForWorktreeSetup = true;
const result = await useMultiRunStore.getState().createMultiRun({
name: 'Fix thing',
isolateRuns: true,
groups: [{
prompt: 'Fix it',
models: [{ providerID: 'anthropic', modelID: 'claude-sonnet-4-5' }],
}],
});
expect(result?.sessionIds).toEqual(['ses_multirun']);
expect(worktreeBootstrapWaitCalls).toEqual(['/repo-worktrees/fix-thing']);
expect(operationOrder).toEqual([
'wait:/repo-worktrees/fix-thing',
'createSession:/repo-worktrees/fix-thing',
]);
});
});
+6 -1
View File
@@ -4,9 +4,10 @@ import { routeMessage, useSessionUIStore } from '@/sync/session-ui-store';
import { devtools } from 'zustand/middleware';
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
import { opencodeClient } from '@/lib/opencode/client';
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { getWorktreeSetupWaitEnabled, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
import { createWorktreeWithDefaults, resolveRootTrackingRemote } from '@/lib/worktrees/worktreeCreate';
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { checkIsGitRepository } from '@/lib/gitApi';
import { useDirectoryStore } from './useDirectoryStore';
@@ -244,6 +245,10 @@ export const useMultiRunStore = create<MultiRunStore>()(
kind: 'standard' as const,
};
if (await getWorktreeSetupWaitEnabled(project)) {
await waitForWorktreeBootstrap(worktreeMetadata.path);
}
const session = await opencodeClient.withDirectory(
worktreeMetadata.path,
() => opencodeClient.createSession({ title: sessionTitle }),
+5 -5
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
import { devtools, persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import {
startConfigUpdate,
finishConfigUpdate,
@@ -11,7 +11,7 @@ import { opencodeClient } from '@/lib/opencode/client';
import { runtimeFetch } from '@/lib/runtime-fetch';
export type PluginScope = 'user' | 'project';
export type PluginParsedKind = 'npm' | 'path';
type PluginParsedKind = 'npm' | 'path';
export interface PluginEntry {
id: string;
@@ -123,7 +123,7 @@ const getConfigDirectory = (): string | null => {
};
const CLIENT_RELOAD_DELAY_MS = 800;
export const PLUGINS_LOAD_CACHE_TTL_MS = 5000;
const PLUGINS_LOAD_CACHE_TTL_MS = 5000;
const DEFAULT_PLUGINS_CACHE_KEY = '__default__';
const pluginsLastLoadedAt = new Map<string, number>();
const pluginsLoadInFlight = new Map<string, Promise<boolean>>();
@@ -352,7 +352,7 @@ export const usePluginsStore = create<PluginsStore>()(
}),
{
name: 'plugins-store',
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ selectedId: state.selectedId }),
},
),
+85 -15
View File
@@ -6,7 +6,7 @@ import type { ProjectEntry } from '@/lib/api/types';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { createProjectIdFromPath } from '@/lib/projectId';
import { getSafeStorage } from './utils/safeStorage';
import { getDeferredSafeStorage } from './utils/safeStorage';
import { useDirectoryStore } from './useDirectoryStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
import { PROJECT_COLORS } from '@/lib/projectMeta';
@@ -46,13 +46,20 @@ interface VSCodeWorkspaceFolderConfig {
interface ProjectsStore {
projects: ProjectEntry[];
activeProjectId: string | null;
manualProjectOrder: string[];
addProject: (path: string, options?: { label?: string; id?: string }) => ProjectEntry | null;
removeProject: (id: string) => void;
setActiveProject: (id: string) => void;
setActiveProjectIdOnly: (id: string) => void;
renameProject: (id: string, label: string) => void;
updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null; iconBackground?: string | null }) => void;
updateProjectMeta: (id: string, meta: {
label?: string;
icon?: string | null;
color?: string | null;
iconBackground?: string | null;
defaultModel?: string | null;
}) => void;
uploadProjectIcon: (id: string, file: File) => Promise<{ ok: boolean; error?: string }>;
removeProjectIcon: (id: string) => Promise<{ ok: boolean; error?: string }>;
discoverProjectIcon: (id: string, options?: { force?: boolean }) => Promise<{ ok: boolean; skipped?: boolean; reason?: string; error?: string }>;
@@ -64,7 +71,7 @@ interface ProjectsStore {
getActiveProject: () => ProjectEntry | null;
}
const safeStorage = getSafeStorage();
const safeStorage = getDeferredSafeStorage();
const PROJECTS_STORAGE_KEY = 'projects';
const ACTIVE_PROJECT_STORAGE_KEY = 'activeProjectId';
@@ -116,6 +123,21 @@ const resolveTildePath = (value: string, homeDir?: string | null): string => {
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
const normalizeDefaultModel = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const separatorIndex = trimmed.indexOf('/');
if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) {
return undefined;
}
return trimmed;
};
const normalizeIconBackground = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null;
@@ -254,6 +276,10 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
if (typeof candidate.color === 'string' && candidate.color.trim().length > 0) {
project.color = candidate.color.trim();
}
const defaultModel = normalizeDefaultModel(candidate.defaultModel);
if (defaultModel) {
project.defaultModel = defaultModel;
}
if (candidate.iconBackground === null) {
project.iconBackground = null;
} else {
@@ -290,6 +316,17 @@ const readPersistedProjects = (): ProjectEntry[] => {
}
};
const readPersistedManualOrder = (): string[] => {
try {
const raw = safeStorage.getItem(getProjectsStorageKey() + ':manualOrder');
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
} catch {
return [];
}
};
const readPersistedActiveProjectId = (): string | null => {
try {
const raw = safeStorage.getItem(getActiveProjectStorageKey())
@@ -322,11 +359,22 @@ const cacheProjects = (projects: ProjectEntry[], activeProjectId: string | null)
}
};
const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null) => {
const persistProjects = (projects: ProjectEntry[], activeProjectId: string | null, manualOrder?: string[]) => {
cacheProjects(projects, activeProjectId);
if (manualOrder) {
persistManualProjectOrder(manualOrder);
}
void updateDesktopSettings({ projects, activeProjectId: activeProjectId ?? undefined });
};
const persistManualProjectOrder = (manualOrder: string[]) => {
try {
safeStorage.setItem(getProjectsStorageKey() + ':manualOrder', JSON.stringify(manualOrder));
} catch {
// ignored
}
};
const initialProjects = readPersistedProjects();
const normalizeVSCodeWorkspaceFolders = (folders: VSCodeWorkspaceFolderConfig[]): VSCodeWorkspaceFolderConfig[] => {
const result: VSCodeWorkspaceFolderConfig[] = [];
@@ -470,6 +518,7 @@ const vscodeWorkspaceProjectsEqual = (left: ProjectEntry[], right: ProjectEntry[
&& leftProject.icon === rightProject.icon
&& leftProject.color === rightProject.color
&& leftProject.iconBackground === rightProject.iconBackground
&& leftProject.defaultModel === rightProject.defaultModel
&& leftProject.addedAt === rightProject.addedAt
&& leftProject.lastOpenedAt === rightProject.lastOpenedAt
&& leftProject.sidebarCollapsed === rightProject.sidebarCollapsed
@@ -510,6 +559,7 @@ export const useProjectsStore = create<ProjectsStore>()(
devtools((set, get) => ({
projects: effectiveInitialProjects,
activeProjectId: initialActiveProjectId,
manualProjectOrder: readPersistedManualOrder(),
validateProjectPath: (path: string): ProjectPathValidationResult => {
if (typeof path !== 'string' || path.trim().length === 0) {
@@ -578,8 +628,9 @@ export const useProjectsStore = create<ProjectsStore>()(
nextActiveId = nextProjects[0]?.id ?? null;
}
set({ projects: nextProjects, activeProjectId: nextActiveId });
persistProjects(nextProjects, nextActiveId);
const nextManualOrder = get().manualProjectOrder.filter((oid) => oid !== id);
set({ projects: nextProjects, activeProjectId: nextActiveId, manualProjectOrder: nextManualOrder });
persistProjects(nextProjects, nextActiveId, nextManualOrder);
// Clean up worktree entries for the removed project
if (project) {
@@ -621,7 +672,7 @@ export const useProjectsStore = create<ProjectsStore>()(
);
set({ projects: nextProjects, activeProjectId: id });
persistProjects(nextProjects, id);
persistProjects(nextProjects, id, get().manualProjectOrder);
opencodeClient.setDirectory(target.path);
useDirectoryStore.getState().setDirectory(target.path, { showOverlay: false });
@@ -646,7 +697,7 @@ export const useProjectsStore = create<ProjectsStore>()(
);
set({ projects: nextProjects, activeProjectId: id });
persistProjects(nextProjects, id);
persistProjects(nextProjects, id, get().manualProjectOrder);
},
renameProject: (id: string, label: string) => {
@@ -663,10 +714,16 @@ export const useProjectsStore = create<ProjectsStore>()(
project.id === id ? { ...project, label: trimmed } : project
);
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
persistProjects(nextProjects, activeProjectId, get().manualProjectOrder);
},
updateProjectMeta: (id: string, meta: { label?: string; icon?: string | null; color?: string | null; iconBackground?: string | null }) => {
updateProjectMeta: (id: string, meta: {
label?: string;
icon?: string | null;
color?: string | null;
iconBackground?: string | null;
defaultModel?: string | null;
}) => {
if (isVSCodeProjectsRuntime) {
return;
}
@@ -683,10 +740,18 @@ export const useProjectsStore = create<ProjectsStore>()(
if (meta.iconBackground !== undefined) {
updated.iconBackground = normalizeIconBackground(meta.iconBackground);
}
if (meta.defaultModel !== undefined) {
const normalized = normalizeDefaultModel(meta.defaultModel);
if (normalized) {
updated.defaultModel = normalized;
} else {
delete updated.defaultModel;
}
}
return updated;
});
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
persistProjects(nextProjects, activeProjectId, get().manualProjectOrder);
},
uploadProjectIcon: async (id: string, file: File) => {
@@ -823,8 +888,9 @@ export const useProjectsStore = create<ProjectsStore>()(
const [moved] = nextProjects.splice(fromIndex, 1);
nextProjects.splice(toIndex, 0, moved);
set({ projects: nextProjects });
persistProjects(nextProjects, activeProjectId);
const newOrder = nextProjects.map((p) => p.id);
set({ projects: nextProjects, manualProjectOrder: newOrder });
persistProjects(nextProjects, activeProjectId, newOrder);
},
resetForRuntimeSwitch: () => {
@@ -836,7 +902,7 @@ export const useProjectsStore = create<ProjectsStore>()(
const nextActiveProjectId = projects.some((project) => project.id === activeProjectId)
? activeProjectId
: projects[0]?.id ?? null;
set({ projects, activeProjectId: nextActiveProjectId });
set({ projects, activeProjectId: nextActiveProjectId, manualProjectOrder: [] });
},
synchronizeFromSettings: (settings: DesktopSettings) => {
@@ -863,6 +929,7 @@ export const useProjectsStore = create<ProjectsStore>()(
if (activeExists) {
set({ activeProjectId: incomingActive });
cacheProjects(current.projects, incomingActive);
persistManualProjectOrder(get().manualProjectOrder);
}
}
return;
@@ -875,8 +942,11 @@ export const useProjectsStore = create<ProjectsStore>()(
return;
}
set({ projects: incomingProjects, activeProjectId: incomingActive });
const incomingIds = new Set(incomingProjects.map((p) => p.id));
const cleanedOrder = get().manualProjectOrder.filter((id) => incomingIds.has(id));
set({ projects: incomingProjects, activeProjectId: incomingActive, manualProjectOrder: cleanedOrder });
cacheProjects(incomingProjects, incomingActive);
persistManualProjectOrder(cleanedOrder);
if (incomingActive) {
const activeProject = incomingProjects.find((project) => project.id === incomingActive);
@@ -1,17 +1,21 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type SessionDisplayMode = 'default' | 'minimal';
type SessionDisplayMode = 'default' | 'minimal';
type ProjectSortOrder = 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent';
type SessionDisplayStore = {
displayMode: SessionDisplayMode;
showRecentSection: boolean;
showArchivedSessions: boolean;
projectSortOrder: ProjectSortOrder;
setDisplayMode: (mode: SessionDisplayMode) => void;
setShowRecentSection: (show: boolean) => void;
setShowArchivedSessions: (show: boolean) => void;
toggleRecentSection: () => void;
toggleArchivedSessions: () => void;
setProjectSortOrder: (order: ProjectSortOrder) => void;
};
export const useSessionDisplayStore = create<SessionDisplayStore>()(
@@ -24,25 +28,33 @@ export const useSessionDisplayStore = create<SessionDisplayStore>()(
// disappear once the persisted preference rehydrates. Users who opted into
// showing archived have `true` persisted, which is preserved on rehydrate.
showArchivedSessions: false,
projectSortOrder: 'recent',
setDisplayMode: (mode) => set({ displayMode: mode }),
setShowRecentSection: (show) => set({ showRecentSection: show }),
setShowArchivedSessions: (show) => set({ showArchivedSessions: show }),
toggleRecentSection: () => set((state) => ({ showRecentSection: !state.showRecentSection })),
toggleArchivedSessions: () => set((state) => ({ showArchivedSessions: !state.showArchivedSessions })),
setProjectSortOrder: (order) => set({ projectSortOrder: order }),
}),
{
name: 'session-display-mode',
version: 1,
version: 2,
// v0 shipped 'default' as the only/initial mode, so most existing users
// have it persisted by accident rather than choice. Nudge everyone onto
// minimal once so the mode can be evaluated before removing it entirely.
// v1→v2 adds projectSortOrder defaulting to 'recent'.
migrate: (persisted, version) => {
const state = (persisted ?? {}) as Partial<SessionDisplayStore>;
if (version < 1) {
return { ...state, displayMode: 'minimal' };
return { ...state, displayMode: 'minimal', projectSortOrder: 'recent' };
}
if (version < 2) {
return { ...state, projectSortOrder: 'recent' };
}
return state;
},
},
),
);
export type { ProjectSortOrder };
@@ -22,6 +22,7 @@ const safeStorage = {
} as Storage;
mock.module('./utils/safeStorage', () => ({
getDeferredSafeStorage: () => safeStorage,
getSafeStorage: () => safeStorage,
}));
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { getSafeStorage } from './utils/safeStorage';
import { getDeferredSafeStorage } from './utils/safeStorage';
import { isVSCodeRuntime } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -46,7 +46,7 @@ const SESSION_FOLDERS_API_PATH = '/api/session-folders';
const DISK_WRITE_DEBOUNCE_MS = 250;
const ARCHIVED_SCOPE_PREFIX = '__archived__:';
const safeStorage = getSafeStorage();
const safeStorage = getDeferredSafeStorage();
let diskWriteTimer: ReturnType<typeof setTimeout> | null = null;
let diskHydrated = false;
let diskHydrationInFlight = false;
@@ -0,0 +1,29 @@
import { create } from 'zustand';
// Narrow store for the "next message starts a goal" flag. Armed by the
// composer target button (works for existing sessions AND session drafts)
// and by the run-as-goal flows (fork dialog, plan send); consumed by
// sendMessage in session-ui-store, which turns the sent prompt into the
// goal objective — unless the arming flow supplied a richer objective
// override (e.g. the plan content instead of "Implement this plan: X").
interface SessionGoalArmStore {
armed: boolean;
objectiveOverride: string | null;
setArmed: (armed: boolean, objectiveOverride?: string | null) => void;
/** Read-and-clear in one step at send time. */
consume: () => { armed: boolean; objectiveOverride: string | null };
}
export const useSessionGoalArmStore = create<SessionGoalArmStore>((set, get) => ({
armed: false,
objectiveOverride: null,
setArmed: (armed, objectiveOverride = null) => set({
armed,
objectiveOverride: armed ? objectiveOverride : null,
}),
consume: () => {
const { armed, objectiveOverride } = get();
if (armed) set({ armed: false, objectiveOverride: null });
return { armed, objectiveOverride };
},
}));
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { getSafeStorage } from './utils/safeStorage';
import { getDeferredSafeStorage } from './utils/safeStorage';
const SESSION_PINNED_STORAGE_KEY = 'oc.sessions.pinned';
@@ -29,7 +29,7 @@ type SessionPinnedStore = {
toggle: (sessionId: string) => void;
};
const safeStorage = getSafeStorage();
const safeStorage = getDeferredSafeStorage();
export const useSessionPinnedStore = create<SessionPinnedStore>((set, get) => ({
ids: readPinned(safeStorage),
+5 -5
View File
@@ -1,13 +1,13 @@
import { create } from "zustand";
import type { StoreApi, UseBoundStore } from "zustand";
import { devtools, persist, createJSONStorage } from "zustand/middleware";
import { devtools, persist } from "zustand/middleware";
import { emitConfigChange, scopeMatches, subscribeToConfigChanges } from "@/lib/configSync";
import {
startConfigUpdate,
finishConfigUpdate,
updateConfigUpdateMessage,
} from "@/lib/configUpdate";
import { getSafeStorage } from "./utils/safeStorage";
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { runtimeFetch } from "@/lib/runtime-fetch";
import { opencodeClient } from '@/lib/opencode/client';
@@ -40,7 +40,7 @@ export interface SupportingFile {
fullPath: string;
}
export interface SkillSources {
interface SkillSources {
md: {
exists: boolean;
path: string | null;
@@ -123,7 +123,7 @@ export interface SkillDraft {
pendingFiles?: PendingFile[];
}
export interface SkillDetail {
interface SkillDetail {
name: string;
sources: SkillSources;
scope?: SkillScope | null;
@@ -490,7 +490,7 @@ export const useSkillsStore = create<SkillsStore>()(
}),
{
name: "skills-store",
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({
selectedSkillName: state.selectedSkillName,
}),
+6 -3
View File
@@ -4,10 +4,11 @@ import type { Snippet } from '@/types/snippet';
import { opencodeClient } from '@/lib/opencode/client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
export type SnippetScope = 'global' | 'project';
export interface SnippetDraft {
interface SnippetDraft {
name: string;
scope: SnippetScope;
content?: string;
@@ -37,10 +38,12 @@ let loadInFlight: Promise<boolean> | null = null;
const getRequestDirectory = (): string | null => {
try {
const activeProject = useProjectsStore.getState().getActiveProject?.();
if (activeProject?.path?.trim()) return activeProject.path.trim();
const currentDirectory = useDirectoryStore.getState().currentDirectory;
if (currentDirectory?.trim()) return currentDirectory.trim();
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) return clientDir.trim();
const activeProject = useProjectsStore.getState().getActiveProject?.();
if (activeProject?.path?.trim()) return activeProject.path.trim();
} catch (error) {
console.warn('[SnippetsStore] Error resolving config directory:', error);
}
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { createJSONStorage, devtools, persist } from 'zustand/middleware';
import { devtools, persist } from 'zustand/middleware';
import type { Todo } from '@opencode-ai/sdk/v2/client';
import { getSafeStorage } from './utils/safeStorage';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
const MAX_SESSIONS = 50;
@@ -55,7 +55,7 @@ export const useTodosPersistStore = create<TodosPersistState>()(
{
name: 'openchamber-session-todos',
version: 1,
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ sessions: state.sessions }),
},
),
+120 -15
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { devtools, persist } from 'zustand/middleware';
import type { SidebarSection } from '@/constants/sidebar';
import { getSafeStorage } from './utils/safeStorage';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
import type { ShortcutCombo } from '@/lib/shortcuts';
import type { DraftStarterRef } from '@/lib/draftStarters';
@@ -10,6 +10,7 @@ import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobi
import { getRuntimeKey } from '@/lib/runtime-switch';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
export type PendingDiffScope = 'working' | 'staged' | 'turn';
export type RightSidebarTab = 'git' | 'files' | 'context';
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser';
export type MermaidRenderingMode = 'svg' | 'ascii';
@@ -31,8 +32,10 @@ type ContextPanelTab = {
targetPath: string | null;
dedupeKey: string;
label: string | null;
sessionTitleFallback: string | null;
readOnly: boolean;
stagedDiff: boolean;
diffScope: PendingDiffScope | null;
touchedAt: number;
};
@@ -41,8 +44,10 @@ type ContextPanelTabDescriptor = {
targetPath?: string | null;
dedupeKey?: string | null;
label?: string | null;
sessionTitleFallback?: string | null;
readOnly?: boolean;
stagedDiff?: boolean;
diffScope?: PendingDiffScope | null;
};
type ContextPanelDirectoryState = {
@@ -175,6 +180,10 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu
: trimmed;
};
const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
return value === 'working' || value === 'staged' || value === 'turn' ? value : null;
};
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
if (mode === 'file') {
return targetPath || mode;
@@ -223,8 +232,10 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
targetPath: normalizedTargetPath,
dedupeKey,
label: normalizeContextTabLabel(descriptor.label),
sessionTitleFallback: normalizeContextTabLabel(descriptor.sessionTitleFallback),
readOnly: descriptor.readOnly === true,
stagedDiff: descriptor.stagedDiff === true,
diffScope: normalizePendingDiffScope(descriptor.diffScope) ?? (descriptor.stagedDiff === true ? 'staged' : 'working'),
touchedAt: Date.now(),
};
};
@@ -263,8 +274,10 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
targetPath?: unknown;
dedupeKey?: unknown;
label?: unknown;
sessionTitleFallback?: unknown;
readOnly?: unknown;
stagedDiff?: unknown;
diffScope?: unknown;
touchedAt?: unknown;
};
@@ -290,8 +303,10 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
targetPath,
dedupeKey,
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
sessionTitleFallback: normalizeContextTabLabel(typeof candidate.sessionTitleFallback === 'string' ? candidate.sessionTitleFallback : null),
readOnly: candidate.readOnly === true,
stagedDiff: candidate.stagedDiff === true,
diffScope: normalizePendingDiffScope(candidate.diffScope) ?? (candidate.stagedDiff === true ? 'staged' : 'working'),
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
? candidate.touchedAt
: Date.now(),
@@ -350,7 +365,9 @@ const upsertContextPanelTab = (
targetPath: nextTab.targetPath || tab.targetPath,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
stagedDiff: nextTab.stagedDiff,
diffScope: nextTab.diffScope,
readOnly: nextTab.readOnly,
touchedAt: Date.now(),
}
@@ -530,6 +547,7 @@ interface UIStore {
sidebarOpenBeforeFullscreenTab: boolean | null;
pendingDiffFile: string | null;
pendingDiffStaged: boolean;
pendingDiffScope: PendingDiffScope | null;
pendingDiagramFile: string | null;
pendingFileNavigation: PendingFileNavigation | null;
pendingFileFocusPath: string | null;
@@ -554,8 +572,12 @@ interface UIStore {
eventStreamStatus: EventStreamStatus;
eventStreamHint: string | null;
showReasoningTraces: boolean;
sessionRecapEnabled: boolean;
sessionSuggestionEnabled: boolean;
sessionGoalEnabled: boolean;
sessionGoalDefaultBudgetEnabled: boolean;
sessionGoalDefaultBudget: number;
collapsibleThinkingBlocks: boolean;
groupReasoningBlocks: boolean;
chatRenderMode: ChatRenderMode;
activityRenderMode: ActivityRenderMode;
showDeletionDialog: boolean;
@@ -568,6 +590,7 @@ interface UIStore {
// Global draft welcome starters; null = unset (use the default built-in set).
globalDraftStarters: DraftStarterRef[] | null;
terminalFontSize: number;
editorFontSize: number;
uiFont: UiFontOption;
monoFont: MonoFontOption;
padding: number;
@@ -577,6 +600,7 @@ interface UIStore {
favoriteModels: Array<{ providerID: string; modelID: string }>;
hiddenModels: Array<{ providerID: string; modelID: string }>;
providerOrder: string[];
collapsedModelProviders: string[];
recentModels: Array<{ providerID: string; modelID: string }>;
recentAgents: string[];
@@ -591,6 +615,8 @@ interface UIStore {
nativeNotificationsEnabled: boolean;
notificationMode: 'always' | 'hidden-only';
notifyOnSubtasks: boolean;
// Desktop dock badge showing the count of sessions with unseen activity (macOS).
dockBadgeEnabled: boolean;
// Event toggles (which events trigger notifications)
notifyOnCompletion: boolean;
@@ -616,6 +642,7 @@ interface UIStore {
showOpenCodeUpdateNotifications: boolean;
inputSpellcheckEnabled: boolean;
wideChatLayoutEnabled: boolean;
codeBlockLineWrap: boolean;
showToolFileIcons: boolean;
showTurnChangedFiles: boolean;
showExpandedBashTools: boolean;
@@ -645,7 +672,7 @@ interface UIStore {
setRightSidebarWidth: (width: number) => void;
setRightSidebarTab: (tab: RightSidebarTab) => void;
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void;
openContextDiff: (directory: string, filePath: string, staged?: boolean) => void;
openContextDiff: (directory: string, filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void;
openContextFile: (directory: string, filePath: string) => void;
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
openContextOverview: (directory: string) => void;
@@ -671,11 +698,11 @@ interface UIStore {
prepareForRuntimeSwitch: (runtimeKey?: string | null) => void;
restoreForRuntimeSwitch: (runtimeKey?: string | null) => void;
setMainTabGuard: (guard: MainTabGuard | null) => void;
setPendingDiffFile: (filePath: string | null, staged?: boolean) => void;
setPendingDiffFile: (filePath: string | null, staged?: boolean, scope?: PendingDiffScope | null) => void;
setPendingDiagramFile: (filePath: string | null) => void;
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
setPendingFileFocusPath: (path: string | null) => void;
navigateToDiff: (filePath: string, staged?: boolean) => void;
navigateToDiff: (filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void;
consumePendingDiffFile: () => string | null;
navigateToDiagram: (filePath: string) => void;
consumePendingDiagramFile: () => string | null;
@@ -699,6 +726,11 @@ interface UIStore {
setSettingsRemoteInstancesSelectedId: (instanceId: string | null) => void;
setEventStreamStatus: (status: EventStreamStatus, hint?: string | null) => void;
setShowReasoningTraces: (value: boolean) => void;
setSessionRecapEnabled: (value: boolean) => void;
setSessionSuggestionEnabled: (value: boolean) => void;
setSessionGoalEnabled: (value: boolean) => void;
setSessionGoalDefaultBudgetEnabled: (value: boolean) => void;
setSessionGoalDefaultBudget: (value: number) => void;
setCollapsibleThinkingBlocks: (value: boolean) => void;
setChatRenderMode: (value: ChatRenderMode) => void;
setActivityRenderMode: (value: ActivityRenderMode) => void;
@@ -711,6 +743,7 @@ interface UIStore {
setFontSize: (size: number) => void;
setGlobalDraftStarters: (refs: DraftStarterRef[]) => void;
setTerminalFontSize: (size: number) => void;
setEditorFontSize: (size: number) => void;
setUiFont: (font: UiFontOption) => void;
setMonoFont: (font: MonoFontOption) => void;
setPadding: (size: number) => void;
@@ -727,6 +760,7 @@ interface UIStore {
overProviderID: string,
overModelID: string,
) => void;
setProviderOrder: (orderedProviderIDs: string[]) => void;
toggleHiddenModel: (providerID: string, modelID: string) => void;
isHiddenModel: (providerID: string, modelID: string) => boolean;
hideAllModels: (providerID: string, modelIDs: string[]) => void;
@@ -748,6 +782,7 @@ interface UIStore {
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
setShowTerminalQuickKeysOnDesktop: (value: boolean) => void;
setNotifyOnSubtasks: (value: boolean) => void;
setDockBadgeEnabled: (value: boolean) => void;
setNotifyOnCompletion: (value: boolean) => void;
setNotifyOnError: (value: boolean) => void;
setNotifyOnQuestion: (value: boolean) => void;
@@ -760,6 +795,7 @@ interface UIStore {
setShowOpenCodeUpdateNotifications: (value: boolean) => void;
setInputSpellcheckEnabled: (value: boolean) => void;
setWideChatLayoutEnabled: (value: boolean) => void;
setCodeBlockLineWrap: (value: boolean) => void;
setShowToolFileIcons: (value: boolean) => void;
setShowTurnChangedFiles: (value: boolean) => void;
setShowExpandedBashTools: (value: boolean) => void;
@@ -818,6 +854,7 @@ export const useUIStore = create<UIStore>()(
sidebarOpenBeforeFullscreenTab: null,
pendingDiffFile: null,
pendingDiffStaged: false,
pendingDiffScope: null,
pendingDiagramFile: null,
pendingFileNavigation: null,
pendingFileFocusPath: null,
@@ -840,8 +877,12 @@ export const useUIStore = create<UIStore>()(
eventStreamStatus: 'idle',
eventStreamHint: null,
showReasoningTraces: true,
sessionRecapEnabled: true,
sessionSuggestionEnabled: true,
sessionGoalEnabled: true,
sessionGoalDefaultBudgetEnabled: false,
sessionGoalDefaultBudget: 200_000,
collapsibleThinkingBlocks: true,
groupReasoningBlocks: true,
chatRenderMode: 'live',
activityRenderMode: 'summary',
showDeletionDialog: true,
@@ -853,6 +894,7 @@ export const useUIStore = create<UIStore>()(
fontSize: 100,
globalDraftStarters: null,
terminalFontSize: 13,
editorFontSize: 13,
uiFont: DEFAULT_UI_FONT,
monoFont: DEFAULT_MONO_FONT,
padding: 100,
@@ -861,6 +903,7 @@ export const useUIStore = create<UIStore>()(
mobileKeyboardMode: getStoredMobileKeyboardMode(),
favoriteModels: [],
hiddenModels: [],
providerOrder: [],
collapsedModelProviders: [],
recentModels: [],
recentAgents: [],
@@ -874,6 +917,7 @@ export const useUIStore = create<UIStore>()(
nativeNotificationsEnabled: false,
notificationMode: 'hidden-only',
notifyOnSubtasks: true,
dockBadgeEnabled: true,
// Event toggles (which events trigger notifications)
notifyOnCompletion: true,
@@ -897,6 +941,7 @@ export const useUIStore = create<UIStore>()(
showOpenCodeUpdateNotifications: true,
inputSpellcheckEnabled: false,
wideChatLayoutEnabled: false,
codeBlockLineWrap: true,
showToolFileIcons: true,
showTurnChangedFiles: false,
showExpandedBashTools: false,
@@ -1023,17 +1068,20 @@ export const useUIStore = create<UIStore>()(
});
},
openContextDiff: (directory, filePath, staged = false) => {
openContextDiff: (directory, filePath, staged = false, scope = null) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedFilePath = (filePath || '').trim();
if (!normalizedDirectory || !normalizedFilePath) {
return;
}
const diffScope = normalizePendingDiffScope(scope) ?? (staged ? 'staged' : 'working');
get().openContextPanelTab(normalizedDirectory, {
mode: 'diff',
targetPath: normalizedFilePath,
stagedDiff: staged,
stagedDiff: diffScope === 'staged',
diffScope,
});
},
@@ -1395,8 +1443,12 @@ export const useUIStore = create<UIStore>()(
set({ activeMainTab: restored });
},
setPendingDiffFile: (filePath, staged = false) => {
set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false });
setPendingDiffFile: (filePath, staged = false, scope = null) => {
set({
pendingDiffFile: filePath,
pendingDiffStaged: filePath ? staged : false,
pendingDiffScope: filePath ? scope : null,
});
},
setPendingDiagramFile: (filePath) => {
@@ -1411,18 +1463,18 @@ export const useUIStore = create<UIStore>()(
set({ pendingFileFocusPath: path });
},
navigateToDiff: (filePath, staged = false) => {
navigateToDiff: (filePath, staged = false, scope = null) => {
const guard = get().mainTabGuard;
if (guard && !guard('diff')) {
return;
}
set({ pendingDiffFile: filePath, pendingDiffStaged: staged, activeMainTab: 'diff' });
set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope, activeMainTab: 'diff' });
},
consumePendingDiffFile: () => {
const { pendingDiffFile } = get();
if (pendingDiffFile) {
set({ pendingDiffFile: null, pendingDiffStaged: false });
set({ pendingDiffFile: null, pendingDiffStaged: false, pendingDiffScope: null });
}
return pendingDiffFile;
},
@@ -1530,6 +1582,26 @@ export const useUIStore = create<UIStore>()(
set({ showReasoningTraces: value });
},
setSessionRecapEnabled: (value) => {
set({ sessionRecapEnabled: value });
},
setSessionSuggestionEnabled: (value) => {
set({ sessionSuggestionEnabled: value });
},
setSessionGoalEnabled: (value) => {
set({ sessionGoalEnabled: value });
},
setSessionGoalDefaultBudgetEnabled: (value) => {
set({ sessionGoalDefaultBudgetEnabled: value });
},
setSessionGoalDefaultBudget: (value) => {
set({ sessionGoalDefaultBudget: value });
},
setCollapsibleThinkingBlocks: (value) => {
set({ collapsibleThinkingBlocks: value });
},
@@ -1585,6 +1657,12 @@ export const useUIStore = create<UIStore>()(
set({ terminalFontSize: clamped });
},
setEditorFontSize: (size) => {
const rounded = Math.round(size);
const clamped = Math.max(9, Math.min(32, rounded));
set({ editorFontSize: clamped });
},
setUiFont: (font) => {
set({ uiFont: font });
},
@@ -1735,6 +1813,17 @@ export const useUIStore = create<UIStore>()(
});
},
setProviderOrder: (orderedProviderIDs) => {
set((state) => {
const next = orderedProviderIDs.filter((id) => typeof id === 'string' && id.length > 0);
const current = state.providerOrder;
if (current.length === next.length && current.every((id, index) => id === next[index])) {
return state;
}
return { providerOrder: next };
});
},
toggleHiddenModel: (providerID, modelID) => {
set((state) => {
const exists = state.hiddenModels.some(
@@ -1961,6 +2050,10 @@ export const useUIStore = create<UIStore>()(
set({ notifyOnSubtasks: value });
},
setDockBadgeEnabled: (value) => {
set({ dockBadgeEnabled: value });
},
setNotifyOnCompletion: (value) => { set({ notifyOnCompletion: value }); },
setNotifyOnError: (value) => { set({ notifyOnError: value }); },
setNotifyOnQuestion: (value) => { set({ notifyOnQuestion: value }); },
@@ -1981,6 +2074,9 @@ export const useUIStore = create<UIStore>()(
setWideChatLayoutEnabled: (value) => {
set({ wideChatLayoutEnabled: value });
},
setCodeBlockLineWrap: (value) => {
set({ codeBlockLineWrap: value });
},
setShowToolFileIcons: (value) => {
set({ showToolFileIcons: value });
},
@@ -2078,7 +2174,7 @@ export const useUIStore = create<UIStore>()(
}),
{
name: 'ui-store',
storage: createJSONStorage(() => getSafeStorage()),
storage: createDeferredSafeJSONStorage(),
version: 10,
migrate: (persistedState, version) => {
if (!persistedState || typeof persistedState !== 'object') {
@@ -2199,6 +2295,11 @@ export const useUIStore = create<UIStore>()(
isSessionCreateDialogOpen: state.isSessionCreateDialogOpen,
// Note: isSettingsDialogOpen intentionally NOT persisted
showReasoningTraces: state.showReasoningTraces,
sessionRecapEnabled: state.sessionRecapEnabled,
sessionSuggestionEnabled: state.sessionSuggestionEnabled,
sessionGoalEnabled: state.sessionGoalEnabled,
sessionGoalDefaultBudgetEnabled: state.sessionGoalDefaultBudgetEnabled,
sessionGoalDefaultBudget: state.sessionGoalDefaultBudget,
collapsibleThinkingBlocks: state.collapsibleThinkingBlocks,
chatRenderMode: state.chatRenderMode,
activityRenderMode: state.activityRenderMode,
@@ -2211,12 +2312,14 @@ export const useUIStore = create<UIStore>()(
fontSize: state.fontSize,
globalDraftStarters: state.globalDraftStarters,
terminalFontSize: state.terminalFontSize,
editorFontSize: state.editorFontSize,
uiFont: state.uiFont,
monoFont: state.monoFont,
padding: state.padding,
cornerRadius: state.cornerRadius,
favoriteModels: state.favoriteModels,
hiddenModels: state.hiddenModels,
providerOrder: state.providerOrder,
collapsedModelProviders: state.collapsedModelProviders,
recentModels: state.recentModels,
recentAgents: state.recentAgents,
@@ -2228,6 +2331,7 @@ export const useUIStore = create<UIStore>()(
notificationMode: state.notificationMode,
showTerminalQuickKeysOnDesktop: state.showTerminalQuickKeysOnDesktop,
notifyOnSubtasks: state.notifyOnSubtasks,
dockBadgeEnabled: state.dockBadgeEnabled,
notifyOnCompletion: state.notifyOnCompletion,
notifyOnError: state.notifyOnError,
notifyOnQuestion: state.notifyOnQuestion,
@@ -2240,6 +2344,7 @@ export const useUIStore = create<UIStore>()(
showOpenCodeUpdateNotifications: state.showOpenCodeUpdateNotifications,
inputSpellcheckEnabled: state.inputSpellcheckEnabled,
wideChatLayoutEnabled: state.wideChatLayoutEnabled,
codeBlockLineWrap: state.codeBlockLineWrap,
showToolFileIcons: state.showToolFileIcons,
showTurnChangedFiles: state.showTurnChangedFiles,
showExpandedBashTools: state.showExpandedBashTools,
+25 -5
View File
@@ -12,8 +12,11 @@ import {
isWebRuntime,
} from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getClientPlatform, isCapacitorApp } from '@/lib/platform';
export type UpdateState = {
declare const __APP_VERSION__: string | undefined;
type UpdateState = {
checking: boolean;
available: boolean;
downloading: boolean;
@@ -21,7 +24,7 @@ export type UpdateState = {
info: UpdateInfo | null;
progress: UpdateProgress | null;
error: string | null;
runtimeType: 'desktop' | 'web' | 'vscode' | null;
runtimeType: 'desktop' | 'web' | 'vscode' | 'mobile' | null;
lastChecked: number | null;
nextCheckInSec: number | null;
};
@@ -34,7 +37,7 @@ interface UpdateStore extends UpdateState {
reset: () => void;
}
type ClientRuntime = 'desktop' | 'web' | 'vscode';
type ClientRuntime = 'desktop' | 'web' | 'vscode' | 'mobile';
function detectDeviceClass(): 'mobile' | 'tablet' | 'desktop' | 'unknown' {
if (typeof window === 'undefined') return 'unknown';
@@ -64,7 +67,9 @@ function detectArch(): 'arm64' | 'x64' | 'unknown' {
return 'unknown';
}
function detectPlatform(): 'macos' | 'windows' | 'linux' | 'web' {
function detectPlatform(): 'macos' | 'windows' | 'linux' | 'web' | 'android' | 'ios' {
const clientPlatform = getClientPlatform();
if (clientPlatform === 'android' || clientPlatform === 'ios') return clientPlatform;
if (typeof navigator === 'undefined') return 'web';
const platform = (navigator.platform || '').toLowerCase();
if (platform.includes('mac')) return 'macos';
@@ -93,6 +98,12 @@ function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
return params;
}
if (runtime === 'mobile') {
params.set('appType', 'mobile-capacitor');
params.set('instanceMode', 'remote');
return params;
}
params.set('appType', 'web');
params.set('instanceMode', 'unknown');
return params;
@@ -121,6 +132,8 @@ async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: strin
version: data.version,
currentVersion: data.currentVersion ?? 'unknown',
body: data.body,
releaseUrl: data.releaseUrl,
downloadUrl: data.downloadUrl,
nextSuggestedCheckInSec:
typeof data.nextSuggestedCheckInSec === 'number' && Number.isFinite(data.nextSuggestedCheckInSec)
? data.nextSuggestedCheckInSec
@@ -134,7 +147,10 @@ async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: strin
}
}
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null {
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | 'mobile' | null {
if (isCapacitorApp()) {
return 'mobile';
}
if (isElectronShell()) {
return 'desktop';
}
@@ -186,6 +202,10 @@ export const useUpdateStore = create<UpdateStore>()((set, get) => ({
} else if (runtime === 'vscode') {
const vscodeInfo = await checkForWebUpdates('vscode');
suggestedSec = vscodeInfo?.nextSuggestedCheckInSec ?? null;
} else if (runtime === 'mobile') {
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined;
info = await checkForWebUpdates('mobile', appVersion);
suggestedSec = info?.nextSuggestedCheckInSec ?? null;
}
set({
@@ -1,46 +0,0 @@
import type { Message, Part } from '@opencode-ai/sdk/v2';
export const resolveClientRole = (info: Pick<Message, 'role'> & { clientRole?: string | null }): string => {
const role = info.clientRole ?? info.role;
return typeof role === 'string' ? role : '';
};
export const normalizeMessageInfoForProjection = <T extends Message>(info: T): T => {
const clientRole = resolveClientRole(info);
const shouldMarkUser = clientRole === 'user';
return {
...info,
clientRole,
...(shouldMarkUser ? { userMessageMarker: true } : {}),
} as T;
};
export interface ChatMessageRecord {
info: Message;
parts: Part[];
}
export const normalizeMessageRecordsForProjection = (messages: ChatMessageRecord[]): ChatMessageRecord[] => {
return messages.map((message) => ({
...message,
info: normalizeMessageInfoForProjection(message.info),
parts: Array.isArray(message.parts) ? message.parts : [],
}));
};
export const filterMessagesByRevertPoint = <T extends { info: { id: string } }>(
messages: T[],
revertMessageId: string | null,
): T[] => {
if (!revertMessageId) {
return messages;
}
const revertIndex = messages.findIndex((message) => message.info.id === revertMessageId);
if (revertIndex < 0) {
return messages;
}
return messages.slice(0, revertIndex);
};
@@ -1,286 +0,0 @@
import type { Part } from "@opencode-ai/sdk/v2";
import { isFinalToolStatus } from "@/lib/toolStatus";
const isRecord = (value: unknown): value is Record<string, unknown> => {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
};
const readTimestamp = (value: unknown): number | undefined => {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
};
const mergeTimeRange = (existing: unknown, incoming: unknown): Record<string, number> | undefined => {
const existingTime = isRecord(existing) ? existing : undefined;
const incomingTime = isRecord(incoming) ? incoming : undefined;
const startCandidates = [
readTimestamp(existingTime?.start),
readTimestamp(incomingTime?.start),
].filter((value): value is number => typeof value === 'number');
const endCandidates = [
readTimestamp(existingTime?.end),
readTimestamp(incomingTime?.end),
].filter((value): value is number => typeof value === 'number');
if (startCandidates.length === 0 && endCandidates.length === 0) {
return undefined;
}
const merged: Record<string, number> = {};
if (startCandidates.length > 0) {
merged.start = Math.min(...startCandidates);
}
if (endCandidates.length > 0) {
merged.end = Math.max(...endCandidates);
}
return merged;
};
const mergeToolState = (existing: unknown, incoming: unknown): Record<string, unknown> | undefined => {
const existingState = isRecord(existing) ? existing : undefined;
const incomingState = isRecord(incoming) ? incoming : undefined;
if (!existingState && !incomingState) {
return undefined;
}
// Never downgrade a terminal status. Once a tool reaches completed/error/etc,
// a late-arriving "running" SSE must not overwrite it.
const existingStatus = existingState?.status;
const incomingStatus = incomingState?.status;
const existingIsTerminal = typeof existingStatus === 'string' && isFinalToolStatus(existingStatus);
const incomingIsTerminal = typeof incomingStatus === 'string' && isFinalToolStatus(incomingStatus);
const merged: Record<string, unknown> = {
...(existingState ?? {}),
...(incomingState ?? {}),
};
// If existing was terminal but incoming is not, keep the terminal status.
if (existingIsTerminal && !incomingIsTerminal && typeof incomingStatus === 'string') {
merged.status = existingStatus;
}
const mergedTime = mergeTimeRange(existingState?.time, incomingState?.time);
if (mergedTime) {
merged.time = mergedTime;
}
if (isRecord(existingState?.metadata) || isRecord(incomingState?.metadata)) {
merged.metadata = {
...(isRecord(existingState?.metadata) ? existingState.metadata : {}),
...(isRecord(incomingState?.metadata) ? incomingState.metadata : {}),
};
}
if (isRecord(existingState?.input) || isRecord(incomingState?.input)) {
merged.input = {
...(isRecord(existingState?.input) ? existingState.input : {}),
...(isRecord(incomingState?.input) ? incomingState.input : {}),
};
}
return merged;
};
const extractTextFromDelta = (delta: unknown): string => {
if (!delta) return '';
if (typeof delta === 'string') return delta;
if (Array.isArray(delta)) {
return delta.map((item) => extractTextFromDelta(item)).join('');
}
if (typeof delta === 'object') {
if (typeof (delta as { text?: unknown }).text === 'string') {
return (delta as { text: string }).text;
}
if (Array.isArray((delta as { content?: unknown[] }).content)) {
return (delta as { content: unknown[] }).content.map((item: unknown) => extractTextFromDelta(item)).join('');
}
}
return '';
};
export const extractTextFromPart = (part: unknown): string => {
if (!part) return '';
const typedPart = part as { text?: string | unknown[]; content?: string | unknown[]; value?: string | unknown[]; delta?: unknown };
const toText = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value
.map((item: unknown) => {
if (typeof item === 'string') return item;
if (item && typeof item === 'object') {
const typedItem = item as { text?: unknown; content?: unknown; value?: unknown; delta?: unknown };
return toText(typedItem.text) || toText(typedItem.content) || toText(typedItem.value) || extractTextFromDelta(typedItem.delta);
}
return '';
})
.join('');
}
return '';
};
const candidates = [
toText(typedPart.text),
toText(typedPart.content),
toText(typedPart.value),
extractTextFromDelta(typedPart.delta),
];
let best = '';
for (const candidate of candidates) {
if (candidate.length > best.length) {
best = candidate;
}
}
return best;
};
export const normalizeStreamingPart = (incoming: Part, existing?: Part): Part => {
const normalized: { type?: string; text?: string; content?: string; value?: string; delta?: unknown; [key: string]: unknown } = {
...(existing as Record<string, unknown> | undefined),
...incoming,
} as { type?: string; text?: string; content?: string; value?: string; delta?: unknown; [key: string]: unknown };
const existingType = typeof (existing as { type?: unknown } | undefined)?.type === 'string'
? (existing as { type: string }).type
: undefined;
normalized.type = normalized.type || existingType || 'text';
const isStreamingTextLikePart = normalized.type === 'text' || normalized.type === 'reasoning';
if (isStreamingTextLikePart) {
const existingRecord = (existing ?? {}) as { text?: unknown; content?: unknown; value?: unknown };
const existingText = extractTextFromPart(existing);
const directText = extractTextFromPart(incoming);
const deltaText = extractTextFromDelta((incoming as { delta?: unknown }).delta);
let mergedText = '';
const incomingField =
typeof normalized.text === 'string'
? 'text'
: typeof normalized.content === 'string'
? 'content'
: typeof normalized.value === 'string'
? 'value'
: null;
const targetField = incomingField ?? (
typeof existingRecord.text === 'string'
? 'text'
: typeof existingRecord.content === 'string'
? 'content'
: typeof existingRecord.value === 'string'
? 'value'
: 'text'
);
if (deltaText) {
mergedText = existingText ? `${existingText}${deltaText}` : deltaText;
} else if (directText) {
mergedText = directText;
} else {
mergedText = existingText;
}
normalized[targetField] = mergedText;
if (targetField !== 'text') {
normalized.text = mergedText;
}
delete normalized.delta;
}
const mergedTime = mergeTimeRange(
(existing as { time?: unknown } | undefined)?.time,
(incoming as { time?: unknown } | undefined)?.time,
);
if (mergedTime) {
normalized.time = mergedTime;
}
if (normalized.type === 'tool') {
const mergedState = mergeToolState(
(existing as { state?: unknown } | undefined)?.state,
(incoming as { state?: unknown } | undefined)?.state,
);
if (mergedState) {
normalized.state = mergedState;
}
}
return normalized as Part;
};
const deepEqualRecord = (left: Record<string, unknown>, right: Record<string, unknown>): boolean => {
const keys = new Set<string>([
...Object.keys(left),
...Object.keys(right),
]);
for (const key of keys) {
const leftValue = left[key];
const rightValue = right[key];
if (Array.isArray(leftValue) || Array.isArray(rightValue)) {
if (!Array.isArray(leftValue) || !Array.isArray(rightValue) || leftValue.length !== rightValue.length) {
return false;
}
for (let index = 0; index < leftValue.length; index += 1) {
if (!deepEqualUnknown(leftValue[index], rightValue[index])) {
return false;
}
}
continue;
}
if (!deepEqualUnknown(leftValue, rightValue)) {
return false;
}
}
return true;
};
const deepEqualUnknown = (left: unknown, right: unknown): boolean => {
if (left === right) {
return true;
}
if (!left || !right) {
return false;
}
if (typeof left !== typeof right) {
return false;
}
if (typeof left === 'object' && typeof right === 'object') {
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
return false;
}
for (let index = 0; index < left.length; index += 1) {
if (!deepEqualUnknown(left[index], right[index])) {
return false;
}
}
return true;
}
return deepEqualRecord(left as Record<string, unknown>, right as Record<string, unknown>);
}
return false;
};
export const arePartsEquivalent = (left: Part | undefined, right: Part | undefined): boolean => {
if (!left || !right) {
return left === right;
}
return deepEqualUnknown(left, right);
};
@@ -1,20 +1,5 @@
import type { EditPermissionMode } from "../types/sessionTypes";
const EDIT_PERMISSION_TOOL_NAMES = new Set([
'edit',
'multiedit',
'str_replace',
'str_replace_based_edit_tool',
'write',
]);
export const isEditPermissionType = (type?: string | null): boolean => {
if (!type) {
return false;
}
return EDIT_PERMISSION_TOOL_NAMES.has(type.toLowerCase());
};
type PermissionAction = 'allow' | 'deny' | 'ask';
type PermissionRule = {
@@ -4,6 +4,24 @@ const importSafeStorage = async () => {
return await import(`./safeStorage.ts?test=${Date.now()}-${Math.random()}`) as typeof import('./safeStorage');
};
const createFakeStorage = (): Storage => {
const store = new Map<string, string>();
return {
getItem: (k) => (store.has(k) ? store.get(k)! : null),
setItem: (k, v) => {
store.set(k, String(v));
},
removeItem: (k) => {
store.delete(k);
},
clear: () => store.clear(),
key: (i) => Array.from(store.keys())[i] ?? null,
get length() {
return store.size;
},
} as Storage;
};
describe('safeStorage', () => {
test('falls back to memory when storage getters throw', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
@@ -45,4 +63,99 @@ describe('safeStorage', () => {
}
}
});
test('defers persisted JSON serialization and serves pending reads', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousStringify = JSON.stringify;
const stringifyCalls: unknown[] = [];
const backingStorage = createFakeStorage();
const fakeWindow = {
localStorage: backingStorage,
sessionStorage: createFakeStorage(),
addEventListener: () => {},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: fakeWindow,
});
try {
JSON.stringify = ((value: unknown, replacer?: Parameters<typeof JSON.stringify>[1], space?: Parameters<typeof JSON.stringify>[2]) => {
stringifyCalls.push(value);
return previousStringify(value, replacer, space);
}) as typeof JSON.stringify;
const { createDeferredSafeJSONStorage } = await importSafeStorage();
const storage = createDeferredSafeJSONStorage<{ value: string }>();
expect(Boolean(storage)).toBe(true);
if (!storage) throw new Error('storage unavailable');
storage.setItem('k', { state: { value: 'v' } });
// Neither serialization nor the backing write runs on the call site...
expect(stringifyCalls).toHaveLength(0);
expect(backingStorage.getItem('k')).toBeNull();
// ...but read-after-write still returns the pending value.
expect(storage.getItem('k')).toEqual({ state: { value: 'v' } });
// Coalesce: a second write to the same key should not produce two
// stringifications/backing writes, and the latest value wins.
storage.setItem('k', { state: { value: 'v2' } });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(stringifyCalls).toEqual([{ state: { value: 'v2' } }]);
expect(backingStorage.getItem('k')).toBe('{"state":{"value":"v2"}}');
expect(storage.getItem('k')).toEqual({ state: { value: 'v2' } });
} finally {
JSON.stringify = previousStringify;
if (previousWindow) {
Object.defineProperty(globalThis, 'window', previousWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('defers direct storage writes and flushes on pagehide', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const backingStorage = createFakeStorage();
const listeners = new Map<string, Array<() => void>>();
const fakeWindow = {
localStorage: backingStorage,
sessionStorage: createFakeStorage(),
addEventListener: (event: string, listener: () => void) => {
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: fakeWindow,
});
try {
const { getDeferredSafeStorage } = await importSafeStorage();
const storage = getDeferredSafeStorage();
storage.setItem('direct-k', 'direct-v');
expect(backingStorage.getItem('direct-k')).toBeNull();
expect(storage.getItem('direct-k')).toBe('direct-v');
for (const listener of listeners.get('pagehide') ?? []) {
listener();
}
expect(backingStorage.getItem('direct-k')).toBe('direct-v');
} finally {
if (previousWindow) {
Object.defineProperty(globalThis, 'window', previousWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
});
+193
View File
@@ -1,5 +1,191 @@
import type { PersistStorage, StateStorage, StorageValue } from 'zustand/middleware';
let safeStorageInstance: Storage | null = null;
let safeSessionStorageInstance: Storage | null = null;
let deferredSafeStorageInstance: Storage | null = null;
const deferredFlushers = new Set<() => void>();
let deferredFlushListenersRegistered = false;
type JsonStorageOptions = {
reviver?: (key: string, value: unknown) => unknown;
replacer?: (key: string, value: unknown) => unknown;
};
const registerDeferredFlusher = (flush: () => void) => {
deferredFlushers.add(flush);
if (deferredFlushListenersRegistered || typeof window === 'undefined') return;
deferredFlushListenersRegistered = true;
const flushAll = () => {
for (const flushDeferredStorage of deferredFlushers) {
flushDeferredStorage();
}
};
try {
window.addEventListener('pagehide', flushAll, { capture: true });
window.addEventListener('beforeunload', flushAll, { capture: true });
window.addEventListener('visibilitychange', () => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') flushAll();
});
window.addEventListener('freeze', flushAll);
} catch {
// Restricted environments can reject listeners; timers still flush.
}
};
const createDeferredJSONStorage = <S>(
getStorage: () => StateStorage,
options?: JsonStorageOptions,
): PersistStorage<S> | undefined => {
let storage: StateStorage;
try {
storage = getStorage();
} catch {
return undefined;
}
const pendingWrites = new Map<string, StorageValue<S>>();
const pendingDeletes = new Set<string>();
let flushTimer: ReturnType<typeof setTimeout> | undefined;
const flush = () => {
flushTimer = undefined;
if (pendingWrites.size === 0 && pendingDeletes.size === 0) return;
const writes = Array.from(pendingWrites.entries());
const deletes = Array.from(pendingDeletes);
pendingWrites.clear();
pendingDeletes.clear();
for (const [name, value] of writes) {
try {
storage.setItem(name, JSON.stringify(value, options?.replacer));
} catch (error) {
console.error('Failed to persist deferred storage value', error);
}
}
for (const name of deletes) {
try {
storage.removeItem(name);
} catch (error) {
console.error('Failed to remove deferred storage value', error);
}
}
};
const scheduleFlush = () => {
if (flushTimer !== undefined) return;
flushTimer = setTimeout(flush, 0);
};
registerDeferredFlusher(flush);
return {
getItem: (name) => {
if (pendingWrites.has(name)) {
return pendingWrites.get(name) ?? null;
}
if (pendingDeletes.has(name)) {
return null;
}
const parse = (value: string | null): StorageValue<S> | null => {
if (value === null) return null;
return JSON.parse(value, options?.reviver) as StorageValue<S>;
};
const value = storage.getItem(name);
if (value instanceof Promise) {
return value.then(parse);
}
return parse(value);
},
setItem: (name, value) => {
pendingWrites.set(name, value);
pendingDeletes.delete(name);
scheduleFlush();
},
removeItem: (name) => {
pendingWrites.delete(name);
pendingDeletes.add(name);
scheduleFlush();
},
};
};
export const createDeferredSafeJSONStorage = <S>(options?: JsonStorageOptions) => (
createDeferredJSONStorage<S>(() => getSafeStorage(), options)
);
const createDeferredStorage = (storage: Storage): Storage => {
const pendingWrites = new Map<string, string>();
const pendingDeletes = new Set<string>();
let flushTimer: ReturnType<typeof setTimeout> | undefined;
const flush = () => {
flushTimer = undefined;
if (pendingWrites.size === 0 && pendingDeletes.size === 0) return;
const writes = Array.from(pendingWrites.entries());
const deletes = Array.from(pendingDeletes);
pendingWrites.clear();
pendingDeletes.clear();
for (const [key, value] of writes) {
try {
storage.setItem(key, value);
} catch (error) {
console.error('Failed to persist deferred storage value', error);
}
}
for (const key of deletes) {
try {
storage.removeItem(key);
} catch (error) {
console.error('Failed to remove deferred storage value', error);
}
}
};
const scheduleFlush = () => {
if (flushTimer !== undefined) return;
flushTimer = setTimeout(flush, 0);
};
registerDeferredFlusher(flush);
return {
getItem: (key) => {
if (pendingWrites.has(key)) return pendingWrites.get(key) ?? null;
if (pendingDeletes.has(key)) return null;
return storage.getItem(key);
},
setItem: (key, value) => {
pendingWrites.set(key, value);
pendingDeletes.delete(key);
scheduleFlush();
},
removeItem: (key) => {
pendingWrites.delete(key);
pendingDeletes.add(key);
scheduleFlush();
},
clear: () => {
pendingWrites.clear();
pendingDeletes.clear();
if (flushTimer !== undefined) {
clearTimeout(flushTimer);
flushTimer = undefined;
}
storage.clear();
},
key: (index) => storage.key(index),
get length() {
return storage.length;
},
} as Storage;
};
const getWindowStorage = (key: 'localStorage' | 'sessionStorage'): Storage | null => {
if (typeof window === 'undefined') {
@@ -135,6 +321,13 @@ export const getSafeStorage = (): Storage => {
return safeStorageInstance;
};
export const getDeferredSafeStorage = (): Storage => {
if (!deferredSafeStorageInstance) {
deferredSafeStorageInstance = createDeferredStorage(getSafeStorage());
}
return deferredSafeStorageInstance;
};
const createSafeSessionStorage = (): Storage => {
const baseStorage = getWindowStorage('sessionStorage');
+2 -11
View File
@@ -7,15 +7,6 @@ export const streamDebugEnabled = (): boolean => {
}
};
export const sessionStatusDebugEnabled = (): boolean => {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem('openchamber_session_status_debug') === '1';
} catch {
return false;
}
};
const STREAM_PERF_STORAGE_KEY = 'openchamber_stream_perf';
type PerfCounter = {
@@ -31,7 +22,7 @@ type StreamPerfState = {
lastUpdatedAt: number;
};
export type StreamPerfEntry = {
type StreamPerfEntry = {
metric: string;
count: number;
avg: number;
@@ -61,7 +52,7 @@ declare global {
}
}
export const streamPerfEnabled = (): boolean => {
const streamPerfEnabled = (): boolean => {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(STREAM_PERF_STORAGE_KEY) === '1';
@@ -1,55 +0,0 @@
import type { MessageStreamLifecycle } from "../types/sessionTypes";
export type { MessageStreamLifecycle };
export const touchStreamingLifecycle = (
source: Map<string, MessageStreamLifecycle>,
messageId: string
): Map<string, MessageStreamLifecycle> => {
const now = Date.now();
const existing = source.get(messageId);
const next = new Map(source);
next.set(messageId, {
phase: 'streaming',
startedAt: existing?.startedAt ?? now,
lastUpdateAt: now,
});
return next;
};
export const removeLifecycleEntries = (
source: Map<string, MessageStreamLifecycle>,
ids: Iterable<string>
): Map<string, MessageStreamLifecycle> => {
const idsArray = Array.from(ids);
const shouldClone = idsArray.some((id) => source.has(id));
if (!shouldClone) {
return source;
}
const next = new Map(source);
idsArray.forEach((id) => {
next.delete(id);
});
return next;
};
const lifecycleCompletionTimers = new Map<string, ReturnType<typeof setTimeout>>();
export const clearLifecycleCompletionTimer = (messageId: string) => {
const timer = lifecycleCompletionTimers.get(messageId);
if (timer) {
clearTimeout(timer);
lifecycleCompletionTimers.delete(messageId);
}
};
export const clearLifecycleTimersForIds = (ids: Iterable<string>) => {
for (const id of ids) {
clearLifecycleCompletionTimer(id);
}
};