diff --git a/app-prefixable/src/context/config.tsx b/app-prefixable/src/context/config.tsx index b433a72f..2a7317ca 100644 --- a/app-prefixable/src/context/config.tsx +++ b/app-prefixable/src/context/config.tsx @@ -35,6 +35,11 @@ export function ConfigProvider(props: ParentProps) { let refreshSeq = 0 let lastUpdateAt = 0 + // Validate that a response has the expected Config shape (must be a non-array object) + function isValidConfig(data: unknown): data is Config { + return !!data && typeof data === "object" && !Array.isArray(data) + } + async function refresh() { const seq = ++refreshSeq setLoading(true) @@ -45,7 +50,11 @@ export function ConfigProvider(props: ParentProps) { try { const projRes = await sdk.client.config.get() if (seq !== refreshSeq) return // superseded by newer refresh - setProject(reconcile((projRes?.data as Config) ?? {})) + const projData = projRes?.data + if (projData && !isValidConfig(projData)) { + console.error("[Config] Unexpected project config response shape:", projData) + } + setProject(reconcile(isValidConfig(projData) ? projData : {})) } catch (e) { console.error("[Config] Failed to fetch project config:", e) if (seq !== refreshSeq) return @@ -58,7 +67,11 @@ export function ConfigProvider(props: ParentProps) { try { const globalRes = await sdk.client.global.config.get() if (seq !== refreshSeq) return - setGlobal(reconcile((globalRes?.data as Config) ?? {})) + const globalData = globalRes?.data + if (globalData && !isValidConfig(globalData)) { + console.error("[Config] Unexpected global config response shape:", globalData) + } + setGlobal(reconcile(isValidConfig(globalData) ? globalData : {})) } catch (e) { console.error("[Config] Failed to fetch global config:", e) if (seq !== refreshSeq) return @@ -76,8 +89,13 @@ export function ConfigProvider(props: ParentProps) { setError(null) try { const res = await sdk.client.config.update({ config: patch }) - const data = res.data as Config | undefined - if (data) { + const data = res.data + if (data && !isValidConfig(data)) { + console.error("[Config] Unexpected project update response shape:", data) + setError("Failed to save project configuration") + return null + } + if (isValidConfig(data)) { lastUpdateAt = Date.now() setProject(reconcile(data)) return data @@ -101,8 +119,13 @@ export function ConfigProvider(props: ParentProps) { ? { ...patch, disabled_providers: global.disabled_providers } : patch const res = await sdk.client.global.config.update({ config: safePatch }) - const data = res.data as Config | undefined - if (data) { + const data = res.data + if (data && !isValidConfig(data)) { + console.error("[Config] Unexpected global update response shape:", data) + setError("Failed to save global configuration") + return null + } + if (isValidConfig(data)) { lastUpdateAt = Date.now() setGlobal(reconcile(data)) return data diff --git a/app-prefixable/src/context/providers.tsx b/app-prefixable/src/context/providers.tsx index 67f3cc71..f21f2e2b 100644 --- a/app-prefixable/src/context/providers.tsx +++ b/app-prefixable/src/context/providers.tsx @@ -79,6 +79,7 @@ interface ProviderContextValue { providers: Provider[] connected: string[] defaults: Record + providerError: string | null authMethods: Record agents: Agent[] loading: boolean @@ -89,7 +90,7 @@ interface ProviderContextValue { setSelectedAgent: (agent: string) => void getSessionModel: (sessionID: string) => ModelKey | null setSessionModel: (sessionID: string, model: ModelKey | null) => void - refetch: () => void + refetch: () => Promise connectProvider: (providerID: string, apiKey: string) => Promise startOAuth: (providerID: string, methodIndex: number) => Promise completeOAuth: (providerID: string, methodIndex: number, code?: string) => Promise @@ -159,23 +160,106 @@ export function ProviderProvider(props: ParentProps) { } }) + function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) + } + + function normalizeModel(providerID: string, modelID: string, model: unknown): Model { + if (!isRecord(model)) { + throw new Error(`[Providers] Provider "${providerID}" model "${modelID}" must be an object`) + } + const name = typeof model.name === "string" && model.name ? model.name : modelID + const rawLimit = model.limit + const limit = isRecord(rawLimit) ? rawLimit : {} + return { + ...(model as Omit), + id: typeof model.id === "string" && model.id ? model.id : modelID, + name, + providerID, + limit: { + context: typeof limit.context === "number" ? limit.context : 0, + input: typeof limit.input === "number" ? limit.input : undefined, + output: typeof limit.output === "number" ? limit.output : 0, + }, + } + } + + function normalizeProviderListData(data: unknown): ProviderListData { + if (!isRecord(data)) { + throw new Error("[Providers] Provider list response must be an object") + } + if (!Array.isArray(data.all)) { + throw new Error("[Providers] Provider list field \"all\" must be an array") + } + if (!Array.isArray(data.connected)) { + throw new Error("[Providers] Provider list field \"connected\" must be an array") + } + if (data.default !== undefined && !isRecord(data.default)) { + throw new Error("[Providers] Provider list field \"default\" must be an object") + } + + const all = data.all.map((entry, i) => { + if (!isRecord(entry)) { + throw new Error(`[Providers] Provider entry at index ${i} must be an object`) + } + if (typeof entry.id !== "string" || !entry.id) { + throw new Error(`[Providers] Provider entry at index ${i} is missing a string \"id\"`) + } + if (typeof entry.name !== "string" || !entry.name) { + throw new Error(`[Providers] Provider \"${entry.id}\" is missing a string \"name\"`) + } + + const rawModels = entry.models + if (rawModels === undefined || rawModels === null) { + return { ...(entry as Provider), id: entry.id, name: entry.name, models: {} } + } + if (!isRecord(rawModels)) { + throw new Error(`[Providers] Provider \"${entry.id}\" field \"models\" must be an object`) + } + const models = Object.fromEntries( + Object.entries(rawModels).map(([modelID, model]) => [modelID, normalizeModel(entry.id, modelID, model)]) + ) + return { ...(entry as Provider), id: entry.id, name: entry.name, models } + }) + + const connected = data.connected.map((id, i) => { + if (typeof id !== "string" || !id) { + throw new Error(`[Providers] Connected provider at index ${i} must be a string`) + } + return id + }) + + const defaults = Object.fromEntries( + Object.entries(data.default ?? {}).map(([k, v]) => { + if (typeof v !== "string") { + throw new Error(`[Providers] Default model for provider \"${k}\" must be a string`) + } + return [k, v] + }) + ) + + return { all, connected, default: defaults } + } + + async function refetchProvidersWithRetry(providerID: string): Promise { + const delays = [0, 150, 300, 600, 1200] + for (const delay of delays) { + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) + const data = await refetchProviders() + if (data?.connected.includes(providerID)) return true + } + return false + } + // Fetch providers const [providerData, { refetch: refetchProviders }] = createResource(async () => { try { const res = await client.provider.list() - const data = res.data as ProviderListData | undefined - if (!data) return undefined - // Inject providerID into each model since the SDK response doesn't include it - const all = data.all.map((provider) => ({ - ...provider, - models: Object.fromEntries( - Object.entries(provider.models).map(([k, m]) => [k, { ...m, providerID: provider.id }]) - ), - })) - return { ...data, all } + return normalizeProviderListData(res.data) } catch (e) { console.error("Failed to fetch providers:", e) - return undefined + const msg = e instanceof Error ? e.message : "Failed to fetch providers" + throw new Error(msg) } }) @@ -236,7 +320,12 @@ export function ProviderProvider(props: ParentProps) { const [authData] = createResource(async () => { try { const res = await client.provider.auth() - return (res.data as Record) ?? {} + const data = res.data + if (!data || typeof data !== "object" || Array.isArray(data)) { + console.error("[Providers] Invalid auth methods response shape:", data) + return {} + } + return data as Record } catch (e) { console.error("Failed to fetch auth methods:", e) return {} @@ -304,10 +393,10 @@ export function ProviderProvider(props: ParentProps) { providerID, auth: { type: "api", key: apiKey }, }) - // Dispose instance to reload provider state, then refresh + // Dispose instance to reload provider state, then wait for the + // server to reinitialize and provider state to be observable. await client.instance.dispose() - await refetchProviders() - return true + return await refetchProvidersWithRetry(providerID) } catch (e) { console.error("Failed to connect provider:", e) return false @@ -334,19 +423,18 @@ export function ProviderProvider(props: ParentProps) { method: methodIndex, code, }) - // Dispose instance to reload provider state, then refresh + // Dispose instance to reload provider state, then wait for the + // server to reinitialize and provider state to be observable. await client.instance.dispose() - await refetchProviders() - return true + return await refetchProvidersWithRetry(providerID) } catch (e) { console.error("Failed to complete OAuth:", e) return false } } - function refetch() { - refetchProviders() - refetchAgents() + async function refetch() { + await Promise.all([refetchProviders(), refetchAgents()]) } const value: ProviderContextValue = { @@ -361,6 +449,11 @@ export function ProviderProvider(props: ParentProps) { get defaults() { return providerData()?.default ?? {} }, + get providerError() { + const err = providerData.error + if (!err) return null + return err instanceof Error ? err.message : String(err) + }, get authMethods() { return authData() ?? {} }, diff --git a/app-prefixable/src/context/saved-prompts.tsx b/app-prefixable/src/context/saved-prompts.tsx index 0708b031..1e54eae6 100644 --- a/app-prefixable/src/context/saved-prompts.tsx +++ b/app-prefixable/src/context/saved-prompts.tsx @@ -1,45 +1,71 @@ -import { createContext, useContext, createSignal, createEffect, on, type ParentProps, type Accessor } from "solid-js" +import { createContext, useContext, createSignal, createEffect, createMemo, on, type ParentProps, type Accessor } from "solid-js" import { deriveDirectoryFromPathname } from "../utils/path" -interface SavedPrompt { +export type PromptScope = "global" | "project" + +export interface SavedPrompt { + id: string + title: string + text: string + createdAt: number + scope: PromptScope +} + +/** Shape stored in localStorage (scope is optional for backwards compat). */ +interface StoredPrompt { id: string title: string text: string createdAt: number + scope?: PromptScope } interface SavedPromptsContextValue { + /** All visible prompts (global + project-scoped, sorted newest first). */ prompts: () => SavedPrompt[] - add: (title: string, text: string) => void + /** Only global prompts. */ + globalPrompts: () => SavedPrompt[] + /** Only project-scoped prompts. */ + projectPrompts: () => SavedPrompt[] + /** Whether there is an active project directory. */ + hasProject: () => boolean + add: (title: string, text: string, scope?: PromptScope) => void + move: (id: string, scope: PromptScope) => void update: (id: string, fields: Partial>) => void remove: (id: string) => void reorder: (ids: string[]) => void } -const LEGACY_KEY = "opencode.savedPrompts" +const GLOBAL_KEY = "opencode.savedPrompts" -function storageKey(directory?: string): string { - if (!directory) return LEGACY_KEY - // Normalize trailing separators so "/path/to/project" and "/path/to/project/" share the same key +function projectKey(directory: string): string { const normalized = directory.replace(/[\\/]+$/, "") return `opencode.savedPrompts.${normalized}` } const SavedPromptsContext = createContext() -function loadFromStorage(key: string): SavedPrompt[] { +function loadFromStorage(key: string, defaultScope: PromptScope): SavedPrompt[] { try { const stored = localStorage.getItem(key) if (!stored) return [] const parsed = JSON.parse(stored) if (!Array.isArray(parsed)) return [] - return parsed.filter( - (p): p is SavedPrompt => - typeof p.id === "string" && - typeof p.title === "string" && - typeof p.text === "string" && - typeof p.createdAt === "number", - ) + return (parsed as StoredPrompt[]) + .filter( + (p) => + typeof p.id === "string" && + typeof p.title === "string" && + typeof p.text === "string" && + typeof p.createdAt === "number", + ) + .map((p) => ({ + id: p.id, + title: p.title, + text: p.text, + createdAt: p.createdAt, + scope: p.scope === "global" || p.scope === "project" ? p.scope : defaultScope, + })) } catch { return [] } @@ -53,34 +79,80 @@ function saveToStorage(key: string, prompts: SavedPrompt[]) { } } -/** Migrate legacy prompts to the project-scoped key (one-time, non-destructive). */ +const sortNewest = (a: SavedPrompt, b: SavedPrompt) => b.createdAt - a.createdAt + +/** + * Migration helper that backfills missing `scope` tags in both stores. + * + * We intentionally keep legacy prompts in the global store and do not copy + * them into project storage. + */ function migrateIfNeeded(directory: string) { try { - const projectKey = storageKey(directory) - // Already has project-scoped data — no migration needed - if (localStorage.getItem(projectKey)) return - const legacy = localStorage.getItem(LEGACY_KEY) - if (!legacy) return - // Copy legacy data to project-scoped key; do NOT delete old key - localStorage.setItem(projectKey, legacy) + const pKey = projectKey(directory) + // Already has project-scoped data — just ensure scope tags exist + const existing = localStorage.getItem(pKey) + if (existing) { + const parsed = JSON.parse(existing) + if (Array.isArray(parsed)) { + let needsWrite = false + for (const p of parsed) { + if (!p.scope) { + p.scope = "project" + needsWrite = true + } + } + if (needsWrite) localStorage.setItem(pKey, JSON.stringify(parsed)) + } + } } catch { // Ignore storage errors during migration } + + // Ensure global key entries have scope tag + try { + const raw = localStorage.getItem(GLOBAL_KEY) + if (raw) { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) { + let needsWrite = false + for (const p of parsed) { + if (!p.scope) { + p.scope = "global" + needsWrite = true + } + } + if (needsWrite) localStorage.setItem(GLOBAL_KEY, JSON.stringify(parsed)) + } + } + } catch { + // Ignore + } +} + +/** + * Deduplicate prompts that exist in both the global and project stores + * (artefact of the old migration that copied everything). When duplicate + * IDs are found, keep the project-scoped copy and drop the global one from + * the merged view — the global store itself is left untouched. + */ +function mergePrompts(global: SavedPrompt[], project: SavedPrompt[]): SavedPrompt[] { + const projectIds = new Set(project.map((p) => p.id)) + const dedupedGlobal = global.filter((p) => !projectIds.has(p.id)) + return [...dedupedGlobal, ...project].sort(sortNewest) +} + +function isPrompt(p: SavedPrompt | undefined): p is SavedPrompt { + return p !== undefined } export function SavedPromptsProvider(props: ParentProps & { directory?: Accessor }) { // Keep a "sticky" directory that survives transient undefined flickers // during SolidJS router transitions (e.g. project → project settings). - // - // Initialise from the prop, falling back to the URL-derived directory. - // This avoids starting with undefined when the prop signal hasn't - // settled yet but the URL already encodes the directory. const [sticky, setSticky] = createSignal( props.directory?.() ?? deriveDirectoryFromPathname(), ) - // Track a pending clear so we can cancel it if the directory reappears - // before the microtask fires (avoids stale-closure clears). let pendingClear = false createEffect(() => { @@ -90,14 +162,8 @@ export function SavedPromptsProvider(props: ParentProps & { directory?: Accessor setSticky(d) return } - // Directory became undefined — check whether the URL still indicates a - // project route. If the URL also shows no directory, schedule a - // microtask-delayed clear to confirm the state is stable (the pathname - // can briefly flash to `/` during SolidJS router transitions). const fromUrl = deriveDirectoryFromPathname() if (fromUrl) { - // URL still has a directory even though the prop is undefined; - // keep the sticky value (may be a transient prop flicker). pendingClear = false setSticky(fromUrl) return @@ -114,75 +180,204 @@ export function SavedPromptsProvider(props: ParentProps & { directory?: Accessor }) const dir = sticky - const key = () => storageKey(dir()) + const pKey = () => { + const d = dir() + return d ? projectKey(d) : undefined + } // Run migration synchronously before initial load so first render has data const initialDir = dir() if (initialDir) migrateIfNeeded(initialDir) - const initialKey = key() - const [prompts, setPrompts] = createSignal( - loadFromStorage(initialKey).sort((a, b) => b.createdAt - a.createdAt), + // Load initial data from both stores + const [globalPrompts, setGlobalPrompts] = createSignal( + loadFromStorage(GLOBAL_KEY, "global").sort(sortNewest), + ) + const initialPKey = pKey() + const [projectPrompts, setProjectPrompts] = createSignal( + initialPKey ? loadFromStorage(initialPKey, "project").sort(sortNewest) : [], ) - // Reload prompts (with migration) when the storage key changes (e.g. - // switching projects or the sticky directory settling after mount). - // We track the previous key to skip redundant reloads — this replaces - // the old `defer: true` approach that could miss corrections when the - // provider remounted with a stale initial key. - let prevKey = initialKey - createEffect(on(key, (k) => { - if (k === prevKey) return - prevKey = k + // Merged view: global + project (deduplicated) + const allPrompts = createMemo(() => mergePrompts(globalPrompts(), projectPrompts())) + + // Reload project prompts when the project key changes + let prevPKey = pKey() + createEffect(on(pKey, (k) => { + if (k === prevPKey) return + prevPKey = k const d = dir() if (d) migrateIfNeeded(d) - setPrompts(loadFromStorage(k).sort((a, b) => b.createdAt - a.createdAt)) + setProjectPrompts(k ? loadFromStorage(k, "project").sort(sortNewest) : []) + })) + + // Also reload global prompts when the project key changes (migration may + // have tagged previously-unscoped prompts) + createEffect(on(pKey, () => { + setGlobalPrompts(loadFromStorage(GLOBAL_KEY, "global").sort(sortNewest)) })) - function add(title: string, text: string) { - setPrompts((prev) => { - const prompt: SavedPrompt = { - id: crypto.randomUUID(), - title, - text, - createdAt: Date.now(), + function add(title: string, text: string, scope: PromptScope = "global") { + const prompt: SavedPrompt = { + id: crypto.randomUUID(), + title, + text, + createdAt: Date.now(), + scope, + } + if (scope === "project") { + const k = pKey() + if (!k) { + // Fallback to global if no project context + addGlobal(prompt) + return } - const updated = [prompt, ...prev] - saveToStorage(key(), updated) + setProjectPrompts((prev) => { + const updated = [prompt, ...prev] + saveToStorage(k, updated) + return updated + }) + } else { + addGlobal(prompt) + } + } + + function addGlobal(prompt: SavedPrompt) { + setGlobalPrompts((prev) => { + const updated = [{ ...prompt, scope: "global" as const }, ...prev] + saveToStorage(GLOBAL_KEY, updated) return updated }) } - function update(id: string, fields: Partial>) { - setPrompts((prev) => { - const updated = prev.map((p) => (p.id === id ? { ...p, ...fields } : p)) - saveToStorage(key(), updated) + function move(id: string, scope: PromptScope) { + const project = projectPrompts().find((p) => p.id === id) + if (project) { + if (scope === "project") return + const k = pKey() + if (!k) return + setProjectPrompts((prev) => { + const updated = prev.filter((p) => p.id !== id) + saveToStorage(k, updated) + return updated + }) + setGlobalPrompts((prev) => { + const updated = [{ ...project, scope: "global" as const }, ...prev.filter((p) => p.id !== id)] + saveToStorage(GLOBAL_KEY, updated) + return updated + }) + return + } + + const global = globalPrompts().find((p) => p.id === id) + if (!global) return + if (scope === "global") return + + const k = pKey() + if (!k) return + + setGlobalPrompts((prev) => { + const updated = prev.filter((p) => p.id !== id) + saveToStorage(GLOBAL_KEY, updated) + return updated + }) + setProjectPrompts((prev) => { + const updated = [{ ...global, scope: "project" as const }, ...prev.filter((p) => p.id !== id)] + saveToStorage(k, updated) return updated }) } + function update(id: string, fields: Partial>) { + // Find which store the prompt belongs to + if (projectPrompts().some((p) => p.id === id)) { + const k = pKey() + if (!k) return + setProjectPrompts((prev) => { + const updated = prev.map((p) => (p.id === id ? { ...p, ...fields } : p)) + saveToStorage(k, updated) + return updated + }) + } else { + setGlobalPrompts((prev) => { + const updated = prev.map((p) => (p.id === id ? { ...p, ...fields } : p)) + saveToStorage(GLOBAL_KEY, updated) + return updated + }) + } + } + function remove(id: string) { - setPrompts((prev) => { - const filtered = prev.filter((p) => p.id !== id) - saveToStorage(key(), filtered) - return filtered - }) + // Remove from whichever store contains it + if (projectPrompts().some((p) => p.id === id)) { + const k = pKey() + if (!k) return + setProjectPrompts((prev) => { + const filtered = prev.filter((p) => p.id !== id) + saveToStorage(k, filtered) + return filtered + }) + } else { + setGlobalPrompts((prev) => { + const filtered = prev.filter((p) => p.id !== id) + saveToStorage(GLOBAL_KEY, filtered) + return filtered + }) + } } function reorder(ids: string[]) { - setPrompts((prev) => { - const map = new Map(prev.map((p) => [p.id, p])) - const reordered = ids.map((id) => map.get(id)).filter(Boolean) as SavedPrompt[] - // Append any prompts not in the ids list (shouldn't happen, but be safe) - const remaining = prev.filter((p) => !ids.includes(p.id)) - const updated = [...reordered, ...remaining] - saveToStorage(key(), updated) - return updated - }) + // Reorder only applies to visible merged prompts. + // Persist per-store ordering without dropping hidden duplicate entries. + const merged = allPrompts() + const map = new Map(merged.map((p) => [p.id, p])) + const reordered = ids.map((id) => map.get(id)).filter(isPrompt) + const remaining = merged.filter((p) => !ids.includes(p.id)) + const visible = [...reordered, ...remaining] + const rank = new Map(visible.map((p, i) => [p.id, i])) + + const reorderStore = (items: SavedPrompt[]) => { + const indexed = items.map((p, i) => ({ p, i })) + indexed.sort((a, b) => { + const ar = rank.get(a.p.id) + const br = rank.get(b.p.id) + if (ar !== undefined && br !== undefined) { + if (ar !== br) return ar - br + return a.i - b.i + } + if (ar !== undefined) return -1 + if (br !== undefined) return 1 + return a.i - b.i + }) + return indexed.map((x) => x.p) + } + + const newGlobal = reorderStore(globalPrompts()) + setGlobalPrompts(newGlobal) + saveToStorage(GLOBAL_KEY, newGlobal) + + const k = pKey() + if (k) { + const newProject = reorderStore(projectPrompts()) + setProjectPrompts(newProject) + saveToStorage(k, newProject) + } } return ( - + !!dir(), + add, + move, + update, + remove, + reorder, + }} + > {props.children} ) diff --git a/app-prefixable/src/pages/layout.tsx b/app-prefixable/src/pages/layout.tsx index d09fcb82..1fd622ec 100644 --- a/app-prefixable/src/pages/layout.tsx +++ b/app-prefixable/src/pages/layout.tsx @@ -55,7 +55,7 @@ import { import { useSync } from "../context/sync"; import { usePermission } from "../context/permission"; import { useGlobalEvents } from "../context/global-events"; -import { useSavedPrompts } from "../context/saved-prompts"; +import { useSavedPrompts, type PromptScope } from "../context/saved-prompts"; import { useCommand, isDialogOpen } from "../context/command"; import { ResizeHandle } from "../components/resize-handle"; import { ConfirmDialog } from "../components/confirm-dialog"; @@ -122,7 +122,7 @@ export function groupSessionsByDate( } function PromptDropdown(props: { - prompts: { id: string; title: string; text: string }[]; + prompts: { id: string; title: string; text: string; scope: PromptScope }[]; activeIndex: number; onSelect: (text: string) => void; onClose: () => void; @@ -188,7 +188,7 @@ function PromptDropdown(props: { {(prompt, i) => ( )} diff --git a/app-prefixable/src/pages/session.tsx b/app-prefixable/src/pages/session.tsx index aaeb32f3..a64e3547 100644 --- a/app-prefixable/src/pages/session.tsx +++ b/app-prefixable/src/pages/session.tsx @@ -21,7 +21,7 @@ import { useMCP } from "../context/mcp"; import { usePermission } from "../context/permission"; import { useLayout } from "../context/layout"; import { useBranding } from "../context/branding"; -import { useSavedPrompts } from "../context/saved-prompts"; +import { useSavedPrompts, type PromptScope } from "../context/saved-prompts"; import { useTerminal } from "../context/terminal"; import { useConfig } from "../context/config"; import { MessageTimeline } from "../components/message-timeline"; @@ -98,6 +98,8 @@ export function Session() { const terminal = useTerminal(); const appConfig = useConfig(); + const [sessionId, setSessionId] = createSignal(params.id); + // Per-session model: reads from providers.sessionModels, falls back to global default const sessionModel = createMemo(() => { const id = sessionId(); @@ -147,6 +149,7 @@ export function Session() { id: p.id, title: p.title, description: p.text.length > 80 ? p.text.slice(0, 80) + "..." : p.text, + group: p.scope === "project" ? "Project" : "Global", })), ); @@ -157,7 +160,6 @@ export function Session() { const [loading, setLoading] = createSignal(false); const [processing, setProcessing] = createSignal(false); const [loadingHistory, setLoadingHistory] = createSignal(false); - const [sessionId, setSessionId] = createSignal(params.id); // Find the Nth-from-last user message (1-indexed: 1 = last, 2 = second-to-last) function getNthLastUserMsg(msgs: DisplayMessage[], n: number) { @@ -240,6 +242,7 @@ export function Session() { const [showSavePrompt, setShowSavePrompt] = createSignal(false); const [savePromptTitle, setSavePromptTitle] = createSignal(""); const [savePromptBody, setSavePromptBody] = createSignal(""); + const [savePromptScope, setSavePromptScope] = createSignal(savedPrompts.hasProject() ? "project" : "global"); const [fileContext, setFileContext] = createSignal([]); const [imageAttachments, setImageAttachments] = createSignal< @@ -1696,11 +1699,22 @@ export function Session() { e.currentTarget.style.background = "var(--background-base)"; }} > -
- {prompt.title} +
+
+ {prompt.title} +
+ + {prompt.scope === "project" ? "Project" : "Global"} +
{ const title = savePromptTitle().trim(); const body = savePromptBody(); if (!title || !body) return; - savedPrompts.add(title, body); + savedPrompts.add(title, body, savePromptScope()); setShowSavePrompt(false); showToast("Prompt saved"); }} @@ -2391,6 +2409,9 @@ export function Session() { function SavePromptDialog(props: { title: () => string setTitle: (v: string) => void + scope: () => PromptScope + setScope: (v: PromptScope) => void + hasProject: boolean onSave: () => void onClose: () => void }) { @@ -2498,6 +2519,43 @@ function SavePromptDialog(props: {

The current input text will be saved as the prompt body.

+
+ +
+ + +
+
); } - diff --git a/app-prefixable/src/pages/settings.tsx b/app-prefixable/src/pages/settings.tsx index 49fcf477..54827ec8 100644 --- a/app-prefixable/src/pages/settings.tsx +++ b/app-prefixable/src/pages/settings.tsx @@ -1,4 +1,4 @@ -import { createSignal, For, Show, type JSX, createMemo, onMount, onCleanup, createEffect } from "solid-js" +import { createSignal, For, Show, type JSX, createMemo, onMount, onCleanup, createEffect, ErrorBoundary } from "solid-js" import { Portal } from "solid-js/web" import { Spinner } from "../components/ui/spinner" import { useProviders } from "../context/providers" @@ -11,15 +11,64 @@ import { ConfirmDialog } from "../components/confirm-dialog" import { Button } from "../components/ui/button" import { Check, Copy, Plug, GitBranch, Server, Globe, ExternalLink, Key, Search, X, Trash2, BookmarkPlus, Pencil, Palette, Sun, Moon, Monitor, BookOpen, Plus, Save, Volume2, Play, Settings2, Code, Shield, Cpu, Wrench, ChevronDown, ChevronRight, Info } from "lucide-solid" import { SOUND_OPTIONS, readSoundSettings, writeSoundSettings, playSound, primeAudioContext, SOUND_STORAGE_KEY, type SoundSettings } from "../utils/sound" -import { useSavedPrompts } from "../context/saved-prompts" +import { useSavedPrompts, type PromptScope } from "../context/saved-prompts" import { useTheme } from "../context/theme" import { useServer } from "../context/server" import { ServerDialog } from "../components/server-dialog" import { writeFile } from "../utils/extended-api" import type { Config, PermissionActionConfig } from "../sdk/client" +function getThrowableMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message + if (typeof error === "string" && error.trim()) return error + if (typeof error === "object" && error && "message" in error && typeof error.message === "string" && error.message.trim()) { + return error.message + } + return "An unexpected error occurred while rendering this section." +} + +function SectionErrorFallback(props: { error: unknown; reset: () => void; onRetry?: () => Promise | void }) { + return ( +
+
+

+ Something went wrong +

+

+ {getThrowableMessage(props.error)} +

+
+ +
+ ) +} + export function Settings() { const providers = useProviders() + const config = useConfig() const mcp = useMCP() const { client, global, url, directory } = useSDK() const theme = useTheme() @@ -49,6 +98,7 @@ export function Settings() { const [editingPromptId, setEditingPromptId] = createSignal(null) const [promptTitle, setPromptTitle] = createSignal("") const [promptText, setPromptText] = createSignal("") + const [promptScope, setPromptScope] = createSignal("global") const [promptToDelete, setPromptToDelete] = createSignal(null) // Sound settings @@ -139,6 +189,16 @@ export function Settings() { }) }) + const settingsRenderError = createMemo(() => { + const message = providers.providerError || config.error() + if (!message) return null + return new Error(message) + }) + + async function retrySettingsSection() { + await Promise.allSettled([providers.refetch(), config.refresh()]) + } + // Load SSH key when Git tab is first accessed function onTabChange(tabId: string) { setActiveTab(tabId) @@ -601,6 +661,7 @@ Add your project-specific instructions here. setEditingPromptId(null) setPromptTitle("") setPromptText("") + setPromptScope(savedPrompts.hasProject() ? "project" : "global") setPromptDialogOpen(true) } @@ -610,6 +671,7 @@ Add your project-specific instructions here. setEditingPromptId(id) setPromptTitle(prompt.title) setPromptText(prompt.text) + setPromptScope(prompt.scope) setPromptDialogOpen(true) } @@ -619,9 +681,19 @@ Add your project-specific instructions here. if (!title || !text) return const editing = editingPromptId() if (editing) { - savedPrompts.update(editing, { title, text }) + const existing = savedPrompts.prompts().find((p) => p.id === editing) + if (existing && existing.scope !== promptScope()) { + // Scope changed — move across stores while preserving id/createdAt + savedPrompts.move(editing, promptScope()) + } + if (existing && (existing.title !== title || existing.text !== text)) { + savedPrompts.update(editing, { title, text }) + } + if (!existing) { + savedPrompts.add(title, text, promptScope()) + } } else { - savedPrompts.add(title, text) + savedPrompts.add(title, text, promptScope()) } setPromptDialogOpen(false) setEditingPromptId(null) @@ -645,7 +717,7 @@ Add your project-specific instructions here. { id: "servers", label: "Servers", icon: () => , scope: "Global" }, { id: "git", label: "Git", icon: () => , scope: "Global" }, { id: "mcp", label: "MCP Servers", icon: () => , scope: "Global + Project" }, - { id: "prompts", label: "Prompts", icon: () => , scope: directory ? "Project" : null }, + { id: "prompts", label: "Prompts", icon: () => , scope: directory ? "Global + Project" : "Global" }, { id: "instructions", label: "Instructions", icon: () => , scope: directory ? "Project" : null }, ] // Only show Project Config tab when a project directory is selected @@ -723,6 +795,12 @@ Add your project-specific instructions here. {/* Content */}
+ }> + {(() => { + const err = settingsRenderError() + if (!err) return null + throw err + })()} {/* Project header banner */}

- Create reusable prompts for quick access from the welcome screen or /prompt command + Create reusable prompts for quick access from the welcome screen or /prompt command. + {directory + ? " Global prompts are available in all projects; project prompts are scoped to this project." + : " Prompts shown here are available in all projects."}

@@ -1804,8 +1885,19 @@ Add your project-specific instructions here. {(prompt) => (
-
- {prompt.title} +
+
+ {prompt.title} +
+ + {prompt.scope === "project" ? "Project" : "Global"} +

+
@@ -2287,6 +2380,9 @@ Add your project-specific instructions here. setTitle={setPromptTitle} text={promptText} setText={setPromptText} + scope={promptScope} + setScope={setPromptScope} + hasProject={savedPrompts.hasProject()} onSave={savePromptDialog} onClose={() => setPromptDialogOpen(false)} /> @@ -3111,6 +3207,9 @@ function PromptDialog(props: { setTitle: (v: string) => void text: () => string setText: (v: string) => void + scope: () => PromptScope + setScope: (v: PromptScope) => void + hasProject: boolean onSave: () => void onClose: () => void }) { @@ -3213,6 +3312,45 @@ function PromptDialog(props: { }} />
+
+ +
+ + +
+

+ {props.scope() === "project" + ? "This prompt will only appear in the current project." + : "This prompt will be available in all projects."} +

+