summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/context')
-rw-r--r--packages/app/src/context/command.tsx42
-rw-r--r--packages/app/src/context/comments.test.ts41
-rw-r--r--packages/app/src/context/comments.tsx58
-rw-r--r--packages/app/src/context/file.tsx101
-rw-r--r--packages/app/src/context/global-sdk.tsx22
-rw-r--r--packages/app/src/context/global-sync.tsx70
-rw-r--r--packages/app/src/context/highlights.tsx79
-rw-r--r--packages/app/src/context/language.tsx147
-rw-r--r--packages/app/src/context/layout.tsx105
-rw-r--r--packages/app/src/context/local.tsx72
-rw-r--r--packages/app/src/context/models.tsx39
-rw-r--r--packages/app/src/context/notification.tsx142
-rw-r--r--packages/app/src/context/permission.tsx16
-rw-r--r--packages/app/src/context/platform.tsx14
-rw-r--r--packages/app/src/context/prompt.tsx109
-rw-r--r--packages/app/src/context/sdk.tsx8
-rw-r--r--packages/app/src/context/server.tsx131
-rw-r--r--packages/app/src/context/settings.tsx35
-rw-r--r--packages/app/src/context/sync.tsx169
-rw-r--r--packages/app/src/context/terminal.tsx67
20 files changed, 817 insertions, 650 deletions
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx
index e6a16fd4b..b286364c6 100644
--- a/packages/app/src/context/command.tsx
+++ b/packages/app/src/context/command.tsx
@@ -11,6 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
+const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id
@@ -33,6 +34,11 @@ function signatureFromEvent(event: KeyboardEvent) {
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
}
+function isAllowedEditableKeybind(id: string | undefined) {
+ if (!id) return false
+ return EDITABLE_KEYBIND_IDS.has(actionId(id))
+}
+
export type KeybindConfig = string
export interface Keybind {
@@ -56,6 +62,8 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void
}
+type CommandSource = "palette" | "keybind" | "slash"
+
export type CommandCatalogItem = {
title: string
description?: string
@@ -169,6 +177,14 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+")
}
+function isEditableTarget(target: EventTarget | null) {
+ if (!(target instanceof HTMLElement)) return false
+ if (target.isContentEditable) return true
+ if (target.closest("[contenteditable='true']")) return true
+ if (target.closest("input, textarea, select")) return true
+ return false
+}
+
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
@@ -275,13 +291,18 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return map
})
- const run = (id: string, source?: "palette" | "keybind" | "slash") => {
+ const optionMap = createMemo(() => {
+ const map = new Map<string, CommandOption>()
for (const option of options()) {
- if (option.id === id || option.id === "suggested." + id) {
- option.onSelect?.(source)
- return
- }
+ map.set(option.id, option)
+ map.set(actionId(option.id), option)
}
+ return map
+ })
+
+ const run = (id: string, source?: CommandSource) => {
+ const option = optionMap().get(id)
+ option?.onSelect?.(source)
}
const showPalette = () => {
@@ -292,14 +313,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (suspended() || dialog.active) return
const sig = signatureFromEvent(event)
+ const isPalette = palette().has(sig)
+ const option = keymap().get(sig)
+
+ if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id)) return
- if (palette().has(sig)) {
+ if (isPalette) {
event.preventDefault()
showPalette()
return
}
- const option = keymap().get(sig)
if (!option) return
event.preventDefault()
option.onSelect?.("keybind")
@@ -332,7 +356,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return {
register,
- trigger(id: string, source?: "palette" | "keybind" | "slash") {
+ trigger(id: string, source?: CommandSource) {
run(id, source)
},
keybind(id: string) {
@@ -351,7 +375,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
},
show: showPalette,
keybinds(enabled: boolean) {
- setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
+ setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
},
suspended,
get catalog() {
diff --git a/packages/app/src/context/comments.test.ts b/packages/app/src/context/comments.test.ts
index 4f223e5f8..bee5c7871 100644
--- a/packages/app/src/context/comments.test.ts
+++ b/packages/app/src/context/comments.test.ts
@@ -109,4 +109,45 @@ describe("comments session indexing", () => {
dispose()
})
})
+
+ test("remove keeps focus when same comment id exists in another file", () => {
+ createRoot((dispose) => {
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "shared", 10)],
+ "b.ts": [line("b.ts", "shared", 20)],
+ })
+
+ comments.setFocus({ file: "b.ts", id: "shared" })
+ comments.remove("a.ts", "shared")
+
+ expect(comments.focus()).toEqual({ file: "b.ts", id: "shared" })
+ expect(comments.list("a.ts")).toEqual([])
+ expect(comments.list("b.ts").map((item) => item.id)).toEqual(["shared"])
+
+ dispose()
+ })
+ })
+
+ test("setFocus and setActive updater callbacks receive current state", () => {
+ createRoot((dispose) => {
+ const comments = createCommentSessionForTest()
+
+ comments.setFocus({ file: "a.ts", id: "a1" })
+ comments.setFocus((current) => {
+ expect(current).toEqual({ file: "a.ts", id: "a1" })
+ return { file: "b.ts", id: "b1" }
+ })
+
+ comments.setActive({ file: "c.ts", id: "c1" })
+ comments.setActive((current) => {
+ expect(current).toEqual({ file: "c.ts", id: "c1" })
+ return null
+ })
+
+ expect(comments.focus()).toEqual({ file: "b.ts", id: "b1" })
+ expect(comments.active()).toBeNull()
+
+ dispose()
+ })
+ })
})
diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx
index a88ea0d86..ecf63e45b 100644
--- a/packages/app/src/context/comments.tsx
+++ b/packages/app/src/context/comments.tsx
@@ -1,4 +1,4 @@
-import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
+import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
@@ -20,6 +20,19 @@ type CommentFocus = { file: string; id: string }
const WORKSPACE_KEY = "__workspace__"
const MAX_COMMENT_SESSIONS = 20
+function sessionKey(dir: string, id: string | undefined) {
+ return `${dir}\n${id ?? WORKSPACE_KEY}`
+}
+
+function decodeSessionKey(key: string) {
+ const split = key.lastIndexOf("\n")
+ if (split < 0) return { dir: key, id: WORKSPACE_KEY }
+ return {
+ dir: key.slice(0, split),
+ id: key.slice(split + 1),
+ }
+}
+
type CommentStore = {
comments: Record<string, LineComment[]>
}
@@ -31,24 +44,24 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time)
}
-function insert(items: LineComment[], next: LineComment) {
- const index = items.findIndex((item) => item.time > next.time)
- if (index < 0) return [...items, next]
- return [...items.slice(0, index), next, ...items.slice(index)]
-}
-
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
active: null as CommentFocus | null,
- all: aggregate(store.comments),
})
+ const all = () => aggregate(store.comments)
+
+ const setRef = (
+ key: "focus" | "active",
+ value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null),
+ ) => setState(key, value)
+
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
- setState("focus", value)
+ setRef("focus", value)
const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
- setState("active", value)
+ setRef("active", value)
const list = (file: string) => store.comments[file] ?? []
@@ -61,7 +74,6 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
batch(() => {
setStore("comments", input.file, (items) => [...(items ?? []), next])
- setState("all", (items) => insert(items, next))
setFocus({ file: input.file, id: next.id })
})
@@ -71,15 +83,13 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
const remove = (file: string, id: string) => {
batch(() => {
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
- setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
- setFocus((current) => (current?.id === id ? null : current))
+ setFocus((current) => (current?.file === file && current.id === id ? null : current))
})
}
const clear = () => {
batch(() => {
setStore("comments", reconcile({}))
- setState("all", [])
setFocus(null)
setActive(null)
})
@@ -87,17 +97,16 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
return {
list,
- all: () => state.all,
+ all,
add,
remove,
clear,
focus: () => state.focus,
setFocus,
- clearFocus: () => setFocus(null),
+ clearFocus: () => setRef("focus", null),
active: () => state.active,
setActive,
- clearActive: () => setActive(null),
- reindex: () => setState("all", aggregate(store.comments)),
+ clearActive: () => setRef("active", null),
}
}
@@ -117,11 +126,6 @@ function createCommentSession(dir: string, id: string | undefined) {
)
const session = createCommentSessionState(store, setStore)
- createEffect(() => {
- if (!ready()) return
- session.reindex()
- })
-
return {
ready,
list: session.list,
@@ -145,11 +149,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const params = useParams()
const cache = createScopedCache(
(key) => {
- const split = key.lastIndexOf("\n")
- const dir = split >= 0 ? key.slice(0, split) : key
- const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
+ const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({
- value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
+ value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
dispose,
}))
},
@@ -162,7 +164,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
onCleanup(() => cache.clear())
const load = (dir: string, id: string | undefined) => {
- const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+ const key = sessionKey(dir, id)
return cache.get(key).value
}
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx
index 88b70cd41..99c6d2e42 100644
--- a/packages/app/src/context/file.tsx
+++ b/packages/app/src/context/file.tsx
@@ -43,6 +43,12 @@ export {
touchFileContent,
}
+function errorMessage(error: unknown) {
+ if (error instanceof Error && error.message) return error.message
+ if (typeof error === "string" && error) return error
+ return "Unknown error"
+}
+
export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File",
gate: false,
@@ -110,6 +116,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setStore("file", file, { path: file, name: getFilename(file) })
}
+ const setLoading = (file: string) => {
+ setStore(
+ "file",
+ file,
+ produce((draft) => {
+ draft.loading = true
+ draft.error = undefined
+ }),
+ )
+ }
+
+ const setLoaded = (file: string, content: FileState["content"]) => {
+ setStore(
+ "file",
+ file,
+ produce((draft) => {
+ draft.loaded = true
+ draft.loading = false
+ draft.content = content
+ }),
+ )
+ }
+
+ const setLoadError = (file: string, message: string) => {
+ setStore(
+ "file",
+ file,
+ produce((draft) => {
+ draft.loading = false
+ draft.error = message
+ }),
+ )
+ showToast({
+ variant: "error",
+ title: language.t("toast.file.loadFailed.title"),
+ description: message,
+ })
+ }
+
const load = (input: string, options?: { force?: boolean }) => {
const file = path.normalize(input)
if (!file) return Promise.resolve()
@@ -124,29 +169,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const pending = inflight.get(key)
if (pending) return pending
- setStore(
- "file",
- file,
- produce((draft) => {
- draft.loading = true
- draft.error = undefined
- }),
- )
+ setLoading(file)
const promise = sdk.client.file
.read({ path: file })
.then((x) => {
if (scope() !== directory) return
const content = x.data
- setStore(
- "file",
- file,
- produce((draft) => {
- draft.loaded = true
- draft.loading = false
- draft.content = content
- }),
- )
+ setLoaded(file, content)
if (!content) return
touchFileContent(file, approxBytes(content))
@@ -154,19 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
.catch((e) => {
if (scope() !== directory) return
- setStore(
- "file",
- file,
- produce((draft) => {
- draft.loading = false
- draft.error = e.message
- }),
- )
- showToast({
- variant: "error",
- title: language.t("toast.file.loadFailed.title"),
- description: e.message,
- })
+ setLoadError(file, errorMessage(e))
})
.finally(() => {
inflight.delete(key)
@@ -211,21 +229,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return state
}
- const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
- const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
- const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
-
- const setScrollTop = (input: string, top: number) => {
- view().setScrollTop(path.normalize(input), top)
- }
-
- const setScrollLeft = (input: string, left: number) => {
- view().setScrollLeft(path.normalize(input), left)
- }
-
- const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
- view().setSelectedLines(path.normalize(input), range)
+ function withPath(input: string, action: (file: string) => unknown) {
+ return action(path.normalize(input))
}
+ const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
+ const scrollLeft = (input: string) => withPath(input, (file) => view().scrollLeft(file))
+ const selectedLines = (input: string) => withPath(input, (file) => view().selectedLines(file))
+ const setScrollTop = (input: string, top: number) => withPath(input, (file) => view().setScrollTop(file, top))
+ const setScrollLeft = (input: string, left: number) => withPath(input, (file) => view().setScrollLeft(file, left))
+ const setSelectedLines = (input: string, range: SelectedLineRange | null) =>
+ withPath(input, (file) => view().setSelectedLines(file, range))
onCleanup(() => {
stop()
diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx
index cb610bf6e..8c50a8878 100644
--- a/packages/app/src/context/global-sdk.tsx
+++ b/packages/app/src/context/global-sdk.tsx
@@ -31,9 +31,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}>()
type Queued = { directory: string; payload: Event }
+ const FLUSH_FRAME_MS = 16
+ const STREAM_YIELD_MS = 8
- let queue: Array<Queued | undefined> = []
- let buffer: Array<Queued | undefined> = []
+ let queue: Queued[] = []
+ let buffer: Queued[] = []
const coalesced = new Map<string, number>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
@@ -62,7 +64,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
last = Date.now()
batch(() => {
for (const event of events) {
- if (!event) continue
emitter.emit(event.directory, event.payload)
}
})
@@ -73,9 +74,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
- timer = setTimeout(flush, Math.max(0, 16 - elapsed))
+ timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
+ let streamErrorLogged = false
+
void (async () => {
const events = await eventSdk.global.event()
let yielded = Date.now()
@@ -86,20 +89,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
- queue[i] = undefined
+ queue[i] = { directory, payload }
+ continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
- if (Date.now() - yielded < 8) continue
+ if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
})()
.finally(flush)
- .catch(() => undefined)
+ .catch((error) => {
+ if (streamErrorLogged) return
+ streamErrorLogged = true
+ console.error("[global-sdk] event stream failed", error)
+ })
onCleanup(() => {
abort.abort()
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index e2bf44980..62c7eb66e 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -47,6 +47,20 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
+function errorMessage(error: unknown) {
+ if (error instanceof Error && error.message) return error.message
+ if (typeof error === "string" && error) return error
+ return "Unknown error"
+}
+
+function setDevStats(value: {
+ activeDirectoryStores: number
+ evictions: number
+ loadSessionsFullFetchFallback: number
+}) {
+ ;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
+}
+
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
@@ -81,19 +95,11 @@ function createGlobalSync() {
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
- ;(
- globalThis as {
- __OPENCODE_GLOBAL_SYNC_STATS?: {
- activeDirectoryStores: number
- evictions: number
- loadSessionsFullFetchFallback: number
- }
- }
- ).__OPENCODE_GLOBAL_SYNC_STATS = {
+ setDevStats({
activeDirectoryStores,
evictions: stats.evictions,
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
- }
+ })
}
const paused = () => untrack(() => globalStore.reload) !== undefined
@@ -204,7 +210,10 @@ function createGlobalSync() {
.catch((err) => {
console.error("Failed to load sessions", err)
const project = getFilename(directory)
- showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message })
+ showToast({
+ title: language.t("toast.session.listFailed.title", { project }),
+ description: errorMessage(err),
+ })
})
sessionLoads.set(directory, promise)
@@ -307,12 +316,28 @@ function createGlobalSync() {
void bootstrap()
})
- function projectMeta(directory: string, patch: ProjectMeta) {
- children.projectMeta(directory, patch)
+ const projectApi = {
+ loadSessions,
+ meta(directory: string, patch: ProjectMeta) {
+ children.projectMeta(directory, patch)
+ },
+ icon(directory: string, value: string | undefined) {
+ children.projectIcon(directory, value)
+ },
}
- function projectIcon(directory: string, value: string | undefined) {
- children.projectIcon(directory, value)
+ const updateConfig = async (config: Config) => {
+ setGlobalStore("reload", "pending")
+ return globalSDK.client.global.config
+ .update({ config })
+ .then(bootstrap)
+ .then(() => {
+ setGlobalStore("reload", "complete")
+ })
+ .catch((error) => {
+ setGlobalStore("reload", undefined)
+ throw error
+ })
}
return {
@@ -326,19 +351,8 @@ function createGlobalSync() {
},
child: children.child,
bootstrap,
- updateConfig: (config: Config) => {
- setGlobalStore("reload", "pending")
- return globalSDK.client.global.config.update({ config }).finally(() => {
- setTimeout(() => {
- setGlobalStore("reload", "complete")
- }, 1000)
- })
- },
- project: {
- loadSessions,
- meta: projectMeta,
- icon: projectIcon,
- },
+ updateConfig,
+ project: projectApi,
}
}
diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx
index cc4c021be..476209e41 100644
--- a/packages/app/src/context/highlights.tsx
+++ b/packages/app/src/context/highlights.tsx
@@ -119,9 +119,7 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
const seen = new Set<string>()
const unique = highlights.filter((highlight) => {
- const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join(
- "\n",
- )
+ const key = dedupeKey(highlight)
if (seen.has(key)) return false
seen.add(key)
return true
@@ -129,6 +127,16 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
return unique.slice(0, 5)
}
+function dedupeKey(highlight: Highlight) {
+ return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n")
+}
+
+function loadReleaseHighlights(value: unknown, current?: string, previous?: string) {
+ const releases = parseChangelog(value)
+ if (!releases?.length) return []
+ return sliceHighlights({ releases, current, previous })
+}
+
export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
name: "Highlights",
gate: false,
@@ -140,32 +148,21 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
const [from, setFrom] = createSignal<string | undefined>(undefined)
const [to, setTo] = createSignal<string | undefined>(undefined)
- const [timer, setTimer] = createSignal<ReturnType<typeof setTimeout> | undefined>(undefined)
const state = { started: false }
+ let timer: ReturnType<typeof setTimeout> | undefined
+
+ const clearTimer = () => {
+ if (timer === undefined) return
+ clearTimeout(timer)
+ timer = undefined
+ }
const markSeen = () => {
if (!platform.version) return
setStore("version", platform.version)
}
- createEffect(() => {
- if (state.started) return
- if (!ready()) return
- if (!settings.ready()) return
- if (!platform.version) return
- state.started = true
-
- const previous = store.version
- if (!previous) {
- setStore("version", platform.version)
- return
- }
-
- if (previous === platform.version) return
-
- setFrom(previous)
- setTo(platform.version)
-
+ const start = (previous: string) => {
if (!settings.general.releaseNotes()) {
markSeen()
return
@@ -175,9 +172,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
const controller = new AbortController()
onCleanup(() => {
controller.abort()
- const id = timer()
- if (id === undefined) return
- clearTimeout(id)
+ clearTimer()
})
fetcher(CHANGELOG_URL, {
@@ -187,15 +182,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
.then((json) => {
if (!json) return
- const releases = parseChangelog(json)
- if (!releases) return
- if (releases.length === 0) return
- const highlights = sliceHighlights({
- releases,
- current: platform.version,
- previous,
- })
-
+ const highlights = loadReleaseHighlights(json, platform.version, previous)
if (controller.signal.aborted) return
if (highlights.length === 0) {
@@ -203,13 +190,33 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
return
}
- const timer = setTimeout(() => {
+ timer = setTimeout(() => {
+ timer = undefined
markSeen()
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
}, 500)
- setTimer(timer)
})
.catch(() => undefined)
+ }
+
+ createEffect(() => {
+ if (state.started) return
+ if (!ready()) return
+ if (!settings.ready()) return
+ if (!platform.version) return
+ state.started = true
+
+ const previous = store.version
+ if (!previous) {
+ setStore("version", platform.version)
+ return
+ }
+
+ if (previous === platform.version) return
+
+ setFrom(previous)
+ setTo(platform.version)
+ start(previous)
})
return {
diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx
index 22f7bcca1..a5d894e62 100644
--- a/packages/app/src/context/language.tsx
+++ b/packages/app/src/context/language.tsx
@@ -76,6 +76,66 @@ const LOCALES: readonly Locale[] = [
"th",
]
+const LABEL_KEY: Record<Locale, keyof Dictionary> = {
+ en: "language.en",
+ zh: "language.zh",
+ zht: "language.zht",
+ ko: "language.ko",
+ de: "language.de",
+ es: "language.es",
+ fr: "language.fr",
+ da: "language.da",
+ ja: "language.ja",
+ pl: "language.pl",
+ ru: "language.ru",
+ ar: "language.ar",
+ no: "language.no",
+ br: "language.br",
+ th: "language.th",
+ bs: "language.bs",
+}
+
+const base = i18n.flatten({ ...en, ...uiEn })
+const DICT: Record<Locale, Dictionary> = {
+ en: base,
+ zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
+ zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
+ ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
+ de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
+ es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
+ fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
+ da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
+ ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
+ pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
+ ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
+ ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
+ no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
+ br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
+ th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
+ bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
+}
+
+const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
+ { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
+ { locale: "zh", match: (language) => language.startsWith("zh") },
+ { locale: "ko", match: (language) => language.startsWith("ko") },
+ { locale: "de", match: (language) => language.startsWith("de") },
+ { locale: "es", match: (language) => language.startsWith("es") },
+ { locale: "fr", match: (language) => language.startsWith("fr") },
+ { locale: "da", match: (language) => language.startsWith("da") },
+ { locale: "ja", match: (language) => language.startsWith("ja") },
+ { locale: "pl", match: (language) => language.startsWith("pl") },
+ { locale: "ru", match: (language) => language.startsWith("ru") },
+ { locale: "ar", match: (language) => language.startsWith("ar") },
+ {
+ locale: "no",
+ match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
+ },
+ { locale: "br", match: (language) => language.startsWith("pt") },
+ { locale: "th", match: (language) => language.startsWith("th") },
+ { locale: "bs", match: (language) => language.startsWith("bs") },
+]
+
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
zh,
@@ -102,28 +162,9 @@ function detectLocale(): Locale {
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
- if (language.toLowerCase().startsWith("zh")) {
- if (language.toLowerCase().includes("hant")) return "zht"
- return "zh"
- }
- if (language.toLowerCase().startsWith("ko")) return "ko"
- if (language.toLowerCase().startsWith("de")) return "de"
- if (language.toLowerCase().startsWith("es")) return "es"
- if (language.toLowerCase().startsWith("fr")) return "fr"
- if (language.toLowerCase().startsWith("da")) return "da"
- if (language.toLowerCase().startsWith("ja")) return "ja"
- if (language.toLowerCase().startsWith("pl")) return "pl"
- if (language.toLowerCase().startsWith("ru")) return "ru"
- if (language.toLowerCase().startsWith("ar")) return "ar"
- if (
- language.toLowerCase().startsWith("no") ||
- language.toLowerCase().startsWith("nb") ||
- language.toLowerCase().startsWith("nn")
- )
- return "no"
- if (language.toLowerCase().startsWith("pt")) return "br"
- if (language.toLowerCase().startsWith("th")) return "th"
- if (language.toLowerCase().startsWith("bs")) return "bs"
+ const normalized = language.toLowerCase()
+ const match = localeMatchers.find((entry) => entry.match(normalized))
+ if (match) return match.locale
}
return "en"
@@ -139,24 +180,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
}),
)
- const locale = createMemo<Locale>(() => {
- if (store.locale === "zh") return "zh"
- if (store.locale === "zht") return "zht"
- if (store.locale === "ko") return "ko"
- if (store.locale === "de") return "de"
- if (store.locale === "es") return "es"
- if (store.locale === "fr") return "fr"
- if (store.locale === "da") return "da"
- if (store.locale === "ja") return "ja"
- if (store.locale === "pl") return "pl"
- if (store.locale === "ru") return "ru"
- if (store.locale === "ar") return "ar"
- if (store.locale === "no") return "no"
- if (store.locale === "br") return "br"
- if (store.locale === "th") return "th"
- if (store.locale === "bs") return "bs"
- return "en"
- })
+ const locale = createMemo<Locale>(() =>
+ LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
+ )
createEffect(() => {
const current = locale()
@@ -164,48 +190,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
setStore("locale", current)
})
- const base = i18n.flatten({ ...en, ...uiEn })
- const dict = createMemo<Dictionary>(() => {
- if (locale() === "en") return base
- if (locale() === "zh") return { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }
- if (locale() === "zht") return { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }
- if (locale() === "de") return { ...base, ...i18n.flatten({ ...de, ...uiDe }) }
- if (locale() === "es") return { ...base, ...i18n.flatten({ ...es, ...uiEs }) }
- if (locale() === "fr") return { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }
- if (locale() === "da") return { ...base, ...i18n.flatten({ ...da, ...uiDa }) }
- if (locale() === "ja") return { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }
- if (locale() === "pl") return { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }
- if (locale() === "ru") return { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }
- if (locale() === "ar") return { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }
- if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
- if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
- if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) }
- if (locale() === "bs") return { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }
- return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
- })
+ const dict = createMemo<Dictionary>(() => DICT[locale()])
const t = i18n.translator(dict, i18n.resolveTemplate)
- const labelKey: Record<Locale, keyof Dictionary> = {
- en: "language.en",
- zh: "language.zh",
- zht: "language.zht",
- ko: "language.ko",
- de: "language.de",
- es: "language.es",
- fr: "language.fr",
- da: "language.da",
- ja: "language.ja",
- pl: "language.pl",
- ru: "language.ru",
- ar: "language.ar",
- no: "language.no",
- br: "language.br",
- th: "language.th",
- bs: "language.bs",
- }
-
- const label = (value: Locale) => t(labelKey[value])
+ const label = (value: Locale) => t(LABEL_KEY[value])
createEffect(() => {
if (typeof document !== "object") return
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 4019b2f29..71f0294e7 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -11,6 +11,9 @@ import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
+const DEFAULT_PANEL_WIDTH = 344
+const DEFAULT_SESSION_WIDTH = 600
+const DEFAULT_TERMINAL_HEIGHT = 280
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
export function getAvatarColors(key?: string) {
@@ -85,6 +88,14 @@ export function pruneSessionKeys(input: {
.slice(input.max)
}
+function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
+ const all = current?.all ?? []
+ if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
+ if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
+ if (!all.includes(tab)) return { all: [...all, tab], active: tab }
+ return { all, active: tab }
+}
+
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -116,11 +127,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!isRecord(fileTree)) return fileTree
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
- const width = typeof fileTree.width === "number" ? fileTree.width : 344
+ const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
return {
...fileTree,
opened: true,
- width: width === 260 ? 344 : width,
+ width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
tab: "changes",
}
})()
@@ -151,12 +162,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createStore({
sidebar: {
opened: false,
- width: 344,
+ width: DEFAULT_PANEL_WIDTH,
workspaces: {} as Record<string, boolean>,
workspacesDefault: false,
},
terminal: {
- height: 280,
+ height: DEFAULT_TERMINAL_HEIGHT,
opened: false,
},
review: {
@@ -165,11 +176,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
fileTree: {
opened: true,
- width: 344,
+ width: DEFAULT_PANEL_WIDTH,
tab: "changes" as "changes" | "all",
},
session: {
- width: 600,
+ width: DEFAULT_SESSION_WIDTH,
},
mobileSidebar: {
opened: false,
@@ -184,8 +195,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
- const meta = { active: undefined as string | undefined, pruned: false }
- const used = new Map<string, number>()
+ const usage = {
+ active: undefined as string | undefined,
+ pruned: false,
+ used: new Map<string, number>(),
+ }
const SESSION_STATE_KEYS = [
{ key: "prompt", legacy: "prompt", version: "v2" },
@@ -214,7 +228,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const drop = pruneSessionKeys({
keep,
max: MAX_SESSION_KEYS,
- used,
+ used: usage.used,
view: Object.keys(store.sessionView),
tabs: Object.keys(store.sessionTabs),
})
@@ -233,18 +247,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
dropSessionState(drop)
for (const key of drop) {
- used.delete(key)
+ usage.used.delete(key)
}
}
function touch(sessionKey: string) {
- meta.active = sessionKey
- used.set(sessionKey, Date.now())
+ usage.active = sessionKey
+ usage.used.set(sessionKey, Date.now())
if (!ready()) return
- if (meta.pruned) return
+ if (usage.pruned) return
- meta.pruned = true
+ usage.pruned = true
prune(sessionKey)
}
@@ -253,7 +267,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
onFlush: (sessionKey, next) => {
const current = store.sessionView[sessionKey]
- const keep = meta.active ?? sessionKey
+ const keep = usage.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
prune(keep)
@@ -269,10 +283,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createEffect(() => {
if (!ready()) return
- if (meta.pruned) return
- const active = meta.active
+ if (usage.pruned) return
+ const active = usage.active
if (!active) return
- meta.pruned = true
+ usage.pruned = true
prune(active)
})
@@ -546,32 +560,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
fileTree: {
opened: createMemo(() => store.fileTree?.opened ?? true),
- width: createMemo(() => store.fileTree?.width ?? 344),
+ width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
setTab(tab: "changes" | "all") {
if (!store.fileTree) {
- setStore("fileTree", { opened: true, width: 344, tab })
+ setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
return
}
setStore("fileTree", "tab", tab)
},
open() {
if (!store.fileTree) {
- setStore("fileTree", { opened: true, width: 344, tab: "changes" })
+ setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", true)
},
close() {
if (!store.fileTree) {
- setStore("fileTree", { opened: false, width: 344, tab: "changes" })
+ setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", false)
},
toggle() {
if (!store.fileTree) {
- setStore("fileTree", { opened: true, width: 344, tab: "changes" })
+ setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", (x) => !x)
@@ -585,7 +599,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
session: {
- width: createMemo(() => store.session?.width ?? 600),
+ width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
resize(width: number) {
if (!store.session) {
setStore("session", { width })
@@ -617,7 +631,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
pendingMessage: messageID,
pendingMessageAt: at,
})
- prune(meta.active ?? sessionKey)
+ prune(usage.active ?? sessionKey)
return
}
@@ -658,7 +672,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function setTerminalOpened(next: boolean) {
const current = store.terminal
if (!current) {
- setStore("terminal", { height: 280, opened: next })
+ setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
return
}
@@ -755,43 +769,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
async open(tab: string) {
const session = key()
- const current = store.sessionTabs[session] ?? { all: [] }
-
- if (tab === "review") {
- if (!store.sessionTabs[session]) {
- setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), active: tab })
- return
- }
- setStore("sessionTabs", session, "active", tab)
- return
- }
-
- if (tab === "context") {
- const all = [tab, ...current.all.filter((x) => x !== tab)]
- if (!store.sessionTabs[session]) {
- setStore("sessionTabs", session, { all, active: tab })
- return
- }
- setStore("sessionTabs", session, "all", all)
- setStore("sessionTabs", session, "active", tab)
- return
- }
-
- if (!current.all.includes(tab)) {
- if (!store.sessionTabs[session]) {
- setStore("sessionTabs", session, { all: [tab], active: tab })
- return
- }
- setStore("sessionTabs", session, "all", [...current.all, tab])
- setStore("sessionTabs", session, "active", tab)
- return
- }
-
- if (!store.sessionTabs[session]) {
- setStore("sessionTabs", session, { all: current.all, active: tab })
- return
- }
- setStore("sessionTabs", session, "active", tab)
+ const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
+ setStore("sessionTabs", session, next)
},
close(tab: string) {
const session = key()
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index 85f93f368..ac5da60e8 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -16,16 +16,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK()
const sync = useSync()
const providers = useProviders()
+ const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
function isModelValid(model: ModelKey) {
const provider = providers.all().find((x) => x.id === model.providerID)
- return (
- !!provider?.models[model.modelID] &&
- providers
- .connected()
- .map((p) => p.id)
- .includes(model.providerID)
- )
+ return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
@@ -36,6 +31,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
+ let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined
+
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [store, setStore] = createStore<{
@@ -75,7 +72,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!value) return
setStore("current", value.name)
if (value.model)
- model.set({
+ setModel({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
@@ -92,38 +89,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model: {},
})
- const fallbackModel = createMemo<ModelKey | undefined>(() => {
- if (sync.data.config.model) {
- const [providerID, modelID] = sync.data.config.model.split("/")
- if (isModelValid({ providerID, modelID })) {
- return {
- providerID,
- modelID,
- }
- }
- }
+ const resolveConfigured = () => {
+ if (!sync.data.config.model) return
+ const [providerID, modelID] = sync.data.config.model.split("/")
+ const key = { providerID, modelID }
+ if (isModelValid(key)) return key
+ }
+ const resolveRecent = () => {
for (const item of models.recent.list()) {
- if (isModelValid(item)) {
- return item
- }
+ if (isModelValid(item)) return item
}
+ }
+ const resolveDefault = () => {
const defaults = providers.default()
- for (const p of providers.connected()) {
- const configured = defaults[p.id]
+ for (const provider of providers.connected()) {
+ const configured = defaults[provider.id]
if (configured) {
- const key = { providerID: p.id, modelID: configured }
+ const key = { providerID: provider.id, modelID: configured }
if (isModelValid(key)) return key
}
- const first = Object.values(p.models)[0]
+ const first = Object.values(provider.models)[0]
if (!first) continue
- const key = { providerID: p.id, modelID: first.id }
+ const key = { providerID: provider.id, modelID: first.id }
if (isModelValid(key)) return key
}
+ }
- return undefined
+ const fallbackModel = createMemo<ModelKey | undefined>(() => {
+ return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
})
const current = createMemo(() => {
@@ -163,21 +159,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
}
+ const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
+ batch(() => {
+ const currentAgent = agent.current()
+ const next = model ?? fallbackModel()
+ if (currentAgent) setEphemeral("model", currentAgent.name, next)
+ if (model) models.setVisibility(model, true)
+ if (options?.recent && model) models.recent.push(model)
+ })
+ }
+
+ setModel = set
+
return {
ready: models.ready,
current,
recent,
list: models.list,
cycle,
- set(model: ModelKey | undefined, options?: { recent?: boolean }) {
- batch(() => {
- const currentAgent = agent.current()
- const next = model ?? fallbackModel()
- if (currentAgent) setEphemeral("model", currentAgent.name, next)
- if (model) models.setVisibility(model, true)
- if (options?.recent && model) models.recent.push(model)
- })
- },
+ set,
visible(model: ModelKey) {
return models.visible(model)
},
diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx
index fee3c10c6..12ec8371a 100644
--- a/packages/app/src/context/models.tsx
+++ b/packages/app/src/context/models.tsx
@@ -16,6 +16,12 @@ type Store = {
variant?: Record<string, string | undefined>
}
+const RECENT_LIMIT = 5
+
+function modelKey(model: ModelKey) {
+ return `${model.providerID}:${model.modelID}`
+}
+
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models",
init: () => {
@@ -39,10 +45,27 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
),
)
+ const release = createMemo(
+ () =>
+ new Map(
+ available().map((model) => {
+ const parsed = DateTime.fromISO(model.release_date)
+ return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
+ }),
+ ),
+ )
+
const latest = createMemo(() =>
pipe(
available(),
- filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
+ filter(
+ (x) =>
+ Math.abs(
+ (release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
+ .diffNow()
+ .as("months"),
+ ) < 6,
+ ),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
@@ -61,7 +84,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
),
)
- const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
+ const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
const visibility = createMemo(() => {
const map = new Map<string, Visibility>()
@@ -82,20 +105,20 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
function update(model: ModelKey, state: Visibility) {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
- setStore("user", index, { visibility: state })
+ setStore("user", index, (current) => ({ ...current, visibility: state }))
return
}
setStore("user", store.user.length, { ...model, visibility: state })
}
const visible = (model: ModelKey) => {
- const key = `${model.providerID}:${model.modelID}`
+ const key = modelKey(model)
const state = visibility().get(key)
if (state === "hide") return false
if (state === "show") return true
if (latestSet().has(key)) return true
- const m = find(model)
- if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
+ const date = release().get(key)
+ if (!date?.isValid) return true
return false
}
@@ -104,8 +127,8 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
}
const push = (model: ModelKey) => {
- const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
- if (uniq.length > 5) uniq.pop()
+ const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
+ if (uniq.length > RECENT_LIMIT) uniq.pop()
setStore("recent", uniq)
}
diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx
index cade70a53..e35e815f9 100644
--- a/packages/app/src/context/notification.tsx
+++ b/packages/app/src/context/notification.tsx
@@ -18,7 +18,7 @@ import { buildNotificationIndex } from "./notification-index"
type NotificationBase = {
directory?: string
session?: string
- metadata?: any
+ metadata?: unknown
time: number
viewed: boolean
}
@@ -84,89 +84,93 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const index = createMemo(() => buildNotificationIndex(store.list))
- const lookup = (directory: string, sessionID?: string) => {
- if (!sessionID) return Promise.resolve(undefined)
+ const lookup = async (directory: string, sessionID?: string) => {
+ if (!sessionID) return undefined
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
- if (match.found) return Promise.resolve(syncStore.session[match.index])
+ if (match.found) return syncStore.session[match.index]
return globalSDK.client.session
.get({ directory, sessionID })
.then((x) => x.data)
.catch(() => undefined)
}
+ const viewedInCurrentSession = (directory: string, sessionID?: string) => {
+ const activeDirectory = currentDirectory()
+ const activeSession = currentSession()
+ if (!activeDirectory) return false
+ if (!activeSession) return false
+ if (!sessionID) return false
+ if (directory !== activeDirectory) return false
+ return sessionID === activeSession
+ }
+
+ const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
+ const sessionID = event.properties.sessionID
+ void lookup(directory, sessionID).then((session) => {
+ if (meta.disposed) return
+ if (!session) return
+ if (session.parentID) return
+
+ playSound(soundSrc(settings.sounds.agent()))
+
+ append({
+ directory,
+ time,
+ viewed: viewedInCurrentSession(directory, sessionID),
+ type: "turn-complete",
+ session: sessionID,
+ })
+
+ const href = `/${base64Encode(directory)}/session/${sessionID}`
+ if (settings.notifications.agent()) {
+ void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
+ }
+ })
+ }
+
+ const handleSessionError = (
+ directory: string,
+ event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
+ time: number,
+ ) => {
+ const sessionID = event.properties.sessionID
+ void lookup(directory, sessionID).then((session) => {
+ if (meta.disposed) return
+ if (session?.parentID) return
+
+ playSound(soundSrc(settings.sounds.errors()))
+
+ const error = "error" in event.properties ? event.properties.error : undefined
+ append({
+ directory,
+ time,
+ viewed: viewedInCurrentSession(directory, sessionID),
+ type: "error",
+ session: sessionID ?? "global",
+ error,
+ })
+ const description =
+ session?.title ??
+ (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
+ const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
+ if (settings.notifications.errors()) {
+ void platform.notify(language.t("notification.session.error.title"), description, href)
+ }
+ })
+ }
+
const unsub = globalSDK.event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
- const viewed = (sessionID?: string) => {
- const activeDirectory = currentDirectory()
- const activeSession = currentSession()
- if (!activeDirectory) return false
- if (!activeSession) return false
- if (!sessionID) return false
- if (directory !== activeDirectory) return false
- return sessionID === activeSession
- }
- switch (event.type) {
- case "session.idle": {
- const sessionID = event.properties.sessionID
- void lookup(directory, sessionID).then((session) => {
- if (meta.disposed) return
- if (!session) return
- if (session.parentID) return
-
- playSound(soundSrc(settings.sounds.agent()))
-
- append({
- directory,
- time,
- viewed: viewed(sessionID),
- type: "turn-complete",
- session: sessionID,
- })
-
- const href = `/${base64Encode(directory)}/session/${sessionID}`
- if (settings.notifications.agent()) {
- void platform.notify(
- language.t("notification.session.responseReady.title"),
- session.title ?? sessionID,
- href,
- )
- }
- })
- break
- }
- case "session.error": {
- const sessionID = event.properties.sessionID
- void lookup(directory, sessionID).then((session) => {
- if (meta.disposed) return
- if (session?.parentID) return
-
- playSound(soundSrc(settings.sounds.errors()))
-
- const error = "error" in event.properties ? event.properties.error : undefined
- append({
- directory,
- time,
- viewed: viewed(sessionID),
- type: "error",
- session: sessionID ?? "global",
- error,
- })
- const description =
- session?.title ??
- (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
- const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
- if (settings.notifications.errors()) {
- void platform.notify(language.t("notification.session.error.title"), description, href)
- }
- })
- break
- }
+ if (event.type === "session.idle") {
+ handleSessionIdle(directory, event, time)
+ return
}
+ handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx
index a701dbd1f..988723834 100644
--- a/packages/app/src/context/permission.tsx
+++ b/packages/app/src/context/permission.tsx
@@ -33,7 +33,7 @@ function isNonAllowRule(rule: unknown) {
return false
}
-function hasAutoAcceptPermissionConfig(permission: unknown) {
+function hasPermissionPromptRules(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
@@ -57,7 +57,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const directory = decode64(params.dir)
if (!directory) return false
const [store] = globalSync.child(directory)
- return hasAutoAcceptPermissionConfig(store.config.permission)
+ return hasPermissionPromptRules(store.config.permission)
})
const [store, setStore, _, ready] = persisted(
@@ -70,6 +70,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
+ const enableVersion = new Map<string, number>()
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
@@ -114,6 +115,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
}
+ function bumpEnableVersion(sessionID: string, directory?: string) {
+ const key = acceptKey(sessionID, directory)
+ const next = (enableVersion.get(key) ?? 0) + 1
+ enableVersion.set(key, next)
+ return next
+ }
+
const unsubscribe = globalSDK.event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
@@ -128,6 +136,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory)
+ const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAcceptEdits[key] = true
@@ -138,6 +147,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
globalSDK.client.permission
.list({ directory })
.then((x) => {
+ if (enableVersion.get(key) !== version) return
+ if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (perm.sessionID !== sessionID) continue
@@ -149,6 +160,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function disable(sessionID: string, directory?: string) {
+ bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : undefined
setStore(
produce((draft) => {
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index e260c1977..6d4464258 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -2,6 +2,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
+type PickerPaths = string | string[] | null
+type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
+type OpenFilePickerOptions = { title?: string; multiple?: boolean }
+type SaveFilePickerOptions = { title?: string; defaultPath?: string }
+type UpdateInfo = { updateAvailable: boolean; version?: string }
+
export type Platform = {
/** Platform discriminator */
platform: "web" | "desktop"
@@ -31,19 +37,19 @@ export type Platform = {
notify(title: string, description?: string, href?: string): Promise<void>
/** Open directory picker dialog (native on Tauri, server-backed on web) */
- openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
+ openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
/** Open native file picker dialog (Tauri only) */
- openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
+ openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
/** Save file picker dialog (Tauri only) */
- saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
+ saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Check for updates (Tauri only) */
- checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
+ checkUpdate?(): Promise<UpdateInfo>
/** Install updates (Tauri only) */
update?(): Promise<void>
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 99fab6c19..064892105 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -1,4 +1,4 @@
-import { createStore } from "solid-js/store"
+import { createStore, type SetStoreFunction } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
@@ -60,27 +60,23 @@ function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
)
}
+function isPartEqual(partA: ContentPart, partB: ContentPart) {
+ switch (partA.type) {
+ case "text":
+ return partB.type === "text" && partA.content === partB.content
+ case "file":
+ return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
+ case "agent":
+ return partB.type === "agent" && partA.name === partB.name
+ case "image":
+ return partB.type === "image" && partA.id === partB.id
+ }
+}
+
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
- const partA = promptA[i]
- const partB = promptB[i]
- if (partA.type !== partB.type) return false
- if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
- return false
- }
- if (partA.type === "file") {
- const fileA = partA as FileAttachmentPart
- const fileB = partB as FileAttachmentPart
- if (fileA.path !== fileB.path) return false
- if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
- }
- if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
- return false
- }
- if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
- return false
- }
+ if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
@@ -104,6 +100,48 @@ function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
+function contextItemKey(item: ContextItem) {
+ if (item.type !== "file") return item.type
+ const start = item.selection?.startLine
+ const end = item.selection?.endLine
+ const key = `${item.type}:${item.path}:${start}:${end}`
+
+ if (item.commentID) {
+ return `${key}:c=${item.commentID}`
+ }
+
+ const comment = item.comment?.trim()
+ if (!comment) return key
+ const digest = checksum(comment) ?? comment
+ return `${key}:c=${digest.slice(0, 8)}`
+}
+
+function createPromptActions(
+ setStore: SetStoreFunction<{
+ prompt: Prompt
+ cursor?: number
+ context: {
+ items: (ContextItem & { key: string })[]
+ }
+ }>,
+) {
+ return {
+ set(prompt: Prompt, cursorPosition?: number) {
+ const next = clonePrompt(prompt)
+ batch(() => {
+ setStore("prompt", next)
+ if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
+ })
+ },
+ reset() {
+ batch(() => {
+ setStore("prompt", clonePrompt(DEFAULT_PROMPT))
+ setStore("cursor", 0)
+ })
+ },
+ }
+}
+
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
@@ -134,21 +172,7 @@ function createPromptSession(dir: string, id: string | undefined) {
}),
)
- function keyForItem(item: ContextItem) {
- if (item.type !== "file") return item.type
- const start = item.selection?.startLine
- const end = item.selection?.endLine
- const key = `${item.type}:${item.path}:${start}:${end}`
-
- if (item.commentID) {
- return `${key}:c=${item.commentID}`
- }
-
- const comment = item.comment?.trim()
- if (!comment) return key
- const digest = checksum(comment) ?? comment
- return `${key}:c=${digest.slice(0, 8)}`
- }
+ const actions = createPromptActions(setStore)
return {
ready,
@@ -158,7 +182,7 @@ function createPromptSession(dir: string, id: string | undefined) {
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
- const key = keyForItem(item)
+ const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
@@ -166,19 +190,8 @@ function createPromptSession(dir: string, id: string | undefined) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
- set(prompt: Prompt, cursorPosition?: number) {
- const next = clonePrompt(prompt)
- batch(() => {
- setStore("prompt", next)
- if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
- })
- },
- reset() {
- batch(() => {
- setStore("prompt", clonePrompt(DEFAULT_PROMPT))
- setStore("cursor", 0)
- })
- },
+ set: actions.set,
+ reset: actions.reset,
}
}
diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx
index 3a404ec93..555933619 100644
--- a/packages/app/src/context/sdk.tsx
+++ b/packages/app/src/context/sdk.tsx
@@ -5,6 +5,10 @@ import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
+type SDKEventMap = {
+ [key in Event["type"]]: Extract<Event, { type: key }>
+}
+
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: Accessor<string> }) => {
@@ -21,9 +25,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
}),
)
- const emitter = createGlobalEmitter<{
- [key in Event["type"]]: Extract<Event, { type: key }>
- }>()
+ const emitter = createGlobalEmitter<SDKEventMap>()
createEffect(() => {
const unsub = globalSDK.event.on(directory(), (event) => {
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index 351407d91..5d3d0cf3a 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist"
import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
+const HEALTH_POLL_INTERVAL_MS = 10_000
export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
@@ -48,81 +49,51 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const healthy = () => state.healthy
- function setActive(input: string) {
- const url = normalizeServerUrl(input)
- if (!url) return
- setState("active", url)
- }
+ const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
- function add(input: string) {
- const url = normalizeServerUrl(input)
- if (!url) return
+ function reconcileStartup() {
+ const fallback = defaultUrl()
+ if (!fallback) return
- const fallback = normalizeServerUrl(props.defaultUrl)
- if (fallback && url === fallback) {
+ const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
+ const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
+ if (!props.isSidecar) {
batch(() => {
- if (!store.list.includes(url)) {
- // Add the fallback url to the list if it's not already in the list
- setStore("list", store.list.length, url)
- }
- setState("active", url)
+ setStore("list", list)
+ if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
+ setState("active", fallback)
})
return
}
+ const nextList = list.includes(fallback) ? list : [...list, fallback]
batch(() => {
- if (!store.list.includes(url)) {
- setStore("list", store.list.length, url)
- }
- setState("active", url)
+ setStore("list", nextList)
+ setStore("currentSidecarUrl", fallback)
+ setState("active", fallback)
})
}
- function remove(input: string) {
- const url = normalizeServerUrl(input)
- if (!url) return
-
- const list = store.list.filter((x) => x !== url)
- const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
-
- batch(() => {
- setStore("list", list)
- setState("active", next)
- })
- }
+ function updateServerList(url: string, remove = false) {
+ if (remove) {
+ const list = store.list.filter((x) => x !== url)
+ const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
+ batch(() => {
+ setStore("list", list)
+ setState("active", next)
+ })
+ return
+ }
- createEffect(() => {
- if (!ready()) return
- if (state.active) return
- const url = normalizeServerUrl(props.defaultUrl)
- if (!url) return
batch(() => {
- // Remove the previous startup sidecar url
- if (store.currentSidecarUrl) {
- remove(store.currentSidecarUrl)
- }
-
- // Add the new sidecar url
- if (props.isSidecar && props.defaultUrl) {
- add(props.defaultUrl)
- setStore("currentSidecarUrl", props.defaultUrl)
+ if (!store.list.includes(url)) {
+ setStore("list", store.list.length, url)
}
-
setState("active", url)
})
- })
-
- const isReady = createMemo(() => ready() && !!state.active)
-
- const fetcher = platform.fetch ?? globalThis.fetch
- const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
-
- createEffect(() => {
- const url = state.active
- if (!url) return
-
- setState("healthy", undefined)
+ }
+ function startHealthPolling(url: string) {
let alive = true
let busy = false
@@ -140,12 +111,48 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
run()
- const interval = setInterval(run, 10_000)
-
- onCleanup(() => {
+ const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
+ return () => {
alive = false
clearInterval(interval)
- })
+ }
+ }
+
+ function setActive(input: string) {
+ const url = normalizeServerUrl(input)
+ if (!url) return
+ setState("active", url)
+ }
+
+ function add(input: string) {
+ const url = normalizeServerUrl(input)
+ if (!url) return
+ updateServerList(url)
+ }
+
+ function remove(input: string) {
+ const url = normalizeServerUrl(input)
+ if (!url) return
+ updateServerList(url, true)
+ }
+
+ createEffect(() => {
+ if (!ready()) return
+ if (state.active) return
+ reconcileStartup()
+ })
+
+ const isReady = createMemo(() => ready() && !!state.active)
+
+ const fetcher = platform.fetch ?? globalThis.fetch
+ const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
+
+ createEffect(() => {
+ const url = state.active
+ if (!url) return
+
+ setState("healthy", undefined)
+ onCleanup(startHealthPolling(url))
})
const origin = createMemo(() => projectsKey(state.active))
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx
index 19b3846f8..a8efb1eac 100644
--- a/packages/app/src/context/settings.tsx
+++ b/packages/app/src/context/settings.tsx
@@ -85,6 +85,10 @@ export function monoFontFamily(font: string | undefined) {
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
}
+function withFallback<T>(read: () => T | undefined, fallback: T) {
+ return createMemo(() => read() ?? fallback)
+}
+
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
init: () => {
@@ -101,27 +105,27 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
return store
},
general: {
- autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave),
+ autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
setAutoSave(value: boolean) {
setStore("general", "autoSave", value)
},
- releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes),
+ releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
},
updates: {
- startup: createMemo(() => store.updates?.startup ?? defaultSettings.updates.startup),
+ startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
setStartup(value: boolean) {
setStore("updates", "startup", value)
},
},
appearance: {
- fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
+ fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) {
setStore("appearance", "fontSize", value)
},
- font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font),
+ font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
setFont(value: string) {
setStore("appearance", "font", value)
},
@@ -132,42 +136,47 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("keybinds", action, keybind)
},
reset(action: string) {
- setStore("keybinds", action, undefined!)
+ setStore("keybinds", (current) => {
+ if (!Object.prototype.hasOwnProperty.call(current, action)) return current
+ const next = { ...current }
+ delete next[action]
+ return next
+ })
},
resetAll() {
setStore("keybinds", reconcile({}))
},
},
permissions: {
- autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),
+ autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
setAutoApprove(value: boolean) {
setStore("permissions", "autoApprove", value)
},
},
notifications: {
- agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent),
+ agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
setAgent(value: boolean) {
setStore("notifications", "agent", value)
},
- permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions),
+ permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
setPermissions(value: boolean) {
setStore("notifications", "permissions", value)
},
- errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors),
+ errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
setErrors(value: boolean) {
setStore("notifications", "errors", value)
},
},
sounds: {
- agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent),
+ agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
setAgent(value: string) {
setStore("sounds", "agent", value)
},
- permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions),
+ permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
setPermissions(value: string) {
setStore("sounds", "permissions", value)
},
- errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors),
+ errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
setErrors(value: string) {
setStore("sounds", "errors", value)
},
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 66c53dc80..e5916598b 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -7,6 +7,20 @@ import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+function sortParts(parts: Part[]) {
+ return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
+}
+
+function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
+ const pending = map.get(key)
+ if (pending) return pending
+ const promise = task().finally(() => {
+ map.delete(key)
+ })
+ map.set(key, promise)
+ return promise
+}
+
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -36,7 +50,7 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
}
- draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
+ draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
@@ -48,6 +62,34 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
delete draft.part[input.messageID]
}
+function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
+ setStore("message", input.sessionID, (messages: Message[] | undefined) => {
+ if (!messages) return [input.message]
+ const result = Binary.search(messages, input.message.id, (m) => m.id)
+ const next = [...messages]
+ next.splice(result.index, 0, input.message)
+ return next
+ })
+ setStore("part", input.message.id, sortParts(input.parts))
+}
+
+function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
+ setStore("message", input.sessionID, (messages: Message[] | undefined) => {
+ if (!messages) return messages
+ const result = Binary.search(messages, input.messageID, (m) => m.id)
+ if (!result.found) return messages
+ const next = [...messages]
+ next.splice(result.index, 1)
+ return next
+ })
+ setStore("part", (part: Record<string, Part[] | undefined>) => {
+ if (!(input.messageID in part)) return part
+ const next = { ...part }
+ delete next[input.messageID]
+ return next
+ })
+}
+
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@@ -63,7 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
- const chunk = 400
+ const messagePageSize = 400
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -81,8 +123,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
const limitFor = (count: number) => {
- if (count <= chunk) return chunk
- return Math.ceil(count / chunk) * chunk
+ if (count <= messagePageSize) return messagePageSize
+ return Math.ceil(count / messagePageSize) * messagePageSize
+ }
+
+ const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
+ const messages = await retry(() =>
+ input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
+ )
+ const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
+ const session = items
+ .map((x) => x.info)
+ .filter((m) => !!m?.id)
+ .sort((a, b) => cmp(a.id, b.id))
+ const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
+ return {
+ session,
+ part,
+ complete: session.length < input.limit,
+ }
}
const loadMessages = async (input: {
@@ -96,30 +155,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (meta.loading[key]) return
setMeta("loading", key, true)
- await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }))
- .then((messages) => {
- const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
- const next = items
- .map((x) => x.info)
- .filter((m) => !!m?.id)
- .sort((a, b) => cmp(a.id, b.id))
-
+ await fetchMessages(input)
+ .then((next) => {
batch(() => {
- input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
-
- for (const message of items) {
- input.setStore(
- "part",
- message.info.id,
- reconcile(
- message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
- { key: "id" },
- ),
- )
+ input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
+ for (const message of next.part) {
+ input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
}
-
setMeta("limit", key, input.limit)
- setMeta("complete", key, next.length < input.limit)
+ setMeta("complete", key, next.complete)
})
})
.finally(() => {
@@ -151,19 +195,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const [, setStore] = target(input.directory)
- setStore(
- produce((draft) => {
- applyOptimisticAdd(draft as OptimisticStore, input)
- }),
- )
+ setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const [, setStore] = target(input.directory)
- setStore(
- produce((draft) => {
- applyOptimisticRemove(draft as OptimisticStore, input)
- }),
- )
+ setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
@@ -182,15 +218,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
model: input.model,
}
const [, setStore] = target()
- setStore(
- produce((draft) => {
- applyOptimisticAdd(draft as OptimisticStore, {
- sessionID: input.sessionID,
- message,
- parts: input.parts,
- })
- }),
- )
+ setOptimisticAdd(setStore as (...args: unknown[]) => void, {
+ sessionID: input.sessionID,
+ message,
+ parts: input.parts,
+ })
},
async sync(sessionID: string) {
const directory = sdk.directory
@@ -205,11 +237,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const hasMessages = store.message[sessionID] !== undefined
const hydrated = meta.limit[key] !== undefined
if (hasSession && hasMessages && hydrated) return
- const pending = inflight.get(key)
- if (pending) return pending
const count = store.message[sessionID]?.length ?? 0
- const limit = hydrated ? (meta.limit[key] ?? chunk) : limitFor(count)
+ const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
const sessionReq = hasSession
? Promise.resolve()
@@ -240,14 +270,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
limit,
})
- const promise = Promise.all([sessionReq, messagesReq])
- .then(() => {})
- .finally(() => {
- inflight.delete(key)
- })
-
- inflight.set(key, promise)
- return promise
+ return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
},
async diff(sessionID: string) {
const directory = sdk.directory
@@ -256,19 +279,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (store.session_diff[sessionID] !== undefined) return
const key = keyFor(directory, sessionID)
- const pending = inflightDiff.get(key)
- if (pending) return pending
-
- const promise = retry(() => client.session.diff({ sessionID }))
- .then((diff) => {
+ return runInflight(inflightDiff, key, () =>
+ retry(() => client.session.diff({ sessionID })).then((diff) => {
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
- })
- .finally(() => {
- inflightDiff.delete(key)
- })
-
- inflightDiff.set(key, promise)
- return promise
+ }),
+ )
},
async todo(sessionID: string) {
const directory = sdk.directory
@@ -277,19 +292,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (store.todo[sessionID] !== undefined) return
const key = keyFor(directory, sessionID)
- const pending = inflightTodo.get(key)
- if (pending) return pending
-
- const promise = retry(() => client.session.todo({ sessionID }))
- .then((todo) => {
+ return runInflight(inflightTodo, key, () =>
+ retry(() => client.session.todo({ sessionID })).then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
- })
- .finally(() => {
- inflightTodo.delete(key)
- })
-
- inflightTodo.set(key, promise)
- return promise
+ }),
+ )
},
history: {
more(sessionID: string) {
@@ -304,7 +311,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false
},
- async loadMore(sessionID: string, count = chunk) {
+ async loadMore(sessionID: string, count = messagePageSize) {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
@@ -312,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (meta.loading[key]) return
if (meta.complete[key]) return
- const currentLimit = meta.limit[key] ?? chunk
+ const currentLimit = meta.limit[key] ?? messagePageSize
await loadMessages({
directory,
client,
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index c7816158c..0e6aa08cb 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -79,19 +79,38 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}),
)
- const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
- const id = event.properties.id
- if (!store.all.some((x) => x.id === id)) return
+ const pickNextTerminalNumber = () => {
+ const existingTitleNumbers = new Set(
+ store.all.flatMap((pty) => {
+ const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
+ if (direct !== undefined) return [direct]
+ const parsed = numberFromTitle(pty.title)
+ if (parsed === undefined) return []
+ return [parsed]
+ }),
+ )
+
+ return (
+ Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
+ (number) => !existingTitleNumbers.has(number),
+ ) ?? 1
+ )
+ }
+
+ const removeExited = (id: string) => {
+ const all = store.all
+ const index = all.findIndex((x) => x.id === id)
+ if (index === -1) return
+ const filtered = all.filter((x) => x.id !== id)
+ const active = store.active === id ? filtered[0]?.id : store.active
batch(() => {
- setStore(
- "all",
- store.all.filter((x) => x.id !== id),
- )
- if (store.active === id) {
- const remaining = store.all.filter((x) => x.id !== id)
- setStore("active", remaining[0]?.id)
- }
+ setStore("all", filtered)
+ setStore("active", active)
})
+ }
+
+ const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
+ removeExited(event.properties.id)
})
onCleanup(unsub)
@@ -117,7 +136,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
return {
ready,
- all: createMemo(() => Object.values(store.all)),
+ all: createMemo(() => store.all),
active: createMemo(() => store.active),
clear() {
batch(() => {
@@ -126,20 +145,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
})
},
new() {
- const existingTitleNumbers = new Set(
- store.all.flatMap((pty) => {
- const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
- if (direct !== undefined) return [direct]
- const parsed = numberFromTitle(pty.title)
- if (parsed === undefined) return []
- return [parsed]
- }),
- )
-
- const nextNumber =
- Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
- (number) => !existingTitleNumbers.has(number),
- ) ?? 1
+ const nextNumber = pickNextTerminalNumber()
sdk.client.pty
.create({ title: `Terminal ${nextNumber}` })
@@ -162,10 +168,8 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
- const index = store.all.findIndex((x) => x.id === pty.id)
- if (index !== -1) {
- setStore("all", index, (existing) => ({ ...existing, ...pty }))
- }
+ const previous = store.all.find((x) => x.id === pty.id)
+ if (previous) setStore("all", (all) => all.map((item) => (item.id === pty.id ? { ...item, ...pty } : item)))
sdk.client.pty
.update({
ptyID: pty.id,
@@ -173,6 +177,9 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((error: unknown) => {
+ if (previous) {
+ setStore("all", (all) => all.map((item) => (item.id === pty.id ? previous : item)))
+ }
console.error("Failed to update terminal", error)
})
},