Merge remote-tracking branch 'openchamber/main' into requests-in-flight
# Conflicts: # packages/ui/src/components/ui/MemoryDebugPanel.tsx
This commit is contained in:
@@ -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()),
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
}
|
||||
),
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user