diff --git a/packages/app/src/composer/composer.stories.tsx b/packages/app/src/composer/composer.stories.tsx index f7a7b34230..63aa5456cb 100644 --- a/packages/app/src/composer/composer.stories.tsx +++ b/packages/app/src/composer/composer.stories.tsx @@ -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({ 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 ?? [] }, }) diff --git a/packages/app/src/composer/editor/actions.ts b/packages/app/src/composer/editor/actions.ts index 352aabb425..c1bef65719 100644 --- a/packages/app/src/composer/editor/actions.ts +++ b/packages/app/src/composer/editor/actions.ts @@ -7,6 +7,7 @@ import type { ComposerPersistedState, ComposerPrompt, } from "../types" +import { promptLength } from "../prompt-parts" export type ComposerStateStore = [ Store | Accessor>, @@ -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) -} diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index d2acc35844..be27cf8432 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -18,6 +18,7 @@ import { type ComposerInteractionCommand, type ComposerInteractionEvent, } from "../suggestions/machine" +import { clonePrompt, promptLength } from "../prompt-parts" export type ComposerSelectControl = { options: Accessor @@ -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 diff --git a/packages/app/src/composer/history/entry.test.ts b/packages/app/src/composer/history/entry.test.ts index 0f6c79bcbd..066ff57870 100644 --- a/packages/app/src/composer/history/entry.test.ts +++ b/packages/app/src/composer/history/entry.test.ts @@ -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) - }) }) diff --git a/packages/app/src/composer/history/entry.ts b/packages/app/src/composer/history/entry.ts index 680f879886..a3d4a2a8a1 100644 --- a/packages/app/src/composer/history/entry.ts +++ b/packages/app/src/composer/history/entry.ts @@ -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] diff --git a/packages/app/src/composer/history/store.ts b/packages/app/src/composer/history/store.ts index 361908bf52..decdc2994d 100644 --- a/packages/app/src/composer/history/store.ts +++ b/packages/app/src/composer/history/store.ts @@ -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)) }, diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index 72fcca14a0..2e6f6dc816 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -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[]), diff --git a/packages/app/src/composer/prompt-parts.test.ts b/packages/app/src/composer/prompt-parts.test.ts new file mode 100644 index 0000000000..c3b3b97c8d --- /dev/null +++ b/packages/app/src/composer/prompt-parts.test.ts @@ -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) + }) +}) diff --git a/packages/app/src/composer/prompt-parts.ts b/packages/app/src/composer/prompt-parts.ts new file mode 100644 index 0000000000..1455170c3e --- /dev/null +++ b/packages/app/src/composer/prompt-parts.ts @@ -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) +} diff --git a/packages/app/src/composer/state.ts b/packages/app/src/composer/state.ts index 2c4018dfb4..2cf9bca6f6 100644 --- a/packages/app/src/composer/state.ts +++ b/packages/app/src/composer/state.ts @@ -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 diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 7fb5ab7565..1087917188 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -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) -} diff --git a/packages/app/src/runtime/server/global-sync/bootstrap.ts b/packages/app/src/runtime/server/global-sync/bootstrap.ts index 508b334464..c51a811b35 100644 --- a/packages/app/src/runtime/server/global-sync/bootstrap.ts +++ b/packages/app/src/runtime/server/global-sync/bootstrap.ts @@ -53,22 +53,6 @@ function runAll(list: Array<() => Promise>) { return Promise.allSettled(list.map((item) => item())) } -function showErrors(input: { - errors: unknown[] - title: string - translate: (key: string, vars?: Record) => 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 - formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction 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[]) { diff --git a/packages/app/src/runtime/server/sync.tsx b/packages/app/src/runtime/server/sync.tsx index 970701ef25..5c1a960576 100644 --- a/packages/app/src/runtime/server/sync.tsx +++ b/packages/app/src/runtime/server/sync.tsx @@ -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, }) diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 72ef77f1b6..cf6f5a041d 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -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" diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index dc6461c505..3eabfd1850 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -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 ( -
-

{language.t("settings.general.section.sounds")}

- - - - - -
- ) -} - -const SoundSetting: Component<{ - kind: "agent" | "permissions" | "errors" - channel: SoundSettingsController["agent"] -}> = (props) => { - const language = useLanguage() - const config = () => soundSettings[props.kind] - return ( - -