diff options
| author | Adam <[email protected]> | 2026-02-12 11:26:19 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-12 11:26:19 -0600 |
| commit | da952135cabba2926698298797cd301e7adaf48c (patch) | |
| tree | 78635fe4f7d656266ad3cc1c353b04b56969515c | |
| parent | 789705ea96ae28af7e30801fd6039ce89b6ac48e (diff) | |
| download | opencode-da952135cabba2926698298797cd301e7adaf48c.tar.gz opencode-da952135cabba2926698298797cd301e7adaf48c.zip | |
chore(app): refactor for better solidjs hygiene (#13344)
18 files changed, 438 insertions, 201 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index d591b22c7..146f1b64e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -345,6 +345,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { .filter((agent) => !agent.hidden && agent.mode !== "primary") .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) + const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) const handleAtSelect = (option: AtOption | undefined) => { if (!option) return @@ -1038,7 +1039,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { keybind={command.keybind("agent.cycle")} > <Select - options={local.agent.list().map((agent) => agent.name)} + options={agentNames()} current={local.agent.current()?.name ?? ""} onSelect={local.agent.set} class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`} diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx index 1ab184535..5054253b8 100644 --- a/packages/app/src/components/question-dock.tsx +++ b/packages/app/src/components/question-dock.tsx @@ -7,32 +7,6 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" -const writeAt = <T,>(list: T[], index: number, value: T) => { - const next = [...list] - next[index] = value - return next -} - -const pickAnswer = (list: QuestionAnswer[], index: number, value: string) => { - return writeAt(list, index, [value]) -} - -const toggleAnswer = (list: QuestionAnswer[], index: number, value: string) => { - const current = list[index] ?? [] - const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value] - return writeAt(list, index, next) -} - -const appendAnswer = (list: QuestionAnswer[], index: number, value: string) => { - const current = list[index] ?? [] - if (current.includes(value)) return list - return writeAt(list, index, [...current, value]) -} - -const writeCustom = (list: string[], index: number, value: string) => { - return writeAt(list, index, value) -} - export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { const sdk = useSDK() const language = useLanguage() @@ -95,10 +69,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } const pick = (answer: string, custom: boolean = false) => { - setStore("answers", pickAnswer(store.answers, store.tab, answer)) + setStore("answers", store.tab, [answer]) if (custom) { - setStore("custom", writeCustom(store.custom, store.tab, answer)) + setStore("custom", store.tab, answer) } if (single()) { @@ -110,7 +84,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } const toggle = (answer: string) => { - setStore("answers", toggleAnswer(store.answers, store.tab, answer)) + setStore("answers", store.tab, (current = []) => { + if (current.includes(answer)) return current.filter((item) => item !== answer) + return [...current, answer] + }) } const selectTab = (index: number) => { @@ -146,7 +123,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } if (multi()) { - setStore("answers", appendAnswer(store.answers, store.tab, value)) + setStore("answers", store.tab, (current = []) => { + if (current.includes(value)) return current + return [...current, value] + }) setStore("editing", false) return } @@ -239,7 +219,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => value={input()} disabled={store.sending} onInput={(e) => { - setStore("custom", writeCustom(store.custom, store.tab, e.currentTarget.value)) + setStore("custom", store.tab, e.currentTarget.value) }} /> <Button type="submit" variant="primary" size="small" disabled={store.sending}> diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index eb5b4197d..81220b3ad 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -168,34 +168,27 @@ export function SessionContextTab(props: SessionContextTabProps) { return language.t("context.breakdown.other") } - const stats = createMemo(() => { - const c = ctx() - const count = counts() - return [ - { label: language.t("context.stats.session"), value: props.info()?.title ?? params.id ?? "—" }, - { label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) }, - { label: language.t("context.stats.provider"), value: providerLabel() }, - { label: language.t("context.stats.model"), value: modelLabel() }, - { label: language.t("context.stats.limit"), value: formatter().number(c?.limit) }, - { label: language.t("context.stats.totalTokens"), value: formatter().number(c?.total) }, - { label: language.t("context.stats.usage"), value: formatter().percent(c?.usage) }, - { label: language.t("context.stats.inputTokens"), value: formatter().number(c?.input) }, - { label: language.t("context.stats.outputTokens"), value: formatter().number(c?.output) }, - { label: language.t("context.stats.reasoningTokens"), value: formatter().number(c?.reasoning) }, - { - label: language.t("context.stats.cacheTokens"), - value: `${formatter().number(c?.cacheRead)} / ${formatter().number(c?.cacheWrite)}`, - }, - { label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) }, - { - label: language.t("context.stats.assistantMessages"), - value: count.assistant.toLocaleString(language.locale()), - }, - { label: language.t("context.stats.totalCost"), value: cost() }, - { label: language.t("context.stats.sessionCreated"), value: formatter().time(props.info()?.time.created) }, - { label: language.t("context.stats.lastActivity"), value: formatter().time(c?.message.time.created) }, - ] satisfies { label: string; value: JSX.Element }[] - }) + const stats = [ + { label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" }, + { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) }, + { label: "context.stats.provider", value: providerLabel }, + { label: "context.stats.model", value: modelLabel }, + { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, + { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, + { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, + { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, + { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) }, + { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) }, + { + label: "context.stats.cacheTokens", + value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`, + }, + { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) }, + { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) }, + { label: "context.stats.totalCost", value: cost }, + { label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) }, + { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, + ] satisfies { label: string; value: () => JSX.Element }[] let scroll: HTMLDivElement | undefined let frame: number | undefined @@ -257,7 +250,9 @@ export function SessionContextTab(props: SessionContextTabProps) { > <div class="px-6 pt-4 flex flex-col gap-10"> <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4"> - <For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For> + <For each={stats}> + {(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />} + </For> </div> <Show when={breakdown().length > 0}> diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index c1468ce37..274398ee0 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useParams } from "@solidjs/router" @@ -404,23 +404,25 @@ export function SessionHeader() { setPrefs("app", value as OpenApp) }} > - {options().map((o) => ( - <DropdownMenu.RadioItem - value={o.id} - onSelect={() => { - setMenu("open", false) - openDir(o.id) - }} - > - <div class="flex size-5 shrink-0 items-center justify-center"> - <AppIcon id={o.icon} class={openIconSize(o.icon)} /> - </div> - <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel> - <DropdownMenu.ItemIndicator> - <Icon name="check-small" size="small" class="text-icon-weak" /> - </DropdownMenu.ItemIndicator> - </DropdownMenu.RadioItem> - ))} + <For each={options()}> + {(o) => ( + <DropdownMenu.RadioItem + value={o.id} + onSelect={() => { + setMenu("open", false) + openDir(o.id) + }} + > + <div class="flex size-5 shrink-0 items-center justify-center"> + <AppIcon id={o.icon} class={openIconSize(o.icon)} /> + </div> + <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel> + <DropdownMenu.ItemIndicator> + <Icon name="check-small" size="small" class="text-icon-weak" /> + </DropdownMenu.ItemIndicator> + </DropdownMenu.RadioItem> + )} + </For> </DropdownMenu.RadioGroup> </DropdownMenu.Group> <DropdownMenu.Separator /> diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 26ee2d070..38152b823 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -173,12 +173,9 @@ export function StatusPopover() { const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) const mcp = useMcpToggle({ sync, sdk, language }) const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl) - const mcpItems = createMemo(() => - Object.entries(sync.data.mcp ?? {}) - .map(([name, status]) => ({ name, status: status.status })) - .sort((a, b) => a.name.localeCompare(b.name)), - ) - const mcpConnected = createMemo(() => mcpItems().filter((item) => item.status === "connected").length) + const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) + const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status + const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const lspItems = createMemo(() => sync.data.lsp ?? []) const lspCount = createMemo(() => lspItems().length) const plugins = createMemo(() => sync.data.config.plugin ?? []) @@ -186,7 +183,10 @@ export function StatusPopover() { const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) const overallHealthy = createMemo(() => { const serverHealthy = server.healthy() === true - const anyMcpIssue = mcpItems().some((item) => item.status !== "connected" && item.status !== "disabled") + const anyMcpIssue = mcpNames().some((name) => { + const status = mcpStatus(name) + return status !== "connected" && status !== "disabled" + }) return serverHealthy && !anyMcpIssue }) @@ -306,39 +306,40 @@ export function StatusPopover() { <div class="flex flex-col px-2 pb-2"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <Show - when={mcpItems().length > 0} + when={mcpNames().length > 0} fallback={ <div class="text-14-regular text-text-base text-center my-auto"> {language.t("dialog.mcp.empty")} </div> } > - <For each={mcpItems()}> - {(item) => { - const enabled = () => item.status === "connected" + <For each={mcpNames()}> + {(name) => { + const status = () => mcpStatus(name) + const enabled = () => status() === "connected" return ( <button type="button" class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" - onClick={() => mcp.toggle(item.name)} - disabled={mcp.loading() === item.name} + onClick={() => mcp.toggle(name)} + disabled={mcp.loading() === name} > <div classList={{ "size-1.5 rounded-full shrink-0": true, - "bg-icon-success-base": item.status === "connected", - "bg-icon-critical-base": item.status === "failed", - "bg-border-weak-base": item.status === "disabled", + "bg-icon-success-base": status() === "connected", + "bg-icon-critical-base": status() === "failed", + "bg-border-weak-base": status() === "disabled", "bg-icon-warning-base": - item.status === "needs_auth" || item.status === "needs_client_registration", + status() === "needs_auth" || status() === "needs_client_registration", }} /> - <span class="text-14-regular text-text-base truncate flex-1">{item.name}</span> + <span class="text-14-regular text-text-base truncate flex-1">{name}</span> <div onClick={(event) => event.stopPropagation()}> <Switch checked={enabled()} - disabled={mcp.loading() === item.name} - onChange={() => mcp.toggle(item.name)} + disabled={mcp.loading() === name} + onChange={() => mcp.toggle(name)} /> </div> </button> diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts index 2614b2fb5..6e8ddf62d 100644 --- a/packages/app/src/context/file/view-cache.ts +++ b/packages/app/src/context/file/view-cache.ts @@ -23,6 +23,16 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { } } +function equalSelectedLines(a: SelectedLineRange | null | undefined, b: SelectedLineRange | null | undefined) { + if (!a && !b) return true + if (!a || !b) return false + const left = normalizeSelectedLines(a) + const right = normalizeSelectedLines(b) + return ( + left.start === right.start && left.end === right.end && left.side === right.side && left.endSide === right.endSide + ) +} + function createViewSession(dir: string, id: string | undefined) { const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` @@ -65,36 +75,36 @@ function createViewSession(dir: string, id: string | undefined) { const selectedLines = (path: string) => view.file[path]?.selectedLines const setScrollTop = (path: string, top: number) => { - setView("file", path, (current) => { - if (current?.scrollTop === top) return current - return { - ...(current ?? {}), - scrollTop: top, - } - }) + setView( + produce((draft) => { + const file = draft.file[path] ?? (draft.file[path] = {}) + if (file.scrollTop === top) return + file.scrollTop = top + }), + ) pruneView(path) } const setScrollLeft = (path: string, left: number) => { - setView("file", path, (current) => { - if (current?.scrollLeft === left) return current - return { - ...(current ?? {}), - scrollLeft: left, - } - }) + setView( + produce((draft) => { + const file = draft.file[path] ?? (draft.file[path] = {}) + if (file.scrollLeft === left) return + file.scrollLeft = left + }), + ) pruneView(path) } const setSelectedLines = (path: string, range: SelectedLineRange | null) => { const next = range ? normalizeSelectedLines(range) : null - setView("file", path, (current) => { - if (current?.selectedLines === next) return current - return { - ...(current ?? {}), - selectedLines: next, - } - }) + setView( + produce((draft) => { + const file = draft.file[path] ?? (draft.file[path] = {}) + if (equalSelectedLines(file.selectedLines, next)) return + file.selectedLines = next + }), + ) pruneView(path) } diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index c658d82c8..fa1a43d47 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -233,6 +233,7 @@ export function applyDirectoryEvent(input: { } case "vcs.branch.updated": { const props = event.properties as { branch: string } + if (input.store.vcs?.branch === props.branch) break const next = { branch: props.branch } input.setStore("vcs", next) if (input.vcsCache) input.vcsCache.setStore("value", next) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index e35e815f9..bf880d115 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,5 +1,5 @@ -import { createStore } from "solid-js/store" -import { createEffect, createMemo, onCleanup } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSDK } from "./global-sdk" @@ -13,7 +13,6 @@ import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSound, soundSrc } from "@/utils/sound" -import { buildNotificationIndex } from "./notification-index" type NotificationBase = { directory?: string @@ -34,6 +33,21 @@ type ErrorNotification = NotificationBase & { export type Notification = TurnCompleteNotification | ErrorNotification +type NotificationIndex = { + session: { + all: Record<string, Notification[]> + unseen: Record<string, Notification[]> + unseenCount: Record<string, number> + unseenHasError: Record<string, boolean> + } + project: { + all: Record<string, Notification[]> + unseen: Record<string, Notification[]> + unseenCount: Record<string, number> + unseenHasError: Record<string, boolean> + } +} + const MAX_NOTIFICATIONS = 500 const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30 @@ -44,6 +58,53 @@ function pruneNotifications(list: Notification[]) { return pruned.slice(pruned.length - MAX_NOTIFICATIONS) } +function createNotificationIndex(): NotificationIndex { + return { + session: { + all: {}, + unseen: {}, + unseenCount: {}, + unseenHasError: {}, + }, + project: { + all: {}, + unseen: {}, + unseenCount: {}, + unseenHasError: {}, + }, + } +} + +function buildNotificationIndex(list: Notification[]) { + const index = createNotificationIndex() + + list.forEach((notification) => { + if (notification.session) { + const all = index.session.all[notification.session] ?? [] + index.session.all[notification.session] = [...all, notification] + if (!notification.viewed) { + const unseen = index.session.unseen[notification.session] ?? [] + index.session.unseen[notification.session] = [...unseen, notification] + index.session.unseenCount[notification.session] = unseen.length + 1 + if (notification.type === "error") index.session.unseenHasError[notification.session] = true + } + } + + if (notification.directory) { + const all = index.project.all[notification.directory] ?? [] + index.project.all[notification.directory] = [...all, notification] + if (!notification.viewed) { + const unseen = index.project.unseen[notification.directory] ?? [] + index.project.unseen[notification.directory] = [...unseen, notification] + index.project.unseenCount[notification.directory] = unseen.length + 1 + if (notification.type === "error") index.project.unseenHasError[notification.directory] = true + } + } + }) + + return index +} + export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ name: "Notification", init: () => { @@ -68,21 +129,81 @@ export const { use: useNotification, provider: NotificationProvider } = createSi list: [] as Notification[], }), ) + const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list)) const meta = { pruned: false, disposed: false } + const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => { + setIndex(scope, "unseen", key, unseen) + setIndex(scope, "unseenCount", key, unseen.length) + setIndex( + scope, + "unseenHasError", + key, + unseen.some((notification) => notification.type === "error"), + ) + } + + const appendToIndex = (notification: Notification) => { + if (notification.session) { + setIndex("session", "all", notification.session, (all = []) => [...all, notification]) + if (!notification.viewed) { + setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification]) + setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1) + if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true) + } + } + + if (notification.directory) { + setIndex("project", "all", notification.directory, (all = []) => [...all, notification]) + if (!notification.viewed) { + setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification]) + setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1) + if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true) + } + } + } + + const removeFromIndex = (notification: Notification) => { + if (notification.session) { + setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification)) + if (!notification.viewed) { + const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification) + updateUnseen("session", notification.session, unseen) + } + } + + if (notification.directory) { + setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification)) + if (!notification.viewed) { + const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification) + updateUnseen("project", notification.directory, unseen) + } + } + } + createEffect(() => { if (!ready()) return if (meta.pruned) return meta.pruned = true - setStore("list", pruneNotifications(store.list)) + const list = pruneNotifications(store.list) + batch(() => { + setStore("list", list) + setIndex(reconcile(buildNotificationIndex(list), { merge: false })) + }) }) const append = (notification: Notification) => { - setStore("list", (list) => pruneNotifications([...list, notification])) - } + const list = pruneNotifications([...store.list, notification]) + const keep = new Set(list) + const removed = store.list.filter((n) => !keep.has(n)) - const index = createMemo(() => buildNotificationIndex(store.list)) + batch(() => { + if (keep.has(notification)) appendToIndex(notification) + removed.forEach((n) => removeFromIndex(n)) + setStore("list", list) + }) + } const lookup = async (directory: string, sessionID?: string) => { if (!sessionID) return undefined @@ -181,36 +302,66 @@ export const { use: useNotification, provider: NotificationProvider } = createSi ready, session: { all(session: string) { - return index().session.all.get(session) ?? empty + return index.session.all[session] ?? empty }, unseen(session: string) { - return index().session.unseen.get(session) ?? empty + return index.session.unseen[session] ?? empty }, unseenCount(session: string) { - return index().session.unseenCount.get(session) ?? 0 + return index.session.unseenCount[session] ?? 0 }, unseenHasError(session: string) { - return index().session.unseenHasError.get(session) ?? false + return index.session.unseenHasError[session] ?? false }, markViewed(session: string) { - setStore("list", (n) => n.session === session, "viewed", true) + const unseen = index.session.unseen[session] ?? empty + if (!unseen.length) return + + const projects = [ + ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))), + ] + batch(() => { + setStore("list", (n) => n.session === session && !n.viewed, "viewed", true) + updateUnseen("session", session, []) + projects.forEach((directory) => { + const next = (index.project.unseen[directory] ?? empty).filter( + (notification) => notification.session !== session, + ) + updateUnseen("project", directory, next) + }) + }) }, }, project: { all(directory: string) { - return index().project.all.get(directory) ?? empty + return index.project.all[directory] ?? empty }, unseen(directory: string) { - return index().project.unseen.get(directory) ?? empty + return index.project.unseen[directory] ?? empty }, unseenCount(directory: string) { - return index().project.unseenCount.get(directory) ?? 0 + return index.project.unseenCount[directory] ?? 0 }, unseenHasError(directory: string) { - return index().project.unseenHasError.get(directory) ?? false + return index.project.unseenHasError[directory] ?? false }, markViewed(directory: string) { - setStore("list", (n) => n.directory === directory, "viewed", true) + const unseen = index.project.unseen[directory] ?? empty + if (!unseen.length) return + + const sessions = [ + ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))), + ] + batch(() => { + setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true) + updateUnseen("project", directory, []) + sessions.forEach((session) => { + const next = (index.session.unseen[session] ?? empty).filter( + (notification) => notification.directory !== directory, + ) + updateUnseen("session", session, next) + }) + }) }, }, } diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 0e6aa08cb..64f026219 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -101,11 +101,15 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str 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 + const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active batch(() => { - setStore("all", filtered) setStore("active", active) + setStore( + "all", + produce((draft) => { + draft.splice(index, 1) + }), + ) }) } @@ -157,10 +161,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str title: pty.data?.title ?? "Terminal", titleNumber: nextNumber, } - setStore("all", (all) => { - const newAll = [...all, newTerminal] - return newAll - }) + setStore("all", store.all.length, newTerminal) setStore("active", id) }) .catch((error: unknown) => { @@ -168,8 +169,11 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str }) }, update(pty: Partial<LocalPTY> & { id: string }) { - 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))) + const index = store.all.findIndex((x) => x.id === pty.id) + const previous = index >= 0 ? store.all[index] : undefined + if (index >= 0) { + setStore("all", index, (item) => ({ ...item, ...pty })) + } sdk.client.pty .update({ ptyID: pty.id, @@ -178,7 +182,8 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str }) .catch((error: unknown) => { if (previous) { - setStore("all", (all) => all.map((item) => (item.id === pty.id ? previous : item))) + const currentIndex = store.all.findIndex((item) => item.id === pty.id) + if (currentIndex >= 0) setStore("all", currentIndex, previous) } console.error("Failed to update terminal", error) }) @@ -232,15 +237,21 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str setStore("active", store.all[prevIndex]?.id) }, async close(id: string) { - batch(() => { - const filtered = store.all.filter((x) => x.id !== id) - if (store.active === id) { - const index = store.all.findIndex((f) => f.id === id) - const next = index > 0 ? index - 1 : 0 - setStore("active", filtered[next]?.id) - } - setStore("all", filtered) - }) + const index = store.all.findIndex((f) => f.id === id) + if (index !== -1) { + batch(() => { + if (store.active === id) { + const next = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id + setStore("active", next) + } + setStore( + "all", + produce((all) => { + all.splice(index, 1) + }), + ) + }) + } await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { console.error("Failed to close terminal", error) diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 55184aa1b..502364afd 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -4,6 +4,7 @@ import { useParams } from "@solidjs/router" import { createMemo } from "solid-js" export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] +const popularProviderSet = new Set(popularProviders) export function useProviders() { const globalSync = useGlobalSync() @@ -16,11 +17,12 @@ export function useProviders() { } return globalSync.data.provider }) - const connected = createMemo(() => providers().all.filter((p) => providers().connected.includes(p.id))) + const connectedIDs = createMemo(() => new Set(providers().connected)) + const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id))) const paid = createMemo(() => connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)), ) - const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id))) + const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id))) return { all: createMemo(() => providers().all), default: createMemo(() => providers().default), diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index aca52564b..5f001177f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2,6 +2,7 @@ import { batch, createEffect, createMemo, + createSignal, For, on, onCleanup, @@ -124,7 +125,7 @@ export default function Layout(props: ParentProps) { const [state, setState] = createStore({ autoselect: !initialDirectory, - busyWorkspaces: new Set<string>(), + busyWorkspaces: {} as Record<string, boolean>, hoverSession: undefined as string | undefined, hoverProject: undefined as string | undefined, scrollSessionKey: undefined as string | undefined, @@ -134,15 +135,28 @@ export default function Layout(props: ParentProps) { const editor = createInlineEditorController() const setBusy = (directory: string, value: boolean) => { const key = workspaceKey(directory) - setState("busyWorkspaces", (prev) => { - const next = new Set(prev) - if (value) next.add(key) - else next.delete(key) - return next - }) + if (value) { + setState("busyWorkspaces", key, true) + return + } + setState( + "busyWorkspaces", + produce((draft) => { + delete draft[key] + }), + ) } - const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory)) + const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)] const navLeave = { current: undefined as number | undefined } + const [sortNow, setSortNow] = createSignal(Date.now()) + let sortNowInterval: ReturnType<typeof setInterval> | undefined + const sortNowTimeout = setTimeout( + () => { + setSortNow(Date.now()) + sortNowInterval = setInterval(() => setSortNow(Date.now()), 60_000) + }, + 60_000 - (Date.now() % 60_000), + ) const aim = createAim({ enabled: () => !layout.sidebar.opened(), @@ -157,6 +171,8 @@ export default function Layout(props: ParentProps) { onCleanup(() => { if (navLeave.current !== undefined) clearTimeout(navLeave.current) + clearTimeout(sortNowTimeout) + if (sortNowInterval) clearInterval(sortNowInterval) aim.reset() }) @@ -518,10 +534,13 @@ export default function Layout(props: ParentProps) { const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) - setStore("workspaceName", (prev) => ({ ...(prev ?? {}), [key]: next })) + setStore("workspaceName", key, next) if (!projectId) return if (!branch) return - setStore("workspaceBranchName", projectId, (prev) => ({ ...(prev ?? {}), [branch]: next })) + if (!store.workspaceBranchName[projectId]) { + setStore("workspaceBranchName", projectId, {}) + } + setStore("workspaceBranchName", projectId, branch, next) } const workspaceLabel = (directory: string, branch?: string, projectId?: string) => @@ -1447,23 +1466,41 @@ export default function Layout(props: ParentProps) { document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) }) + const loadedSessionDirs = new Set<string>() + createEffect(() => { const project = currentProject() - if (!project) return + const workspaces = workspaceSetting() + const next = new Set<string>() + if (!project) { + loadedSessionDirs.clear() + return + } - if (workspaceSetting()) { + if (workspaces) { const activeDir = currentDir() const dirs = [project.worktree, ...(project.sandboxes ?? [])] for (const directory of dirs) { const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree const active = directory === activeDir if (!expanded && !active) continue - globalSync.project.loadSessions(directory) + next.add(directory) } - return } - globalSync.project.loadSessions(project.worktree) + if (!workspaces) { + next.add(project.worktree) + } + + for (const directory of next) { + if (loadedSessionDirs.has(directory)) continue + globalSync.project.loadSessions(directory) + } + + loadedSessionDirs.clear() + for (const directory of next) { + loadedSessionDirs.add(directory) + } }) function handleDragStart(event: unknown) { @@ -1766,7 +1803,12 @@ export default function Layout(props: ParentProps) { </TooltipKeybind> </div> <div class="flex-1 min-h-0"> - <LocalWorkspace ctx={workspaceSidebarCtx} project={p()} mobile={panelProps.mobile} /> + <LocalWorkspace + ctx={workspaceSidebarCtx} + project={p()} + sortNow={sortNow} + mobile={panelProps.mobile} + /> </div> </> } @@ -1805,6 +1847,7 @@ export default function Layout(props: ParentProps) { ctx={workspaceSidebarCtx} directory={directory} project={p()} + sortNow={sortNow} mobile={panelProps.mobile} /> )} @@ -1890,7 +1933,9 @@ export default function Layout(props: ParentProps) { opened={() => layout.sidebar.opened()} aimMove={aim.move} projects={() => layout.projects.list()} - renderProject={(project) => <SortableProject ctx={projectSidebarCtx} project={project} />} + renderProject={(project) => ( + <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} /> + )} handleDragStart={handleDragStart} handleDragEnd={handleDragEnd} handleDragOver={handleDragOver} @@ -1953,7 +1998,9 @@ export default function Layout(props: ParentProps) { opened={() => layout.sidebar.opened()} aimMove={aim.move} projects={() => layout.projects.list()} - renderProject={(project) => <SortableProject ctx={projectSidebarCtx} project={project} mobile />} + renderProject={(project) => ( + <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile /> + )} handleDragStart={handleDragStart} handleDragEnd={handleDragEnd} handleDragOver={handleDragOver} diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 9afa205b6..931053616 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -244,6 +244,7 @@ export const SortableProject = (props: { project: LocalProject mobile?: boolean ctx: ProjectSidebarContext + sortNow: Accessor<number> }): JSX.Element => { const globalSync = useGlobalSync() const language = useLanguage() @@ -284,11 +285,11 @@ export const SortableProject = (props: { } const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) - const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2)) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()).slice(0, 2)) const projectChildren = createMemo(() => childMapByParent(projectStore().session)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - return sortedRootSessions(data, Date.now()).slice(0, 2) + return sortedRootSessions(data, props.sortNow()).slice(0, 2) } const workspaceChildren = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 1d9c2e685..43d99cf89 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -302,6 +302,7 @@ export const SortableWorkspace = (props: { ctx: WorkspaceSidebarContext directory: string project: LocalProject + sortNow: Accessor<number> mobile?: boolean }): JSX.Element => { const navigate = useNavigate() @@ -315,7 +316,7 @@ export const SortableWorkspace = (props: { pendingRename: false, }) const slug = createMemo(() => base64Encode(props.directory)) - const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now())) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const children = createMemo(() => childMapByParent(workspaceStore.session)) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => props.ctx.currentDir() === props.directory) @@ -464,6 +465,7 @@ export const SortableWorkspace = (props: { export const LocalWorkspace = (props: { ctx: WorkspaceSidebarContext project: LocalProject + sortNow: Accessor<number> mobile?: boolean }): JSX.Element => { const globalSync = useGlobalSync() @@ -473,7 +475,7 @@ export const LocalWorkspace = (props: { return { store, setStore } }) const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now())) + const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const loading = createMemo(() => !booted() && sessions().length === 0) diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index c94c0ff35..5b3f57dbe 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -1,5 +1,5 @@ import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, produce } from "solid-js/store" import { Dynamic } from "solid-js/web" import { checksum } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" @@ -112,6 +112,12 @@ export function FileTabContent(props: { return props.comments.list(p) }) + const commentLayout = createMemo(() => { + return fileComments() + .map((comment) => `${comment.id}:${comment.selection.start}:${comment.selection.end}`) + .join("|") + }) + const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) const [note, setNote] = createStore({ @@ -164,7 +170,22 @@ export function FileTabContent(props: { next[comment.id] = markerTop(el, marker) } - setNote("positions", next) + const removed = Object.keys(note.positions).filter((id) => next[id] === undefined) + const changed = Object.entries(next).filter(([id, top]) => note.positions[id] !== top) + if (removed.length > 0 || changed.length > 0) { + setNote( + "positions", + produce((draft) => { + for (const id of removed) { + delete draft[id] + } + + for (const [id, top] of changed) { + draft[id] = top + } + }), + ) + } const range = note.commenting if (!range) { @@ -186,7 +207,7 @@ export function FileTabContent(props: { } createEffect(() => { - fileComments() + commentLayout() scheduleComments() }) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index dcf2c8784..6ead7a7f2 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -1,4 +1,5 @@ import type { CommandOption } from "@/context/command" +import { batch } from "solid-js" export const focusTerminalById = (id: string) => { const wrapper = document.getElementById(`terminal-wrapper-${id}`) @@ -27,9 +28,11 @@ export const createOpenReviewFile = (input: { loadFile: (path: string) => void }) => { return (path: string) => { - input.showAllFiles() - input.openTab(input.tabForPath(path)) - input.loadFile(path) + batch(() => { + input.showAllFiles() + input.openTab(input.tabForPath(path)) + input.loadFile(path) + }) } } diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 15ad90ffe..33954f64a 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -72,6 +72,8 @@ export function SessionSidePanel(props: { activeDiff?: string focusReviewDiff: (path: string) => void }) { + const openedTabs = createMemo(() => props.openedTabs()) + return ( <Show when={props.open}> <aside @@ -140,8 +142,8 @@ export function SessionSidePanel(props: { </div> </Tabs.Trigger> </Show> - <SortableProvider ids={props.openedTabs()}> - <For each={props.openedTabs()}> + <SortableProvider ids={openedTabs()}> + <For each={openedTabs()}> {(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />} </For> </SortableProvider> diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index d3475c714..7ec4356b1 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -1,4 +1,4 @@ -import { For, Show } from "solid-js" +import { For, Show, createMemo } from "solid-js" import { Tabs } from "@opencode-ai/ui/tabs" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -8,7 +8,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd" import { ConstrainDragYAxis } from "@/utils/solid-dnd" import { SortableTerminalTab } from "@/components/session" import { Terminal } from "@/components/terminal" -import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useTerminal } from "@/context/terminal" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" import { terminalTabLabel } from "@/pages/session/terminal-label" @@ -28,6 +28,10 @@ export function TerminalPanel(props: { handleTerminalDragEnd: () => void onCloseTab: () => void }) { + const all = createMemo(() => props.terminal.all()) + const ids = createMemo(() => all().map((pty) => pty.id)) + const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty]))) + return ( <Show when={props.open}> <div @@ -86,8 +90,8 @@ export function TerminalPanel(props: { class="!h-auto !flex-none" > <Tabs.List class="h-10"> - <SortableProvider ids={props.terminal.all().map((t: LocalPTY) => t.id)}> - <For each={props.terminal.all()}> + <SortableProvider ids={ids()}> + <For each={all()}> {(pty) => ( <SortableTerminalTab terminal={pty} @@ -117,7 +121,7 @@ export function TerminalPanel(props: { </Tabs.List> </Tabs> <div class="flex-1 min-h-0 relative"> - <For each={props.terminal.all()}> + <For each={all()}> {(pty) => ( <div id={`terminal-wrapper-${pty.id}`} @@ -142,7 +146,7 @@ export function TerminalPanel(props: { <Show when={props.activeTerminalDraggable()}> {(draggedId) => { return ( - <Show when={props.terminal.all().find((t: LocalPTY) => t.id === draggedId())}> + <Show when={byId().get(draggedId())}> {(t) => ( <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> {terminalTabLabel({ diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 8952bbd98..555761ad1 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,4 +1,4 @@ -import { createEffect, on, onCleanup } from "solid-js" +import { createEffect, createMemo, on, onCleanup } from "solid-js" import { UserMessage } from "@opencode-ai/sdk/v2" export const messageIdFromHash = (hash: string) => { @@ -26,6 +26,10 @@ export const useSessionHashScroll = (input: { scheduleScrollState: (el: HTMLDivElement) => void consumePendingMessage: (key: string) => string | undefined }) => { + const visibleUserMessages = createMemo(() => input.visibleUserMessages()) + const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m]))) + const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) + const clearMessageHash = () => { if (!window.location.hash) return window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) @@ -47,10 +51,9 @@ export const useSessionHashScroll = (input: { } const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { - input.setActiveMessage(message) + if (input.currentMessageId() !== message.id) input.setActiveMessage(message) - const msgs = input.visibleUserMessages() - const index = msgs.findIndex((m) => m.id === message.id) + const index = messageIndex().get(message.id) ?? -1 if (index !== -1 && index < input.turnStart()) { input.setTurnStart(index) input.scheduleTurnBackfill() @@ -107,7 +110,7 @@ export const useSessionHashScroll = (input: { const messageId = messageIdFromHash(hash) if (messageId) { input.autoScroll.pause() - const msg = input.visibleUserMessages().find((m) => m.id === messageId) + const msg = messageById().get(messageId) if (msg) { scrollToMessage(msg, behavior) return @@ -144,14 +147,14 @@ export const useSessionHashScroll = (input: { createEffect(() => { if (!input.sessionID() || !input.messagesReady()) return - input.visibleUserMessages().length + visibleUserMessages() input.turnStart() const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash) if (!targetId) return if (input.currentMessageId() === targetId) return - const msg = input.visibleUserMessages().find((m) => m.id === targetId) + const msg = messageById().get(targetId) if (!msg) return if (input.pendingMessage() === targetId) input.setPendingMessage(undefined) |
