diff options
| author | Adam <[email protected]> | 2026-02-12 09:49:14 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-12 09:49:14 -0600 |
| commit | ff4414bb152acfddb5c0eb073c38bedc1df4ae14 (patch) | |
| tree | 78381c67d21ef6f089647f6b19e7aa2976840dbc /packages/app/src/context | |
| parent | 56ad2db02055955f926fda0e4a89055b22ead6f9 (diff) | |
| download | opencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.tar.gz opencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.zip | |
chore: refactor packages/app files (#13236)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <[email protected]>
Diffstat (limited to 'packages/app/src/context')
| -rw-r--r-- | packages/app/src/context/command.tsx | 42 | ||||
| -rw-r--r-- | packages/app/src/context/comments.test.ts | 41 | ||||
| -rw-r--r-- | packages/app/src/context/comments.tsx | 58 | ||||
| -rw-r--r-- | packages/app/src/context/file.tsx | 101 | ||||
| -rw-r--r-- | packages/app/src/context/global-sdk.tsx | 22 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 70 | ||||
| -rw-r--r-- | packages/app/src/context/highlights.tsx | 79 | ||||
| -rw-r--r-- | packages/app/src/context/language.tsx | 147 | ||||
| -rw-r--r-- | packages/app/src/context/layout.tsx | 105 | ||||
| -rw-r--r-- | packages/app/src/context/local.tsx | 72 | ||||
| -rw-r--r-- | packages/app/src/context/models.tsx | 39 | ||||
| -rw-r--r-- | packages/app/src/context/notification.tsx | 142 | ||||
| -rw-r--r-- | packages/app/src/context/permission.tsx | 16 | ||||
| -rw-r--r-- | packages/app/src/context/platform.tsx | 14 | ||||
| -rw-r--r-- | packages/app/src/context/prompt.tsx | 109 | ||||
| -rw-r--r-- | packages/app/src/context/sdk.tsx | 8 | ||||
| -rw-r--r-- | packages/app/src/context/server.tsx | 131 | ||||
| -rw-r--r-- | packages/app/src/context/settings.tsx | 35 | ||||
| -rw-r--r-- | packages/app/src/context/sync.tsx | 169 | ||||
| -rw-r--r-- | packages/app/src/context/terminal.tsx | 67 |
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) }) }, |
