refactor(app): remove redundant UI state (#43975)

This commit is contained in:
Kit Langton
2026-08-21 15:30:00 -04:00
committed by GitHub
parent 2eecf076c4
commit 7fd1eee35a
23 changed files with 147 additions and 309 deletions
@@ -7,6 +7,7 @@ import type { ComposerModel } from "./model"
import { createComposerEditor } from "./editor/interaction"
import type { ComposerPersistedState, ComposerSuggestion } from "./types"
import { buildPromptRequest } from "./request"
import { promptLength } from "./prompt-parts"
import { SessionPreview } from "@/session/story-model"
import { Skill } from "@opencode-ai/schema/skill"
import { resolveSessionComposerSelection } from "@/session/composer/selection"
@@ -57,7 +58,7 @@ function ComposerStory(props: {
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
cursor: props.prompt?.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) ?? 0,
cursor: props.prompt ? promptLength(props.prompt) : 0,
model: { providerID: STORY_MODEL.providerID, modelID: STORY_MODEL.id, variant: STORY_MODEL.variant },
context: { items: props.comments ?? [] },
})
+1 -4
View File
@@ -7,6 +7,7 @@ import type {
ComposerPersistedState,
ComposerPrompt,
} from "../types"
import { promptLength } from "../prompt-parts"
export type ComposerStateStore = [
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
@@ -134,7 +135,3 @@ function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
return next
})
}
function promptLength(prompt: ComposerPrompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
@@ -18,6 +18,7 @@ import {
type ComposerInteractionCommand,
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, promptLength } from "../prompt-parts"
export type ComposerSelectControl = {
options: Accessor<ComposerOption[]>
@@ -434,16 +435,6 @@ function canNavigateHistory(direction: "up" | "down", text: string, cursor: numb
return position === text.length
}
function clonePrompt(prompt: ComposerPersistedState["prompt"]): ComposerPersistedState["prompt"] {
return prompt.map((part) =>
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
)
}
function promptLength(prompt: ComposerPersistedState["prompt"]) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
function editorCursor(editor: HTMLElement) {
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
+24 -23
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { clonePromptParts, prependHistoryEntry, promptLength, type PromptHistoryComment } from "./entry"
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
import { upgradeHistoryState } from "./store"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -34,31 +34,32 @@ describe("Composer history", () => {
expect(dedupedComments).toBe(commentsOnly)
})
test("insertion isolates canonical entries from source mutations", () => {
const prompt: Prompt = [
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 0,
end: 9,
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 0 },
},
]
const comments = [comment("c1")]
const entries = prependHistoryEntry([], prompt, comments)
const stored = entries[0]
if (prompt[0]?.type !== "file" || stored?.prompt[0]?.type !== "file") throw new Error("expected file")
prompt[0].selection!.startLine = 9
comments[0].selection.start = 9
expect(stored.prompt[0].selection?.startLine).toBe(1)
expect(stored.comments[0]?.selection.start).toBe(2)
})
test("upgrades stored prompt arrays once at the persistence boundary", () => {
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
entries: [{ prompt: text("stored"), comments: [] }],
})
})
test("helpers clone prompt and count text content length", () => {
const original: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 3,
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
const copy = clonePromptParts(original)
expect(copy).not.toBe(original)
expect(promptLength(copy)).toBe(12)
if (copy[1]?.type !== "file") throw new Error("expected file")
copy[1].selection!.startLine = 9
if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1)
})
})
+3 -28
View File
@@ -1,5 +1,6 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { clonePrompt } from "../prompt-parts"
export const MAX_HISTORY = 100
@@ -20,19 +21,6 @@ export type PromptHistoryEntry = {
export type PromptHistoryStoredEntry = PromptHistoryEntry
export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "skill") return { ...part }
return {
...part,
selection: part.selection ? { ...part.selection } : undefined,
}
})
}
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
return {
start: selection.start,
@@ -49,17 +37,6 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
}))
}
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
return {
prompt: clonePromptParts(entry.prompt),
comments: clonePromptHistoryComments(entry.comments),
}
}
export function promptLength(prompt: Prompt) {
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
}
export function prependHistoryEntry(
entries: PromptHistoryStoredEntry[],
prompt: Prompt,
@@ -75,7 +52,7 @@ export function prependHistoryEntry(
if (!text && !hasImages && !hasComments) return entries
const entry = {
prompt: clonePromptParts(prompt),
prompt: clonePrompt(prompt),
comments: clonePromptHistoryComments(comments),
} satisfies PromptHistoryEntry
const last = entries[0]
@@ -96,9 +73,7 @@ function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryC
)
}
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
const entryA = normalizePromptHistoryEntry(promptA)
const entryB = normalizePromptHistoryEntry(promptB)
function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistoryStoredEntry) {
if (entryA.prompt.length !== entryB.prompt.length) return false
for (let i = 0; i < entryA.prompt.length; i++) {
const partA = entryA.prompt[i]
+3 -3
View File
@@ -3,11 +3,11 @@ import type { Prompt } from "@/composer/state"
import { Persist, persisted } from "@/runtime/persistence/storage"
import {
clonePromptHistoryComments,
clonePromptParts,
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./entry"
import { clonePrompt } from "../prompt-parts"
export type ComposerHistoryStore = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
@@ -23,7 +23,7 @@ export function upgradeHistoryState(value: unknown) {
return {
...value,
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
if (Array.isArray(entry)) return [{ prompt: clonePromptParts(entry as Prompt), comments: [] }]
if (Array.isArray(entry)) return [{ prompt: clonePrompt(entry as Prompt), comments: [] }]
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
return [entry as PromptHistoryStoredEntry]
@@ -64,7 +64,7 @@ export function createComposerHistory() {
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
const saved = clonePromptParts(prompt)
const saved = clonePrompt(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
+2 -6
View File
@@ -18,7 +18,7 @@ import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerAdapter, ComposerControls } from "./adapter"
import type { ImageAttachmentPart } from "./state"
import { normalizePromptHistoryEntry, type PromptHistoryComment } from "./history/entry"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
@@ -284,11 +284,7 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
store: prompt.store,
state: interaction,
history: {
entries: (mode) =>
history.entries(mode).map((value) => {
const entry = normalizePromptHistoryEntry(value)
return { prompt: entry.prompt, metadata: entry.comments }
}),
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
capture: historyComments,
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
describe("composer prompt parts", () => {
test("clones parts shallowly and copies file selections", () => {
const original: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 3,
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
const copy = clonePrompt(original)
expect(copy).not.toBe(original)
expect(copy[0]).not.toBe(original[0])
expect(copy[1]).not.toBe(original[1])
expect(copy[2]).not.toBe(original[2])
if (copy[1]?.type !== "file" || original[1]?.type !== "file") throw new Error("expected file parts")
if (copy[2]?.type !== "image" || original[2]?.type !== "image") throw new Error("expected image parts")
expect(copy[2].blob).toBe(original[2].blob)
expect(copy[1].selection).not.toBe(original[1].selection)
copy[1].selection!.startLine = 9
expect(original[1].selection?.startLine).toBe(1)
})
test("counts the content of text and mention parts", () => {
const prompt: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{ type: "agent", content: "@build", start: 3, end: 9, name: "build" },
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
expect(promptLength(prompt)).toBe(9)
})
})
+11
View File
@@ -0,0 +1,11 @@
import type { Prompt } from "./state"
export function clonePrompt(prompt: Prompt): Prompt {
return prompt.map((part) =>
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
)
}
export function promptLength(prompt: Prompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
+1 -20
View File
@@ -8,6 +8,7 @@ import type { BlobReference } from "@/runtime/persistence/drafts"
import type { Platform } from "@/runtime/platform/platform"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Skill } from "@opencode-ai/schema/skill"
import { clonePrompt } from "./prompt-parts"
interface PartBase {
content: string
@@ -108,26 +109,6 @@ type InitialPrompt = {
model?: PromptModel
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "skill") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
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
+3 -6
View File
@@ -1,8 +1,9 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Event } from "@opencode-ai/schema/event"
import type { Accessor } from "solid-js"
import { clonePromptParts, type PromptHistoryComment } from "./history/entry"
import type { PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
@@ -48,7 +49,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const submission = createComposerSubmission({
target: input.adapter.state,
prompt: clonePromptParts(input.adapter.state.current()),
prompt: clonePrompt(input.adapter.state.current()),
context: input.adapter.state.context.items().map((item) => ({
...item,
selection: item.selection ? { ...item.selection } : undefined,
@@ -317,7 +318,3 @@ function failSubmission(
restore()
input.notify.failed(kind, error)
}
function promptLength(prompt: Prompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
@@ -53,22 +53,6 @@ function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
function showErrors(input: {
errors: unknown[]
title: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
}) {
if (input.errors.length === 0) return
const message = formatServerError(input.errors[0], input.translate)
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
showToast({
variant: "error",
title: input.title,
description: message + more,
})
}
export const loadGlobalConfigQuery = (scope: ServerScope) =>
queryOptions({
queryKey: [scope, "config"],
@@ -126,9 +110,6 @@ export async function bootstrapGlobal(input: {
readonly worktree: WorktreeApi
}
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
}) {
@@ -141,12 +122,6 @@ export async function bootstrapGlobal(input: {
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
// showErrors({
// errors: errors(),
// title: input.requestFailedTitle,
// translate: input.translate,
// formatMoreCount: input.formatMoreCount,
// })
}
function projectID(directory: string, projects: Project[]) {
-3
View File
@@ -97,9 +97,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
await bootstrapGlobal({
serverAPI: serverSDK.api,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
queryClient,
})
+1 -1
View File
@@ -10,7 +10,7 @@ import { createComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
import { setCursorPosition } from "@/composer/editor/dom"
import { promptLength } from "@/composer/history/entry"
import { promptLength } from "@/composer/prompt-parts"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
@@ -16,12 +16,9 @@ import {
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
createSoundSettingsController,
soundOptions,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
type SoundSettingsController,
} from "./controllers"
import "@/settings/settings.css"
import { ServerConnection } from "@/runtime/server/registry"
@@ -50,24 +47,6 @@ const fontSettings = {
input: "setTerminal",
},
} as const
const soundSettings = {
agent: {
action: "settings-sounds-agent",
title: "settings.general.sounds.agent.title",
description: "settings.general.sounds.agent.description",
},
permissions: {
action: "settings-sounds-permissions",
title: "settings.general.sounds.permissions.title",
description: "settings.general.sounds.permissions.description",
},
errors: {
action: "settings-sounds-errors",
title: "settings.general.sounds.errors.title",
description: "settings.general.sounds.errors.description",
},
} as const
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const language = useLanguage()
return (
@@ -228,43 +207,6 @@ const FontSetting: Component<{
)
}
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
const language = useLanguage()
return (
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsList>
<SoundSetting kind="agent" channel={props.controller.agent} />
<SoundSetting kind="permissions" channel={props.controller.permissions} />
<SoundSetting kind="errors" channel={props.controller.errors} />
</SettingsList>
</div>
)
}
const SoundSetting: Component<{
kind: "agent" | "permissions" | "errors"
channel: SoundSettingsController["agent"]
}> = (props) => {
const language = useLanguage()
const config = () => soundSettings[props.kind]
return (
<SettingsRow title={language.t(config().title)} description={language.t(config().description)}>
<Select
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
value={(option) => option.id}
label={(option) => language.t(option.label)}
onHighlight={props.channel.highlight}
onSelect={props.channel.select}
placement="bottom-end"
gutter={6}
/>
</SettingsRow>
)
}
const LanguageSetting = () => {
const language = useLanguage()
const options = createMemo(() =>
+39 -44
View File
@@ -1,6 +1,6 @@
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { createMemo, createResource, For, type JSXElement, Show } from "solid-js"
import { createMemo, createResource, For, Index, type JSXElement, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -27,14 +27,12 @@ export function StatusPopoverBody(props: { shown: boolean }) {
const language = useLanguage()
const toggleMcp = useMcpToggle(() => sdk().directory)
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
const mcpNames = createMemo(() =>
mcp()
.map((server) => server.name)
.sort((a, b) => a.localeCompare(b)),
const mcpServers = createMemo(() =>
(data.location.mcp.server.list({ directory: sdk().directory }) ?? []).toSorted((a, b) =>
a.name.localeCompare(b.name),
),
)
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const mcpConnected = createMemo(() => mcpServers().filter((server) => server.status.status === "connected").length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
@@ -58,26 +56,25 @@ export function StatusPopoverBody(props: { shown: boolean }) {
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
<Show when={true}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Show>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcp">
<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={mcpNames().length > 0}
when={mcpServers().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
}
>
<For each={mcpNames()}>
{(name) => {
const status = () => mcpStatus(name)
<Index each={mcpServers()}>
{(server) => {
const name = () => server().name
const status = () => server().status.status
const enabled = () => status() === "connected"
return (
<button
@@ -85,9 +82,9 @@ export function StatusPopoverBody(props: { shown: boolean }) {
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
toggleMcp.mutate(name())
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
>
<div
classList={{
@@ -100,7 +97,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
/>
<span class="flex flex-col min-w-0 flex-1">
<span class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-base truncate">{name}</span>
<span class="text-14-regular text-text-base truncate">{name()}</span>
</span>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker truncate">
@@ -112,43 +109,41 @@ export function StatusPopoverBody(props: { shown: boolean }) {
<Switch
appearance="standard"
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
toggleMcp.mutate(name())
}}
/>
</div>
</button>
)
}}
</For>
</Index>
</Show>
</div>
</div>
</Tabs.Content>
<Show when={true}>
<Tabs.Content value="plugins">
<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={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
<Tabs.Content value="plugins">
<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={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</Tabs.Content>
</Show>
</div>
</Tabs.Content>
</Tabs>
</div>
)
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import * as NodePath from "@effect/platform-node/NodePath"
import { Effect } from "effect"
import { isNushell, mergeShellEnv, parseShellEnv, resolveUserShell } from "./shell-env"
import { isNushell, parseShellEnv, resolveUserShell } from "./shell-env"
describe("shell env", () => {
test("parseShellEnv supports null-delimited pairs", () => {
@@ -19,23 +19,6 @@ describe("shell env", () => {
expect(env.OK).toBe("1")
})
test("mergeShellEnv keeps explicit overrides", () => {
const env = mergeShellEnv(
{
PATH: "/shell/path",
HOME: "/tmp/home",
},
{
PATH: "/desktop/path",
OPENCODE_CLIENT: "desktop",
},
)
expect(env.PATH).toBe("/desktop/path")
expect(env.HOME).toBe("/tmp/home")
expect(env.OPENCODE_CLIENT).toBe("desktop")
})
test("resolveUserShell falls back to the login shell before /bin/sh", () => {
expect(resolveUserShell("/custom/env-shell", "/bin/zsh")).toBe("/custom/env-shell")
expect(resolveUserShell(undefined, "/bin/zsh")).toBe("/bin/zsh")
@@ -89,10 +89,3 @@ export const loadShellEnv = Effect.fn("ShellEnv.load")(function* (shell: string)
yield* Effect.logInfo(`[server] Falling back to app environment: ${shell}`)
return null
})
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
return {
...shell,
...env,
}
}
@@ -24,12 +24,10 @@ let backgroundColor: string | undefined
export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved) {
const mode = tone()
const storedBackground = getStore().get(BACKGROUND_COLOR_KEY)
return {
title: "OpenCode",
icon: iconPath(path, paths),
backgroundColor:
backgroundColor ?? (typeof storedBackground === "string" ? storedBackground : undefined) ?? oc2Background[mode],
backgroundColor: getBackgroundColor() ?? oc2Background[mode],
...(process.platform === "darwin"
? {
titleBarStyle: "hidden" as const,
@@ -124,9 +122,7 @@ export function wireFullscreen(win: BrowserWindow) {
}
function iconsDir(path: Path.Path, paths: DesktopPaths.Resolved) {
return app.isPackaged
? path.join(process.resourcesPath, "icons")
: path.join(paths.developmentResourcesRoot, "icons")
return app.isPackaged ? path.join(process.resourcesPath, "icons") : path.join(paths.developmentResourcesRoot, "icons")
}
function iconPath(path: Path.Path, paths: DesktopPaths.Resolved) {
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { initializationData, initializationReady } from "./initialization"
import { initializationData } from "./initialization"
describe("desktop renderer initialization", () => {
test("throws the original initialization error before rendering server providers", () => {
@@ -45,27 +45,4 @@ describe("desktop renderer initialization", () => {
expect(caught.message).toBe("")
expect((caught as Error & { localServerStartup?: boolean }).localServerStartup).toBe(true)
})
test("checks initialization errors before rendering server providers", () => {
const error = new Error("sidecar startup failed")
expect(() => initializationReady(Object.assign(() => undefined, { error, loading: false }))).toThrow(error)
})
test("waits for pending initialization without reading it", () => {
let reads = 0
expect(
initializationReady(
Object.assign(
() => {
reads++
return undefined
},
{ error: undefined, loading: true },
),
),
).toBe(false)
expect(reads).toBe(0)
})
})
@@ -8,9 +8,3 @@ function markLocalServerStartup(error: unknown) {
Object.defineProperty(failure, "localServerStartup", { value: true })
return failure
}
export function initializationReady<A>(state: (() => A | undefined) & { error: unknown; loading: boolean }) {
if (state.loading) return false
initializationData(state)
return true
}
@@ -8,9 +8,9 @@ export type CountItem = {
}
export function AnimatedCountList(props: { items: CountItem[]; fallback?: string; class?: string }) {
const visible = createMemo(() => props.items.filter((item) => item.count > 0))
const firstPositive = createMemo(() => props.items.findIndex((item) => item.count > 0))
const fallback = createMemo(() => props.fallback ?? "")
const showEmpty = createMemo(() => visible().length === 0 && fallback().length > 0)
const showEmpty = createMemo(() => firstPositive() === -1 && fallback().length > 0)
return (
<span data-component="tool-count-summary" class={props.class}>
@@ -21,16 +21,12 @@ export function AnimatedCountList(props: { items: CountItem[]; fallback?: string
<Index each={props.items}>
{(item, index) => {
const active = createMemo(() => item().count > 0)
const hasPrev = createMemo(() => {
for (let i = index - 1; i >= 0; i--) {
if (props.items[i].count > 0) return true
}
return false
})
return (
<>
<span data-slot="tool-count-summary-prefix" data-active={active() && hasPrev() ? "true" : "false"}>
<span
data-slot="tool-count-summary-prefix"
data-active={active() && firstPositive() !== index ? "true" : "false"}
>
,
</span>
<span data-slot="tool-count-summary-item" data-active={active() ? "true" : "false"}>
+3 -6
View File
@@ -1,5 +1,5 @@
import { Root } from "@kobalte/core/button"
import { type ComponentProps, Show, createMemo, splitProps } from "solid-js"
import { type ComponentProps, Show, splitProps } from "solid-js"
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import "./button.css"
@@ -13,22 +13,19 @@ export interface ButtonProps
export function Button(props: ButtonProps) {
const [split, rest] = splitProps(props, ["variant", "size", "icon", "class", "classList"])
const resolvedIcon = createMemo(() => split.icon)
return (
<Root
{...rest}
data-component="button-v2"
data-size={split.size || "normal"}
data-variant={split.variant || "neutral"}
data-icon={resolvedIcon()}
data-icon={split.icon}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
<Show when={resolvedIcon()}>
<Icon name={resolvedIcon()!} />
</Show>
<Show when={split.icon}>{(icon) => <Icon name={icon()} />}</Show>
{props.children}
</Root>
)