mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-07 15:58:23 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
287ea33fda | ||
|
|
e1b7299bc9 | ||
|
|
68aa4f8017 | ||
|
|
69219470a2 | ||
|
|
10b11ba946 | ||
|
|
2e3d3a735a | ||
|
|
f655e7b2b5 | ||
|
|
6273bd7924 | ||
|
|
34cf183c4c | ||
|
|
fd99516e51 |
No files matched your search
@@ -285,7 +285,7 @@ const AnthropicThinkingEnabled = Schema.Struct({
|
||||
...AnthropicThinkingFields,
|
||||
})
|
||||
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
|
||||
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
|
||||
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.Literals(["disabled", "between_tools"]) })
|
||||
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
type AnthropicThinking = typeof AnthropicThinking.Type
|
||||
|
||||
@@ -1001,18 +1001,21 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
|
||||
// TODO: Move per-model capability heuristics (`supportsEffortUpdates`, `supportsNativeSystemUpdates`,
|
||||
// `supportsThinkingBlockBinding`) into explicit model/provider `compatibility` metadata so the protocol
|
||||
// only reads `request.model.compatibility`.
|
||||
const isThinkingOff = Schema.is(AnthropicThinkingDisabled)
|
||||
|
||||
// Per-turn effort started with Claude Opus 5 and every Claude 5.1 model; later versions of any family inherit it.
|
||||
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsEffortUpdates
|
||||
const supportsEffortUpdates = (request: LLMRequest) => {
|
||||
if (isThinkingOff(request.providerOptions?.thinking)) return false
|
||||
const override = request.model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
const version = claudeVersion(model.id)
|
||||
const version = claudeVersion(request.model.id)
|
||||
if (version === undefined) return false
|
||||
if (version.family === "opus" && version.major >= 5) return true
|
||||
return version.major > 5 || (version.major === 5 && version.minor >= 1)
|
||||
}
|
||||
|
||||
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
|
||||
if (thinking?.type === "disabled") return thinking
|
||||
if (isThinkingOff(thinking)) return thinking
|
||||
if (!supportsThinkingBlockBinding(model)) return thinking
|
||||
return {
|
||||
...(thinking ?? { type: "adaptive" as const }),
|
||||
@@ -1607,7 +1610,7 @@ export const protocol = Protocol.make({
|
||||
}),
|
||||
step,
|
||||
},
|
||||
supportsEffortUpdates: (request) => supportsEffortUpdates(request.model),
|
||||
supportsEffortUpdates,
|
||||
})
|
||||
|
||||
export const transport = <
|
||||
@@ -1653,7 +1656,7 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
|
||||
betas.push("mid-conversation-output-config-2026-07-01")
|
||||
|
||||
const thinking = body.thinking
|
||||
if (thinking && thinking.type !== "disabled" && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
|
||||
if (thinking && !isThinkingOff(thinking) && thinking.block_binding) betas.push(THINKING_BINDING_BETA)
|
||||
return betas
|
||||
}
|
||||
|
||||
|
||||
@@ -476,7 +476,9 @@ const MIN_THINKING_BUDGET = 1_024
|
||||
|
||||
const isThinkingDisabled = Schema.is(
|
||||
Schema.Struct({
|
||||
additionalModelRequestFields: Schema.Struct({ thinking: Schema.Struct({ type: Schema.Literal("disabled") }) }),
|
||||
additionalModelRequestFields: Schema.Struct({
|
||||
thinking: Schema.Struct({ type: Schema.Literals(["disabled", "between_tools"]) }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ const messagesRoute = Route.make({
|
||||
...AnthropicMessages.protocol,
|
||||
// Mantle rejects mid-conversation `output_config` on Opus 5.0; support starts at 5.1+.
|
||||
supportsEffortUpdates: (request) => {
|
||||
const override = request.model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
if (!(AnthropicMessages.protocol.supportsEffortUpdates?.(request) ?? false)) return false
|
||||
if (request.model.compatibility?.supportsEffortUpdates !== undefined) return true
|
||||
const version = claudeVersion(request.model.id)
|
||||
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
|
||||
},
|
||||
|
||||
@@ -268,6 +268,22 @@ describe("Anthropic Messages effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips markers when thinking is disabled or between_tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const sonnet = anthropic("claude-sonnet-5-5")
|
||||
const betweenTools = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: sonnet,
|
||||
messages: conversation,
|
||||
providerOptions: { thinking: { type: "between_tools" }, effort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(systemMessages(betweenTools.body)).toHaveLength(0)
|
||||
expect(betweenTools.body.output_config).toEqual({ effort: "low" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips markers for Opus 5.0 on Bedrock Mantle Messages while lowering Opus 5.5", () =>
|
||||
Effect.gen(function* () {
|
||||
const mantle = AmazonBedrockMantle.configure({ apiKey: "test", region: "us-east-1" })
|
||||
|
||||
@@ -44,6 +44,7 @@ it.effect("preserves explicit thinking settings and combines required beta heade
|
||||
Effect.gen(function* () {
|
||||
for (const thinking of [
|
||||
{ type: "disabled" },
|
||||
{ type: "between_tools" },
|
||||
{ type: "adaptive", block_binding: { prefix_mismatch_behavior: "error" } },
|
||||
] as const) {
|
||||
const request = LLM.request({
|
||||
@@ -55,7 +56,7 @@ it.effect("preserves explicit thinking settings and combines required beta heade
|
||||
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
|
||||
expect(compiled.body.thinking).toEqual(thinking)
|
||||
expect(prepared.request.headers["anthropic-beta"]).toBe(
|
||||
thinking.type === "disabled"
|
||||
thinking.type === "disabled" || thinking.type === "between_tools"
|
||||
? "interleaved-thinking-2025-05-14,compact-2026-01-12"
|
||||
: "interleaved-thinking-2025-05-14,compact-2026-01-12,thinking-binding-controls-2026-08-01",
|
||||
)
|
||||
|
||||
@@ -38,6 +38,16 @@ test("home commands do not wait for session search", async ({ page }) => {
|
||||
release.resolve()
|
||||
})
|
||||
|
||||
test("lists recent sessions before a query is entered", async ({ page }) => {
|
||||
const { dialog } = await openCommandPalette(page, true)
|
||||
const session = dialog.getByRole("option", { name: /Palette fixture session/ })
|
||||
await expect(dialog.getByText("Recent sessions", { exact: true })).toBeVisible()
|
||||
await expect(session).toHaveAttribute("aria-selected", "false")
|
||||
await session.click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("appends search results without resetting the selected command", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const files = Promise.withResolvers<void>()
|
||||
|
||||
@@ -106,6 +106,37 @@ for (const mount of ["cached", "explicit"] as const) {
|
||||
})
|
||||
}
|
||||
|
||||
test("reconnects the same terminal after a transient connection failure", async ({ page }) => {
|
||||
const { pty } = await openSession(page, workspace)
|
||||
let tickets = 0
|
||||
await page.route(/\/api\/pty\/[^/]+\/connect-token/, (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
tickets += 1
|
||||
|
||||
if (tickets !== 2) return route.fallback()
|
||||
|
||||
return route.fulfill({
|
||||
status: 503,
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: "Service Unavailable",
|
||||
})
|
||||
})
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await expect.poll(() => pty.sockets.length).toBe(1)
|
||||
const id = pty.sockets[0]!.id
|
||||
await terminal.evaluate((element) => element.setAttribute("data-connection-probe", "original"))
|
||||
|
||||
await pty.sockets[0]!.close(1011, "Temporary disconnection")
|
||||
await expect.poll(() => tickets).toBe(3)
|
||||
await expect.poll(() => pty.sockets.length).toBe(2)
|
||||
expect(pty.sockets[1]!.id).toBe(id)
|
||||
expect(pty.rejected).toEqual([])
|
||||
await expect(terminal).toHaveAttribute("data-connection-probe", "original")
|
||||
expect(pty.created.map((item) => item.id)).toEqual([id])
|
||||
})
|
||||
|
||||
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||
const { editor } = await openSession(page, workspace)
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
|
||||
@@ -63,6 +63,12 @@ export class MockShellNotFound extends Schema.TaggedError<MockShellNotFound>()("
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
// The server's error for an unknown PTY, or one owned by another workspace.
|
||||
export class MockPtyNotFound extends Schema.TaggedError<MockPtyNotFound>()("PtyNotFoundError", {
|
||||
ptyID: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
// The server's error for a request without its password.
|
||||
export class MockUnauthorized extends Schema.TaggedError<MockUnauthorized>()("UnauthorizedError", {
|
||||
message: Schema.String,
|
||||
@@ -75,6 +81,8 @@ export class MockUnsupported extends Schema.TaggedError<MockUnsupported>()("Mock
|
||||
|
||||
const Unsupported = MockUnsupported.pipe(HttpApiSchema.status(501))
|
||||
|
||||
const PtyMissing = [MockNotFound.pipe(HttpApiSchema.status(404)), MockPtyNotFound.pipe(HttpApiSchema.status(404))]
|
||||
|
||||
const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
|
||||
.add(
|
||||
@@ -242,7 +250,7 @@ const Group = HttpApiGroup.make("mock")
|
||||
HttpApiEndpoint.get("ptyGet", "/api/pty/:ptyID", {
|
||||
params: PtyParams,
|
||||
success: Json,
|
||||
error: MockNotFound.pipe(HttpApiSchema.status(404)),
|
||||
error: PtyMissing,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
@@ -250,21 +258,21 @@ const Group = HttpApiGroup.make("mock")
|
||||
params: PtyParams,
|
||||
payload: Pty.UpdateInput,
|
||||
success: Json,
|
||||
error: MockNotFound.pipe(HttpApiSchema.status(404)),
|
||||
error: PtyMissing,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("ptyRemove", "/api/pty/:ptyID", {
|
||||
params: PtyParams,
|
||||
success: NoContent,
|
||||
error: MockNotFound.pipe(HttpApiSchema.status(404)),
|
||||
error: PtyMissing,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("ptyConnectToken", "/api/pty/:ptyID/connect-token", {
|
||||
params: PtyParams,
|
||||
success: Json,
|
||||
error: MockNotFound.pipe(HttpApiSchema.status(404)),
|
||||
error: PtyMissing,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MockBadRequest,
|
||||
MockInternal,
|
||||
MockNotFound,
|
||||
MockPtyNotFound,
|
||||
MockShellNotFound,
|
||||
MockUnauthorized,
|
||||
MockUnsupported,
|
||||
@@ -149,7 +150,14 @@ export type MockPtyInfo = {
|
||||
pid: number
|
||||
}
|
||||
|
||||
export type MockPtySocket = { id: string; url: URL; input: string[]; closed: boolean; send(data: string): void }
|
||||
export type MockPtySocket = {
|
||||
id: string
|
||||
url: URL
|
||||
input: string[]
|
||||
closed: boolean
|
||||
send(data: string): void
|
||||
close(code: number, reason: string): Promise<void>
|
||||
}
|
||||
|
||||
export type MockPty = {
|
||||
list: MockPtyInfo[]
|
||||
@@ -447,6 +455,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
input: [],
|
||||
closed: false,
|
||||
send: (data) => ws.send(data),
|
||||
close: (code, reason) => ws.close({ code, reason }),
|
||||
}
|
||||
|
||||
ws.onMessage((message) => socket.input.push(message.toString()))
|
||||
@@ -697,7 +706,9 @@ function mockHandlers(
|
||||
const directory = requestDirectory(config, request)
|
||||
const found = state.pty.find(id, directory)
|
||||
|
||||
return found ? Effect.succeed(found) : Effect.fail(new MockNotFound({ message: "PTY not found" }))
|
||||
return found
|
||||
? Effect.succeed(found)
|
||||
: Effect.fail(new MockPtyNotFound({ ptyID: id, message: `PTY not found: ${id}` }))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createCommandPaletteCommandEntry,
|
||||
createServerSessionEntries,
|
||||
type CommandPaletteEntry,
|
||||
type PaletteHighlight,
|
||||
} from "@/shell/commands/palette"
|
||||
import { CommandPaletteView, matchesCommandPaletteEntry } from "@/shell/commands/dialog"
|
||||
|
||||
@@ -20,7 +21,7 @@ export function HomeCommandPalette(props: {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const server = global.ensureServerCtx(props.server)
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const state: PaletteHighlight = { cleanup: undefined, committed: false }
|
||||
|
||||
const commandEntries = createMemo(() => {
|
||||
const category = language.t("palette.group.commands")
|
||||
@@ -36,6 +37,7 @@ export function HomeCommandPalette(props: {
|
||||
get: (sessionID, signal) => server.sdk.api.session.get({ sessionID }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
recentCategory: () => language.t("palette.group.recentSessions"),
|
||||
})
|
||||
|
||||
const highlight = (item: CommandPaletteEntry | undefined) => {
|
||||
|
||||
@@ -70,7 +70,7 @@ export function NewSessionView(props: {
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
<Show when={props.project.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">
|
||||
<div class="flex min-h-7 min-w-0 flex-row flex-wrap items-center justify-center gap-0 text-v2-text-text-faint">
|
||||
<PromptProjectSelector controller={props.project} placement="bottom" />
|
||||
<Show
|
||||
when={props.workspace.bar.visible()}
|
||||
|
||||
@@ -327,7 +327,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true })))
|
||||
}}
|
||||
>
|
||||
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(element) => {
|
||||
@@ -355,7 +355,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Show when={search.branches.trim()}>
|
||||
<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"
|
||||
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setSearch("branches", "")
|
||||
@@ -363,7 +363,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
}}
|
||||
aria-label={language.t("common.clear")}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
<Icon name="circle-xmark" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useFilteredList } from "@opencode/ui/hooks"
|
||||
@@ -24,7 +22,7 @@ export const DialogManageModels: Component = () => {
|
||||
const local = useLocal()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const [store, setStore] = createStore({ collapsed: {} as Record<string, boolean> })
|
||||
const [store, setStore] = createStore<{ collapsed: Record<string, boolean> }>({ collapsed: {} })
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
@@ -107,33 +105,24 @@ export const DialogManageModels: Component = () => {
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="px-4 pt-px pb-3">
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
type="search"
|
||||
appearance="base"
|
||||
class="!w-full self-stretch"
|
||||
value={list.filter()}
|
||||
onInput={(event) => list.onInput(event.currentTarget.value)}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autofocus
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<Show when={list.filter()}>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-tab-search-clear"
|
||||
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => list.clear()}
|
||||
aria-label={language.t("common.clear")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<TextInput
|
||||
type="search"
|
||||
appearance="base"
|
||||
class="!w-full self-stretch"
|
||||
value={list.filter()}
|
||||
onInput={(event) => list.onInput(event.currentTarget.value)}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autofocus
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
showClearButton={!!list.filter()}
|
||||
clearIcon="circle-xmark"
|
||||
clearLabel={language.t("common.clear")}
|
||||
onClearClick={() => list.clear()}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
|
||||
<div class="settings-panel settings-models h-full px-4 pt-1 pb-4">
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Button } from "@opencode/ui/button"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
@@ -186,55 +185,46 @@ const ModelList: Component<{
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div data-slot="model-selector-search" class="shrink-0 pt-px pb-3" classList={{ "px-4": !props.mobile }}>
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
type="search"
|
||||
appearance="base"
|
||||
class="!w-full self-stretch"
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
value={store.search}
|
||||
autofocus={!props.mobile}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.altKey || event.metaKey) return
|
||||
<TextInput
|
||||
type="search"
|
||||
appearance="base"
|
||||
class="!w-full self-stretch"
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
value={store.search}
|
||||
autofocus={!props.mobile}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.altKey || event.metaKey) return
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
selectActive()
|
||||
}
|
||||
}}
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<Show when={store.search}>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-tab-search-clear"
|
||||
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={language.t("common.clear")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
selectActive()
|
||||
}
|
||||
}}
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
showClearButton={!!store.search}
|
||||
clearIcon="circle-xmark"
|
||||
clearLabel={language.t("common.clear")}
|
||||
onClearClick={() => setSearch("")}
|
||||
/>
|
||||
</div>
|
||||
<div class="relative min-h-0" classList={{ "flex-1": !props.mobile }}>
|
||||
<div
|
||||
@@ -613,7 +603,7 @@ export function ModelSelectorPopoverView(props: {
|
||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||
>
|
||||
<div class="flex flex-col p-0.5">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(el) => (searchRef = el)}
|
||||
@@ -663,12 +653,12 @@ export function ModelSelectorPopoverView(props: {
|
||||
<Show when={store.search.trim()}>
|
||||
<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"
|
||||
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={language.t("common.clear")}
|
||||
>
|
||||
<Icon name="close" size="small" />
|
||||
<Icon name="circle-xmark" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -86,6 +86,7 @@ export const dict = {
|
||||
"palette.empty": "No results found",
|
||||
"palette.group.commands": "Commands",
|
||||
"palette.group.files": "Files",
|
||||
"palette.group.recentSessions": "Recent sessions",
|
||||
|
||||
"dialog.provider.search.placeholder": "Search providers",
|
||||
"dialog.provider.empty": "No providers found",
|
||||
|
||||
@@ -1217,14 +1217,6 @@
|
||||
.settings-project-card[data-menu="true"] {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
.settings-tab-search-clear {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
inset-inline-end: 6px;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.settings-models {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
|
||||
}
|
||||
|
||||
/** The highlighted option's preview cleanup, and whether the palette committed a choice. */
|
||||
type PaletteHighlight = { cleanup: (() => void) | void; committed: boolean }
|
||||
export type PaletteHighlight = { cleanup: (() => void) | void; committed: boolean }
|
||||
|
||||
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||
const command = useCommand()
|
||||
@@ -148,6 +148,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
|
||||
get: (sessionID, signal) => serverSDK.api.session.get({ sessionID }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
recentCategory: () => language.t("palette.group.recentSessions"),
|
||||
})
|
||||
|
||||
const highlight = (item: CommandPaletteEntry | undefined) => {
|
||||
@@ -232,6 +233,7 @@ export function createServerSessionEntries(props: {
|
||||
get: (sessionID: string, signal: AbortSignal) => Promise<SessionInfo>
|
||||
untitled: () => string
|
||||
category: () => string
|
||||
recentCategory: () => string
|
||||
}) {
|
||||
let abort: AbortController | undefined
|
||||
|
||||
@@ -240,17 +242,13 @@ export function createServerSessionEntries(props: {
|
||||
return async (text: string): Promise<CommandPaletteEntry[]> => {
|
||||
const search = text.trim()
|
||||
|
||||
if (!search) {
|
||||
abort?.abort()
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
abort?.abort()
|
||||
const current = new AbortController()
|
||||
abort = current
|
||||
|
||||
// Typed searches wait for a pause; an empty query lists recent sessions right away.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 100)
|
||||
const timer = setTimeout(resolve, search ? 100 : 0)
|
||||
current.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
@@ -264,8 +262,9 @@ export function createServerSessionEntries(props: {
|
||||
if (current.signal.aborted) return []
|
||||
const opened = props.opened()
|
||||
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
||||
const category = search ? props.category() : props.recentCategory()
|
||||
|
||||
return Promise.all([
|
||||
const entries = await Promise.all([
|
||||
props.load(search, current.signal).then(
|
||||
(result) => result.data,
|
||||
() => [],
|
||||
@@ -289,7 +288,7 @@ export function createServerSessionEntries(props: {
|
||||
type: "session" as const,
|
||||
title: session.title || props.untitled(),
|
||||
description: project ? displayName(project) : getFilename(session.location.directory),
|
||||
category: props.category(),
|
||||
category,
|
||||
directory: session.location.directory,
|
||||
sessionID: session.id,
|
||||
server: props.server,
|
||||
@@ -300,5 +299,7 @@ export function createServerSessionEntries(props: {
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return search ? entries : entries.slice(0, ENTRY_LIMIT)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SessionTab, Tab } from "./tabs"
|
||||
import type { SessionTab, Tab, TabInfo } from "./tabs"
|
||||
import type { TabStorage } from "./schema"
|
||||
|
||||
export type ClosedTab = typeof TabStorage.ClosedTab.Type
|
||||
@@ -7,15 +7,43 @@ const CLOSED_TAB_LIMIT = 25
|
||||
|
||||
// Only session tabs are recorded; closing a draft tab deletes its persisted
|
||||
// state, so a reopened draft would come back empty anyway.
|
||||
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] {
|
||||
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number, info?: TabInfo): ClosedTab[] {
|
||||
if (tab.type !== "session") return stack
|
||||
|
||||
return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT)
|
||||
const entry: ClosedTab = { tab: { ...tab }, index }
|
||||
|
||||
if (info) entry.info = { ...info }
|
||||
|
||||
return [...stack.filter((item) => !sameTab(item.tab, tab)), entry].slice(-CLOSED_TAB_LIMIT)
|
||||
}
|
||||
|
||||
export function listClosedTabs(stack: ClosedTab[], tabs: Tab[]) {
|
||||
const seen = new Set<string>()
|
||||
|
||||
return stack.toReversed().filter((entry) => {
|
||||
const key = `${entry.tab.server}\n${entry.tab.sessionId}`
|
||||
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
|
||||
return !isOpen(tabs, entry.tab)
|
||||
})
|
||||
}
|
||||
|
||||
export type TakenClosedTab = { entry?: ClosedTab; stack: ClosedTab[] }
|
||||
|
||||
// Pops the most recently closed tab that is not open again,
|
||||
// discarding stale entries along the way.
|
||||
export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } {
|
||||
export function takeClosedTab(stack: ClosedTab[], tabs: Tab[], target?: SessionTab): TakenClosedTab {
|
||||
if (target) {
|
||||
const index = stack.findLastIndex((entry) => sameTab(entry.tab, target))
|
||||
const entry = stack[index]
|
||||
|
||||
if (!entry || isOpen(tabs, entry.tab)) return { stack }
|
||||
|
||||
return { entry, stack: [...stack.slice(0, index), ...stack.slice(index + 1)] }
|
||||
}
|
||||
|
||||
const remaining = [...stack]
|
||||
|
||||
while (remaining.length) {
|
||||
@@ -40,5 +68,9 @@ export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) {
|
||||
}
|
||||
|
||||
function isOpen(tabs: Tab[], tab: SessionTab) {
|
||||
return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId)
|
||||
return tabs.some((item) => item.type === "session" && sameTab(item, tab))
|
||||
}
|
||||
|
||||
function sameTab(first: SessionTab, second: SessionTab) {
|
||||
return first.server === second.server && first.sessionId === second.sessionId
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export const Recent = Persistence.struct({
|
||||
export const Info = Persistence.struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
directory: Schema.optional(Schema.String),
|
||||
prompted: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
|
||||
@@ -59,7 +60,11 @@ export const Regions = Schema.Record(
|
||||
),
|
||||
)
|
||||
|
||||
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
|
||||
export const ClosedTab = Persistence.struct({
|
||||
tab: SessionCodec,
|
||||
index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
info: Persistence.optional(Info),
|
||||
})
|
||||
|
||||
export const Closed = Persistence.array(ClosedTab)
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createTabMemory } from "./memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
|
||||
import {
|
||||
listClosedTabs,
|
||||
nextTabAfterClose,
|
||||
pushClosedTab,
|
||||
removeClosedTabs,
|
||||
takeClosedTab,
|
||||
type ClosedTab,
|
||||
} from "./closed"
|
||||
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
|
||||
import { Schema } from "effect"
|
||||
import { TabStorage } from "./schema"
|
||||
@@ -144,10 +151,21 @@ describe("tab memory", () => {
|
||||
})
|
||||
|
||||
describe("closed tab stack", () => {
|
||||
test("records session tabs with their index and ignores draft tabs", () => {
|
||||
test("records session tabs with their index", () => {
|
||||
const stack = pushClosedTab([], sessionTab("a"), 2, {
|
||||
title: "Alpha",
|
||||
directory: "/project",
|
||||
prompted: true,
|
||||
})
|
||||
|
||||
expect(stack).toEqual([
|
||||
{ tab: sessionTab("a"), index: 2, info: { title: "Alpha", directory: "/project", prompted: true } },
|
||||
])
|
||||
})
|
||||
|
||||
test("ignores draft tabs", () => {
|
||||
const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" }
|
||||
|
||||
expect(pushClosedTab([], sessionTab("a"), 2)).toEqual([{ tab: sessionTab("a"), index: 2 }])
|
||||
expect(pushClosedTab([], draft, 0)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -162,16 +180,85 @@ describe("closed tab stack", () => {
|
||||
expect(stack.at(-1)?.tab.sessionId).toBe("s29")
|
||||
})
|
||||
|
||||
test("pops the most recently closed tab that is not already open", () => {
|
||||
test("keeps only the newest close record for a session", () => {
|
||||
const stack = pushClosedTab(
|
||||
pushClosedTab([], sessionTab("a"), 1, { title: "Old", directory: "/old", prompted: true }),
|
||||
sessionTab("a"),
|
||||
3,
|
||||
{ title: "New", directory: "/new", prompted: true },
|
||||
)
|
||||
|
||||
expect(stack).toEqual([
|
||||
{ tab: sessionTab("a"), index: 3, info: { title: "New", directory: "/new", prompted: true } },
|
||||
])
|
||||
})
|
||||
|
||||
test("pops the most recently closed tab", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
]
|
||||
|
||||
expect(takeClosedTab(stack, [])).toEqual({ entry: stack[1], stack: [stack[0]] })
|
||||
expect(takeClosedTab(stack, [sessionTab("b")])).toEqual({ entry: stack[0], stack: [] })
|
||||
expect(takeClosedTab(stack, [sessionTab("a"), sessionTab("b")])).toEqual({ entry: undefined, stack: [] })
|
||||
const result = takeClosedTab(stack, [])
|
||||
|
||||
expect(result.entry?.tab.sessionId).toBe("b")
|
||||
expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }])
|
||||
})
|
||||
|
||||
test("skips entries whose tab is already open", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
]
|
||||
|
||||
const result = takeClosedTab(stack, [sessionTab("b")])
|
||||
|
||||
expect(result.entry?.tab.sessionId).toBe("a")
|
||||
expect(result.stack).toEqual([])
|
||||
})
|
||||
|
||||
test("lists closed tabs from newest to oldest and excludes open tabs", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
{ tab: sessionTab("c"), index: 2 },
|
||||
]
|
||||
|
||||
expect(listClosedTabs(stack, [sessionTab("b")]).map((entry) => entry.tab.sessionId)).toEqual(["c", "a"])
|
||||
})
|
||||
|
||||
test("deduplicates previously stored close records", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
{ tab: sessionTab("a"), index: 2 },
|
||||
]
|
||||
|
||||
expect(listClosedTabs(stack, []).map((entry) => [entry.tab.sessionId, entry.index])).toEqual([
|
||||
["a", 2],
|
||||
["b", 1],
|
||||
])
|
||||
})
|
||||
|
||||
test("takes a selected closed tab without removing newer entries", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
{ tab: sessionTab("c"), index: 2 },
|
||||
]
|
||||
|
||||
const result = takeClosedTab(stack, [], sessionTab("a"))
|
||||
|
||||
expect(result.entry?.tab.sessionId).toBe("a")
|
||||
expect(result.stack.map((entry) => entry.tab.sessionId)).toEqual(["b", "c"])
|
||||
})
|
||||
|
||||
test("returns no entry when everything is open or empty", () => {
|
||||
expect(takeClosedTab([], []).entry).toBeUndefined()
|
||||
|
||||
const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")])
|
||||
expect(result.entry).toBeUndefined()
|
||||
expect(result.stack).toEqual([])
|
||||
})
|
||||
|
||||
test("purges removed sessions", () => {
|
||||
|
||||
@@ -416,24 +416,26 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
if (!tab) return
|
||||
|
||||
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
|
||||
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index, info[tabKey(tab)]))
|
||||
removeTab(index)
|
||||
},
|
||||
reopenClosedTab() {
|
||||
reopenClosedTab(target?: SessionTab, options?: { append?: boolean }) {
|
||||
if (!closedReady()) {
|
||||
void closedReady.promise?.then(() => actions.reopenClosedTab())
|
||||
void closedReady.promise?.then(() => actions.reopenClosedTab(target, options))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = takeClosedTab(closed, store)
|
||||
const result = takeClosedTab(closed, store, target)
|
||||
|
||||
if (result.stack.length === closed.length) return
|
||||
setClosed(() => result.stack)
|
||||
const entry = result.entry
|
||||
|
||||
if (!entry) return
|
||||
const index = Math.min(entry.index, store.length)
|
||||
const index = options?.append ? store.length : Math.min(entry.index, store.length)
|
||||
|
||||
if (entry.info) setInfo(tabKey(entry.tab), entry.info)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
@@ -532,12 +534,23 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
for (const key of removed) removeInfo(key)
|
||||
},
|
||||
rememberSessionInfo(tab: SessionTab, session: SessionInfo) {
|
||||
rememberSessionInfo(tab: SessionTab, session: SessionInfo, prompted: boolean) {
|
||||
const key = tabKey(tab)
|
||||
const next = { title: session.title, directory: session.location.directory }
|
||||
const current = info[key]
|
||||
|
||||
if (current && current.title === next.title && current.directory === next.directory) return
|
||||
const next = {
|
||||
title: session.title,
|
||||
directory: session.location.directory,
|
||||
prompted: current?.prompted === true || prompted,
|
||||
}
|
||||
|
||||
if (
|
||||
current &&
|
||||
current.title === next.title &&
|
||||
current.directory === next.directory &&
|
||||
current.prompted === next.prompted
|
||||
)
|
||||
return
|
||||
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
|
||||
setInfo(key, next)
|
||||
},
|
||||
@@ -626,6 +639,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
}
|
||||
|
||||
return { ...actions, store, info, ready, infoReady, recentReady, regionsReady }
|
||||
return { ...actions, store, info, closed, ready, infoReady, recentReady, closedReady, regionsReady }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createMemo, For, Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
|
||||
import { listClosedTabs, type ClosedTab } from "@/shell/tabs/closed"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
|
||||
export function RecentlyClosedTabsMenu(props: {
|
||||
onNewTab: () => void
|
||||
tooltip?: JSX.Element
|
||||
vertical?: boolean
|
||||
keybind?: string
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const tabs = useTabs()
|
||||
|
||||
const recent = createMemo(() =>
|
||||
listClosedTabs(tabs.closed, tabs.store).filter((entry) => entry.info?.prompted === true),
|
||||
)
|
||||
|
||||
return (
|
||||
<Menu.Context modal={false}>
|
||||
<Show
|
||||
when={props.vertical}
|
||||
fallback={
|
||||
<Tooltip placement="bottom" value={props.tooltip}>
|
||||
<Menu.Context.Trigger
|
||||
as={IconButton}
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<Icon name="plus" />}
|
||||
onClick={props.onNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Menu.Context.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
|
||||
onClick={props.onNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="edit" class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
|
||||
<span
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{props.keybind}</bdi>
|
||||
</span>
|
||||
</Menu.Context.Trigger>
|
||||
</Show>
|
||||
<Menu.Context.Portal>
|
||||
<Menu.Context.Content class="max-h-[66.667dvh] w-56 max-w-56 overflow-y-auto">
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("home.recentlyClosed")}</Menu.GroupLabel>
|
||||
<For each={recent()}>
|
||||
{(entry) => (
|
||||
<RecentlyClosedTabItem
|
||||
entry={entry}
|
||||
onSelect={() => tabs.reopenClosedTab(entry.tab, { append: true })}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Group>
|
||||
</Menu.Context.Content>
|
||||
</Menu.Context.Portal>
|
||||
</Menu.Context>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentlyClosedTabItem(props: { entry: ClosedTab; onSelect: () => void }) {
|
||||
const global = useGlobal()
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ truncated: false })
|
||||
const placement = language.direction() === "rtl" ? "left-start" : "right-start"
|
||||
|
||||
const serverCtx = useServerCtx(() =>
|
||||
global.servers.list().find((item) => ServerConnection.key(item) === props.entry.tab.server),
|
||||
)
|
||||
|
||||
const session = createMemo(() => serverCtx()?.data.session.get(props.entry.tab.sessionId))
|
||||
const directory = () => props.entry.info?.directory ?? session()?.location.directory ?? ""
|
||||
|
||||
const project = createMemo(() => {
|
||||
const value = session()
|
||||
|
||||
if (value) return serverCtx()?.projects.forSession(value)
|
||||
|
||||
if (!directory()) return
|
||||
|
||||
return serverCtx()?.projects.resolve({ worktree: directory(), expanded: false })
|
||||
})
|
||||
|
||||
const title = () => sessionTabTitle(session()?.title ?? props.entry.info?.title, language.t("session.tab.session"))
|
||||
|
||||
return (
|
||||
<Menu.Item class="max-w-56" onSelect={props.onSelect}>
|
||||
<span class="flex size-4 shrink-0 items-center justify-center">
|
||||
<SessionTabAvatarView project={project()} directory={directory()} unread={false} loading={false} />
|
||||
</span>
|
||||
<Tooltip
|
||||
placement={placement}
|
||||
value={<bdi dir="auto">{title()}</bdi>}
|
||||
disabled={!state.truncated}
|
||||
class="min-w-0 flex-1"
|
||||
contentClass="max-w-[min(480px,calc(100vw-32px))] break-words"
|
||||
>
|
||||
<span
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth))
|
||||
}
|
||||
dir="auto"
|
||||
class="block min-w-0 truncate text-start"
|
||||
>
|
||||
{title()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Menu.Item>
|
||||
)
|
||||
}
|
||||
@@ -162,7 +162,11 @@ function SessionTabEntry(props: {
|
||||
const value = session()
|
||||
|
||||
if (!value) return
|
||||
tabs.rememberSessionInfo(props.tab, value)
|
||||
tabs.rememberSessionInfo(
|
||||
props.tab,
|
||||
value,
|
||||
props.serverCtx?.data.session.message.list(value.id).some((message) => message.type === "user") ?? false,
|
||||
)
|
||||
const current = sdk()
|
||||
|
||||
if (!current) return
|
||||
|
||||
@@ -28,6 +28,7 @@ import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigg
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import { RecentlyClosedTabsMenu } from "./recently-closed-tabs-menu"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { rootSession } from "@/shell/routes/session"
|
||||
import { TitlebarItem } from "@opencode/gui-extensions/sdk"
|
||||
@@ -692,25 +693,15 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={
|
||||
<RecentlyClosedTabsMenu
|
||||
onNewTab={openNewTab}
|
||||
tooltip={
|
||||
<>
|
||||
{language.t("command.session.new")}
|
||||
<Keybind keys={command.keybindParts("tab.new")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
icon={<Icon name="plus" />}
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
/>
|
||||
</Tooltip>
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -732,23 +723,11 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
<ChannelIndicator sidebar />
|
||||
</Show>
|
||||
{homeButton(true)}
|
||||
<button
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
<Icon name="edit" class="shrink-0" />
|
||||
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
|
||||
<span
|
||||
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
|
||||
</span>
|
||||
</button>
|
||||
<RecentlyClosedTabsMenu
|
||||
vertical
|
||||
onNewTab={openNewTab}
|
||||
keybind={command.keybind("tab.new")}
|
||||
/>
|
||||
<div class="h-4 w-full shrink-0" aria-hidden="true" />
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<TitlebarTabStrip
|
||||
|
||||
@@ -40,31 +40,31 @@
|
||||
"workerd": "./src/pty/pty.workerd.ts",
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
"default": "./src/pty/pty.node.ts"
|
||||
},
|
||||
"#persistent-pty-binary": {
|
||||
"workerd": "./src/persistent-pty/binary.workerd.ts",
|
||||
"bun": "./src/persistent-pty/binary.bun.ts",
|
||||
"node": "./src/persistent-pty/binary.node.ts",
|
||||
"default": "./src/persistent-pty/binary.bun.ts"
|
||||
"default": "./src/persistent-pty/binary.node.ts"
|
||||
},
|
||||
"#fff": {
|
||||
"workerd": "./src/filesystem/fff.workerd.ts",
|
||||
"bun": "./src/filesystem/fff.bun.ts",
|
||||
"node": "./src/filesystem/fff.node.ts",
|
||||
"default": "./src/filesystem/fff.bun.ts"
|
||||
"default": "./src/filesystem/fff.node.ts"
|
||||
},
|
||||
"#photon-wasm": {
|
||||
"workerd": "./src/image/photon-wasm.workerd.ts",
|
||||
"bun": "./src/image/photon-wasm.bun.ts",
|
||||
"node": "./src/image/photon-wasm.node.ts",
|
||||
"default": "./src/image/photon-wasm.bun.ts"
|
||||
"default": "./src/image/photon-wasm.node.ts"
|
||||
},
|
||||
"#shell-parser-wasm": {
|
||||
"workerd": "./src/shell/parser-wasm.workerd.ts",
|
||||
"bun": "./src/shell/parser-wasm.bun.ts",
|
||||
"node": "./src/shell/parser-wasm.node.ts",
|
||||
"default": "./src/shell/parser-wasm.bun.ts"
|
||||
"default": "./src/shell/parser-wasm.node.ts"
|
||||
},
|
||||
"#v1-migration": {
|
||||
"types": "./src/database/v1-migration.bun.ts",
|
||||
|
||||
@@ -198,7 +198,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) {
|
||||
const resolved = prepareRuntimeModel(model, credential)
|
||||
const resolved = prepareRuntimeModel(model)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
|
||||
if (Provider.isAISDK(resolved.package)) {
|
||||
@@ -256,15 +256,9 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
})
|
||||
})
|
||||
|
||||
function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value | undefined) {
|
||||
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
|
||||
return {
|
||||
...model,
|
||||
...(model.settings?.apiKey === "" ? { settings: Struct.omit(model.settings, ["apiKey"]) } : {}),
|
||||
...(credential?.type === "key" && credential.metadata !== undefined
|
||||
? { body: Provider.mergeOverlay(model.body, credential.metadata) }
|
||||
: {}),
|
||||
}
|
||||
function prepareRuntimeModel(model: RuntimeInfo) {
|
||||
if (model.settings?.apiKey !== "") return model
|
||||
return { ...model, settings: Struct.omit(model.settings, ["apiKey"]) }
|
||||
}
|
||||
|
||||
function validateProviderVariables(
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
@@ -1,6 +1,6 @@
|
||||
// opencode-pty does not ship Windows binaries.
|
||||
export const available = process.platform !== "win32"
|
||||
|
||||
export async function resolveBinary() {
|
||||
export async function resolveBinary(_bin: string) {
|
||||
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export const available = false
|
||||
|
||||
export async function resolveBinary(): Promise<string> {
|
||||
export async function resolveBinary(_bin: string): Promise<string> {
|
||||
throw new Error("Persistent PTYs are unavailable in this runtime")
|
||||
}
|
||||
@@ -487,11 +487,12 @@ function expandResourceName(baseURL: string, resourceName: string) {
|
||||
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
|
||||
}
|
||||
|
||||
// The Azure CLI method stores the resource as credential metadata, the API key method as its form answer.
|
||||
// The Azure CLI method stores the resource as credential metadata, the API key method as its form answer. API keys
|
||||
// imported from V1 keep their connect-form answer as metadata.
|
||||
function credentialResource(credential: Credential.Value | undefined) {
|
||||
const resource =
|
||||
credential?.type === "key"
|
||||
? credential.configuration?.resourceName
|
||||
? (credential.configuration?.resourceName ?? credential.metadata?.resourceName)
|
||||
: credential?.methodID === methodID
|
||||
? credential.metadata?.resourceName
|
||||
: undefined
|
||||
|
||||
@@ -710,7 +710,7 @@ describe("ModelResolver", () => {
|
||||
|
||||
it.effect("prefers stored credentials over configured auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
||||
const credential = Credential.Key.make({ type: "key", key: "stored-secret" })
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
|
||||
@@ -728,7 +728,25 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
|
||||
expect(headers.authorization).toBe("Bearer stored-secret")
|
||||
expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not project API key metadata into the request body", () =>
|
||||
Effect.gen(function* () {
|
||||
// V1 auth.json stored connect-form answers as API key metadata, and the legacy import preserves them there.
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
modelID: "responses-deployment",
|
||||
settings: { apiVersion: "2025-01-01-preview" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "secret", metadata: { resourceName: "migrated-resource" } }),
|
||||
)
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://migrated-resource.openai.azure.com/openai/v1")
|
||||
expect(resolved.route.defaults.http?.body).toEqual({})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -574,9 +574,15 @@ describe("AzurePlugin resource name", () => {
|
||||
name: "prefers the connection's resource over configuration, as requests do",
|
||||
env: { AZURE_RESOURCE_NAME: "from-env" },
|
||||
settings: { resourceName: "from-config" },
|
||||
connection: true,
|
||||
credential: keyCredential(),
|
||||
expected: "test-resource",
|
||||
},
|
||||
{
|
||||
name: "reads the resource of an API key imported from V1",
|
||||
env: { AZURE_RESOURCE_NAME: "from-env" },
|
||||
credential: Credential.Key.make({ type: "key", key: "secret", metadata: { resourceName: "imported-resource" } }),
|
||||
expected: "imported-resource",
|
||||
},
|
||||
]
|
||||
|
||||
cases.forEach((item) =>
|
||||
@@ -586,7 +592,7 @@ describe("AzurePlugin resource name", () => {
|
||||
yield* seedProvider(item.settings)
|
||||
const providers = yield* Provider.Service
|
||||
yield* providers.transform((editor) => editor.update(Provider.ID.openai, () => {}))
|
||||
if (item.connection) yield* connect(keyCredential())
|
||||
if (item.credential) yield* connect(item.credential)
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* providers.get(Provider.ID.azure)).settings?.resourceName).toBe(item.expected)
|
||||
|
||||
@@ -444,7 +444,7 @@ function ArtifactMarkdown(props: { session: MountedSession; path: string; text:
|
||||
openLocalFile={(href) => void links.open({ href, base: dir(), session: props.session })}
|
||||
>
|
||||
<div class="mx-auto w-full max-w-3xl px-8 py-6">
|
||||
<Markdown text={props.text} cacheKey={props.cacheKey} class="select-text" />
|
||||
<Markdown text={props.text} cacheKey={props.cacheKey} />
|
||||
</div>
|
||||
</MarkdownProvider>
|
||||
)
|
||||
@@ -454,7 +454,7 @@ function ArtifactMarkdown(props: { session: MountedSession; path: string; text:
|
||||
function ArtifactMermaid(props: { text: string; cacheKey?: string }) {
|
||||
return (
|
||||
<div class="mx-auto w-full max-w-4xl px-8 py-6">
|
||||
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} class="select-text" />
|
||||
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -648,7 +648,8 @@ export const Terminal = (props: TerminalProps) => {
|
||||
cursor: seek,
|
||||
})
|
||||
.catch((err) => {
|
||||
fail(err)
|
||||
// A failed connection does not mean the PTY exited; retry checks its status first.
|
||||
retry(err)
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -201,6 +201,25 @@ for (const module of modules) {
|
||||
const api = module.OpenCode ?? module.OpenCodeWorkerd
|
||||
if (typeof api?.create !== "function") throw new Error("Packed SDK entrypoint is missing create()")
|
||||
}
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
join(consumer, "imports-default.mjs"),
|
||||
`import { registerHooks } from "node:module"
|
||||
|
||||
// Hide the "node" condition from package subpath imports so Node resolves their
|
||||
// "default" targets, as condition-less loaders and bundlers do.
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (!specifier.startsWith("#")) return nextResolve(specifier, context)
|
||||
return nextResolve(specifier, {
|
||||
...context,
|
||||
conditions: context.conditions.filter((condition) => condition !== "node"),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await import("./imports.mjs")
|
||||
`,
|
||||
),
|
||||
])
|
||||
@@ -214,6 +233,7 @@ for (const module of modules) {
|
||||
}
|
||||
await $`bun imports.mjs`.cwd(consumer)
|
||||
await $`node imports.mjs`.cwd(consumer)
|
||||
await $`node imports-default.mjs`.cwd(consumer)
|
||||
await $`bun --conditions=workerd imports.mjs`.cwd(consumer)
|
||||
await $`node_modules/.bin/wrangler deploy --dry-run --config wrangler.jsonc --outdir dist`.cwd(consumer)
|
||||
|
||||
|
||||
@@ -36,6 +36,17 @@ story("renders small completed Markdown immediately without skipping sanitizatio
|
||||
await expect(harness.locator("pre.shiki")).toBeVisible()
|
||||
})
|
||||
|
||||
story("keeps Markdown text selectable inside a container that disables selection", async ({ page }) => {
|
||||
await page.evaluate(async (fixture) => {
|
||||
document.body.style.userSelect = "none"
|
||||
const { mountMarkdown } = await import(fixture)
|
||||
await mountMarkdown({ text: "Selectable answer text" })
|
||||
}, fixture)
|
||||
|
||||
await page.getByTestId("markdown-fixture").getByText("Selectable answer text").click({ clickCount: 3 })
|
||||
expect(await page.evaluate(() => getSelection()?.toString().trim())).toBe("Selectable answer text")
|
||||
})
|
||||
|
||||
story("sanitizes raw HTML while preserving supported Markdown markup", async ({ page }) => {
|
||||
const result = await page.evaluate(async (fixture) => {
|
||||
const { sanitizeMarkdown } = await import(fixture)
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base); /* 14px */
|
||||
line-height: 160%;
|
||||
/* Shells such as the app's turn off selection for chrome; Markdown is content, so it stays selectable. */
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
|
||||
[data-markdown-word] {
|
||||
display: inline;
|
||||
|
||||
@@ -29,17 +29,17 @@
|
||||
"#attention-sounds": {
|
||||
"bun": "./src/attention-sounds.bun.ts",
|
||||
"node": "./src/attention-sounds.node.ts",
|
||||
"default": "./src/attention-sounds.bun.ts"
|
||||
"default": "./src/attention-sounds.node.ts"
|
||||
},
|
||||
"#string-width": {
|
||||
"bun": "./src/util/string-width.bun.ts",
|
||||
"node": "./src/util/string-width.node.ts",
|
||||
"default": "./src/util/string-width.bun.ts"
|
||||
"default": "./src/util/string-width.node.ts"
|
||||
},
|
||||
"#zed-sqlite": {
|
||||
"bun": "./src/editor-zed-sqlite.bun.ts",
|
||||
"node": "./src/editor-zed-sqlite.node.ts",
|
||||
"default": "./src/editor-zed-sqlite.bun.ts"
|
||||
"default": "./src/editor-zed-sqlite.node.ts"
|
||||
},
|
||||
"#runtime-plugin-support": {
|
||||
"bun": "./src/plugin/runtime-plugin-support.bun.ts",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"workerd": "./src/runtime/import.workerd.ts",
|
||||
"bun": "./src/runtime/import.bun.ts",
|
||||
"node": "./src/runtime/import.node.ts",
|
||||
"default": "./src/runtime/import.bun.ts"
|
||||
"default": "./src/runtime/import.node.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
Reference in new issue
Block a user