chore: merge dev into v2
This commit is contained in:
commit
655dad8bf3
209 changed files with 6882 additions and 2469 deletions
|
|
@ -0,0 +1,50 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/PromptInputV2Editing"
|
||||
const projectID = "proj_prompt_input_v2_editing"
|
||||
const sessionID = "ses_prompt_input_v2_editing"
|
||||
|
||||
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "prompt-input-v2-editing",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "prompt-input-v2-editing",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Prompt input V2 editing",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||
const input = composer.locator('[data-component="prompt-input"]')
|
||||
await expectAppVisible(composer)
|
||||
|
||||
await input.fill("keep me")
|
||||
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||
await page.getByRole("menuitem", { name: "Commands" }).click()
|
||||
await page.locator('[data-suggestion-id="model.choose"]').click()
|
||||
|
||||
await expect(input).toHaveText("keep me")
|
||||
})
|
||||
|
|
@ -54,18 +54,15 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
|||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="session-composer"]')
|
||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||
const input = composer.locator('[data-component="prompt-input"]')
|
||||
const control = composer.locator('[data-component="prompt-variant-control"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
await expectAppVisible(composer)
|
||||
|
||||
await idleComposer(page)
|
||||
await expect(control).toBeHidden()
|
||||
|
||||
await composer.hover()
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await control.locator('[data-action="prompt-model-variant"]').click()
|
||||
await control.click()
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
|
|
|
|||
|
|
@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin
|
|||
}
|
||||
|
||||
async function expectSessionReady(page: Page) {
|
||||
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
||||
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.3",
|
||||
"version": "1.18.4",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,14 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
|
|||
}
|
||||
|
||||
describe("SerializeAddon", () => {
|
||||
test("preserves color scheme reporting mode", async () => {
|
||||
const { term, addon } = createTerminal()
|
||||
await writeAndWait(term, "\x1b[?2031h")
|
||||
|
||||
expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true)
|
||||
expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false)
|
||||
})
|
||||
|
||||
describe("ANSI color preservation", () => {
|
||||
test("should preserve text attributes (bold, italic, underline)", async () => {
|
||||
const { term, addon } = createTerminal()
|
||||
|
|
|
|||
|
|
@ -89,6 +89,13 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
|
|||
return { active, normal, alternate }
|
||||
}
|
||||
|
||||
const getTerminalMode = (value: ITerminalCore, mode: number) => {
|
||||
if (!isRecord(value)) return false
|
||||
const terminal = value.wasmTerm
|
||||
if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false
|
||||
return terminal.getMode(mode) === true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
|
@ -544,7 +551,8 @@ export class SerializeAddon implements ITerminalAddon {
|
|||
return ""
|
||||
}
|
||||
|
||||
let content = options?.range
|
||||
let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : ""
|
||||
content += options?.range
|
||||
? this._serializeBufferByRange(normalBuffer, options.range, true)
|
||||
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,16 @@ import { Font } from "@opencode-ai/ui/font"
|
|||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import {
|
||||
type BaseRouterProps,
|
||||
Navigate,
|
||||
Route,
|
||||
Router,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { Effect } from "effect"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
|
@ -29,6 +38,7 @@ import {
|
|||
Show,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
|||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
type Mem = Performance & {
|
||||
memory?: {
|
||||
|
|
@ -107,8 +108,45 @@ function Cell(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
|
||||
const content = () => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Force focus styles on all interactive elements"
|
||||
aria-pressed={props.active}
|
||||
classList={{
|
||||
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
||||
"min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline,
|
||||
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
|
||||
"bg-surface-raised-base text-text-strong": props.active,
|
||||
}}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
|
||||
<span classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}>
|
||||
{props.active ? "ON" : "OFF"}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
if (props.inline) {
|
||||
return (
|
||||
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
|
||||
{content()}
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip value="Force focus styles on all interactive elements" placement="top">
|
||||
{content()}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const location = useLocation()
|
||||
const routing = useIsRouting()
|
||||
const [state, setState] = createStore({
|
||||
|
|
@ -116,6 +154,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||
delay: undefined as number | undefined,
|
||||
fps: undefined as number | undefined,
|
||||
gap: undefined as number | undefined,
|
||||
focus: false,
|
||||
heap: {
|
||||
limit: undefined as number | undefined,
|
||||
used: undefined as number | undefined,
|
||||
|
|
@ -142,6 +181,16 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||
}
|
||||
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
||||
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
||||
const toggleFocus = async () => {
|
||||
if (!platform.setForceFocus) return
|
||||
const enabled = !state.focus
|
||||
await platform.setForceFocus(enabled)
|
||||
setState("focus", enabled)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
|
||||
})
|
||||
|
||||
let prev = ""
|
||||
let start = 0
|
||||
|
|
@ -490,8 +539,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
|||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
inline={props.inline}
|
||||
wide
|
||||
wide={!platform.setForceFocus}
|
||||
/>
|
||||
{platform.setForceFocus && (
|
||||
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
// @ts-nocheck
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { mockProviderAuth } from "@/context/server-sync"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||
|
||||
function ConnectProviderDialogStory() {
|
||||
const dialog = useDialog()
|
||||
const open = () => dialog.show(() => <DialogConnectProvider />)
|
||||
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Button variant="secondary" onClick={open}>
|
||||
Open connect provider dialog
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderConnectionDialogStory(props) {
|
||||
onCleanup(mockProviderAuth(props.provider, props.methods))
|
||||
const dialog = useDialog()
|
||||
const controller = useProviderConnectController()
|
||||
controller.select(props.provider)
|
||||
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
|
||||
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Button variant="secondary" onClick={open}>
|
||||
Open {props.provider} connection dialog
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderConnection(provider, methods) {
|
||||
return () => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ProviderConnectionDialogStory provider={provider} methods={methods} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Dialogs/Connect Provider",
|
||||
id: "app-dialog-connect-provider",
|
||||
}
|
||||
|
||||
export const V2 = {
|
||||
render: () => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ConnectProviderDialogStory />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
}
|
||||
|
||||
export const ApiKey = {
|
||||
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
|
||||
}
|
||||
|
||||
export const OpenCodeZen = {
|
||||
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
|
||||
}
|
||||
|
||||
export const LoginMethods = {
|
||||
render: renderConnection("openai", [
|
||||
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
|
||||
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
|
||||
{ type: "api", label: "API key" },
|
||||
]),
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import {
|
||||
type Accessor,
|
||||
|
|
@ -16,6 +19,8 @@ import {
|
|||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createUniqueId,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
|
|
@ -27,6 +32,7 @@ import { Link } from "@/components/link"
|
|||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
|
||||
|
|
@ -50,32 +56,22 @@ export const DialogConnectProvider: Component<{
|
|||
const fallback = useProviderConnectController()
|
||||
const controller = props.controller ?? fallback
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const reset = controller.back
|
||||
const back = { current: reset }
|
||||
let focusHost: HTMLDivElement | undefined
|
||||
const holdFocus = () => focusHost?.focus({ preventScroll: true })
|
||||
const select = (provider?: string) => {
|
||||
back.current = reset
|
||||
controller.select(provider)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
class="h-full"
|
||||
transition
|
||||
title={
|
||||
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={() => back.current()}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
function Content() {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={controller.selected() === CUSTOM_ID}>
|
||||
<CustomProviderForm />
|
||||
<CustomProviderForm autofocus={!newLayout()} />
|
||||
</Match>
|
||||
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
||||
{(provider) => (
|
||||
|
|
@ -88,14 +84,76 @@ export const DialogConnectProvider: Component<{
|
|||
)}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<ProviderPicker directory={props.directory} onSelect={select} />
|
||||
<ProviderPicker
|
||||
directory={props.directory}
|
||||
onSelect={select}
|
||||
onPrepare={newLayout() ? holdFocus : undefined}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={newLayout()}
|
||||
fallback={
|
||||
<Dialog
|
||||
class="h-full"
|
||||
transition
|
||||
title={
|
||||
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={() => back.current()}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Content />
|
||||
</Dialog>
|
||||
}
|
||||
>
|
||||
<DialogV2
|
||||
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||
>
|
||||
<DialogHeader closeLabel={language.t("common.close")}>
|
||||
<Show
|
||||
when={controller.selected()}
|
||||
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => back.current()}
|
||||
aria-label={language.t("common.goBack")}
|
||||
>
|
||||
<Icon name="arrow-left" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</DialogHeader>
|
||||
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
|
||||
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
|
||||
<Content />
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
|
||||
function ProviderPicker(props: {
|
||||
directory?: Accessor<string | undefined>
|
||||
onSelect: (provider: string) => void
|
||||
onPrepare?: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
if (settings.general.newLayoutDesigns())
|
||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
||||
const providers = useProviders(props.directory)
|
||||
const language = useLanguage()
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
|
|
@ -163,6 +221,171 @@ function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSel
|
|||
)
|
||||
}
|
||||
|
||||
function ProviderPickerV2(props: {
|
||||
directory?: Accessor<string | undefined>
|
||||
onSelect: (provider: string) => void
|
||||
onPrepare?: () => void
|
||||
}) {
|
||||
const providers = useProviders(props.directory)
|
||||
const language = useLanguage()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
active: undefined as string | undefined,
|
||||
connecting: undefined as string | undefined,
|
||||
})
|
||||
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
|
||||
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
|
||||
const all = createMemo(() => {
|
||||
language.locale()
|
||||
const query = store.filter.trim().toLowerCase()
|
||||
const values = [custom(), ...providers.all().values()]
|
||||
if (!query) return values
|
||||
return values.filter((provider) => `${provider.id} ${provider.name}`.toLowerCase().includes(query))
|
||||
})
|
||||
const popular = createMemo(() =>
|
||||
all()
|
||||
.filter((provider) => featured.includes(provider.id))
|
||||
.sort((a, b) => featured.indexOf(a.id) - featured.indexOf(b.id)),
|
||||
)
|
||||
const other = createMemo(() =>
|
||||
all()
|
||||
.filter((provider) => !featured.includes(provider.id))
|
||||
.sort((a, b) => {
|
||||
if (a.id === CUSTOM_ID) return -1
|
||||
if (b.id === CUSTOM_ID) return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}),
|
||||
)
|
||||
const rows = createMemo(() => [...popular(), ...other()])
|
||||
let picker: HTMLDivElement | undefined
|
||||
let search: HTMLInputElement | undefined
|
||||
|
||||
onMount(() => search?.focus({ preventScroll: true }))
|
||||
|
||||
const connect = (provider: string) => {
|
||||
props.onPrepare?.()
|
||||
if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) {
|
||||
props.onSelect(provider)
|
||||
return
|
||||
}
|
||||
if (store.connecting) return
|
||||
setStore("connecting", provider)
|
||||
void serverSDK()
|
||||
.client.provider.auth()
|
||||
.then((response) => {
|
||||
serverSync().set("provider_auth", response.data ?? {})
|
||||
props.onSelect(provider)
|
||||
})
|
||||
.catch(() => props.onSelect(provider))
|
||||
}
|
||||
|
||||
const move = (event: KeyboardEvent, direction: number) => {
|
||||
const items = rows()
|
||||
if (items.length === 0) return
|
||||
const index = items.findIndex((provider) => provider.id === store.active)
|
||||
const next = index < 0 ? (direction > 0 ? 0 : items.length - 1) : (index + direction + items.length) % items.length
|
||||
setStore("active", items[next].id)
|
||||
picker
|
||||
?.querySelector<HTMLElement>(`[data-provider-id="${CSS.escape(items[next].id)}"]`)
|
||||
?.focus({ preventScroll: true })
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "ArrowDown") return move(event, 1)
|
||||
if (event.key === "ArrowUp") return move(event, -1)
|
||||
if (event.key !== "Enter" || !store.active) return
|
||||
connect(store.active)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={picker} class="flex min-h-0 flex-1 flex-col gap-4" onKeyDown={handleKeyDown}>
|
||||
<div class="shrink-0 px-1 pt-px">
|
||||
<TextInputV2
|
||||
ref={search}
|
||||
type="search"
|
||||
class="!w-full [font-family:var(--v2-font-family-sans)]"
|
||||
leadingIcon={<Icon name="magnifying-glass" size="small" />}
|
||||
placeholder={language.t("dialog.provider.search.placeholder")}
|
||||
value={store.filter}
|
||||
onInput={(event) => {
|
||||
setStore({ filter: event.currentTarget.value, active: undefined })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div class="flex size-full min-h-0 flex-col gap-4 overflow-y-auto pb-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<For
|
||||
each={[
|
||||
{ title: language.t("dialog.provider.group.popular"), items: popular },
|
||||
{ title: language.t("dialog.provider.group.other"), items: other },
|
||||
]}
|
||||
>
|
||||
{(group) => (
|
||||
<Show when={group.items().length > 0}>
|
||||
<section class="flex flex-col">
|
||||
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
|
||||
{group.title}
|
||||
</div>
|
||||
<For each={group.items()}>
|
||||
{(provider) => (
|
||||
<button
|
||||
type="button"
|
||||
data-provider-id={provider.id}
|
||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
|
||||
onMouseEnter={() => setStore("active", provider.id)}
|
||||
disabled={store.connecting !== undefined}
|
||||
aria-busy={store.connecting === provider.id}
|
||||
onClick={() => connect(provider.id)}
|
||||
>
|
||||
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-base" />
|
||||
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
|
||||
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
||||
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
|
||||
{language.t(
|
||||
provider.id === "opencode"
|
||||
? "dialog.provider.opencode.tagline"
|
||||
: "dialog.provider.opencodeGo.tagline",
|
||||
)}
|
||||
</span>
|
||||
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||
{language.t("dialog.provider.tag.recommended")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={provider.id === CUSTOM_ID}>
|
||||
<span class="flex h-4 shrink-0 items-center rounded-xs border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-03 px-1 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||
{language.t("settings.providers.tag.custom")}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={store.connecting === provider.id}>
|
||||
<Spinner class="ml-auto size-4 shrink-0 text-v2-icon-icon-muted" />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</section>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<Show when={rows().length === 0}>
|
||||
<div class="flex h-24 items-center justify-center text-[13px] font-[440] text-v2-text-text-muted">
|
||||
{language.t("dialog.provider.empty")}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-0 h-10"
|
||||
style={{ background: "linear-gradient(to bottom, transparent, var(--v2-background-bg-layer-01))" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderConnection(props: {
|
||||
provider: string
|
||||
directory?: Accessor<string | undefined>
|
||||
|
|
@ -173,6 +396,8 @@ function ProviderConnection(props: {
|
|||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const providers = useProviders(props.directory)
|
||||
|
||||
const alive = { value: true }
|
||||
|
|
@ -207,11 +432,19 @@ function ProviderConnection(props: {
|
|||
)
|
||||
const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider])
|
||||
const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback())
|
||||
const cachedMethods = serverSync().data.provider_auth[props.provider]
|
||||
const directMethod =
|
||||
cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
methodIndex: directMethod as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
state: (directMethod === undefined ? "pending" : undefined) as
|
||||
| undefined
|
||||
| "pending"
|
||||
| "complete"
|
||||
| "error"
|
||||
| "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
|
|
@ -279,6 +512,16 @@ function ProviderConnection(props: {
|
|||
return value.label ?? ""
|
||||
}
|
||||
|
||||
const methodDetails = (value?: { type?: string; label?: string }) => {
|
||||
const label = methodLabel(value)
|
||||
const suffix = value?.label?.match(/\s+\((browser|headless)\)$/i)
|
||||
const hint = suffix?.[1]
|
||||
return {
|
||||
label: suffix ? label.slice(0, -suffix[0].length) : label,
|
||||
hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(value: unknown, fallback: string): string {
|
||||
if (value && typeof value === "object" && "data" in value) {
|
||||
const data = (value as { data?: { message?: unknown } }).data
|
||||
|
|
@ -519,6 +762,37 @@ function ProviderConnection(props: {
|
|||
props.setBack(goBack)
|
||||
|
||||
function MethodSelection() {
|
||||
if (newLayout())
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<For each={methods()}>
|
||||
{(item, index) => {
|
||||
const details = () => methodDetails(item)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => void selectMethod(index())}
|
||||
>
|
||||
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
||||
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
||||
</span>
|
||||
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
|
||||
<Show when={details().hint}>
|
||||
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="text-14-regular text-text-base">
|
||||
|
|
@ -552,11 +826,18 @@ function ProviderConnection(props: {
|
|||
}
|
||||
|
||||
function ApiAuthView() {
|
||||
let apiKey: HTMLInputElement | undefined
|
||||
const errorID = createUniqueId()
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: "",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!newLayout()) return
|
||||
apiKey?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
|
|
@ -581,6 +862,58 @@ function ProviderConnection(props: {
|
|||
await complete()
|
||||
}
|
||||
|
||||
if (newLayout())
|
||||
return (
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<Show
|
||||
when={provider().id === "opencode"}
|
||||
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||
<div>
|
||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||
<Link
|
||||
href="https://opencode.ai/zen"
|
||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
>
|
||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||
</Link>
|
||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||
<TextInputV2
|
||||
ref={apiKey}
|
||||
class="!w-full"
|
||||
name="apiKey"
|
||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||
value={formStore.value}
|
||||
invalid={formStore.error !== undefined}
|
||||
aria-describedby={formStore.error ? errorID : undefined}
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<Show when={formStore.error}>
|
||||
{(error) => (
|
||||
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<ButtonV2 type="submit" variant="contrast">
|
||||
{language.t("common.continue")}
|
||||
</ButtonV2>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<Switch>
|
||||
|
|
@ -605,7 +938,8 @@ function ProviderConnection(props: {
|
|||
</Switch>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
autofocus={!newLayout()}
|
||||
ref={apiKey}
|
||||
type="text"
|
||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||
|
|
@ -624,11 +958,18 @@ function ProviderConnection(props: {
|
|||
}
|
||||
|
||||
function OAuthCodeView() {
|
||||
let codeInput: HTMLInputElement | undefined
|
||||
const errorID = createUniqueId()
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: "",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!newLayout()) return
|
||||
codeInput?.focus({ preventScroll: true })
|
||||
})
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
|
|
@ -657,6 +998,46 @@ function ProviderConnection(props: {
|
|||
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||
}
|
||||
|
||||
if (newLayout())
|
||||
return (
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div>
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<Link href={store.authorization!.url} class="text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</Link>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||
<TextInputV2
|
||||
ref={codeInput}
|
||||
class="!w-full"
|
||||
name="code"
|
||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||
value={formStore.value}
|
||||
invalid={formStore.error !== undefined}
|
||||
aria-describedby={formStore.error ? errorID : undefined}
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
<Show when={formStore.error}>
|
||||
{(error) => (
|
||||
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<ButtonV2 type="submit" variant="contrast">
|
||||
{language.t("common.continue")}
|
||||
</ButtonV2>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
|
|
@ -666,7 +1047,8 @@ function ProviderConnection(props: {
|
|||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
autofocus={!newLayout()}
|
||||
ref={codeInput}
|
||||
type="text"
|
||||
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
|
||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||
|
|
@ -738,10 +1120,19 @@ function ProviderConnection(props: {
|
|||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">
|
||||
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
|
||||
<div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
|
||||
<ProviderIcon
|
||||
id={props.provider}
|
||||
class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
|
||||
/>
|
||||
<div
|
||||
class={
|
||||
newLayout()
|
||||
? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
|
||||
: "text-16-medium text-text-strong"
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||
{language.t("provider.connect.title.anthropicProMax")}
|
||||
|
|
@ -750,8 +1141,12 @@ function ProviderConnection(props: {
|
|||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
|
||||
<div
|
||||
onKeyDown={handleKey}
|
||||
tabIndex={newLayout() ? undefined : 0}
|
||||
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function DialogCustomProvider(props: Props) {
|
|||
)
|
||||
}
|
||||
|
||||
export function CustomProviderForm() {
|
||||
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
|
|
@ -192,7 +192,7 @@ export function CustomProviderForm() {
|
|||
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
autofocus={props.autofocus ?? true}
|
||||
label={language.t("provider.custom.field.providerID.label")}
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
// @ts-nocheck
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
|
||||
|
||||
const names = [
|
||||
"MiMo V2.5 Free",
|
||||
"Nemotron 3 Ultra Free",
|
||||
"Deepseek V4 Flash Free",
|
||||
"North Mini Code Free",
|
||||
"Hy3 Free",
|
||||
"Big Pickle",
|
||||
]
|
||||
|
||||
function SelectModelWithoutProviders() {
|
||||
const dialog = useDialog()
|
||||
const models = names.map((name, index) => ({
|
||||
id: name.toLowerCase().replaceAll(" ", "-"),
|
||||
name,
|
||||
provider: { id: "opencode", name: "OpenCode" },
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 128_000 },
|
||||
capabilities: {
|
||||
reasoning: index !== 5,
|
||||
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||
},
|
||||
}))
|
||||
const [current, setCurrent] = createSignal(models[2])
|
||||
const model = {
|
||||
list: () => models,
|
||||
current,
|
||||
set(value) {
|
||||
setCurrent(models.find((item) => item.id === value?.modelID))
|
||||
},
|
||||
}
|
||||
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
|
||||
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Button variant="secondary" onClick={open}>
|
||||
Open select model dialog
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Dialogs/Select Model",
|
||||
id: "app-dialog-select-model",
|
||||
}
|
||||
|
||||
export const WithoutProviders = {
|
||||
render: () => <SelectModelWithoutProviders />,
|
||||
}
|
||||
|
|
@ -1,23 +1,26 @@
|
|||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
|
||||
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
||||
|
||||
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
||||
const local = useLocal()
|
||||
const model = props.model ?? local.model
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme()
|
||||
const directory = () => decode64(local.slug())
|
||||
const providers = useProviders(directory)
|
||||
const language = useLanguage()
|
||||
|
|
@ -28,6 +31,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||
})
|
||||
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||
const freeModels = createMemo(() => model.list().filter(isFree))
|
||||
|
||||
const openProviders = (provider?: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
|
|
@ -62,111 +66,109 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
|||
})
|
||||
|
||||
return (
|
||||
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
|
||||
<DialogV2
|
||||
fit
|
||||
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||
>
|
||||
<DialogHeader closeLabel={language.t("common.close")}>
|
||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
|
||||
<DialogBody class="min-h-0 flex-1 gap-0">
|
||||
<ScrollView class="min-h-0 flex-1 w-full">
|
||||
<div ref={listEl} class="flex min-h-full flex-col">
|
||||
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
|
||||
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||
</div>
|
||||
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
||||
<div ref={listEl} class="flex min-h-0 flex-col">
|
||||
<div class="flex w-full flex-col items-start pb-3">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
||||
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||
</div>
|
||||
<For each={model.list()}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<Show when={isFree(item)}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||
</Show>
|
||||
<Show when={currentKey() === modelKey(item)}>
|
||||
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col p-2.5 pt-0">
|
||||
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
|
||||
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
|
||||
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.addMore.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col">
|
||||
<For
|
||||
each={[...providers.popular()].sort((a, b) => {
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})}
|
||||
>
|
||||
{(provider) => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => openProviders(provider.id)}
|
||||
>
|
||||
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{provider.name}</span>
|
||||
<Show when={provider.id === "opencode"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.opencode.tagline")}
|
||||
</span>
|
||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={provider.id === "opencode-go"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.opencodeGo.tagline")}
|
||||
</span>
|
||||
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={provider.id === "anthropic"}>
|
||||
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.anthropic.note")}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<For each={freeModels()}>
|
||||
{(item) => (
|
||||
<TooltipV2
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={{ ...item, name: displayModelName(item.name) }}
|
||||
latest={item.latest}
|
||||
free={isFree(item)}
|
||||
v2
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => openProviders()}
|
||||
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => selectModel(item)}
|
||||
>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="dot-grid" size="small" />
|
||||
</span>
|
||||
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</span>
|
||||
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
|
||||
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||
<Show when={item.latest}>
|
||||
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||
</Show>
|
||||
<Show when={currentKey() === modelKey(item)}>
|
||||
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
</TooltipV2>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col">
|
||||
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
|
||||
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
|
||||
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||
{language.t("dialog.model.unpaid.addMore.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
||||
<For
|
||||
each={[...providers.popular()]
|
||||
.filter((provider) => featuredProviders.includes(provider.id))
|
||||
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
||||
>
|
||||
{(provider) => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
||||
classList={{
|
||||
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
||||
theme.mode() !== "dark",
|
||||
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
|
||||
}}
|
||||
onClick={() => openProviders(provider.id)}
|
||||
>
|
||||
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="truncate">{provider.name}</span>
|
||||
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
||||
<span class="truncate font-[440] text-v2-text-text-muted">
|
||||
{language.t(
|
||||
provider.id === "opencode"
|
||||
? "dialog.provider.opencode.tagline"
|
||||
: "dialog.provider.opencodeGo.tagline",
|
||||
)}
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<button
|
||||
type="button"
|
||||
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
onClick={() => openProviders()}
|
||||
>
|
||||
{language.t("dialog.model.unpaid.viewMoreProviders")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogV2>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export function TabsInfoPopup() {
|
|||
const settings = useSettings()
|
||||
const platform = usePlatform()
|
||||
const [drawerOpen, setDrawerOpen] = createSignal(false)
|
||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||
|
||||
return (
|
||||
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
|
||||
|
|
@ -70,12 +71,40 @@ export function TabsInfoPopup() {
|
|||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<DrawerContent>
|
||||
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
|
||||
<DrawerContent
|
||||
style={
|
||||
windows()
|
||||
? {
|
||||
inset: "0 0 0 auto",
|
||||
"max-height": "100vh",
|
||||
"max-width": "100vw",
|
||||
"border-radius": "0",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Show when={windows()}>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="neutral"
|
||||
aria-label="Close"
|
||||
icon={<IconV2 name="xmark-small" />}
|
||||
class="absolute top-[10px] left-[-36px]"
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
class="flex w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted"
|
||||
classList={{
|
||||
"h-[40px] px-4": windows(),
|
||||
"h-[52px] p-4": !windows(),
|
||||
}}
|
||||
>
|
||||
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
|
||||
July 14
|
||||
</p>
|
||||
<Show when={platform.platform !== "desktop" || platform.os !== "windows"}>
|
||||
<Show when={!windows()}>
|
||||
<DrawerClose
|
||||
as={IconButtonV2}
|
||||
type="button"
|
||||
|
|
|
|||
591
packages/app/src/components/prompt-input-v2.tsx
Normal file
591
packages/app/src/components/prompt-input-v2.tsx
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
|
||||
import { createEffect, createMemo, on, Show } from "solid-js"
|
||||
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
|
||||
import type { PromptInputProps } from "@/components/prompt-input/contracts"
|
||||
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
|
||||
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
|
||||
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
|
||||
import { createPromptSubmit } from "@/components/prompt-input/submit"
|
||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
||||
import {
|
||||
createPromptInputV2Controller,
|
||||
createPromptInputV2State,
|
||||
type PromptInputV2Interaction,
|
||||
} from "@opencode-ai/session-ui/v2/prompt-input/interaction"
|
||||
|
||||
export type PromptInputV2ComposerProps = {
|
||||
class?: string
|
||||
controller: PromptInputV2ComposerController
|
||||
borderUnderlay?: boolean
|
||||
edit?: PromptInputProps["edit"]
|
||||
onEditLoaded?: PromptInputProps["onEditLoaded"]
|
||||
}
|
||||
|
||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
|
||||
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||
readonly model: PromptInputProps["controls"]["model"]
|
||||
}
|
||||
|
||||
export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
|
||||
useCommands(props)
|
||||
useEditHandler(props)
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-3">
|
||||
<PromptInputV2
|
||||
controller={props.controller}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
attachShortcut={command.keybind("file.attach")}
|
||||
modelControl={
|
||||
<PromptInputV2ModelControl
|
||||
loading={props.controller.model.loading}
|
||||
paid={props.controller.model.paid}
|
||||
title={language.t("command.model.choose")}
|
||||
keybind={command.keybindParts("model.choose")}
|
||||
model={props.controller.model.selection}
|
||||
providerID={props.controller.model.selection.current()?.provider?.id}
|
||||
modelName={props.controller.model.selection.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
onClose={props.controller.restoreFocus}
|
||||
onUnpaidClick={() =>
|
||||
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controller.model.selection} />)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useEditHandler = (props: PromptInputV2ComposerProps) => {
|
||||
const prompt = usePrompt()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.edit?.id,
|
||||
(id) => {
|
||||
const edit = props.edit
|
||||
if (!id || !edit) return
|
||||
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
|
||||
edit.context.forEach((item) =>
|
||||
prompt.context.add({
|
||||
type: item.type,
|
||||
path: item.path,
|
||||
selection: item.selection,
|
||||
comment: item.comment,
|
||||
commentID: item.commentID,
|
||||
commentOrigin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
}),
|
||||
)
|
||||
props.controller.dispatch({ type: "mode.normal" })
|
||||
props.controller.resetHistory()
|
||||
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||
props.controller.restoreFocus()
|
||||
props.onEditLoaded?.()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const useCommands = (props: PromptInputV2ComposerProps) => {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
|
||||
command.register("prompt-input", () => [
|
||||
{
|
||||
id: "file.attach",
|
||||
title: language.t("prompt.action.attachFile"),
|
||||
category: language.t("command.category.file"),
|
||||
keybind: "mod+u",
|
||||
disabled: props.controller.state.mode !== "normal",
|
||||
onSelect: () => props.controller.attach(),
|
||||
},
|
||||
{
|
||||
id: "prompt.mode.shell",
|
||||
title: language.t("command.prompt.mode.shell"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+x",
|
||||
disabled: props.controller.state.mode === "shell",
|
||||
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
|
||||
},
|
||||
{
|
||||
id: "prompt.mode.normal",
|
||||
title: language.t("command.prompt.mode.normal"),
|
||||
category: language.t("command.category.session"),
|
||||
keybind: "mod+shift+e",
|
||||
disabled: props.controller.state.mode === "normal",
|
||||
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const files = useFile()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const prompt = props.state ?? usePrompt()
|
||||
let editor: HTMLDivElement | undefined
|
||||
|
||||
const interaction = createPromptInputV2State()
|
||||
const mode = () => interaction[0].mode
|
||||
const history = props.history ?? createPersistedPromptInputHistory()
|
||||
const tabs = () => props.controls.session.tabs
|
||||
const activeFileTab = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: files.pathFromTab,
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
const recent = createMemo(() => {
|
||||
const all = tabs().all()
|
||||
const active = activeFileTab()
|
||||
const order = active ? [active, ...all.filter((tab) => tab !== active)] : all
|
||||
return order.reduce<string[]>((result, tab) => {
|
||||
const path = files.pathFromTab(tab)
|
||||
if (!path || result.includes(path)) return result
|
||||
return [...result, path]
|
||||
}, [])
|
||||
})
|
||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||
const attachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
const commentCount = createMemo(() => {
|
||||
if (mode() === "shell") return 0
|
||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||
})
|
||||
const blank = createMemo(() => {
|
||||
const text = prompt
|
||||
.current()
|
||||
.map((part) => ("content" in part ? part.content : ""))
|
||||
.join("")
|
||||
return text.trim().length === 0 && attachments().length === 0 && commentCount() === 0
|
||||
})
|
||||
const stopping = createMemo(() => working() && blank())
|
||||
const placeholder = createMemo(() =>
|
||||
promptPlaceholder({
|
||||
mode: mode(),
|
||||
commentCount: commentCount(),
|
||||
example: mode() === "shell" ? "git status" : "",
|
||||
suggest: false,
|
||||
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
|
||||
}),
|
||||
)
|
||||
const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder())
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
return prompt.context.items().flatMap((item) => {
|
||||
const comment = item.comment?.trim()
|
||||
if (!comment) return []
|
||||
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
|
||||
const nextSelection =
|
||||
selection ??
|
||||
(item.selection
|
||||
? ({ start: item.selection.startLine, end: item.selection.endLine } satisfies SelectedLineRange)
|
||||
: undefined)
|
||||
if (!nextSelection) return []
|
||||
return [
|
||||
{
|
||||
id: item.commentID ?? item.key,
|
||||
path: item.path,
|
||||
selection: { ...nextSelection },
|
||||
comment,
|
||||
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
|
||||
origin: item.commentOrigin,
|
||||
preview: item.preview,
|
||||
} satisfies PromptHistoryComment,
|
||||
]
|
||||
})
|
||||
}
|
||||
const restoreHistoryComments = (items: PromptHistoryComment[]) => {
|
||||
comments.replace(
|
||||
items.map((item) => ({
|
||||
id: item.id,
|
||||
file: item.path,
|
||||
selection: { ...item.selection },
|
||||
comment: item.comment,
|
||||
time: item.time,
|
||||
})),
|
||||
)
|
||||
prompt.context.replaceComments(
|
||||
items.map((item) => ({
|
||||
type: "file",
|
||||
path: item.path,
|
||||
selection: selectionFromLines(item.selection),
|
||||
comment: item.comment,
|
||||
commentID: item.id,
|
||||
commentOrigin: item.origin,
|
||||
preview: item.preview,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const accepting = createMemo(() => {
|
||||
const id = props.controls.session.id
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||
return permission.isAutoAccepting(id, sdk().directory)
|
||||
})
|
||||
const submission = createPromptSubmit({
|
||||
prompt,
|
||||
info,
|
||||
imageAttachments: attachments,
|
||||
commentCount,
|
||||
autoAccept: accepting,
|
||||
mode,
|
||||
working,
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
promptLength,
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
resetHistoryNavigation: () => controller.resetHistory(),
|
||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||
setPopover: (popover) => {
|
||||
if (!popover) controller.dispatch({ type: "popover.close" })
|
||||
},
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
model: props.controls.model.selection,
|
||||
})
|
||||
|
||||
const referenceDescription = (reference: ReferenceInfo) =>
|
||||
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||
const references = createMemo(() =>
|
||||
sync()
|
||||
.data.reference.filter((reference) => !reference.hidden)
|
||||
.map((reference) => ({
|
||||
id: `reference:${reference.name}`,
|
||||
kind: "reference" as const,
|
||||
label: `@${reference.name}`,
|
||||
path: reference.path,
|
||||
description: reference.description ?? referenceDescription(reference),
|
||||
mention: {
|
||||
type: "file" as const,
|
||||
path: reference.path,
|
||||
content: `@${reference.name}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
mime: "application/x-directory",
|
||||
filename: reference.name,
|
||||
},
|
||||
})),
|
||||
)
|
||||
const resources = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||
id: `resource:${resource.client}:${resource.uri}`,
|
||||
kind: "resource" as const,
|
||||
label: `@${resource.name}`,
|
||||
path: resource.uri,
|
||||
description: resource.description,
|
||||
mention: {
|
||||
type: "file" as const,
|
||||
path: resource.uri,
|
||||
content: `@${resource.name}`,
|
||||
start: 0,
|
||||
end: 0,
|
||||
mime: resource.mimeType ?? "text/plain",
|
||||
filename: resource.name,
|
||||
url: resource.uri,
|
||||
source: {
|
||||
type: "resource" as const,
|
||||
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||
clientName: resource.client,
|
||||
uri: resource.uri,
|
||||
},
|
||||
},
|
||||
resource,
|
||||
})),
|
||||
)
|
||||
const context = createMemo<PromptInputV2Suggestion[]>(() => [
|
||||
...references(),
|
||||
...props.controls.agents.available
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.map((agent) => ({
|
||||
id: `agent:${agent.name}`,
|
||||
kind: "agent" as const,
|
||||
label: `@${agent.name}`,
|
||||
mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
|
||||
})),
|
||||
...resources(),
|
||||
...recent().map((path) => ({
|
||||
id: `file:${path}`,
|
||||
kind: "file" as const,
|
||||
label: path,
|
||||
path,
|
||||
recent: true,
|
||||
mention: { type: "file" as const, path, content: `@${path}`, start: 0, end: 0 },
|
||||
})),
|
||||
])
|
||||
const slashCommands = createMemo(() => [
|
||||
...sync().data.command.map((item) => ({
|
||||
id: `custom.${item.name}`,
|
||||
trigger: item.name,
|
||||
title: item.name,
|
||||
description: item.description,
|
||||
type: "custom" as const,
|
||||
})),
|
||||
...command.options
|
||||
.filter((item) => !item.disabled && !item.id.startsWith("suggested.") && item.slash)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
trigger: item.slash!,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
type: "builtin" as const,
|
||||
})),
|
||||
])
|
||||
const commands = createMemo<PromptInputV2Suggestion[]>(() =>
|
||||
slashCommands().map((item) => ({
|
||||
id: item.id,
|
||||
kind: "command",
|
||||
label: `/${item.trigger}`,
|
||||
trigger: item.trigger,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
keybind: command.keybindParts(item.id),
|
||||
})),
|
||||
)
|
||||
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
||||
const controller = createPromptInputV2Controller({
|
||||
store: () => prompt.capture().store,
|
||||
state: interaction,
|
||||
identity: () => prompt.capture(),
|
||||
history: {
|
||||
entries: (mode) =>
|
||||
history.entries(mode).map((value) => {
|
||||
const entry = normalizePromptHistoryEntry(value)
|
||||
return { prompt: entry.prompt, metadata: entry.comments }
|
||||
}),
|
||||
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
|
||||
capture: historyComments,
|
||||
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
|
||||
},
|
||||
commands,
|
||||
context,
|
||||
searchContextFiles: async (query) =>
|
||||
(await files.searchFilesAndDirectories(query)).map((path) => ({
|
||||
id: `file:${path}`,
|
||||
kind: "file",
|
||||
label: path,
|
||||
path,
|
||||
mention: { type: "file", path, content: `@${path}`, start: 0, end: 0 },
|
||||
})),
|
||||
onContextRemove(item) {
|
||||
if (item?.commentID) comments.remove(item.path, item.commentID)
|
||||
},
|
||||
openAttachment: (attachment) =>
|
||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />),
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, props, sync, layout, files, comments)
|
||||
},
|
||||
onEditor(element) {
|
||||
editor = element as HTMLDivElement
|
||||
props.ref?.(editor)
|
||||
},
|
||||
onSuggestionSelect(item) {
|
||||
if (item.kind !== "command") return
|
||||
const selected = slashCommands().find((entry) => entry.id === item.id)
|
||||
if (!selected || selected.type === "custom") return
|
||||
return () => command.trigger(selected.id, "slash")
|
||||
},
|
||||
attachments: {
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk().directory,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
warn: () =>
|
||||
showToast({
|
||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||
}),
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
readClipboardImage: platform.readClipboardImage,
|
||||
getPathForFile: platform.getPathForFile,
|
||||
},
|
||||
view: {
|
||||
placeholder: designPlaceholder,
|
||||
agent:
|
||||
props.controls.agents.visible && props.controls.agents.options.length > 0
|
||||
? {
|
||||
options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })),
|
||||
current: () => props.controls.agents.current,
|
||||
onSelect: props.controls.agents.select,
|
||||
keybind: () => command.keybindParts("agent.cycle"),
|
||||
}
|
||||
: undefined,
|
||||
variant: {
|
||||
options: () => variants().map((value) => ({ id: value, label: value })),
|
||||
current: () => props.controls.model.selection.variant.current() ?? "default",
|
||||
onSelect: (value) => props.controls.model.selection.variant.set(value === "default" ? undefined : value),
|
||||
keybind: () => command.keybindParts("model.variant.cycle"),
|
||||
},
|
||||
submit: {
|
||||
stopping,
|
||||
working,
|
||||
onSubmit: () => void submission.handleSubmit(new Event("submit")),
|
||||
onStop: () => void submission.abort(),
|
||||
},
|
||||
},
|
||||
})
|
||||
Object.defineProperty(controller, "model", { get: () => props.controls.model })
|
||||
return controller as PromptInputV2ComposerController
|
||||
}
|
||||
|
||||
function PromptInputV2ModelControl(props: {
|
||||
loading: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string[]
|
||||
model: PromptInputV2ComposerController["model"]["selection"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
onClose: () => void
|
||||
onUnpaidClick: () => void
|
||||
}) {
|
||||
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
|
||||
const content = () => (
|
||||
<>
|
||||
<Show when={props.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate leading-4">{props.modelName}</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<Icon name="chevron-down" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<Show when={!props.loading}>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.title}
|
||||
<KeybindV2 keys={props.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.paid}
|
||||
fallback={
|
||||
<ButtonV2
|
||||
data-action="prompt-model"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||
style={{ height: "28px" }}
|
||||
onClick={props.onUnpaidClick}
|
||||
>
|
||||
{content()}
|
||||
</ButtonV2>
|
||||
}
|
||||
>
|
||||
<ModelSelectorPopoverV2
|
||||
model={props.model}
|
||||
triggerAs={ButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "normal",
|
||||
style: { height: "28px" },
|
||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||
classList: { "animate-in fade-in": shouldAnimate() },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
{content()}
|
||||
</ModelSelectorPopoverV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function openComment(
|
||||
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||
props: PromptInputV2ControllerProps,
|
||||
sync: ReturnType<typeof useSync>,
|
||||
layout: ReturnType<typeof useLayout>,
|
||||
files: ReturnType<typeof useFile>,
|
||||
comments: ReturnType<typeof useComments>,
|
||||
) {
|
||||
if (!item.commentID) return
|
||||
const focus = { file: item.path, id: item.commentID }
|
||||
comments.setActive(focus)
|
||||
const queueFocus = (attempts = 6) => {
|
||||
requestAnimationFrame(() => {
|
||||
comments.setFocus({ ...focus })
|
||||
if (attempts <= 0) return
|
||||
requestAnimationFrame(() => {
|
||||
const current = comments.focus()
|
||||
if (current?.file === focus.file && current.id === focus.id) queueFocus(attempts - 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
|
||||
const review =
|
||||
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
if (review) {
|
||||
layout.fileTree.setTab("changes")
|
||||
props.controls.session.tabs.setActive("review")
|
||||
queueFocus()
|
||||
return
|
||||
}
|
||||
layout.fileTree.setTab("all")
|
||||
const tab = files.tab(item.path)
|
||||
void props.controls.session.tabs.open(tab)
|
||||
props.controls.session.tabs.setActive(tab)
|
||||
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -38,7 +38,7 @@ type PromptAttachmentsCoreInput = {
|
|||
getPathForFile?: (file: File) => string
|
||||
}
|
||||
|
||||
type PromptAttachmentsInput = {
|
||||
export type PromptAttachmentsInput = {
|
||||
prompt: ReturnType<typeof usePrompt>
|
||||
editor: () => HTMLDivElement | undefined
|
||||
isDialogActive: () => boolean
|
||||
|
|
|
|||
|
|
@ -89,13 +89,15 @@ const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID:
|
|||
}
|
||||
|
||||
export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
const requestParts: PromptRequestPart[] = [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input.text,
|
||||
},
|
||||
]
|
||||
const requestParts: PromptRequestPart[] = input.text.trim()
|
||||
? [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: input.text,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
|
||||
const path = absolute(input.sessionDirectory, attachment.path)
|
||||
|
|
|
|||
57
packages/app/src/components/prompt-input/contracts.ts
Normal file
57
packages/app/src/components/prompt-input/contracts.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { useLocal } from "@/context/local"
|
||||
import type { Prompt, usePrompt } from "@/context/prompt"
|
||||
import type { PromptInputHistory } from "./history-store"
|
||||
import type { FollowupDraft } from "./submit"
|
||||
|
||||
export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
|
||||
export type PromptInputSubmission = {
|
||||
abort: () => Promise<void> | void
|
||||
handleSubmit: (event: Event) => Promise<void> | void
|
||||
}
|
||||
|
||||
export type PromptInputControls = {
|
||||
agents: {
|
||||
available: { name: string; hidden?: boolean; mode: string }[]
|
||||
options: string[]
|
||||
current: string
|
||||
loading: boolean
|
||||
visible: boolean
|
||||
select: (name: string | undefined) => void
|
||||
}
|
||||
model: {
|
||||
selection: ReturnType<typeof useLocal>["model"]
|
||||
paid: boolean
|
||||
loading: boolean
|
||||
}
|
||||
session: {
|
||||
id?: string
|
||||
tabs: {
|
||||
active: () => string | undefined
|
||||
all: () => string[]
|
||||
open: (tab: string) => void | Promise<void>
|
||||
setActive: (tab: string) => void
|
||||
}
|
||||
reviewPanel: {
|
||||
opened: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PromptInputProps {
|
||||
class?: string
|
||||
state?: PromptInputState
|
||||
history?: PromptInputHistory
|
||||
submission?: PromptInputSubmission
|
||||
controls: PromptInputControls
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
newSessionWorktree?: string
|
||||
onNewSessionWorktreeReset?: () => void
|
||||
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||
onEditLoaded?: () => void
|
||||
shouldQueue?: () => boolean
|
||||
onQueue?: (draft: FollowupDraft) => void
|
||||
onAbort?: () => void
|
||||
onSubmit?: () => void
|
||||
}
|
||||
47
packages/app/src/components/prompt-input/history-store.ts
Normal file
47
packages/app/src/components/prompt-input/history-store.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { prependHistoryEntry, type PromptHistoryComment, type PromptHistoryStoredEntry } from "./history"
|
||||
|
||||
export type PromptInputHistory = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
|
||||
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||
|
||||
function createPromptInputHistoryStore(
|
||||
normal: Store<PromptHistoryState>,
|
||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||
shell: Store<PromptHistoryState>,
|
||||
setShell: SetStoreFunction<PromptHistoryState>,
|
||||
): PromptInputHistory {
|
||||
return {
|
||||
entries: (mode) => (mode === "shell" ? shell.entries : normal.entries),
|
||||
add(prompt, mode, comments) {
|
||||
const current = mode === "shell" ? shell : normal
|
||||
const setCurrent = mode === "shell" ? setShell : setNormal
|
||||
const next = prependHistoryEntry(current.entries, prompt, comments)
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createPromptInputHistory(): PromptInputHistory {
|
||||
const [normal, setNormal] = createStore<PromptHistoryState>({ entries: [] })
|
||||
const [shell, setShell] = createStore<PromptHistoryState>({ entries: [] })
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
}
|
||||
|
||||
export function createPersistedPromptInputHistory() {
|
||||
const [normal, setNormal] = persisted(
|
||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const [shell, setShell] = persisted(
|
||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
return createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
}
|
||||
|
|
@ -13,3 +13,8 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
|
|||
if (!input.suggest) return input.t("prompt.placeholder.simple")
|
||||
return input.t("prompt.placeholder.normal", { example: input.example })
|
||||
}
|
||||
|
||||
export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) {
|
||||
if (mode === "shell") return placeholder
|
||||
return "Ask anything, / for commands, @ for context..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { Prompt } from "@/context/prompt"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Prompt, PromptStore } from "@/context/prompt"
|
||||
import type { ModelSelection } from "@/context/local"
|
||||
|
||||
let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||
|
|
@ -31,7 +32,13 @@ let permissionServer = "server-a"
|
|||
let createSessionGate: Promise<void> | undefined
|
||||
|
||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
prompt: promptValue,
|
||||
cursor: 0,
|
||||
context: { items: [] },
|
||||
})
|
||||
const prompt = {
|
||||
store: [() => promptStore, setPromptStore] as [() => PromptStore, typeof setPromptStore],
|
||||
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
|
||||
current: () => promptValue,
|
||||
cursor: () => 0,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ export type PromptInputTransientState = {
|
|||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
variantOpen: boolean
|
||||
}
|
||||
|
||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||
|
|
@ -25,7 +24,6 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
|
|||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -40,7 +38,6 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
|
|||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
|
||||
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function createPromptProjectController(input: {
|
|||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
|
||||
const selected = () => {
|
||||
const current = () => {
|
||||
const key = pathKey(input.controls().directory)
|
||||
return input
|
||||
.controls()
|
||||
|
|
@ -65,6 +65,7 @@ export function createPromptProjectController(input: {
|
|||
(pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)),
|
||||
)
|
||||
}
|
||||
const selected = () => current() ?? input.controls().available[0]
|
||||
const projects = () => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
if (!search) return input.controls().available
|
||||
|
|
@ -100,8 +101,8 @@ export function createPromptProjectController(input: {
|
|||
}
|
||||
const select = (project: PromptProject) => {
|
||||
if (
|
||||
pathKey(project.worktree) !== pathKey(selected()?.worktree ?? "") ||
|
||||
project.server?.key !== selected()?.server?.key
|
||||
pathKey(project.worktree) !== pathKey(current()?.worktree ?? "") ||
|
||||
project.server?.key !== current()?.server?.key
|
||||
) {
|
||||
input.controls().select(project.worktree, project.server?.key)
|
||||
}
|
||||
|
|
@ -124,6 +125,7 @@ export function createPromptProjectController(input: {
|
|||
|
||||
return {
|
||||
selected,
|
||||
empty: () => input.controls().available.length === 0,
|
||||
projects,
|
||||
servers,
|
||||
projectKey,
|
||||
|
|
|
|||
|
|
@ -93,24 +93,36 @@ export function PromptWorkspaceSelector(props: {
|
|||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<Show when={props.branch}>
|
||||
{(branch) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={branch()}
|
||||
class="min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<PromptGitStatus branch={props.branch} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
|
||||
const language = useLanguage()
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
return props.branch
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={label()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
value={value()}
|
||||
class="min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</TooltipV2>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { createMemo, Show } from "solid-js"
|
|||
import type { JSX } from "solid-js"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
|
@ -19,6 +20,7 @@ export function SortableTabV2(props: {
|
|||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.tab
|
||||
|
|
@ -39,9 +41,15 @@ export function SortableTabV2(props: {
|
|||
<Tabs.Trigger
|
||||
value={props.tab}
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
<TooltipV2
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
|
|
@ -52,7 +60,7 @@ export function SortableTabV2(props: {
|
|||
onClick={() => props.onTabClose(props.tab)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||
|
|
|
|||
|
|
@ -133,9 +133,10 @@
|
|||
}
|
||||
|
||||
[data-slot="settings-v2-row-description"] {
|
||||
margin-block: -3.5px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
line-height: 20px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
|
|
@ -270,10 +271,10 @@
|
|||
}
|
||||
|
||||
.settings-v2-provider-description {
|
||||
margin: 0;
|
||||
margin-block: -3.5px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 1;
|
||||
line-height: 20px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -292,7 +292,12 @@ export const Terminal = (props: TerminalProps) => {
|
|||
|
||||
const scheduleSize = (cols: number, rows: number) => {
|
||||
if (disposed) return
|
||||
if (lastSize?.cols === cols && lastSize?.rows === rows) return
|
||||
if (lastSize?.cols === cols && lastSize?.rows === rows) {
|
||||
pendingSize = undefined
|
||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||
sizeTimer = undefined
|
||||
return
|
||||
}
|
||||
|
||||
pendingSize = { cols, rows }
|
||||
|
||||
|
|
@ -317,8 +322,10 @@ export const Terminal = (props: TerminalProps) => {
|
|||
|
||||
createEffect(() => {
|
||||
const colors = terminalColors()
|
||||
const mode = theme.mode() === "dark" ? "dark" : "light"
|
||||
if (!term) return
|
||||
setOptionIfSupported(term, "theme", colors)
|
||||
setOptionIfSupported(term, "colorScheme", mode)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -396,6 +403,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||
}
|
||||
_ghostty = g
|
||||
term = t
|
||||
setOptionIfSupported(t, "colorScheme", theme.mode() === "dark" ? "dark" : "light")
|
||||
output = terminalWriter((data, done) =>
|
||||
t.write(data, () => {
|
||||
done?.()
|
||||
|
|
@ -595,6 +603,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||
tries = 0
|
||||
local.onConnect?.()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (t.getMode(2031)) t.write("\x1b[?996n")
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function useTitlebarRightMount() {
|
|||
return mount
|
||||
}
|
||||
|
||||
export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const command = useCommand()
|
||||
|
|
@ -462,7 +462,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
"md:pl-4": !mac(),
|
||||
}}
|
||||
>
|
||||
<ChannelIndicator />
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
||||
</Show>
|
||||
|
|
@ -660,9 +660,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
|||
</div>
|
||||
</Show>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
<ChannelIndicator />
|
||||
</div>
|
||||
</div>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -747,12 +747,27 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator() {
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||
if (channel === "dev" && props.debugTools) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono cursor-pointer"
|
||||
onClick={props.debugTools.toggle}
|
||||
aria-label="Toggle debug tools"
|
||||
aria-pressed={props.debugTools.visible}
|
||||
>
|
||||
DEV
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
|
||||
{["beta", "dev"].includes(channel) && (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
|
||||
{channel.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -176,7 +176,14 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
|||
|
||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||
|
||||
const selected = () => ensure(activeServer())
|
||||
const selected = () => {
|
||||
const list = global.servers.list()
|
||||
const key = activeServer()
|
||||
if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key)
|
||||
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
|
||||
if (!conn) throw new Error("Notification server not found")
|
||||
return ensure(ServerConnection.key(conn))
|
||||
}
|
||||
|
||||
return {
|
||||
ready: () => selected().ready(),
|
||||
|
|
|
|||
|
|
@ -115,6 +115,9 @@ type PlatformBase = {
|
|||
/** Export collected diagnostic logs (desktop only) */
|
||||
exportDebugLogs?(): Promise<string>
|
||||
|
||||
/** Force focus styles on interactive elements through desktop devtools (desktop only) */
|
||||
setForceFocus?(enabled: boolean): Promise<void>
|
||||
|
||||
/** Record a fatal renderer error in platform logs (desktop only) */
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export type PromptScope = { draftID: string } | { dir: string; id?: string }
|
|||
|
||||
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
type PromptStore = {
|
||||
export type PromptStore = {
|
||||
prompt: Prompt
|
||||
cursor?: number
|
||||
model?: PromptModel
|
||||
|
|
@ -189,6 +189,7 @@ function promptStore(initial?: InitialPrompt): PromptStore {
|
|||
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
|
||||
const actions = createPromptActions(setStore)
|
||||
const value = {
|
||||
store: [() => store, setStore] as [Accessor<PromptStore>, SetStoreFunction<PromptStore>],
|
||||
current: () => store.prompt,
|
||||
cursor: createMemo(() => store.cursor),
|
||||
dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { ServerConnection } from "./server"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
|
|
@ -36,6 +36,7 @@ export type {
|
|||
ImageAttachmentPart,
|
||||
Prompt,
|
||||
PromptModel,
|
||||
PromptStore,
|
||||
PromptScope,
|
||||
PromptSession,
|
||||
TextPart,
|
||||
|
|
@ -132,18 +133,29 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
|||
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
|
||||
const ready = createPromptReady(session)
|
||||
|
||||
const withSuspense = <T,>(cb: () => T): (() => T) =>
|
||||
createResource(
|
||||
async () => {
|
||||
const value = cb()
|
||||
await session().ready.promise
|
||||
return value
|
||||
},
|
||||
cb,
|
||||
{ initialValue: cb() },
|
||||
)[0]
|
||||
|
||||
return {
|
||||
ready,
|
||||
capture: (scope?: PromptScope) => pick(scope).capture(),
|
||||
current: () => session().current(),
|
||||
cursor: () => session().cursor(),
|
||||
dirty: () => session().dirty(),
|
||||
current: withSuspense(() => session().current()),
|
||||
cursor: withSuspense(() => session().cursor()),
|
||||
dirty: withSuspense(() => session().dirty()),
|
||||
model: {
|
||||
current: () => session().model.current(),
|
||||
current: withSuspense(() => session().model.current()),
|
||||
set: (model: PromptModel | undefined) => session().model.set(model),
|
||||
},
|
||||
context: {
|
||||
items: () => session().context.items(),
|
||||
items: withSuspense(() => session().context.items()),
|
||||
add: (item: ContextItem) => session().context.add(item),
|
||||
remove: (key: string) => session().context.remove(key),
|
||||
removeComment: (path: string, commentID: string) => session().context.removeComment(path, commentID),
|
||||
|
|
|
|||
|
|
@ -199,6 +199,49 @@ describe("server session", () => {
|
|||
expect(store.history.more("child")).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const assistant = assistantMessage("message-2", "message-missing")
|
||||
const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
|
||||
await loading
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.history.more("child")).toBe(true)
|
||||
})
|
||||
|
||||
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const parent = userMessage("message-1")
|
||||
const part = textPart(parent.id)
|
||||
const assistant = assistantMessage("message-2", parent.id)
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response([
|
||||
{ info: parent, parts: [part] },
|
||||
{ info: assistant, parts: [] },
|
||||
]),
|
||||
response([{ info: assistant, parts: [] }], "older"),
|
||||
],
|
||||
[missing.promise],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
await client.rootRequested(1)
|
||||
|
||||
missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.data.part[parent.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not let an optimistic user suppress initial root backfill", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const part = textPart(user.id)
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ function reconcileFetched<T extends { id: string }>(
|
|||
options: {
|
||||
touched?: ReadonlySet<string>
|
||||
retained?: ReadonlySet<string>
|
||||
removed?: ReadonlySet<string>
|
||||
preserveUnfetched?: boolean | ((item: T) => boolean)
|
||||
} = {},
|
||||
) {
|
||||
|
|
@ -131,6 +132,7 @@ function reconcileFetched<T extends { id: string }>(
|
|||
if (item) result.set(id, item)
|
||||
if (!item) result.delete(id)
|
||||
}
|
||||
for (const id of options.removed ?? emptyIDs) result.delete(id)
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
|
|
@ -570,6 +572,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||
touched: touchedMessages,
|
||||
retained: load?.retainedMessages,
|
||||
removed: load?.removedMessages,
|
||||
preserveUnfetched,
|
||||
})
|
||||
batch(() => {
|
||||
|
|
@ -638,7 +641,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
if (generations.get(sessionID) !== active) break
|
||||
const parent = await fetchMessage(sessionID, parentID, () =>
|
||||
resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
|
||||
)
|
||||
).catch((error) => {
|
||||
const cause = error instanceof Error && typeof error.cause === "object" ? error.cause : undefined
|
||||
if (cause && "status" in cause && cause.status === 404) {
|
||||
load.removedMessages.add(parentID)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
})
|
||||
if (!parent) continue
|
||||
if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
|
||||
parents.push(parent)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -610,6 +610,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "محلي",
|
||||
"session.new.workspace.local": "المستودع المحلي",
|
||||
"session.new.workspace.existing": "مساحة عمل…",
|
||||
"session.new.git.none": "لا يوجد Git",
|
||||
"session.new.lastModified": "آخر تعديل",
|
||||
"session.header.search.placeholder": "بحث {{project}}",
|
||||
"session.header.searchFiles": "بحث عن الملفات",
|
||||
|
|
|
|||
|
|
@ -616,6 +616,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Repositório local",
|
||||
"session.new.workspace.existing": "Espaço de trabalho…",
|
||||
"session.new.git.none": "Sem Git",
|
||||
"session.new.lastModified": "Última modificação",
|
||||
"session.header.search.placeholder": "Buscar {{project}}",
|
||||
"session.header.searchFiles": "Buscar arquivos",
|
||||
|
|
|
|||
|
|
@ -672,6 +672,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Lokalno",
|
||||
"session.new.workspace.local": "Lokalni repozitorij",
|
||||
"session.new.workspace.existing": "Radni prostor…",
|
||||
"session.new.git.none": "Nema Gita",
|
||||
"session.new.lastModified": "Posljednja izmjena",
|
||||
|
||||
"session.header.search.placeholder": "Pretraži {{project}}",
|
||||
|
|
|
|||
|
|
@ -667,6 +667,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Lokal",
|
||||
"session.new.workspace.local": "Lokalt repository",
|
||||
"session.new.workspace.existing": "Arbejdsområde…",
|
||||
"session.new.git.none": "Ingen Git",
|
||||
"session.new.lastModified": "Sidst ændret",
|
||||
|
||||
"session.header.search.placeholder": "Søg {{project}}",
|
||||
|
|
|
|||
|
|
@ -625,6 +625,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Lokal",
|
||||
"session.new.workspace.local": "Lokales Repository",
|
||||
"session.new.workspace.existing": "Arbeitsbereich…",
|
||||
"session.new.git.none": "Kein Git",
|
||||
"session.new.lastModified": "Zuletzt geändert",
|
||||
"session.header.search.placeholder": "{{project}} durchsuchen",
|
||||
"session.header.searchFiles": "Dateien suchen",
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export const dict = {
|
|||
"dialog.provider.empty": "No providers found",
|
||||
"dialog.provider.group.popular": "Popular",
|
||||
"dialog.provider.group.other": "Other",
|
||||
"dialog.provider.custom.label": "Custom OpenAI-compatible provider",
|
||||
"dialog.provider.tag.recommended": "Recommended",
|
||||
"dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more",
|
||||
"dialog.provider.opencode.tagline": "Reliable optimized models",
|
||||
|
|
@ -122,6 +123,7 @@ export const dict = {
|
|||
|
||||
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
|
||||
"dialog.model.unpaid.addMore.title": "Add more models from popular providers",
|
||||
"dialog.model.unpaid.viewMoreProviders": "See 70+ more providers",
|
||||
|
||||
"dialog.provider.viewAll": "Show more providers",
|
||||
|
||||
|
|
@ -694,6 +696,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Local repository",
|
||||
"session.new.workspace.existing": "Workspace…",
|
||||
"session.new.git.none": "No Git",
|
||||
"session.new.lastModified": "Last modified",
|
||||
|
||||
"session.header.search.placeholder": "Search {{project}}",
|
||||
|
|
|
|||
|
|
@ -673,6 +673,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Repositorio local",
|
||||
"session.new.workspace.existing": "Espacio de trabajo…",
|
||||
"session.new.git.none": "Sin Git",
|
||||
"session.new.lastModified": "Última modificación",
|
||||
|
||||
"session.header.search.placeholder": "Buscar {{project}}",
|
||||
|
|
|
|||
|
|
@ -621,6 +621,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Local",
|
||||
"session.new.workspace.local": "Dépôt local",
|
||||
"session.new.workspace.existing": "Espace de travail…",
|
||||
"session.new.git.none": "Pas de Git",
|
||||
"session.new.lastModified": "Dernière modification",
|
||||
"session.header.search.placeholder": "Rechercher {{project}}",
|
||||
"session.header.searchFiles": "Rechercher des fichiers",
|
||||
|
|
|
|||
|
|
@ -612,6 +612,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "ローカル",
|
||||
"session.new.workspace.local": "ローカルリポジトリ",
|
||||
"session.new.workspace.existing": "ワークスペース…",
|
||||
"session.new.git.none": "Git なし",
|
||||
"session.new.lastModified": "最終更新",
|
||||
"session.header.search.placeholder": "{{project}}を検索",
|
||||
"session.header.searchFiles": "ファイルを検索",
|
||||
|
|
|
|||
|
|
@ -970,6 +970,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "로컬",
|
||||
"session.new.workspace.local": "로컬 저장소",
|
||||
"session.new.workspace.existing": "작업 공간…",
|
||||
"session.new.git.none": "Git 없음",
|
||||
|
||||
"sidebar.empty.title": "열린 프로젝트 없음",
|
||||
"sidebar.empty.description": "프로젝트를 열어 시작하세요",
|
||||
|
|
|
|||
|
|
@ -1064,6 +1064,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Lokalt",
|
||||
"session.new.workspace.local": "Lokalt depot",
|
||||
"session.new.workspace.existing": "Arbeidsområde…",
|
||||
"session.new.git.none": "Ingen Git",
|
||||
|
||||
"sidebar.empty.title": "Ingen åpne prosjekter",
|
||||
"sidebar.empty.description": "Åpne et prosjekt for å komme i gang",
|
||||
|
|
|
|||
|
|
@ -616,6 +616,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Lokalnie",
|
||||
"session.new.workspace.local": "Lokalne repozytorium",
|
||||
"session.new.workspace.existing": "Przestrzeń robocza…",
|
||||
"session.new.git.none": "Brak Git",
|
||||
"session.new.lastModified": "Ostatnio zmodyfikowano",
|
||||
"session.header.search.placeholder": "Szukaj {{project}}",
|
||||
"session.header.searchFiles": "Szukaj plików",
|
||||
|
|
|
|||
|
|
@ -670,6 +670,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Локально",
|
||||
"session.new.workspace.local": "Локальный репозиторий",
|
||||
"session.new.workspace.existing": "Рабочее пространство…",
|
||||
"session.new.git.none": "Нет Git",
|
||||
"session.new.lastModified": "Последнее изменение",
|
||||
|
||||
"session.header.search.placeholder": "Поиск {{project}}",
|
||||
|
|
|
|||
|
|
@ -666,6 +666,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "ในเครื่อง",
|
||||
"session.new.workspace.local": "รีโพซิทอรีในเครื่อง",
|
||||
"session.new.workspace.existing": "พื้นที่ทำงาน…",
|
||||
"session.new.git.none": "ไม่มี Git",
|
||||
"session.new.lastModified": "แก้ไขล่าสุด",
|
||||
|
||||
"session.header.search.placeholder": "ค้นหา {{project}}",
|
||||
|
|
|
|||
|
|
@ -676,6 +676,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Yerel",
|
||||
"session.new.workspace.local": "Yerel depo",
|
||||
"session.new.workspace.existing": "Çalışma alanı…",
|
||||
"session.new.git.none": "Git yok",
|
||||
"session.new.lastModified": "Son değişiklik",
|
||||
|
||||
"session.header.search.placeholder": "{{project}} ara",
|
||||
|
|
|
|||
|
|
@ -699,6 +699,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "Локально",
|
||||
"session.new.workspace.local": "Локальний репозиторій",
|
||||
"session.new.workspace.existing": "Робоча область…",
|
||||
"session.new.git.none": "Немає Git",
|
||||
"session.new.lastModified": "Востаннє змінено",
|
||||
|
||||
"session.header.search.placeholder": "Пошук {{project}}",
|
||||
|
|
|
|||
|
|
@ -663,6 +663,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "本地",
|
||||
"session.new.workspace.local": "本地仓库",
|
||||
"session.new.workspace.existing": "工作区…",
|
||||
"session.new.git.none": "无 Git",
|
||||
"session.new.lastModified": "最后修改",
|
||||
"session.header.search.placeholder": "搜索 {{project}}",
|
||||
"session.header.searchFiles": "搜索文件",
|
||||
|
|
|
|||
|
|
@ -660,6 +660,7 @@ export const dict = {
|
|||
"session.new.workspace.triggerLocal": "本機",
|
||||
"session.new.workspace.local": "本機儲存庫",
|
||||
"session.new.workspace.existing": "工作區…",
|
||||
"session.new.git.none": "無 Git",
|
||||
"session.new.lastModified": "最後修改",
|
||||
|
||||
"session.header.search.placeholder": "搜尋 {{project}}",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { TabsInfoPopup } from "@/components/help-button"
|
||||
|
|
@ -11,6 +12,7 @@ export default function NewLayout(props: ParentProps) {
|
|||
const platform = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
setNavigate(navigate)
|
||||
const [state, setState] = createStore({ debugTools: true })
|
||||
|
||||
createEffect(() => setV2Toast(true))
|
||||
|
||||
|
|
@ -32,11 +34,18 @@ export default function NewLayout(props: ParentProps) {
|
|||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
}}
|
||||
>
|
||||
<Titlebar update={update} />
|
||||
<Titlebar
|
||||
update={update}
|
||||
debugTools={
|
||||
import.meta.env.DEV
|
||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar inline />}
|
||||
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
||||
<TabsInfoPopup />
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||
sizing: false,
|
||||
peek: undefined as string | undefined,
|
||||
peeked: false,
|
||||
debugTools: true,
|
||||
})
|
||||
|
||||
const updateVersion = () => {
|
||||
|
|
@ -2248,7 +2249,14 @@ export default function LegacyLayout(props: ParentProps) {
|
|||
return (
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<Titlebar
|
||||
update={titlebarUpdate}
|
||||
debugTools={
|
||||
import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1"
|
||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||
</Show>
|
||||
|
|
@ -2393,7 +2401,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && state.debugTools && <DebugBar />}
|
||||
</div>
|
||||
<TabsInfoPopup />
|
||||
<ToastRegion v2={false} />
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
|
|
@ -25,11 +25,11 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
|
|||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import createPresence from "solid-presence"
|
||||
import { useLocal } from "@/context/local"
|
||||
|
|
@ -55,7 +55,11 @@ export default function NewSessionPage() {
|
|||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const openProviderSettings = useSettingsDialog("providers")
|
||||
const openProviders = () => {
|
||||
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
||||
})
|
||||
}
|
||||
useSettingsCommand()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
|
|
@ -64,8 +68,6 @@ export default function NewSessionPage() {
|
|||
|
||||
useComposerCommands({ model })
|
||||
|
||||
let inputRef: HTMLDivElement | undefined
|
||||
|
||||
const inputController = createPromptInputController({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
|
|
@ -73,29 +75,6 @@ export default function NewSessionPage() {
|
|||
model,
|
||||
})
|
||||
const projectControls = createPromptProjectControls()
|
||||
const projectController = createPromptProjectController({
|
||||
controls: projectControls,
|
||||
onDone: () => inputRef?.focus(),
|
||||
})
|
||||
|
||||
command.register("new-session", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||
void dialog.show(() => <DialogSelectFile />)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: () => inputRef?.focus(),
|
||||
},
|
||||
])
|
||||
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
|
@ -115,6 +94,39 @@ export default function NewSessionPage() {
|
|||
if (worktree === "main" || worktree === "create") return localBranch()
|
||||
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||
})
|
||||
const promptInputV2Controller = usePromptInputV2Controller({
|
||||
get controls() {
|
||||
return inputController()
|
||||
},
|
||||
get newSessionWorktree() {
|
||||
return newSessionWorktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
|
||||
onSubmit: () => comments.clear(),
|
||||
})
|
||||
const projectController = createPromptProjectController({
|
||||
controls: projectControls,
|
||||
onDone: promptInputV2Controller.restoreFocus,
|
||||
})
|
||||
|
||||
command.register("new-session", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||
void dialog.show(() => <DialogSelectFile />)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: () => promptInputV2Controller.restoreFocus(),
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
|
|
@ -128,16 +140,18 @@ export default function NewSessionPage() {
|
|||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
requestAnimationFrame(() => inputRef?.focus())
|
||||
promptInputV2Controller.restoreFocus()
|
||||
})
|
||||
|
||||
const ready = Promise.resolve()
|
||||
const [promptReady] = createResource(
|
||||
const [suspendUntilPromptReady] = createResource(
|
||||
() => prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<Show when={rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
|
|
@ -154,69 +168,44 @@ export default function NewSessionPage() {
|
|||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
<NewSessionDesignView>
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Show
|
||||
when={prompt.ready() || promptReady()}
|
||||
fallback={
|
||||
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak pointer-events-none">
|
||||
{language.t("prompt.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar(), "gap-3": !showWorkspaceBar() }}>
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
variant="new-session"
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
|
||||
onSubmit={() => comments.clear()}
|
||||
toolbar={
|
||||
<Show when={!projectController.selected()}>
|
||||
<PromptProjectAddButton controller={projectController} />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<Show when={projectController.selected()}>
|
||||
<div
|
||||
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||
classList={{
|
||||
"flex-col justify-center sm:flex-row": showWorkspaceBar(),
|
||||
"justify-start": !showWorkspaceBar(),
|
||||
}}
|
||||
<div class="flex flex-col gap-8">
|
||||
<PromptInputV2Composer controller={promptInputV2Controller} />
|
||||
<Show when={projectController.empty()}>
|
||||
<PromptProjectAddButton controller={projectController} />
|
||||
</Show>
|
||||
<Show when={projectController.selected()}>
|
||||
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
|
||||
<PromptProjectSelector controller={projectController} placement="bottom" />
|
||||
<Show
|
||||
when={showWorkspaceBar()}
|
||||
fallback={<PromptGitStatus branch={selectedBranch()} noGit={sync().project?.vcs !== "git"} />}
|
||||
>
|
||||
<PromptProjectSelector
|
||||
controller={projectController}
|
||||
placement={showWorkspaceBar() ? "bottom" : "bottom-start"}
|
||||
<PromptWorkspaceSelector
|
||||
value={newSessionWorktree()}
|
||||
projectRoot={projectRoot()}
|
||||
workspaces={sync().project?.sandboxes ?? []}
|
||||
branch={selectedBranch()}
|
||||
onChange={(value) =>
|
||||
setStore(
|
||||
"worktree",
|
||||
value === "main" && sync().project?.worktree !== sdk().directory
|
||||
? sync().project?.worktree
|
||||
: value,
|
||||
)
|
||||
}
|
||||
onDone={promptInputV2Controller.restoreFocus}
|
||||
/>
|
||||
<Show when={showWorkspaceBar()}>
|
||||
<PromptWorkspaceSelector
|
||||
value={newSessionWorktree()}
|
||||
projectRoot={projectRoot()}
|
||||
workspaces={sync().project?.sandboxes ?? []}
|
||||
branch={selectedBranch()}
|
||||
onChange={(value) =>
|
||||
setStore(
|
||||
"worktree",
|
||||
value === "main" && sync().project?.worktree !== sdk().directory
|
||||
? sync().project?.worktree
|
||||
: value,
|
||||
)
|
||||
}
|
||||
onDone={() => inputRef?.focus()}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
{/*</Show>*/}
|
||||
</div>
|
||||
</NewSessionDesignView>
|
||||
<ProviderTip
|
||||
ready={() => serverSync().child(sdk().directory)[0].provider_ready}
|
||||
connected={() => providers.paid().length > 0}
|
||||
openProviders={openProviderSettings}
|
||||
openProviders={openProviders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ import { useSync } from "@/context/sync"
|
|||
import { useTabs } from "@/context/tabs"
|
||||
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||
import { promptLength } from "@/components/prompt-input/history"
|
||||
|
|
@ -665,7 +666,8 @@ export default function Page() {
|
|||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||
const wantsReview = createMemo(() =>
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
? desktopFileTreeOpen() ||
|
||||
(desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab())))
|
||||
: store.mobileTab === "changes",
|
||||
)
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
|
|
@ -2006,78 +2008,6 @@ export default function Page() {
|
|||
|
||||
useUsageExceededDialogs()
|
||||
|
||||
const composerRegion = () => {
|
||||
const controller = createSessionComposerRegionController({
|
||||
state: composer,
|
||||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
prompt,
|
||||
centered,
|
||||
followup: () =>
|
||||
params.id && !isChildSession()
|
||||
? {
|
||||
items: followupDock(),
|
||||
sending: sendingFollowup(),
|
||||
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
|
||||
onEdit: editFollowup,
|
||||
}
|
||||
: undefined,
|
||||
revert: () =>
|
||||
rolled().length > 0
|
||||
? {
|
||||
items: rolled(),
|
||||
restoring: restoring(),
|
||||
disabled: reverting(),
|
||||
onRestore: restore,
|
||||
}
|
||||
: undefined,
|
||||
onResponseSubmit: resumeScroll,
|
||||
openParent: () => {
|
||||
const id = info()?.parentID
|
||||
if (!id) return
|
||||
navigate(
|
||||
params.serverKey
|
||||
? sessionHref(requireServerKey(params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
},
|
||||
setPromptRef: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
setDockRef: (el) => {
|
||||
promptDock = el
|
||||
},
|
||||
})
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={controller}
|
||||
promptInput={
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||
onSubmit={() => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
}}
|
||||
edit={editingFollowup()}
|
||||
onEditLoaded={clearFollowupEdit}
|
||||
shouldQueue={queueEnabled}
|
||||
onQueue={queueFollowup}
|
||||
onAbort={() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setFollowup("paused", id, true)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const mobileTabs = (compact = false, bottom = false) => (
|
||||
<Tabs value={store.mobileTab} class="h-auto">
|
||||
<Tabs.List
|
||||
|
|
@ -2192,7 +2122,118 @@ export default function Page() {
|
|||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>
|
||||
{(_) => {
|
||||
const controller = createSessionComposerRegionController({
|
||||
state: composer,
|
||||
sessionKey,
|
||||
sessionID: () => params.id,
|
||||
prompt,
|
||||
centered,
|
||||
followup: () =>
|
||||
params.id && !isChildSession()
|
||||
? {
|
||||
items: followupDock(),
|
||||
sending: sendingFollowup(),
|
||||
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
|
||||
onEdit: editFollowup,
|
||||
}
|
||||
: undefined,
|
||||
revert: () =>
|
||||
rolled().length > 0
|
||||
? {
|
||||
items: rolled(),
|
||||
restoring: restoring(),
|
||||
disabled: reverting(),
|
||||
onRestore: restore,
|
||||
}
|
||||
: undefined,
|
||||
onResponseSubmit: resumeScroll,
|
||||
openParent: () => {
|
||||
const id = info()?.parentID
|
||||
if (!id) return
|
||||
navigate(
|
||||
params.serverKey
|
||||
? sessionHref(requireServerKey(params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
)
|
||||
},
|
||||
setPromptRef: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
setDockRef: (el) => {
|
||||
promptDock = el
|
||||
},
|
||||
})
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={controller}
|
||||
promptInput={
|
||||
<Show
|
||||
when={newSessionDesign()}
|
||||
fallback={
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
}}
|
||||
newSessionWorktree={newSessionWorktree()}
|
||||
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
|
||||
onSubmit={() => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
}}
|
||||
edit={editingFollowup()}
|
||||
onEditLoaded={clearFollowupEdit}
|
||||
shouldQueue={queueEnabled}
|
||||
onQueue={queueFollowup}
|
||||
onAbort={() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setFollowup("paused", id, true)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(_) => {
|
||||
const controller = usePromptInputV2Controller({
|
||||
get controls() {
|
||||
return inputController()
|
||||
},
|
||||
ref: (el) => {
|
||||
inputRef = el
|
||||
},
|
||||
get newSessionWorktree() {
|
||||
return newSessionWorktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
|
||||
onSubmit: () => {
|
||||
comments.clear()
|
||||
resumeScroll()
|
||||
},
|
||||
shouldQueue: queueEnabled,
|
||||
onQueue: queueFollowup,
|
||||
onAbort: () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
setFollowup("paused", id, true)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<PromptInputV2Composer
|
||||
controller={controller}
|
||||
borderUnderlay
|
||||
edit={editingFollowup()}
|
||||
onEditLoaded={clearFollowupEdit}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||
</>
|
||||
)
|
||||
|
|
@ -2282,6 +2323,9 @@ export default function Page() {
|
|||
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanelV2}
|
||||
diffVersion={vcsQuery.dataUpdatedAt}
|
||||
loadDiff={loadReviewDiff}
|
||||
expandUnchanged={reviewV2State.expandMode() === "expand"}
|
||||
reviewSidebarToggle={(disabled) => (
|
||||
<SessionReviewV2SidebarToggle
|
||||
opened={reviewV2State.sidebarOpened()}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
|
|||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import type { PromptInputControls } from "@/components/prompt-input"
|
||||
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
||||
import type { PromptProjectControls } from "@/components/prompt-project-selector"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useGlobal } from "@/context/global"
|
||||
|
|
@ -12,7 +12,6 @@ import type { QueryOptionsApi } from "@/context/server-sync"
|
|||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { serverName, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
|
@ -27,7 +26,6 @@ export function createPromptInputController(input: {
|
|||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const providers = useProviders()
|
||||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const view = layout.view(input.sessionKey)
|
||||
|
|
@ -54,7 +52,6 @@ export function createPromptInputController(input: {
|
|||
tabs: layout.tabs(input.sessionKey),
|
||||
reviewPanel: view.reviewPanel,
|
||||
},
|
||||
newLayoutDesigns: settings.general.newLayoutDesigns(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DockShell } from "@opencode-ai/ui/dock-surface"
|
||||
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
|
||||
|
|
@ -78,12 +79,17 @@ function Stage(props: { count: number }) {
|
|||
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
|
||||
<div style={{ display: "flex", "flex-direction": "column" }}>
|
||||
<SessionRevertDock items={store.items} onRestore={restore} />
|
||||
<div
|
||||
<DockShell
|
||||
data-dock-border-underlay={v2() ? "v2" : "legacy"}
|
||||
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
|
||||
class="min-h-24 w-full rounded-[12px] border border-v2-border-border-base bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
|
||||
classList={{
|
||||
"min-h-24 w-full rounded-[12px] px-4 py-3 text-[13px]": true,
|
||||
"bg-v2-background-bg-base text-v2-text-text-faint": v2(),
|
||||
"text-text-weak": !v2(),
|
||||
}}
|
||||
>
|
||||
Ask anything...
|
||||
</div>
|
||||
</DockShell>
|
||||
</div>
|
||||
|
||||
<div class="text-[12px] text-v2-text-text-faint">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
|
|
@ -8,6 +8,7 @@ import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/sessi
|
|||
import { createLineCommentController } from "@opencode-ai/session-ui/line-comment-annotations"
|
||||
import { createLineCommentControllerV2 } from "@opencode-ai/session-ui/v2/line-comment-annotations-v2"
|
||||
import { sampledChecksum } from "@opencode-ai/core/util/encode"
|
||||
import { normalize, text } from "@opencode-ai/session-ui/session-diff"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
|
||||
|
|
@ -18,11 +19,23 @@ import { showToast } from "@/utils/toast"
|
|||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { getSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { reviewDiffNeedsLoad, type RenderDiff } from "@/pages/session/v2/review-diff-kinds"
|
||||
|
||||
type SessionFileViewProps = {
|
||||
tab: string
|
||||
diff?: RenderDiff
|
||||
diffVersion?: number
|
||||
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
|
||||
expandUnchanged?: boolean
|
||||
}
|
||||
|
||||
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
|
||||
|
||||
function FileCommentMenu(props: {
|
||||
moreLabel: string
|
||||
|
|
@ -207,11 +220,27 @@ export function FileTabContent(props: { tab: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function SessionFileView(props: { tab: string }) {
|
||||
export function SessionFileView(props: SessionFileViewProps) {
|
||||
const settings = useSettings()
|
||||
const detailSource = createMemo(() => {
|
||||
if (!props.diff || !props.loadDiff || !reviewDiffNeedsLoad(props.diff)) return
|
||||
return { diff: props.diff, load: props.loadDiff, version: props.diffVersion }
|
||||
})
|
||||
const [loadedDiff] = createResource(detailSource, async ({ diff, load, version }) => {
|
||||
const value = await load(diff.file, version)
|
||||
if (value?.file !== diff.file) return
|
||||
return { source: diff, version, value }
|
||||
})
|
||||
const diff = createMemo(() => {
|
||||
const source = props.diff
|
||||
if (!source) return
|
||||
const loaded = loadedDiff()
|
||||
return normalize(loaded?.source === source && loaded.version === props.diffVersion ? loaded.value : source)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={settings.general.newLayoutDesigns()} fallback={<SessionFileViewV1 tab={props.tab} />}>
|
||||
<SessionFileViewV2 tab={props.tab} />
|
||||
<SessionFileViewV2 tab={props.tab} diff={diff()} expandUnchanged={props.expandUnchanged} />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
|
@ -501,11 +530,12 @@ function SessionFileViewV1(props: { tab: string }) {
|
|||
return content()
|
||||
}
|
||||
|
||||
function SessionFileViewV2(props: { tab: string }) {
|
||||
function SessionFileViewV2(props: { tab: string; diff?: ReturnType<typeof normalize>; expandUnchanged?: boolean }) {
|
||||
const file = useFile()
|
||||
const comments = useComments()
|
||||
const language = useLanguage()
|
||||
const prompt = usePrompt()
|
||||
const layout = useLayout()
|
||||
const fileComponent = useFileComponent()
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
const activeFileTab = createSessionTabs({
|
||||
|
|
@ -548,10 +578,15 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||
})
|
||||
}
|
||||
|
||||
const buildPreview = (filePath: string, selection: FileSelection) => {
|
||||
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
|
||||
const buildPreview = (filePath: string, lines: SelectedLineRange) => {
|
||||
const source =
|
||||
filePath === path()
|
||||
? props.diff
|
||||
? text(props.diff, selectionSide(lines))
|
||||
: contents()
|
||||
: file.get(filePath)?.content?.content
|
||||
if (!source) return undefined
|
||||
return selectionPreview(source, selection)
|
||||
return selectionPreview(source, selectionFromLines(lines))
|
||||
}
|
||||
|
||||
const addCommentToContext = (input: {
|
||||
|
|
@ -562,7 +597,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||
origin?: "review" | "file"
|
||||
}) => {
|
||||
const selection = selectionFromLines(input.selection)
|
||||
const preview = input.preview ?? buildPreview(input.file, selection)
|
||||
const preview = input.preview ?? buildPreview(input.file, input.selection)
|
||||
|
||||
const saved = comments.add({
|
||||
file: input.file,
|
||||
|
|
@ -587,7 +622,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||
comment: string
|
||||
}) => {
|
||||
comments.update(input.file, input.id, input.comment)
|
||||
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
|
||||
const preview = input.file === path() ? buildPreview(input.file, input.selection) : undefined
|
||||
prompt.context.updateComment(input.file, input.id, {
|
||||
comment: input.comment,
|
||||
...(preview ? { preview } : {}),
|
||||
|
|
@ -628,7 +663,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||
mention: {
|
||||
items: file.searchFilesAndDirectories,
|
||||
},
|
||||
getSide: (range) => range.endSide ?? range.side ?? "additions",
|
||||
getSide: selectionSide,
|
||||
state: {
|
||||
opened: () => note.openedComment,
|
||||
setOpened: (id) => setNote("openedComment", id),
|
||||
|
|
@ -726,12 +761,21 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||
<div class="relative overflow-hidden pb-40">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: path() ?? "",
|
||||
contents: source,
|
||||
cacheKey: cacheKey(),
|
||||
}}
|
||||
{...(props.diff
|
||||
? {
|
||||
mode: "diff" as const,
|
||||
fileDiff: props.diff.fileDiff,
|
||||
diffStyle: layout.review.diffStyle(),
|
||||
expandUnchanged: props.expandUnchanged,
|
||||
}
|
||||
: {
|
||||
mode: "text" as const,
|
||||
file: {
|
||||
name: path() ?? "",
|
||||
contents: source,
|
||||
cacheKey: cacheKey(),
|
||||
},
|
||||
})}
|
||||
enableLineSelection
|
||||
enableGutterUtility
|
||||
selectedLines={activeSelection()}
|
||||
|
|
@ -761,6 +805,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||
media={{
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
deleted: props.diff?.status === "deleted",
|
||||
current: state()?.content,
|
||||
onLoad: scrollSync.queueRestore,
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
|
|
@ -779,6 +824,7 @@ function SessionFileViewV2(props: { tab: string }) {
|
|||
<div class="mt-3 relative h-full min-h-0">
|
||||
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
||||
<Switch>
|
||||
<Match when={props.diff}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import {
|
|||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
|
||||
import type { RenderDiff } from "@/pages/session/v2/review-diff-kinds"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
|
|
@ -65,6 +66,9 @@ export function SessionSidePanel(props: {
|
|||
reviewHasFocusableContent: () => boolean
|
||||
reviewCount: () => number
|
||||
reviewPanel: () => JSX.Element
|
||||
diffVersion?: number
|
||||
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
|
||||
expandUnchanged?: boolean
|
||||
reviewSidebarToggle?: (disabled: boolean) => JSX.Element
|
||||
fileBrowserState?: SessionFileBrowserState
|
||||
activeDiff?: string
|
||||
|
|
@ -82,6 +86,11 @@ export function SessionSidePanel(props: {
|
|||
const sdk = useSDK()
|
||||
const { sessionKey, tabs, view, params } = useSessionLayout()
|
||||
const projectDirectory = createMemo(() => sdk().directory)
|
||||
const diffForTab = (tab: string) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return
|
||||
return props.diffs().find((diff) => diff.file === path)
|
||||
}
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const shown = settings.visibility.fileTree
|
||||
|
|
@ -231,6 +240,7 @@ export function SessionSidePanel(props: {
|
|||
return active !== "review" && active !== "context" && active !== "empty"
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const [store, setStore] = createStore({
|
||||
activeDraggable: undefined as string | undefined,
|
||||
})
|
||||
|
|
@ -544,7 +554,7 @@ export function SessionSidePanel(props: {
|
|||
>
|
||||
<Show when={props.reviewSidebarToggle}>
|
||||
{(toggle) => (
|
||||
<div class="h-full shrink-0 flex items-center justify-center">
|
||||
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky left-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
{toggle()(activeTab() === SESSION_OPEN_FILE_TAB)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -564,9 +574,15 @@ export function SessionSidePanel(props: {
|
|||
<Tabs.Trigger
|
||||
value="context"
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
<TooltipV2
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
|
|
@ -577,7 +593,7 @@ export function SessionSidePanel(props: {
|
|||
onClick={() => tabs().close("context")}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => tabs().close("context")}
|
||||
|
|
@ -605,9 +621,15 @@ export function SessionSidePanel(props: {
|
|||
<Tabs.Trigger
|
||||
value={SESSION_OPEN_FILE_TAB}
|
||||
closeButton={
|
||||
<TooltipKeybind
|
||||
title={language.t("common.closeTab")}
|
||||
keybind={command.keybind("tab.close")}
|
||||
<TooltipV2
|
||||
value={
|
||||
<>
|
||||
{language.t("common.closeTab")}
|
||||
<Show when={closeTabKeybind().length > 0}>
|
||||
<KeybindV2 keys={closeTabKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
|
|
@ -618,7 +640,7 @@ export function SessionSidePanel(props: {
|
|||
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||
|
|
@ -720,6 +742,10 @@ export function SessionSidePanel(props: {
|
|||
active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")}
|
||||
kinds={kinds()}
|
||||
state={props.fileBrowserState!}
|
||||
diff={diffForTab(browserTab() ?? activeFileTab() ?? "")}
|
||||
diffVersion={props.diffVersion}
|
||||
loadDiff={props.loadDiff}
|
||||
expandUnchanged={props.expandUnchanged}
|
||||
onSelect={(path) => previewTab(file.tab(path))}
|
||||
onSelectPermanent={(path) => openTab(file.tab(path))}
|
||||
filterRef={(element) => (fileFilter = element)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { AssistantMessage, Part, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||
|
||||
export { TimelineRow, type SummaryDiff } from "./timeline-row"
|
||||
|
||||
|
|
@ -137,14 +138,7 @@ export namespace Timeline {
|
|||
|
||||
if (isActive && status === "retry") rows.push(new TimelineRow.Retry({ userMessageID: userMessage.id }))
|
||||
|
||||
const diffs = (userMessage.summary?.diffs ?? [])
|
||||
.reduceRight<SummaryDiff[]>((result, diff) => {
|
||||
if (!isSummaryDiff(diff)) return result
|
||||
if (result.some((item) => item.file === diff.file)) return result
|
||||
result.push(diff)
|
||||
return result
|
||||
}, [])
|
||||
.reverse()
|
||||
const diffs = uniqueSummaryDiffs(userMessage.summary?.diffs)
|
||||
if (diffs.length > 0 && (status === "idle" || !isActive)) {
|
||||
rows.push(
|
||||
new TimelineRow.DiffSummary({
|
||||
|
|
@ -169,10 +163,6 @@ export namespace Timeline {
|
|||
return rows
|
||||
}
|
||||
|
||||
function isSummaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
function reasoningHeading(text: string) {
|
||||
const markdown = text.replace(/\r\n?/g, "\n")
|
||||
const html = markdown.match(/<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/i)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { uniqueSummaryDiffs } from "./summary-diffs"
|
||||
|
||||
const diff = (file: string, additions: number) =>
|
||||
({
|
||||
file,
|
||||
additions,
|
||||
deletions: 0,
|
||||
}) satisfies SnapshotFileDiff
|
||||
|
||||
describe("uniqueSummaryDiffs", () => {
|
||||
test("drops entries without files and preserves unique input", () => {
|
||||
const alpha = diff("alpha.ts", 1)
|
||||
const beta = diff("beta.ts", 1)
|
||||
const invalid = { additions: 1, deletions: 0 } satisfies SnapshotFileDiff
|
||||
|
||||
expect(uniqueSummaryDiffs(undefined)).toEqual([])
|
||||
expect(uniqueSummaryDiffs([])).toEqual([])
|
||||
expect(uniqueSummaryDiffs([invalid])).toEqual([])
|
||||
|
||||
const result = uniqueSummaryDiffs([alpha, invalid, beta])
|
||||
expect(result).toEqual([alpha, beta])
|
||||
expect(result[0]).toBe(alpha)
|
||||
expect(result[1]).toBe(beta)
|
||||
})
|
||||
|
||||
test("keeps the last diff per file in the legacy display order", () => {
|
||||
const oldAlpha = diff("alpha.ts", 1)
|
||||
const oldBeta = diff("beta.ts", 1)
|
||||
const newAlpha = diff("alpha.ts", 2)
|
||||
const charlie = diff("charlie.ts", 1)
|
||||
const newBeta = diff("beta.ts", 2)
|
||||
|
||||
const result = uniqueSummaryDiffs([oldAlpha, oldBeta, newAlpha, charlie, newBeta])
|
||||
|
||||
expect(result).toEqual([newAlpha, charlie, newBeta])
|
||||
expect(result[0]).toBe(newAlpha)
|
||||
expect(result[1]).toBe(charlie)
|
||||
expect(result[2]).toBe(newBeta)
|
||||
})
|
||||
})
|
||||
20
packages/app/src/pages/session/timeline/summary-diffs.ts
Normal file
20
packages/app/src/pages/session/timeline/summary-diffs.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SummaryDiff } from "./timeline-row"
|
||||
|
||||
export function uniqueSummaryDiffs(diffs: SnapshotFileDiff[] | undefined) {
|
||||
const files = new Set<string>()
|
||||
return (diffs ?? [])
|
||||
.reduceRight<SummaryDiff[]>((result, diff) => {
|
||||
if (!isSummaryDiff(diff)) return result
|
||||
const file = diff.file
|
||||
if (files.has(file)) return result
|
||||
files.add(file)
|
||||
result.push(diff)
|
||||
return result
|
||||
}, [])
|
||||
.reverse()
|
||||
}
|
||||
|
||||
function isSummaryDiff(diff: SnapshotFileDiff): diff is SummaryDiff {
|
||||
return typeof diff.file === "string"
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { useSDK } from "@/context/sdk"
|
|||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { SessionFileView } from "@/pages/session/file-tabs"
|
||||
import type { RenderDiff } from "@/pages/session/v2/review-diff-kinds"
|
||||
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
|
|
@ -30,6 +31,10 @@ export function SessionFileBrowserTab(props: {
|
|||
active?: string
|
||||
kinds: ReadonlyMap<string, Kind>
|
||||
state: SessionFileBrowserState
|
||||
diff?: RenderDiff
|
||||
diffVersion?: number
|
||||
loadDiff?: (path: string, version?: number) => Promise<RenderDiff | undefined>
|
||||
expandUnchanged?: boolean
|
||||
onSelect: (path: string) => void
|
||||
onSelectPermanent: (path: string) => void
|
||||
filterRef?: (element: HTMLInputElement) => void
|
||||
|
|
@ -172,7 +177,15 @@ export function SessionFileBrowserTab(props: {
|
|||
>
|
||||
<div class="min-h-0 flex-1">
|
||||
<Show when={props.tab} keyed>
|
||||
{(tab) => <SessionFileView tab={tab} />}
|
||||
{(tab) => (
|
||||
<SessionFileView
|
||||
tab={tab}
|
||||
diff={props.diff}
|
||||
diffVersion={props.diffVersion}
|
||||
loadDiff={props.loadDiff}
|
||||
expandUnchanged={props.expandUnchanged}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ test("resets transient prompt input state when the prompt session changes", () =
|
|||
draggingType: "image",
|
||||
mode: "shell",
|
||||
applyingHistory: true,
|
||||
variantOpen: true,
|
||||
})
|
||||
|
||||
setIdentity("B")
|
||||
|
|
@ -33,7 +32,6 @@ test("resets transient prompt input state when the prompt session changes", () =
|
|||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
variantOpen: false,
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue