feat(app): extract prompt state and add composer model selection (#36227)
This commit is contained in:
parent
d0ba538924
commit
8a03fc265b
19 changed files with 730 additions and 294 deletions
|
|
@ -1335,6 +1335,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
onQueue: props.onQueue,
|
onQueue: props.onQueue,
|
||||||
onAbort: props.onAbort,
|
onAbort: props.onAbort,
|
||||||
onSubmit: props.onSubmit,
|
onSubmit: props.onSubmit,
|
||||||
|
model: props.controls.model.selection,
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||||
import type { Prompt } from "@/context/prompt"
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
import type { ModelSelection } from "@/context/local"
|
||||||
|
|
||||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
|
|
@ -33,6 +34,10 @@ const prompt = {
|
||||||
current: () => promptValue,
|
current: () => promptValue,
|
||||||
cursor: () => 0,
|
cursor: () => 0,
|
||||||
dirty: () => true,
|
dirty: () => true,
|
||||||
|
model: {
|
||||||
|
current: () => undefined,
|
||||||
|
set: () => undefined,
|
||||||
|
},
|
||||||
reset: () => undefined,
|
reset: () => undefined,
|
||||||
set: () => undefined,
|
set: () => undefined,
|
||||||
context: {
|
context: {
|
||||||
|
|
@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("uses an injected model selection", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
const model = {
|
||||||
|
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
|
||||||
|
variant: { current: () => "draft-variant" },
|
||||||
|
} as unknown as ModelSelection
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
|
||||||
|
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
|
|
||||||
|
expect(optimistic[0]).toMatchObject({
|
||||||
|
message: {
|
||||||
|
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("seeds new sessions before optimistic prompts are added", async () => {
|
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
prompt,
|
prompt,
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||||
|
|
@ -191,6 +191,7 @@ type PromptSubmitInput = {
|
||||||
onQueue?: (draft: FollowupDraft) => void
|
onQueue?: (draft: FollowupDraft) => void
|
||||||
onAbort?: () => void
|
onAbort?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
|
model?: ModelSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
|
|
@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentModel = local.model.current()
|
const modelSelection = input.model ?? local.model
|
||||||
|
const currentModel = modelSelection.current()
|
||||||
const currentAgent = local.agent.current()
|
const currentAgent = local.agent.current()
|
||||||
const variant = local.model.variant.current()
|
const variant = modelSelection.variant.current()
|
||||||
if (!currentModel || !currentAgent) {
|
if (!currentModel || !currentAgent) {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||||
|
|
@ -377,7 +379,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
await startTransition(() => {
|
await startTransition(() => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||||
local.session.promote(sessionDirectory, session.id)
|
local.session.promote(sessionDirectory, session.id, {
|
||||||
|
agent: currentAgent.name,
|
||||||
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
|
variant: variant ?? null,
|
||||||
|
})
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
const draftID = search.draftId
|
const draftID = search.draftId
|
||||||
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { tabKey, useTabs } from "@/context/tabs"
|
import { tabKey, useTabs } from "@/context/tabs"
|
||||||
|
import type { PromptSession } from "@/context/prompt"
|
||||||
import "./titlebar.css"
|
import "./titlebar.css"
|
||||||
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
|
||||||
|
|
||||||
|
|
@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
const route = layout.route()
|
const route = layout.route()
|
||||||
const activeSession = session()
|
const activeSession = session()
|
||||||
if (route.type === "session" && activeSession) {
|
if (route.type === "session" && activeSession) {
|
||||||
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
|
const sessionTab = {
|
||||||
|
type: "session" as const,
|
||||||
|
server: route.server ?? server.key,
|
||||||
|
sessionId: activeSession.id,
|
||||||
|
}
|
||||||
|
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
||||||
|
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTab = currentTab()
|
const activeTab = currentTab()
|
||||||
if (activeTab?.type === "draft") {
|
if (activeTab?.type === "draft") {
|
||||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
|
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
||||||
|
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
const [saved, setSaved] = persisted(
|
const [saved, setSaved, , savedReady] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
||||||
migrate,
|
migrate,
|
||||||
|
|
@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
model,
|
model,
|
||||||
agent,
|
agent,
|
||||||
session: {
|
session: {
|
||||||
|
ready: savedReady,
|
||||||
reset() {
|
reset() {
|
||||||
setStore({ draft: undefined, promoting: undefined })
|
setStore({ draft: undefined, promoting: undefined })
|
||||||
},
|
},
|
||||||
promote(dir: string, session: string) {
|
promote(dir: string, session: string, state?: State) {
|
||||||
const next = clone(snapshot())
|
const next = clone(state ?? snapshot())
|
||||||
if (!next) return
|
if (!next) return
|
||||||
const key = handoffKey(serverSDK().scope, dir, session)
|
const key = handoffKey(serverSDK().scope, dir, session)
|
||||||
handoff.set(key, next)
|
handoff.set(key, next)
|
||||||
|
|
@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||||
return result
|
return result
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export type ModelSelection = ReturnType<typeof useLocal>["model"]
|
||||||
|
|
|
||||||
29
packages/app/src/context/prompt-state.test.ts
Normal file
29
packages/app/src/context/prompt-state.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createRoot } from "solid-js"
|
||||||
|
import { createPromptState, DEFAULT_PROMPT } from "./prompt-state"
|
||||||
|
|
||||||
|
describe("prompt state initialization", () => {
|
||||||
|
test("initializes prompt text, cursor, and model together", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const model = { providerID: "anthropic", modelID: "claude", variant: "high" }
|
||||||
|
const prompt = createPromptState({ prompt: "hello", model })
|
||||||
|
|
||||||
|
expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }])
|
||||||
|
expect(prompt.cursor()).toBe(5)
|
||||||
|
expect(prompt.model.current()).toEqual(model)
|
||||||
|
expect(prompt.model.current()).not.toBe(model)
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the default prompt without initial values", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const prompt = createPromptState()
|
||||||
|
|
||||||
|
expect(prompt.current()).toEqual(DEFAULT_PROMPT)
|
||||||
|
expect(prompt.cursor()).toBeUndefined()
|
||||||
|
expect(prompt.model.current()).toBeUndefined()
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
265
packages/app/src/context/prompt-state.ts
Normal file
265
packages/app/src/context/prompt-state.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { checksum } from "@opencode-ai/core/util/encode"
|
||||||
|
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { batch, createMemo, type Accessor } from "solid-js"
|
||||||
|
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||||
|
import type { FileSelection } from "@/context/file"
|
||||||
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
interface PartBase {
|
||||||
|
content: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextPart extends PartBase {
|
||||||
|
type: "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileAttachmentPart extends PartBase {
|
||||||
|
type: "file"
|
||||||
|
path: string
|
||||||
|
selection?: FileSelection
|
||||||
|
mime?: string
|
||||||
|
filename?: string
|
||||||
|
url?: string
|
||||||
|
source?: FilePartSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentPart extends PartBase {
|
||||||
|
type: "agent"
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageAttachmentPart {
|
||||||
|
type: "image"
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
sourcePath?: string
|
||||||
|
mime: string
|
||||||
|
dataUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
||||||
|
export type Prompt = ContentPart[]
|
||||||
|
|
||||||
|
export type PromptModel = {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
variant?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileContextItem = {
|
||||||
|
type: "file"
|
||||||
|
path: string
|
||||||
|
selection?: FileSelection
|
||||||
|
comment?: string
|
||||||
|
commentID?: string
|
||||||
|
commentOrigin?: "review" | "file"
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextItem = FileContextItem
|
||||||
|
export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
||||||
|
|
||||||
|
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
|
type PromptStore = {
|
||||||
|
prompt: Prompt
|
||||||
|
cursor?: number
|
||||||
|
model?: PromptModel
|
||||||
|
context: {
|
||||||
|
items: (ContextItem & { key: string })[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type InitialPrompt = {
|
||||||
|
prompt?: string
|
||||||
|
model?: PromptModel
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
||||||
|
if (!a && !b) return true
|
||||||
|
if (!a || !b) return false
|
||||||
|
return (
|
||||||
|
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
||||||
|
switch (partA.type) {
|
||||||
|
case "text":
|
||||||
|
return partB.type === "text" && partA.content === partB.content
|
||||||
|
case "file":
|
||||||
|
return (
|
||||||
|
partB.type === "file" &&
|
||||||
|
partA.path === partB.path &&
|
||||||
|
partA.mime === partB.mime &&
|
||||||
|
partA.filename === partB.filename &&
|
||||||
|
isSelectionEqual(partA.selection, partB.selection)
|
||||||
|
)
|
||||||
|
case "agent":
|
||||||
|
return partB.type === "agent" && partA.name === partB.name
|
||||||
|
case "image":
|
||||||
|
return partB.type === "image" && partA.id === partB.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
||||||
|
if (promptA.length !== promptB.length) return false
|
||||||
|
for (let i = 0; i < promptA.length; i++) {
|
||||||
|
if (!isPartEqual(promptA[i], promptB[i])) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
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
|
||||||
|
const end = item.selection?.endLine
|
||||||
|
const key = `${item.type}:${item.path}:${start}:${end}`
|
||||||
|
|
||||||
|
if (item.commentID) return `${key}:c=${item.commentID}`
|
||||||
|
const comment = item.comment?.trim()
|
||||||
|
if (!comment) return key
|
||||||
|
const digest = checksum(comment) ?? comment
|
||||||
|
return `${key}:c=${digest.slice(0, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
||||||
|
return item.type === "file" && !!item.comment?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
||||||
|
return {
|
||||||
|
set(prompt: Prompt, cursorPosition?: number) {
|
||||||
|
const next = clonePrompt(prompt)
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", next)
|
||||||
|
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
batch(() => {
|
||||||
|
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
||||||
|
setStore("cursor", 0)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||||
|
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
||||||
|
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||||
|
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||||
|
const text = initial?.prompt
|
||||||
|
return {
|
||||||
|
prompt:
|
||||||
|
text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }],
|
||||||
|
cursor: text === undefined ? undefined : text.length,
|
||||||
|
model: initial?.model ? { ...initial.model } : undefined,
|
||||||
|
context: {
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||||
|
const actions = createPromptActions(setStore)
|
||||||
|
const value = {
|
||||||
|
current: () => store.prompt,
|
||||||
|
cursor: createMemo(() => store.cursor),
|
||||||
|
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||||
|
model: {
|
||||||
|
current: () => store.model,
|
||||||
|
set: (model: PromptModel | undefined) => setStore("model", model),
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
items: createMemo(() => store.context.items),
|
||||||
|
add(item: ContextItem) {
|
||||||
|
const key = contextItemKey(item)
|
||||||
|
if (store.context.items.find((x) => x.key === key)) return
|
||||||
|
setStore("context", "items", (items) => [...items, { key, ...item }])
|
||||||
|
},
|
||||||
|
remove(key: string) {
|
||||||
|
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
||||||
|
},
|
||||||
|
removeComment(path: string, commentID: string) {
|
||||||
|
setStore("context", "items", (items) =>
|
||||||
|
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
||||||
|
setStore("context", "items", (items) =>
|
||||||
|
items.map((item) => {
|
||||||
|
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
||||||
|
const value = { ...item, ...next }
|
||||||
|
return { ...value, key: contextItemKey(value) }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
replaceComments(items: FileContextItem[]) {
|
||||||
|
setStore("context", "items", (current) => [
|
||||||
|
...current.filter((item) => !isCommentItem(item)),
|
||||||
|
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
set: actions.set,
|
||||||
|
reset: actions.reset,
|
||||||
|
capture: () => value,
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPersistedPrompt(target: ReturnType<typeof promptTarget>, initial?: InitialPrompt) {
|
||||||
|
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(promptStore(initial)))
|
||||||
|
return { ready, ...createPromptStateValue(store, setStore) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) {
|
||||||
|
return createPersistedPrompt(promptTarget(serverScope, scope), initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) {
|
||||||
|
return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptSession = ReturnType<typeof createPromptSession>
|
||||||
|
|
||||||
|
export function createPromptReady(session: Accessor<PromptSession>) {
|
||||||
|
return Object.defineProperty(() => session().ready(), "promise", {
|
||||||
|
get: () => session().ready.promise,
|
||||||
|
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPromptState(initial?: InitialPrompt) {
|
||||||
|
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
|
||||||
|
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
||||||
|
return {
|
||||||
|
ready,
|
||||||
|
...createPromptStateValue(store, setStore),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,186 +1,49 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
|
||||||
import { useParams, useSearchParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js"
|
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import type { FileSelection } from "@/context/file"
|
import { ServerConnection } from "./server"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import { useSettings } from "./settings"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useTabs, type Tab } from "./tabs"
|
import { useTabs, type Tab } from "./tabs"
|
||||||
import { ServerConnection } from "./server"
|
import {
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
createPromptReady,
|
||||||
import { useSettings } from "./settings"
|
createPromptSession,
|
||||||
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
|
type ContextItem,
|
||||||
|
type FileContextItem,
|
||||||
|
type Prompt,
|
||||||
|
type PromptModel,
|
||||||
|
type PromptScope,
|
||||||
|
type PromptSession,
|
||||||
|
} from "./prompt-state"
|
||||||
|
|
||||||
interface PartBase {
|
export {
|
||||||
content: string
|
createPromptReady,
|
||||||
start: number
|
createPromptSession,
|
||||||
end: number
|
createPromptState,
|
||||||
}
|
DEFAULT_PROMPT,
|
||||||
|
isPromptEqual,
|
||||||
export interface TextPart extends PartBase {
|
} from "./prompt-state"
|
||||||
type: "text"
|
export type {
|
||||||
}
|
AgentPart,
|
||||||
|
ContentPart,
|
||||||
export interface FileAttachmentPart extends PartBase {
|
ContextItem,
|
||||||
type: "file"
|
FileAttachmentPart,
|
||||||
path: string
|
FileContextItem,
|
||||||
selection?: FileSelection
|
ImageAttachmentPart,
|
||||||
mime?: string
|
Prompt,
|
||||||
filename?: string
|
PromptModel,
|
||||||
url?: string
|
PromptScope,
|
||||||
source?: FilePartSource
|
PromptSession,
|
||||||
}
|
TextPart,
|
||||||
|
} from "./prompt-state"
|
||||||
export interface AgentPart extends PartBase {
|
|
||||||
type: "agent"
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageAttachmentPart {
|
|
||||||
type: "image"
|
|
||||||
id: string
|
|
||||||
filename: string
|
|
||||||
sourcePath?: string
|
|
||||||
mime: string
|
|
||||||
dataUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
|
|
||||||
export type Prompt = ContentPart[]
|
|
||||||
|
|
||||||
export type FileContextItem = {
|
|
||||||
type: "file"
|
|
||||||
path: string
|
|
||||||
selection?: FileSelection
|
|
||||||
comment?: string
|
|
||||||
commentID?: string
|
|
||||||
commentOrigin?: "review" | "file"
|
|
||||||
preview?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ContextItem = FileContextItem
|
|
||||||
|
|
||||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
|
||||||
|
|
||||||
function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
|
|
||||||
if (!a && !b) return true
|
|
||||||
if (!a || !b) return false
|
|
||||||
return (
|
|
||||||
a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPartEqual(partA: ContentPart, partB: ContentPart) {
|
|
||||||
switch (partA.type) {
|
|
||||||
case "text":
|
|
||||||
return partB.type === "text" && partA.content === partB.content
|
|
||||||
case "file":
|
|
||||||
return (
|
|
||||||
partB.type === "file" &&
|
|
||||||
partA.path === partB.path &&
|
|
||||||
partA.mime === partB.mime &&
|
|
||||||
partA.filename === partB.filename &&
|
|
||||||
isSelectionEqual(partA.selection, partB.selection)
|
|
||||||
)
|
|
||||||
case "agent":
|
|
||||||
return partB.type === "agent" && partA.name === partB.name
|
|
||||||
case "image":
|
|
||||||
return partB.type === "image" && partA.id === partB.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
|
|
||||||
if (promptA.length !== promptB.length) return false
|
|
||||||
for (let i = 0; i < promptA.length; i++) {
|
|
||||||
if (!isPartEqual(promptA[i], promptB[i])) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
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
|
|
||||||
const end = item.selection?.endLine
|
|
||||||
const key = `${item.type}:${item.path}:${start}:${end}`
|
|
||||||
|
|
||||||
if (item.commentID) {
|
|
||||||
return `${key}:c=${item.commentID}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const comment = item.comment?.trim()
|
|
||||||
if (!comment) return key
|
|
||||||
const digest = checksum(comment) ?? comment
|
|
||||||
return `${key}:c=${digest.slice(0, 8)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCommentItem(item: ContextItem | (ContextItem & { key: string })) {
|
|
||||||
return item.type === "file" && !!item.comment?.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPromptActions(
|
|
||||||
setStore: SetStoreFunction<{
|
|
||||||
prompt: Prompt
|
|
||||||
cursor?: number
|
|
||||||
context: {
|
|
||||||
items: (ContextItem & { key: string })[]
|
|
||||||
}
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
set(prompt: Prompt, cursorPosition?: number) {
|
|
||||||
const next = clonePrompt(prompt)
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", next)
|
|
||||||
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
reset() {
|
|
||||||
batch(() => {
|
|
||||||
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
|
|
||||||
setStore("cursor", 0)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const MAX_PROMPT_SESSIONS = 20
|
const MAX_PROMPT_SESSIONS = 20
|
||||||
|
|
||||||
type PromptSession = ReturnType<typeof createPromptSession>
|
export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
|
||||||
|
|
||||||
type PromptStore = {
|
|
||||||
prompt: Prompt
|
|
||||||
cursor?: number
|
|
||||||
context: {
|
|
||||||
items: (ContextItem & { key: string })[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Scope = { draftID: string } | { dir: string; id?: string }
|
|
||||||
|
|
||||||
export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) {
|
|
||||||
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
|
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
|
||||||
if (!scope.id) return
|
if (!scope.id) return
|
||||||
return (
|
return (
|
||||||
|
|
@ -189,7 +52,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopeKey(scope: Scope) {
|
function scopeKey(scope: PromptScope) {
|
||||||
if ("draftID" in scope) return `draft:${scope.draftID}`
|
if ("draftID" in scope) return `draft:${scope.draftID}`
|
||||||
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
|
||||||
}
|
}
|
||||||
|
|
@ -199,91 +62,6 @@ type PromptCacheEntry = {
|
||||||
dispose: VoidFunction
|
dispose: VoidFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptTarget(serverScope: ServerScope, scope: Scope) {
|
|
||||||
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
|
|
||||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
|
||||||
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPromptSession(serverScope: ServerScope, scope: Scope) {
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
|
||||||
promptTarget(serverScope, scope),
|
|
||||||
createStore<PromptStore>(promptStore()),
|
|
||||||
)
|
|
||||||
|
|
||||||
return { ready, ...createPromptStateValue(store, setStore) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPromptReady(session: Accessor<PromptSession>) {
|
|
||||||
return Object.defineProperty(() => session().ready(), "promise", {
|
|
||||||
get: () => session().ready.promise,
|
|
||||||
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
|
|
||||||
}
|
|
||||||
|
|
||||||
function promptStore(): PromptStore {
|
|
||||||
return {
|
|
||||||
prompt: clonePrompt(DEFAULT_PROMPT),
|
|
||||||
cursor: undefined,
|
|
||||||
context: {
|
|
||||||
items: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
|
||||||
const actions = createPromptActions(setStore)
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
current: () => store.prompt,
|
|
||||||
cursor: createMemo(() => store.cursor),
|
|
||||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
|
||||||
context: {
|
|
||||||
items: createMemo(() => store.context.items),
|
|
||||||
add(item: ContextItem) {
|
|
||||||
const key = contextItemKey(item)
|
|
||||||
if (store.context.items.find((x) => x.key === key)) return
|
|
||||||
setStore("context", "items", (items) => [...items, { key, ...item }])
|
|
||||||
},
|
|
||||||
remove(key: string) {
|
|
||||||
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
|
|
||||||
},
|
|
||||||
removeComment(path: string, commentID: string) {
|
|
||||||
setStore("context", "items", (items) =>
|
|
||||||
items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
updateComment(path: string, commentID: string, next: Partial<FileContextItem> & { comment?: string }) {
|
|
||||||
setStore("context", "items", (items) =>
|
|
||||||
items.map((item) => {
|
|
||||||
if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item
|
|
||||||
const value = { ...item, ...next }
|
|
||||||
return { ...value, key: contextItemKey(value) }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
replaceComments(items: FileContextItem[]) {
|
|
||||||
setStore("context", "items", (current) => [
|
|
||||||
...current.filter((item) => !isCommentItem(item)),
|
|
||||||
...items.map((item) => ({ ...item, key: contextItemKey(item) })),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
set: actions.set,
|
|
||||||
reset: actions.reset,
|
|
||||||
capture: () => value,
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createPromptState() {
|
|
||||||
const [store, setStore] = createStore<PromptStore>(promptStore())
|
|
||||||
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
|
|
||||||
return {
|
|
||||||
ready,
|
|
||||||
...createPromptStateValue(store, setStore),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createTabPromptState = (
|
export const createTabPromptState = (
|
||||||
tabs: ReturnType<typeof useTabs>,
|
tabs: ReturnType<typeof useTabs>,
|
||||||
tab: Tab,
|
tab: Tab,
|
||||||
|
|
@ -303,9 +81,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||||
const cache = new Map<string, PromptCacheEntry>()
|
const cache = new Map<string, PromptCacheEntry>()
|
||||||
|
|
||||||
const disposeAll = () => {
|
const disposeAll = () => {
|
||||||
for (const entry of cache.values()) {
|
for (const entry of cache.values()) entry.dispose()
|
||||||
entry.dispose()
|
|
||||||
}
|
|
||||||
cache.clear()
|
cache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -324,13 +100,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
const serverKey = () =>
|
const serverKey = () =>
|
||||||
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
|
||||||
const scope = () =>
|
const scope = (): PromptScope =>
|
||||||
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
|
||||||
const load = (scope: Scope) => {
|
const load = (scope: PromptScope) => {
|
||||||
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
|
||||||
if (current) {
|
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
||||||
return createTabPromptState(tabs, current, serverSDK().scope, scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = scopeKey(scope)
|
const key = scopeKey(scope)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
|
|
@ -354,15 +128,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() => load(scope()))
|
const session = createMemo(() => load(scope()))
|
||||||
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||||
const ready = createPromptReady(session)
|
const ready = createPromptReady(session)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
capture: (scope?: Scope) => pick(scope).capture(),
|
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||||
current: () => session().current(),
|
current: () => session().current(),
|
||||||
cursor: () => session().cursor(),
|
cursor: () => session().cursor(),
|
||||||
dirty: () => session().dirty(),
|
dirty: () => session().dirty(),
|
||||||
|
model: {
|
||||||
|
current: () => session().model.current(),
|
||||||
|
set: (model: PromptModel | undefined) => session().model.set(model),
|
||||||
|
},
|
||||||
context: {
|
context: {
|
||||||
items: () => session().context.items(),
|
items: () => session().context.items(),
|
||||||
add: (item: ContextItem) => session().context.add(item),
|
add: (item: ContextItem) => session().context.add(item),
|
||||||
|
|
@ -372,8 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||||
session().context.updateComment(path, commentID, next),
|
session().context.updateComment(path, commentID, next),
|
||||||
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items),
|
||||||
},
|
},
|
||||||
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
|
set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition),
|
||||||
reset: (scope?: Scope) => pick(scope).reset(),
|
reset: (scope?: PromptScope) => pick(scope).reset(),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
get<T>(key: string, name: string) {
|
||||||
|
return entries.get(key)?.get(name)?.value as T | undefined
|
||||||
|
},
|
||||||
ensure<T>(key: string, name: string, init: () => T) {
|
ensure<T>(key: string, name: string, init: () => T) {
|
||||||
const state = entries.get(key) ?? new Map<string, Entry>()
|
const state = entries.get(key) ?? new Map<string, Entry>()
|
||||||
if (!entries.has(key)) entries.set(key, state)
|
if (!entries.has(key)) entries.set(key, state)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ describe("tab memory", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
|
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
|
||||||
|
expect(memory.get<typeof first>("tab", "prompt")).toBe(first)
|
||||||
|
expect(memory.get("missing", "prompt")).toBeUndefined()
|
||||||
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
|
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
|
||||||
|
|
||||||
memory.remove("tab")
|
memory.remove("tab")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||||
import { sessionHref } from "@/utils/session-route"
|
import { sessionHref } from "@/utils/session-route"
|
||||||
import { createTabMemory } from "./tab-memory"
|
import { createTabMemory } from "./tab-memory"
|
||||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||||
|
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
type: "session"
|
type: "session"
|
||||||
|
|
@ -207,15 +208,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
|
||||||
return tab
|
return tab
|
||||||
},
|
},
|
||||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
||||||
const draftID = uuid()
|
const draftID = uuid()
|
||||||
|
const tab = { type: "draft" as const, draftID, ...draft }
|
||||||
|
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
||||||
void startTransition(() => {
|
void startTransition(() => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((tabs) => {
|
produce((tabs) => {
|
||||||
tabs.push({ type: "draft", draftID, ...draft })
|
tabs.push(tab)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
navigate(draftHref(draftID))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||||
|
|
@ -373,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
state<T>(tab: Tab, name: string, init: () => T) {
|
state<T>(tab: Tab, name: string, init: () => T) {
|
||||||
return memory.ensure(tabKey(tab), name, init)
|
return memory.ensure(tabKey(tab), name, init)
|
||||||
},
|
},
|
||||||
|
stateValue<T>(tab: Tab, name: string) {
|
||||||
|
return memory.get<T>(tabKey(tab), name)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...actions, store, info, ready, recentReady }
|
return { ...actions, store, info, ready, recentReady }
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import createPresence from "solid-presence"
|
import createPresence from "solid-presence"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||||
|
|
||||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
@ -54,8 +56,10 @@ export default function NewSessionPage() {
|
||||||
const openProviderSettings = useSettingsDialog("providers")
|
const openProviderSettings = useSettingsDialog("providers")
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||||
|
const local = useLocal()
|
||||||
|
const model = createPromptModelSelection({ agent: local.agent.current })
|
||||||
|
|
||||||
useComposerCommands()
|
useComposerCommands({ model })
|
||||||
|
|
||||||
let inputRef: HTMLDivElement | undefined
|
let inputRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
|
@ -63,6 +67,7 @@ export default function NewSessionPage() {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
sessionID: () => route.params.id,
|
sessionID: () => route.params.id,
|
||||||
queryOptions: serverSync().queryOptions,
|
queryOptions: serverSync().queryOptions,
|
||||||
|
model,
|
||||||
})
|
})
|
||||||
const projectControls = createPromptProjectControls()
|
const projectControls = createPromptProjectControls()
|
||||||
const projectController = createPromptProjectController({
|
const projectController = createPromptProjectController({
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
|
||||||
import { createTimelineModel } from "@/pages/session/timeline/model"
|
import { createTimelineModel } from "@/pages/session/timeline/model"
|
||||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||||
import {
|
import {
|
||||||
clampSessionPanelWidth,
|
clampSessionPanelWidth,
|
||||||
SESSION_PANEL_WIDTH_MIN,
|
SESSION_PANEL_WIDTH_MIN,
|
||||||
|
|
@ -557,6 +557,17 @@ export default function Page() {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let restoredModelSession: string | undefined
|
||||||
|
createEffect(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id || !prompt.ready() || !local.session.ready()) return
|
||||||
|
if (restoredModelSession !== id) {
|
||||||
|
restoredModelSession = id
|
||||||
|
if (restorePromptModel(local, prompt)) return
|
||||||
|
}
|
||||||
|
syncPromptModel(local, prompt)
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ dir: sdk().directory, id: params.id }),
|
() => ({ dir: sdk().directory, id: params.id }),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { batch, createMemo, startTransition } from "solid-js"
|
||||||
|
import { useModels } from "@/context/models"
|
||||||
|
import type { ModelKey, ModelSelection } from "@/context/local"
|
||||||
|
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
|
||||||
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
|
||||||
|
export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const sync = useSync()
|
||||||
|
const models = useModels()
|
||||||
|
const prompt = usePrompt()
|
||||||
|
const providers = useProviders(() => sdk().directory)
|
||||||
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
|
const valid = (model: ModelKey) => {
|
||||||
|
const provider = providers.all().get(model.providerID)
|
||||||
|
return !!provider?.models[model.modelID] && connected().has(model.providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
const configured = () => {
|
||||||
|
const value = sync().data.config.model
|
||||||
|
if (!value) return
|
||||||
|
const [providerID, modelID] = value.split("/")
|
||||||
|
const model = { providerID, modelID }
|
||||||
|
if (valid(model)) return model
|
||||||
|
}
|
||||||
|
|
||||||
|
const recent = () => models.recent.list().find(valid)
|
||||||
|
const fallback = () => {
|
||||||
|
const defaults = providers.default()
|
||||||
|
return providers.connected().flatMap((provider) => {
|
||||||
|
const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id
|
||||||
|
return modelID ? [{ providerID: provider.id, modelID }] : []
|
||||||
|
})[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = () => {
|
||||||
|
const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find(
|
||||||
|
(item): item is ModelKey => !!item && valid(item),
|
||||||
|
)
|
||||||
|
if (!key) return
|
||||||
|
return models.find(key)
|
||||||
|
}
|
||||||
|
const recentModels = createMemo(() =>
|
||||||
|
models.recent
|
||||||
|
.list()
|
||||||
|
.map(models.find)
|
||||||
|
.filter((item): item is NonNullable<typeof item> => !!item),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selection = {
|
||||||
|
ready: models.ready,
|
||||||
|
current,
|
||||||
|
recent: recentModels,
|
||||||
|
list: models.list,
|
||||||
|
cycle(direction: 1 | -1) {
|
||||||
|
const items = recentModels()
|
||||||
|
const item = current()
|
||||||
|
if (!item) return
|
||||||
|
const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id)
|
||||||
|
if (index === -1) return
|
||||||
|
const next = items[(index + direction + items.length) % items.length]
|
||||||
|
if (next) selection.set({ providerID: next.provider.id, modelID: next.id })
|
||||||
|
},
|
||||||
|
set(item: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||||
|
startTransition(() =>
|
||||||
|
batch(() => {
|
||||||
|
prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined)
|
||||||
|
if (!item) return
|
||||||
|
models.setVisibility(item, true)
|
||||||
|
if (options?.recent) models.recent.push(item)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
visible: models.visible,
|
||||||
|
setVisibility: models.setVisibility,
|
||||||
|
variant: {
|
||||||
|
configured() {
|
||||||
|
const item = input.agent()
|
||||||
|
const model = current()
|
||||||
|
if (!item || !model) return
|
||||||
|
return getConfiguredAgentVariant({
|
||||||
|
agent: { model: item.model, variant: item.variant },
|
||||||
|
model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
selected() {
|
||||||
|
return prompt.model.current()?.variant
|
||||||
|
},
|
||||||
|
current() {
|
||||||
|
const resolved = resolveModelVariant({
|
||||||
|
variants: this.list(),
|
||||||
|
selected: this.selected(),
|
||||||
|
configured: this.configured(),
|
||||||
|
})
|
||||||
|
if (resolved) return resolved
|
||||||
|
const model = current()
|
||||||
|
if (!model) return
|
||||||
|
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
|
||||||
|
if (saved && this.list().includes(saved)) return saved
|
||||||
|
},
|
||||||
|
list() {
|
||||||
|
return Object.keys(current()?.variants ?? {})
|
||||||
|
},
|
||||||
|
set(value: string | undefined) {
|
||||||
|
startTransition(() =>
|
||||||
|
batch(() => {
|
||||||
|
const model = current()
|
||||||
|
if (!model) return
|
||||||
|
prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null })
|
||||||
|
models.variant.set({ providerID: model.provider.id, modelID: model.id }, value)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cycle() {
|
||||||
|
const variants = this.list()
|
||||||
|
if (variants.length === 0) return
|
||||||
|
this.set(
|
||||||
|
cycleModelVariant({
|
||||||
|
variants,
|
||||||
|
selected: this.selected(),
|
||||||
|
configured: this.configured(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ModelSelection
|
||||||
|
|
||||||
|
return selection
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import type { PromptProjectControls } from "@/components/prompt-project-selector
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import type { QueryOptionsApi } from "@/context/server-sync"
|
import type { QueryOptionsApi } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { serverName, ServerConnection, useServer } from "@/context/server"
|
import { serverName, ServerConnection, useServer } from "@/context/server"
|
||||||
|
|
@ -22,6 +22,7 @@ export function createPromptInputController(input: {
|
||||||
sessionKey: Accessor<string>
|
sessionKey: Accessor<string>
|
||||||
sessionID: Accessor<string | undefined>
|
sessionID: Accessor<string | undefined>
|
||||||
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
|
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
|
||||||
|
model?: ModelSelection
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
|
@ -44,7 +45,7 @@ export function createPromptInputController(input: {
|
||||||
select: local.agent.set,
|
select: local.agent.set,
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
selection: local.model,
|
selection: input.model ?? local.model,
|
||||||
paid: providers.paid().length > 0,
|
paid: providers.paid().length > 0,
|
||||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
|
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
|
||||||
|
|
||||||
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
|
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
|
||||||
({
|
({
|
||||||
|
|
@ -50,3 +50,102 @@ describe("resetSessionModel", () => {
|
||||||
expect(calls).toEqual(["reset"])
|
expect(calls).toEqual(["reset"])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("syncPromptModel", () => {
|
||||||
|
test("stores the effective session model in prompt state", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
|
||||||
|
syncPromptModel(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
|
||||||
|
set() {},
|
||||||
|
variant: { current: () => "high", set() {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => undefined,
|
||||||
|
set: (model) => calls.push(model),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not rewrite an unchanged prompt model", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const model = { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }
|
||||||
|
|
||||||
|
syncPromptModel(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
|
||||||
|
set() {},
|
||||||
|
variant: { current: () => model.variant, set() {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => model,
|
||||||
|
set: (value) => calls.push(value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("restorePromptModel", () => {
|
||||||
|
test("restores the persisted prompt model into session selection", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const restored = restorePromptModel(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => ({ id: "gpt", provider: { id: "openai" } }),
|
||||||
|
set: (model) => calls.push(model),
|
||||||
|
variant: {
|
||||||
|
current: () => undefined,
|
||||||
|
set: (variant) => calls.push(variant),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }),
|
||||||
|
set() {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(restored).toBe(true)
|
||||||
|
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does nothing without a persisted prompt model", () => {
|
||||||
|
const calls: unknown[] = []
|
||||||
|
const restored = restorePromptModel(
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => ({ id: "gpt", provider: { id: "openai" } }),
|
||||||
|
set: (model) => calls.push(model),
|
||||||
|
variant: {
|
||||||
|
current: () => undefined,
|
||||||
|
set: (variant) => calls.push(variant),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
current: () => undefined,
|
||||||
|
set() {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(restored).toBe(false)
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,24 @@ type Local = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ModelSelection = {
|
||||||
|
model: {
|
||||||
|
current(): { id: string; provider: { id: string } } | undefined
|
||||||
|
set(model: { providerID: string; modelID: string }): void
|
||||||
|
variant: {
|
||||||
|
current(): string | undefined
|
||||||
|
set(variant: string | undefined): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptState = {
|
||||||
|
model: {
|
||||||
|
current(): { providerID: string; modelID: string; variant?: string | null } | undefined
|
||||||
|
set(model: { providerID: string; modelID: string; variant?: string | null }): void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const resetSessionModel = (local: Local) => {
|
export const resetSessionModel = (local: Local) => {
|
||||||
local.session.reset()
|
local.session.reset()
|
||||||
}
|
}
|
||||||
|
|
@ -14,3 +32,32 @@ export const resetSessionModel = (local: Local) => {
|
||||||
export const syncSessionModel = (local: Local, msg: UserMessage) => {
|
export const syncSessionModel = (local: Local, msg: UserMessage) => {
|
||||||
local.session.restore(msg)
|
local.session.restore(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
|
||||||
|
const model = local.model.current()
|
||||||
|
if (!model) return
|
||||||
|
const next = {
|
||||||
|
providerID: model.provider.id,
|
||||||
|
modelID: model.id,
|
||||||
|
variant: local.model.variant.current(),
|
||||||
|
}
|
||||||
|
const current = prompt.model.current()
|
||||||
|
if (current?.providerID === next.providerID && current.modelID === next.modelID && current.variant === next.variant)
|
||||||
|
return
|
||||||
|
prompt.model.set(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => {
|
||||||
|
const model = prompt.model.current()
|
||||||
|
if (!model) return false
|
||||||
|
const current = local.model.current()
|
||||||
|
if (
|
||||||
|
current?.provider.id === model.providerID &&
|
||||||
|
current.id === model.modelID &&
|
||||||
|
local.model.variant.current() === (model.variant ?? undefined)
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
local.model.set({ providerID: model.providerID, modelID: model.modelID })
|
||||||
|
local.model.variant.set(model.variant ?? undefined)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCommand, type CommandOption } from "@/context/command"
|
import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||||
|
|
@ -14,7 +14,7 @@ const withCategory = (category: string) => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useComposerCommands = () => {
|
export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
@ -22,6 +22,7 @@ export const useComposerCommands = () => {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const { sessionKey } = useSessionLayout()
|
const { sessionKey } = useSessionLayout()
|
||||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||||
|
const model = input.model ?? local.model
|
||||||
const modelCommand = withCategory(language.t("command.category.model"))
|
const modelCommand = withCategory(language.t("command.category.model"))
|
||||||
const agentCommand = withCategory(language.t("command.category.agent"))
|
const agentCommand = withCategory(language.t("command.category.agent"))
|
||||||
|
|
||||||
|
|
@ -43,7 +44,7 @@ export const useComposerCommands = () => {
|
||||||
}
|
}
|
||||||
const { DialogSelectModel } = await import("@/components/dialog-select-model")
|
const { DialogSelectModel } = await import("@/components/dialog-select-model")
|
||||||
owner.run(() => {
|
owner.run(() => {
|
||||||
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer)
|
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,7 +62,7 @@ export const useComposerCommands = () => {
|
||||||
title: language.t("command.model.variant.cycle"),
|
title: language.t("command.model.variant.cycle"),
|
||||||
description: language.t("command.model.variant.cycle.description"),
|
description: language.t("command.model.variant.cycle.description"),
|
||||||
keybind: "shift+mod+d",
|
keybind: "shift+mod+d",
|
||||||
onSelect: () => local.model.variant.cycle(),
|
onSelect: () => model.variant.cycle(),
|
||||||
}),
|
}),
|
||||||
agentCommand({
|
agentCommand({
|
||||||
id: "agent.cycle",
|
id: "agent.cycle",
|
||||||
|
|
|
||||||
|
|
@ -463,7 +463,7 @@ function localStorageDirect(): SyncStorage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
|
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"]
|
||||||
|
|
||||||
export function draftPersistedKeys() {
|
export function draftPersistedKeys() {
|
||||||
return DRAFT_PERSISTED_KEYS
|
return DRAFT_PERSISTED_KEYS
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue