refactor(app): remove redundant UI state (#43975)
This commit is contained in:
@@ -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 ?? [] },
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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))
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user