mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-17 01:40:19 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d87f70d188 | ||
|
|
fd0e31fe55 | ||
|
|
7a1482abbe | ||
|
|
64d61ed8fa | ||
|
|
9af574715c | ||
|
|
4f607181e5 | ||
|
|
88c83823c6 | ||
|
|
4397ee44d0 | ||
|
|
b47c6802da | ||
|
|
ae8d59f8b5 | ||
|
|
7ced591c3c | ||
|
|
b55f220a17 | ||
|
|
fbf5f164d9 | ||
|
|
3aa26f1676 | ||
|
|
a8fc567c5c | ||
|
|
7b5f1bd9e1 | ||
|
|
bdf4793286 | ||
|
|
37df62f4a1 | ||
|
|
d099d2cb2a | ||
|
|
97c201931c | ||
|
|
328f74c213 | ||
|
|
32b1a1bf85 | ||
|
|
2ae7ec8a16 | ||
|
|
ccd3aa787a | ||
|
|
e05cc4a18d | ||
|
|
3943b84ef0 | ||
|
|
1e63a3bb73 | ||
|
|
7291f3e07a | ||
|
|
13afea6a1a | ||
|
|
8ffbcc2c81 | ||
|
|
35bb8e44ef | ||
|
|
0ccfe69ff3 | ||
|
|
07c0ff5914 | ||
|
|
fe1717b309 | ||
|
|
943dc005f7 | ||
|
|
cddf1dd3e9 | ||
|
|
b474b84259 | ||
|
|
c0d41d2b26 | ||
|
|
c458ad72d3 |
No files matched your search
@@ -28,6 +28,10 @@ const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAI
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
||||
const CONSOLE_CRM_TOKEN = new sst.Secret("CONSOLE_CRM_TOKEN")
|
||||
const CONSOLE_CRM_LOOKUP_URL = new sst.Linkable("CONSOLE_CRM_LOOKUP_URL", {
|
||||
properties: { value: `https://${domain}/console/api/internal/console-account` },
|
||||
})
|
||||
|
||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
@@ -48,6 +52,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
CONSOLE_CRM_TOKEN,
|
||||
CONSOLE_CRM_LOOKUP_URL,
|
||||
],
|
||||
environment: {
|
||||
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
||||
|
||||
@@ -243,17 +243,6 @@ check_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
unbuffered_sed() {
|
||||
if echo | sed -u -e "" >/dev/null 2>&1; then
|
||||
sed -nu "$@"
|
||||
elif echo | sed -l -e "" >/dev/null 2>&1; then
|
||||
sed -nl "$@"
|
||||
else
|
||||
local pad="$(printf "\n%512s" "")"
|
||||
sed -ne "s/$/\\${pad}/" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
print_progress() {
|
||||
local bytes="$1"
|
||||
local length="$2"
|
||||
@@ -283,52 +272,34 @@ download_with_progress() {
|
||||
exec 4>/dev/null
|
||||
fi
|
||||
|
||||
local tmp_dir=${TMPDIR:-/tmp}
|
||||
local basename="${tmp_dir}/opencode_install_$$"
|
||||
local tracefile="${basename}.trace"
|
||||
|
||||
rm -f "$tracefile"
|
||||
mkfifo "$tracefile"
|
||||
local headers="${output}.headers"
|
||||
|
||||
# Hide cursor
|
||||
printf "\033[?25l" >&4
|
||||
|
||||
trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
|
||||
trap "trap - RETURN; rm -f \"$headers\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
|
||||
|
||||
(
|
||||
curl --trace-ascii "$tracefile" -fsL -o "$output" "$url"
|
||||
) &
|
||||
# Poll the file size on a timer so rendering cost stays fixed no matter how many bytes arrive.
|
||||
# Read the size with ls because busybox wc -c reads the whole file.
|
||||
curl -fsL -D "$headers" -o "$output" "$url" &
|
||||
local curl_pid=$!
|
||||
|
||||
unbuffered_sed \
|
||||
-e 'y/ACDEGHLNORTV/acdeghlnortv/' \
|
||||
-e '/^0000: content-length:/p' \
|
||||
-e '/^<= recv data/p' \
|
||||
"$tracefile" | \
|
||||
{
|
||||
local length=0
|
||||
local bytes=0
|
||||
local length bytes
|
||||
while kill -0 "$curl_pid" 2>/dev/null; do
|
||||
if [ -f "$output" ]; then
|
||||
length=$(grep -i '^content-length:' "$headers" 2>/dev/null | tail -n 1 | tr -dc '0-9' || true)
|
||||
bytes=$(ls -ln "$output" | awk '{print $5}')
|
||||
print_progress "$bytes" "${length:-0}"
|
||||
fi
|
||||
sleep 0.1 2>/dev/null || sleep 1
|
||||
done
|
||||
|
||||
while IFS=" " read -r -a line; do
|
||||
[ "${#line[@]}" -lt 2 ] && continue
|
||||
local tag="${line[0]} ${line[1]}"
|
||||
|
||||
if [ "$tag" = "0000: content-length:" ]; then
|
||||
length="${line[2]}"
|
||||
length=$(echo "$length" | tr -d '\r')
|
||||
bytes=0
|
||||
elif [ "$tag" = "<= recv" ]; then
|
||||
local size="${line[3]}"
|
||||
bytes=$(( bytes + size ))
|
||||
if [ "$length" -gt 0 ]; then
|
||||
print_progress "$bytes" "$length"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
wait $curl_pid
|
||||
local ret=$?
|
||||
local ret=0
|
||||
wait "$curl_pid" || ret=$?
|
||||
if [ "$ret" -eq 0 ]; then
|
||||
bytes=$(ls -ln "$output" | awk '{print $5}')
|
||||
print_progress "$bytes" "$bytes"
|
||||
fi
|
||||
echo "" >&4
|
||||
return $ret
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-Nrl3q4qV3YKGnSoKDjs0HvFyviVhtNfKEmMUIoREITc=",
|
||||
"aarch64-linux": "sha256-AfaHJIwoLA/9icnohKM2DL2dcmgSANfVT3oefOYBylE=",
|
||||
"aarch64-darwin": "sha256-nat5x4P4yGHqqBbw64CT0B66dmR3oSV3Qic1vMGnE4Q="
|
||||
"x86_64-linux": "sha256-wmSDJ8NZOJyfSaBD8hnAAtbYrp3qnMS4eJzgFdyrhFc=",
|
||||
"aarch64-linux": "sha256-6WE4BHW11/gAQ75ktPbWU2Aw7uqivueMrElDAqu7QdE=",
|
||||
"aarch64-darwin": "sha256-6aAic4o1Zx6QiMk8IFU6lV7typClc3yXoWH7Qiq+hFs="
|
||||
}
|
||||
}
|
||||
@@ -334,14 +334,14 @@ export const StreamItem = Schema.StructWithRest(
|
||||
id: Schema.optional(Schema.String),
|
||||
call_id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
namespace: Schema.optional(Schema.String),
|
||||
namespace: optionalNull(Schema.String),
|
||||
arguments: Schema.optional(Schema.String),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
export type OutputItem = StreamItem & { readonly id: string; readonly namespace?: string }
|
||||
|
||||
// Responses-compatible providers put error details at the top level, under `error`, or under
|
||||
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
|
||||
@@ -946,6 +946,8 @@ const resolveItem = (
|
||||
index: number | undefined,
|
||||
): OutputItem => ({
|
||||
...item,
|
||||
// Some gateways serialize an absent namespace as null.
|
||||
namespace: item.namespace ?? undefined,
|
||||
id:
|
||||
item.id ??
|
||||
(index === undefined ? undefined : state.outputItems[index]) ??
|
||||
|
||||
@@ -644,6 +644,27 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a null function call namespace as absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", namespace: null }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { ...item, arguments: "" } },
|
||||
{ type: "response.function_call_arguments.done", item_id: "fc_1", output_index: 0, arguments: "{}" },
|
||||
{ type: "response.output_item.done", output_index: 0, item: { ...item, arguments: "{}" } },
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ ...item, arguments: "{}" }] } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toEqual([expect.objectContaining({ id: "call_1", name: "lookup", input: {} })])
|
||||
expect(response.toolCalls[0]?.namespace).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects missing, null, and non-string event IDs even with a registered output index", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
|
||||
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { mockMissingLocation, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace"
|
||||
|
||||
const directory = "C:/Projects/settings-demo"
|
||||
@@ -248,6 +248,41 @@ test("project settings open as a nested view that keeps its route", async ({ pag
|
||||
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
// The desktop app reopens its last settings URL on launch, so a crash on this page would repeat on every restart.
|
||||
test("a project whose folder is gone opens its settings with a notice", async ({ page }) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Settings demo",
|
||||
directory,
|
||||
provider: NO_PROVIDER,
|
||||
sessions: [],
|
||||
seed: { tabs: [] },
|
||||
})
|
||||
await mockMissingLocation(page, directory)
|
||||
|
||||
const plugins = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
|
||||
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
|
||||
})
|
||||
|
||||
await page.goto(
|
||||
`/settings?server=${encodeURIComponent(SERVER)}&project=${encodeURIComponent(directory)}&tab=extensions`,
|
||||
)
|
||||
expect((await plugins).status()).toBe(404)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const notice = settings.getByRole("status").filter({ hasText: "Project folder not found" })
|
||||
await expect(notice).toContainText(directory)
|
||||
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
|
||||
await expect(
|
||||
settings.getByRole("tabpanel", { name: "Plugins", exact: true }).getByText("No plugins yet", { exact: true }),
|
||||
).toBeVisible()
|
||||
|
||||
await settings.getByRole("tab", { name: "General", exact: true }).click()
|
||||
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toBeVisible()
|
||||
await expect(notice).toContainText(directory)
|
||||
})
|
||||
|
||||
for (const field of [
|
||||
{
|
||||
name: "color",
|
||||
|
||||
@@ -476,6 +476,21 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return { server, pty: transport.pty, push }
|
||||
}
|
||||
|
||||
// Answers every Location-scoped request for `directory` as the server does once that folder is deleted.
|
||||
export function mockMissingLocation(page: Page, directory: string) {
|
||||
return page.route(
|
||||
(url) => url.pathname.startsWith("/api/") && url.searchParams.get("location[directory]") === directory,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
|
||||
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
|
||||
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Mocks several servers on one page. Each origin gets its own handler and its own SSE transport for events.
|
||||
export async function mockServers(page: Page, servers: Record<string, Omit<MockServerConfig, "server" | "events">>) {
|
||||
return Object.fromEntries(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { createMemo, ErrorBoundary, Show, type ParentProps } from "solid-js"
|
||||
import { CommentsProvider } from "@/composer/comments"
|
||||
import { FileProvider } from "@/workspaces/files/model"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -12,6 +12,7 @@ import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
|
||||
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
|
||||
import NewSession from "@/new-session/screen"
|
||||
import { IncompatibleServerPanel } from "@/session/incompatible-server-panel"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
|
||||
export function DraftRoute() {
|
||||
@@ -67,15 +68,25 @@ function ResolvedDraftContent(props: { draft: DraftTab }) {
|
||||
</SessionRouteFrame>
|
||||
}
|
||||
>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<LocationProvider directory={props.draft.directory}>
|
||||
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession draftId={props.draft.draftID} />
|
||||
</DraftProviders>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
</ModelsProvider>
|
||||
<ErrorBoundary
|
||||
fallback={(error) => (
|
||||
<SessionRouteFrame padded>
|
||||
<SessionPanelFrame raised>
|
||||
<ErrorPage error={error} />
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<LocationProvider directory={props.draft.directory}>
|
||||
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession draftId={props.draft.draftID} />
|
||||
</DraftProviders>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
</ModelsProvider>
|
||||
</ErrorBoundary>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -751,6 +751,8 @@ export const dict = {
|
||||
"project.settings.worktree.startup.description": "Runs once after creating a new worktree",
|
||||
"project.settings.worktree.startup.hint.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
|
||||
"project.settings.worktree.startup.hint.new": "Use $OPENCODE_WORKTREE_PATH for the new worktree.",
|
||||
"project.settings.folderMissing.title": "Project folder not found",
|
||||
"project.settings.folderMissing.description": "Restore the folder or close this project.",
|
||||
"project.settings.extensions.description": "View extensions available to this project",
|
||||
"project.settings.extensions.tab.lsps": "LSPs",
|
||||
"project.settings.extensions.added": "Added to this project",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { createTimelineVirtualizer } from "./virtualizer"
|
||||
import { containsDirectory } from "@opencode/util/path"
|
||||
import { isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { parseVoiceDelegation } from "./voice-delegation"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeaderSpacer } from "@/session/header/session-header"
|
||||
@@ -293,11 +294,13 @@ function MessageTimelineView(
|
||||
presentation: (message) => {
|
||||
const value = readPromptPresentation(message.metadata)
|
||||
const parsed = value ? undefined : parseCommentNote(message.text)
|
||||
const voice = parseVoiceDelegation(value?.displayText ?? message.text)
|
||||
|
||||
return {
|
||||
displayText: value?.displayText,
|
||||
displayText: voice ? (voice.framing ?? voice.instruction) : value?.displayText,
|
||||
comments: value?.comments ?? (parsed ? [parsed] : []),
|
||||
references: value?.attachments,
|
||||
voice: !!voice,
|
||||
}
|
||||
},
|
||||
actions: props.actions,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parseVoiceDelegation } from "./voice-delegation"
|
||||
|
||||
// Prompts as the OpenCode iOS app has sent them (`VoiceHandoff.format`), oldest first.
|
||||
const firstRelease = `Voice delegation. Instruction is the cleaned interpretation of the user's request. Transcript delta supplies new or revised conversation for reference, not additional commands. Resolve meaning from the complete utterance, not a disfluent prefix. Lead your reply with one or two plain sentences suitable for reading aloud, then any detail.
|
||||
{
|
||||
"instruction" : "Check why the upload test is flaky.",
|
||||
"transcript_delta" : [
|
||||
"user: the upload test",
|
||||
"assistant: On it."
|
||||
]
|
||||
}`
|
||||
|
||||
const current = `Voice delegation. The user is on a voice call with a voice agent, which hands their requests to you. Instruction is the user's own spoken words since the previous delegation.
|
||||
{
|
||||
"context" : "Session \\"Uploads\\" in project \\"app\\".",
|
||||
"instruction" : "check the upload test",
|
||||
"transcript_delta" : [
|
||||
"user: check the upload test"
|
||||
],
|
||||
"voice" : "Checking the upload test."
|
||||
}`
|
||||
|
||||
const continued = `Voice delegation (continued). Same fields as before.
|
||||
{
|
||||
"instruction" : "and open a PR",
|
||||
"transcript_delta" : [
|
||||
|
||||
]
|
||||
}`
|
||||
|
||||
describe("parseVoiceDelegation", () => {
|
||||
test.each([
|
||||
["the first release's header", firstRelease, { instruction: "Check why the upload test is flaky." }],
|
||||
[
|
||||
"the current header with framing",
|
||||
current,
|
||||
{ instruction: "check the upload test", framing: "Checking the upload test." },
|
||||
],
|
||||
["a continued delegation", continued, { instruction: "and open a PR" }],
|
||||
[
|
||||
"a call log attached as a reference line",
|
||||
`${continued}\n\nAttached file: \`/tmp/uploads/1/voice-call-a.json\``,
|
||||
{ instruction: "and open a PR" },
|
||||
],
|
||||
])("reads %s", (_, text, expected) => {
|
||||
expect(parseVoiceDelegation(text)).toEqual({ framing: undefined, ...expected })
|
||||
})
|
||||
|
||||
test("ignores blank framing", () => {
|
||||
expect(parseVoiceDelegation(current.replace('"Checking the upload test."', '" "'))?.framing).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each([
|
||||
["a typed message that mentions one", "What does a Voice delegation. prompt look like?"],
|
||||
["a header without a body", "Voice delegation. Same fields as before."],
|
||||
["a body that isn't JSON", "Voice delegation. Header\ncheck the upload test"],
|
||||
["a blank instruction", 'Voice delegation. Header\n{"instruction": " ", "transcript_delta": []}'],
|
||||
["a body without an instruction", 'Voice delegation. Header\n{"transcript_delta": []}'],
|
||||
])("rejects %s", (_, text) => {
|
||||
expect(parseVoiceDelegation(text)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Option, Schema } from "effect"
|
||||
|
||||
// The OpenCode iOS app sends each request from a voice call as plain prompt text: a header line, then a JSON
|
||||
// body. Nothing else marks the message, so this mirrors the app's `VoiceHandoff.parse`. The header's wording
|
||||
// has changed between releases; its opening and the body's shape have not.
|
||||
const decodeBody = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
instruction: Schema.String,
|
||||
voice: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
/** The user's spoken request and the voice agent's framing of it, or undefined for any other prompt. */
|
||||
export function parseVoiceDelegation(text: string) {
|
||||
if (!text.startsWith("Voice delegation.") && !text.startsWith("Voice delegation (continued).")) return
|
||||
|
||||
const newline = text.indexOf("\n")
|
||||
|
||||
if (newline === -1) return
|
||||
|
||||
// Without presentation metadata, path attachments such as the call log follow the body as reference lines.
|
||||
const body = Option.getOrUndefined(
|
||||
decodeBody(text.slice(newline + 1).replace(/(\s*\nAttached file: `[^\n]*`)+\s*$/, "")),
|
||||
)
|
||||
|
||||
const instruction = body?.instruction.trim()
|
||||
|
||||
if (!instruction) return
|
||||
|
||||
return { instruction, framing: body?.voice?.trim() || undefined }
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { For, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import { createEffect, ErrorBoundary, For, on, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { useSettingsSurface } from "./surface"
|
||||
import { SettingsSearch } from "./search"
|
||||
import "./search.css"
|
||||
@@ -142,7 +143,18 @@ export function SettingsNavigation(props: {
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="settings-content">{props.children}</div>
|
||||
<div class="settings-content">
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => {
|
||||
// Keep a failed page inside the content area; choosing another page renders it again.
|
||||
createEffect(on(() => props.value, reset, { defer: true }))
|
||||
|
||||
return <ErrorPage error={error} />
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -29,9 +29,14 @@ export const SettingsExtensions: Component<{
|
||||
const serverSdk = useServerSDK()
|
||||
const data = useData()
|
||||
|
||||
// Reading `.latest` rethrows a failed fetch, so these lists fall back to empty instead of crashing the page.
|
||||
const [mcpList, { refetch: refetchMcp }] = createResource(
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.mcp.list().then((result) => result.data),
|
||||
() =>
|
||||
serverSdk.api.mcp
|
||||
.list()
|
||||
.then((result) => result.data)
|
||||
.catch(() => []),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
@@ -51,7 +56,11 @@ export const SettingsExtensions: Component<{
|
||||
|
||||
const [pluginList] = createResource(
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
() =>
|
||||
serverSdk.api.plugin
|
||||
.list()
|
||||
.then((result) => result.data)
|
||||
.catch(() => []),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { pluginLabels } from "@/providers/catalog/plugin"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { ProjectFolderMissing } from "./project-folder-missing"
|
||||
import { configuredLanguageServers } from "./project-lsp"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import "./project.css"
|
||||
@@ -248,15 +249,25 @@ export const ProjectSettingsExtensions: Component<{
|
||||
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)
|
||||
?.status.status === "connected"
|
||||
|
||||
// Reading `.latest` rethrows a failed fetch, such as the project's plugin list after its folder is deleted, so these
|
||||
// lists fall back to empty instead of crashing the page.
|
||||
const [globalPluginList] = createResource(
|
||||
() => serverSDK.connection.status() === "connected",
|
||||
() => serverSDK.api.plugin.list().then((result) => result.data),
|
||||
() =>
|
||||
serverSDK.api.plugin
|
||||
.list()
|
||||
.then((result) => result.data)
|
||||
.catch(() => []),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const [projectPluginList] = createResource(
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
(directory) =>
|
||||
serverSDK.api.plugin
|
||||
.list({ location: { directory } })
|
||||
.then((result) => result.data)
|
||||
.catch(() => []),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
@@ -318,6 +329,7 @@ export const ProjectSettingsExtensions: Component<{
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body">
|
||||
<ProjectFolderMissing />
|
||||
<Tabs
|
||||
variant="pill"
|
||||
value={props.subtab ?? "mcps"}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import "./project.css"
|
||||
|
||||
export function ProjectFolderMissing() {
|
||||
const language = useLanguage()
|
||||
const location = useWorkspaceLocation()
|
||||
|
||||
return (
|
||||
<Show when={location().missing}>
|
||||
{(missing) => (
|
||||
<div role="status" class="project-settings-folder-missing">
|
||||
<Icon name="warning" class="project-settings-folder-missing-icon" />
|
||||
<div class="project-settings-folder-missing-copy">
|
||||
<span class="project-settings-folder-missing-title">
|
||||
{language.t("project.settings.folderMissing.title")}
|
||||
</span>
|
||||
<bdi dir="ltr" class="project-settings-folder-missing-path">
|
||||
{missing().directory}
|
||||
</bdi>
|
||||
<span>{language.t("project.settings.folderMissing.description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -94,6 +94,43 @@
|
||||
background: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
.project-settings-folder-missing {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.project-settings-folder-missing-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-state-fg-warning);
|
||||
}
|
||||
|
||||
.project-settings-folder-missing-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-settings-folder-missing-title {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.project-settings-folder-missing-path {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-settings-shared {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSettingsServers } from "@/settings/servers/inventory"
|
||||
import { useServerCtx } from "@/runtime/server/runtime"
|
||||
import { projectSettingsKey, useSettings } from "@/settings/model"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import { ProjectFolderMissing } from "./project-folder-missing"
|
||||
import { ProjectOptions } from "./project-options"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
@@ -63,6 +64,7 @@ export const SettingsProjectGeneral: Component<{
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body project-settings-general" aria-busy={model.store.saving > 0}>
|
||||
<ProjectFolderMissing />
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("project.settings.name.title")}
|
||||
|
||||
@@ -43,7 +43,16 @@ export function filename(channel = OPENCODE_CHANNEL) {
|
||||
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
|
||||
}
|
||||
|
||||
export function defaultPort(channel = OPENCODE_CHANNEL) {
|
||||
export function defaultPort(channel = OPENCODE_CHANNEL, platform = process.platform) {
|
||||
const port = channelPort(channel)
|
||||
// Windows hands out 0xC000-0xFFFF for outgoing connections, and Hyper-V and WinNAT reserve blocks of that range at
|
||||
// boot, so a port there can fail as in use while nothing listens on it. Moving into the 0xB000 block keeps the low
|
||||
// 12 bits: 0xC0DE becomes 0xB0DE, which is 0xC0DE - 4096, a nod to the v1 port.
|
||||
if (platform === "win32" && port >= 0xc000) return 0xb000 | (port & 0xfff)
|
||||
return port
|
||||
}
|
||||
|
||||
function channelPort(channel: string) {
|
||||
if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return 0xc0de
|
||||
if (channel === "local") return 0xc0df
|
||||
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
|
||||
|
||||
@@ -18,13 +18,27 @@ import { testEffect } from "../../core/test/lib/effect"
|
||||
const it = testEffect(NodeFileSystem.layer)
|
||||
|
||||
test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("dev")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("beta")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
|
||||
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
|
||||
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
|
||||
expect(ServiceConfig.defaultPort("latest", "linux")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("dev", "linux")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("beta", "linux")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("next", "linux")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("local", "linux")).toBe(0xc0df)
|
||||
expect(ServiceConfig.defaultPort("latest", "darwin")).toBe(0xc0de)
|
||||
expect(ServiceConfig.defaultPort("preview-a", "linux")).toBe(ServiceConfig.defaultPort("preview-a", "linux"))
|
||||
expect(ServiceConfig.defaultPort("preview-a", "linux")).not.toBe(ServiceConfig.defaultPort("preview-b", "linux"))
|
||||
})
|
||||
|
||||
test("managed service ports on Windows stay below the range Windows reserves", () => {
|
||||
expect(ServiceConfig.defaultPort("latest", "win32")).toBe(0xc0de - 4096)
|
||||
expect(ServiceConfig.defaultPort("dev", "win32")).toBe(0xc0de - 4096)
|
||||
expect(ServiceConfig.defaultPort("local", "win32")).toBe(0xc0df - 4096)
|
||||
const ports = Array.from({ length: 200 }, (_, index) => ({
|
||||
windows: ServiceConfig.defaultPort(`preview-${index}`, "win32"),
|
||||
linux: ServiceConfig.defaultPort(`preview-${index}`, "linux"),
|
||||
}))
|
||||
expect(ports.some((port) => port.linux >= 0xc000)).toBe(true)
|
||||
expect(ports.every((port) => port.windows >= 10_000 && port.windows < 0xc000)).toBe(true)
|
||||
expect(ports.filter((port) => port.linux < 0xc000 && port.windows !== port.linux)).toEqual([])
|
||||
})
|
||||
|
||||
test("service disabled accepts only booleans without changing configuration on invalid input", async () => {
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"dev:enterprise": "bun --watch script/dev-enterprise.ts",
|
||||
"dev:remote": "bun sst shell --stage=dev bun dev",
|
||||
"build": "bun ./script/generate-sitemap.ts && vite build",
|
||||
"build": "bun ./script/generate-sitemap.ts && vite build && bun ../../schema/script/config-schema.ts ./.output/public/config.json",
|
||||
"start": "vite start"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { handleEnterpriseRequest } from "../src/lib/enterprise"
|
||||
import { z } from "zod"
|
||||
import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response"
|
||||
import { handleConsoleSignup } from "../src/lib/console-signup"
|
||||
import { consoleAccountSchema } from "../src/lib/console-account"
|
||||
|
||||
const org = process.env.SF_TARGET_ORG || "opencode"
|
||||
const crmToken = process.env.CONSOLE_CRM_TOKEN
|
||||
if (!crmToken) throw new Error("Set CONSOLE_CRM_TOKEN to the shared local Console CRM token")
|
||||
const accountUrl = process.env.CONSOLE_CRM_LOOKUP_URL || "http://localhost:3100/console/api/internal/console-account"
|
||||
|
||||
async function authenticatedRequest(
|
||||
path: string,
|
||||
payload?: Record<string, unknown>,
|
||||
method: "GET" | "POST" | "PATCH" = payload ? "POST" : "GET",
|
||||
) {
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
"sf",
|
||||
"api",
|
||||
"request",
|
||||
"rest",
|
||||
path,
|
||||
"--target-org",
|
||||
org,
|
||||
"--json",
|
||||
"--method",
|
||||
method,
|
||||
...(payload ? ["--body", "-", "--header", "Content-Type:application/json"] : []),
|
||||
],
|
||||
{
|
||||
stdin: payload ? new Blob([JSON.stringify(payload)]) : "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
const [output] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
child.exited,
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
return salesforceCliResponse(output)
|
||||
}
|
||||
|
||||
await authenticatedRequest("/services/data/v59.0/")
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(process.env.ENTERPRISE_DEV_PORT || 3101),
|
||||
async fetch(request) {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/health" && request.method === "GET") {
|
||||
return Response.json({ handler: "local-sst-enterprise", salesforceOrg: org })
|
||||
}
|
||||
if (path === "/api/console-signup" && request.method === "POST") {
|
||||
return handleConsoleSignup(request, crmToken, (path, method, payload) =>
|
||||
authenticatedRequest(path, payload, method),
|
||||
)
|
||||
}
|
||||
if (path !== "/api/enterprise") return new Response("Not found", { status: 404 })
|
||||
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 })
|
||||
const failures: string[] = []
|
||||
const response = await handleEnterpriseRequest(request, {
|
||||
salesforceSubmit: async (payload) => {
|
||||
const result = await authenticatedRequest("/services/data/v59.0/sobjects/Lead", payload).catch(
|
||||
(error: unknown) => {
|
||||
const code = error instanceof SalesforceCliError ? error.code : "CLI_ERROR"
|
||||
failures.push(code)
|
||||
console.error(`Local Salesforce submission rejected: ${code}`)
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
return z.object({ success: z.literal(true) }).safeParse(result).success
|
||||
},
|
||||
notifications: false,
|
||||
accountLookup: async (email) => {
|
||||
const response = await fetch(accountUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${crmToken}` },
|
||||
body: JSON.stringify({ email }),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Local Console account lookup failed (${response.status})`)
|
||||
return z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json()).account
|
||||
},
|
||||
})
|
||||
if (failures.length > 0) {
|
||||
return Response.json(
|
||||
{ error: "Salesforce rejected the request.", errorCode: failures[0] },
|
||||
{ status: 502, headers: { "X-Enterprise-Handler": "local-sst-enterprise" } },
|
||||
)
|
||||
}
|
||||
response.headers.set("X-Enterprise-Handler", "local-sst-enterprise")
|
||||
return response
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Local Enterprise handler: ${server.url.origin}/api/enterprise`)
|
||||
console.log(`Salesforce CLI org: ${org}; local email and EmailOctopus delivery disabled.`)
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response"
|
||||
|
||||
test("unwraps a successful Salesforce Lead creation from the CLI HTTP envelope", () => {
|
||||
expect(
|
||||
salesforceCliResponse(
|
||||
JSON.stringify({
|
||||
status: 0,
|
||||
result: { statusCode: 201, headers: {}, body: { id: "test-lead", success: true, errors: [] } },
|
||||
}),
|
||||
),
|
||||
).toEqual({ id: "test-lead", success: true, errors: [] })
|
||||
})
|
||||
|
||||
test("rejects a Salesforce HTTP failure even if the CLI exits successfully", () => {
|
||||
expect(() =>
|
||||
salesforceCliResponse(
|
||||
JSON.stringify({
|
||||
status: 0,
|
||||
result: { statusCode: 400, body: [{ errorCode: "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST" }] },
|
||||
}),
|
||||
),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("rejects CLI errors and responses missing the HTTP envelope", () => {
|
||||
expect(() => salesforceCliResponse(JSON.stringify({ status: 1, message: "CLI error" }))).toThrow()
|
||||
expect(() => salesforceCliResponse(JSON.stringify({ status: 0, result: { success: true } }))).toThrow()
|
||||
})
|
||||
|
||||
test("preserves the Salesforce rejection code when the CLI exits nonzero", () => {
|
||||
expect(() =>
|
||||
salesforceCliResponse(
|
||||
JSON.stringify({
|
||||
status: 1,
|
||||
result: { statusCode: 400, body: [{ errorCode: "DUPLICATES_DETECTED", message: "Duplicate found" }] },
|
||||
}),
|
||||
),
|
||||
).toThrow(new SalesforceCliError("DUPLICATES_DETECTED"))
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export class SalesforceCliError extends Error {
|
||||
constructor(readonly code: string) {
|
||||
super(`Salesforce request failed: ${code}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function salesforceCliResponse(output: string) {
|
||||
const response = z
|
||||
.object({
|
||||
status: z.number(),
|
||||
result: z.object({ statusCode: z.number().int(), body: z.unknown() }).optional(),
|
||||
})
|
||||
.parse(JSON.parse(output))
|
||||
if (!response.result) throw new SalesforceCliError("CLI_ERROR")
|
||||
if (response.status !== 0 || response.result.statusCode < 200 || response.result.statusCode > 299) {
|
||||
const errors = z
|
||||
.array(z.object({ errorCode: z.string().regex(/^[A-Z][A-Z0-9_]+$/) }))
|
||||
.safeParse(response.result.body)
|
||||
throw new SalesforceCliError(errors.success ? (errors.data[0]?.errorCode ?? "REQUEST_FAILED") : "REQUEST_FAILED")
|
||||
}
|
||||
return response.result.body
|
||||
}
|
||||
@@ -299,7 +299,6 @@ export const dict = {
|
||||
"تُنشأ سجلات مراقبة إساءة الاستخدام لكل استخدام لميزات API، ويُحتفظ بها لمدة تصل إلى 30 يومًا.",
|
||||
"go.faq.a5.museRetention":
|
||||
"أسعار توكنات مخفّضة للغاية مقابل منح الإذن باستخدام مطالباتك وإكمالات النموذج لتدريب نماذج Meta المستقبلية.",
|
||||
"go.faq.a5.catJamRetention": "خلال فترته المجانية، قد تُستخدم البيانات التي يتم جمعها لتحسين النموذج.",
|
||||
"go.faq.a5.geminiRetention": "قد تُستخدم مطالباتك وإكمالات النموذج لتدريب نماذج Google المستقبلية.",
|
||||
"go.faq.a5.learnMore": "اعرف المزيد",
|
||||
"go.faq.a5.deepseekRetention": "تُجدَّد اتفاقية ZDR شهريًا. الاتفاقية الحالية سارية حتى 31 أكتوبر 2026.",
|
||||
|
||||
@@ -306,7 +306,6 @@ export const dict = {
|
||||
"Logs de monitoramento de abuso são gerados para todo uso de recursos da API e retidos por até 30 dias.",
|
||||
"go.faq.a5.museRetention":
|
||||
"Preços de tokens com grandes descontos em troca da permissão para usar seus prompts e respostas geradas para treinar futuros modelos da Meta.",
|
||||
"go.faq.a5.catJamRetention": "Durante o período gratuito, os dados coletados podem ser usados para melhorar o modelo.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Seus prompts e respostas geradas podem ser usados para treinar futuros modelos do Google.",
|
||||
"go.faq.a5.learnMore": "Saiba mais",
|
||||
|
||||
@@ -304,7 +304,6 @@ export const dict = {
|
||||
"Logfiler til overvågning af misbrug genereres ved al brug af API-funktioner og opbevares i op til 30 dage.",
|
||||
"go.faq.a5.museRetention":
|
||||
"Kraftigt nedsatte tokenpriser til gengæld for tilladelse til at bruge dine prompts og modelsvar til at træne fremtidige Meta-modeller.",
|
||||
"go.faq.a5.catJamRetention": "I gratisperioden kan indsamlede data blive brugt til at forbedre modellen.",
|
||||
"go.faq.a5.geminiRetention": "Dine prompts og modelsvar kan blive brugt til at træne fremtidige Google-modeller.",
|
||||
"go.faq.a5.learnMore": "Læs mere",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
|
||||
@@ -306,7 +306,6 @@ export const dict = {
|
||||
"Für die Nutzung aller API-Funktionen werden Protokolle zur Missbrauchsüberwachung erstellt und bis zu 30 Tage lang aufbewahrt.",
|
||||
"go.faq.a5.museRetention":
|
||||
"Stark vergünstigte Tokenpreise im Gegenzug für die Erlaubnis, deine Prompts und Vervollständigungen zum Trainieren zukünftiger Meta-Modelle zu verwenden.",
|
||||
"go.faq.a5.catJamRetention": "Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Deine Prompts und Vervollständigungen können zum Trainieren zukünftiger Google-Modelle verwendet werden.",
|
||||
"go.faq.a5.learnMore": "Mehr erfahren",
|
||||
|
||||
@@ -304,7 +304,6 @@ export const dict = {
|
||||
"Heavily discounted token pricing in exchange for permission to use your prompts and completions to train future Meta models.",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
"ZDR agreement is renewed monthly. The current agreement is valid through October 31, 2026.",
|
||||
"go.faq.a5.catJamRetention": "During its free period, collected data may be used to improve the model.",
|
||||
"go.faq.a5.geminiRetention": "Your prompts and completions may be used to train future Google models.",
|
||||
"go.faq.a5.learnMore": "Learn more",
|
||||
|
||||
|
||||
@@ -307,7 +307,6 @@ export const dict = {
|
||||
"Se generan registros de supervisión de abusos para todo el uso de funciones de la API y se conservan durante un máximo de 30 días.",
|
||||
"go.faq.a5.museRetention":
|
||||
"Precios de tokens muy reducidos a cambio de permitir que tus prompts y las respuestas generadas se utilicen para entrenar futuros modelos de Meta.",
|
||||
"go.faq.a5.catJamRetention": "Durante su periodo gratuito, los datos recopilados pueden usarse para mejorar el modelo.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Tus prompts y las respuestas generadas pueden utilizarse para entrenar futuros modelos de Google.",
|
||||
"go.faq.a5.learnMore": "Más información",
|
||||
|
||||
@@ -309,7 +309,6 @@ export const dict = {
|
||||
"Des journaux de surveillance des abus sont générés pour toute utilisation des fonctionnalités API et conservés pendant un maximum de 30 jours.",
|
||||
"go.faq.a5.museRetention":
|
||||
"Des tarifs de tokens fortement réduits en échange de l’autorisation d’utiliser vos prompts et vos complétions pour entraîner de futurs modèles Meta.",
|
||||
"go.faq.a5.catJamRetention": "Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Vos prompts et vos complétions peuvent être utilisés pour entraîner de futurs modèles Google.",
|
||||
"go.faq.a5.learnMore": "En savoir plus",
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"ZDR disabilita importanti funzionalità API che dipendono dai dati archiviati, tra cui la Responses API con stato, Files and Collections e Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"I log di monitoraggio degli abusi vengono generati per l'utilizzo di tutte le funzionalità API e conservati per un massimo di 30 giorni.",
|
||||
"go.faq.a5.catJamRetention": "Durante il periodo gratuito, i dati raccolti possono essere utilizzati per migliorare il modello.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"I tuoi prompt e completamenti possono essere utilizzati per addestrare futuri modelli Google.",
|
||||
"go.faq.a5.learnMore": "Scopri di più",
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention": "不正使用監視ログはすべてのAPI機能の使用時に生成され、最大30日間保持されます。",
|
||||
"go.faq.a5.museRetention":
|
||||
"将来のMetaモデルのトレーニングにプロンプトと生成結果を使用する許可と引き換えに、トークン料金が大幅に割引されます。",
|
||||
"go.faq.a5.catJamRetention": "無料提供期間中、収集されたデータはモデルの改善に使用される場合があります。",
|
||||
"go.faq.a5.geminiRetention": "プロンプトと生成結果は、将来のGoogleモデルのトレーニングに使用される場合があります。",
|
||||
"go.faq.a5.learnMore": "詳しく見る",
|
||||
"go.faq.a5.deepseekRetention": "ZDR契約は毎月更新されます。現在の契約は2026年10月31日まで有効です。",
|
||||
|
||||
@@ -297,7 +297,6 @@ export const dict = {
|
||||
"go.faq.a5.gptRetention": "모든 API 기능 사용에 대해 악용 모니터링 로그가 생성되며 최대 30일 동안 보존됩니다.",
|
||||
"go.faq.a5.museRetention":
|
||||
"향후 Meta 모델 학습에 사용자의 프롬프트와 생성 결과를 사용할 수 있도록 허용하는 대신 토큰 가격이 대폭 할인됩니다.",
|
||||
"go.faq.a5.catJamRetention": "무료 제공 기간 동안 수집된 데이터는 모델 개선에 사용될 수 있습니다.",
|
||||
"go.faq.a5.geminiRetention": "사용자의 프롬프트와 생성 결과는 향후 Google 모델 학습에 사용될 수 있습니다.",
|
||||
"go.faq.a5.learnMore": "자세히 알아보기",
|
||||
"go.faq.a5.deepseekRetention": "ZDR 계약은 매월 갱신됩니다. 현재 계약은 2026년 10월 31일까지 유효합니다.",
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"ZDR deaktiverer viktige API-funksjoner som er avhengige av lagrede data, inkludert den tilstandsbaserte Responses API, Files and Collections og Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Logger for overvåking av misbruk genereres for all bruk av API-funksjoner og oppbevares i opptil 30 dager.",
|
||||
"go.faq.a5.catJamRetention": "I gratisperioden kan innsamlede data brukes til å forbedre modellen.",
|
||||
"go.faq.a5.geminiRetention": "Ledetekstene og fullføringene dine kan brukes til å trene fremtidige Google-modeller.",
|
||||
"go.faq.a5.learnMore": "Les mer",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
|
||||
@@ -303,7 +303,6 @@ export const dict = {
|
||||
"ZDR wyłącza ważne funkcje API zależne od przechowywanych danych, w tym stanowy Responses API, Files and Collections oraz Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Dzienniki monitorowania nadużyć są generowane dla każdego użycia funkcji API i przechowywane przez maksymalnie 30 dni.",
|
||||
"go.faq.a5.catJamRetention": "W okresie bezpłatnego dostępu zebrane dane mogą być wykorzystywane do ulepszania modelu.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Twoje prompty i odpowiedzi mogą być wykorzystywane do trenowania przyszłych modeli Google.",
|
||||
"go.faq.a5.learnMore": "Dowiedz się więcej",
|
||||
|
||||
@@ -306,7 +306,6 @@ export const dict = {
|
||||
"ZDR отключает важные функции API, зависящие от сохраненных данных, включая Responses API с сохранением состояния, Files and Collections и Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Журналы мониторинга злоупотреблений создаются при любом использовании функций API и хранятся до 30 дней.",
|
||||
"go.faq.a5.catJamRetention": "В течение бесплатного периода собранные данные могут использоваться для улучшения модели.",
|
||||
"go.faq.a5.geminiRetention": "Ваши промпты и ответы могут использоваться для обучения будущих моделей Google.",
|
||||
"go.faq.a5.learnMore": "Подробнее",
|
||||
"go.faq.a5.deepseekRetention":
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"ระบบจะสร้างบันทึกการตรวจสอบการใช้งานในทางที่ผิดสำหรับการใช้งานฟีเจอร์ API ทั้งหมด และเก็บรักษาไว้นานสูงสุด 30 วัน",
|
||||
"go.faq.a5.museRetention":
|
||||
"ราคาของ token ลดลงอย่างมาก โดยแลกกับการอนุญาตให้นำพรอมต์และผลลัพธ์ที่สร้างขึ้นของคุณไปใช้ฝึกโมเดล Meta ในอนาคต",
|
||||
"go.faq.a5.catJamRetention": "ในช่วงที่ให้ใช้ฟรี ข้อมูลที่รวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล",
|
||||
"go.faq.a5.geminiRetention": "พรอมต์และผลลัพธ์ที่สร้างขึ้นของคุณอาจถูกนำไปใช้ฝึกโมเดล Google ในอนาคต",
|
||||
"go.faq.a5.learnMore": "ดูข้อมูลเพิ่มเติม",
|
||||
"go.faq.a5.deepseekRetention": "ข้อตกลง ZDR จะต่ออายุทุกเดือน ข้อตกลงปัจจุบันมีผลใช้ถึงวันที่ 31 ตุลาคม 2026",
|
||||
|
||||
@@ -304,7 +304,6 @@ export const dict = {
|
||||
"ZDR, durum bilgisi tutan Responses API, Files and Collections ve Batch API dahil olmak üzere saklanan verilere bağlı önemli API özelliklerini devre dışı bırakır.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Tüm API özelliklerinin kullanımı için kötüye kullanım izleme günlükleri oluşturulur ve 30 güne kadar saklanır.",
|
||||
"go.faq.a5.catJamRetention": "Ücretsiz dönem boyunca toplanan veriler modeli geliştirmek için kullanılabilir.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"İstemleriniz ve tamamlamalarınız gelecekteki Google modellerini eğitmek için kullanılabilir.",
|
||||
"go.faq.a5.learnMore": "Daha fazla bilgi",
|
||||
|
||||
@@ -302,7 +302,6 @@ export const dict = {
|
||||
"ZDR вимикає важливі функції API, які залежать від збережених даних, зокрема Responses API зі збереженням стану, Files and Collections та Batch API.",
|
||||
"go.faq.a5.gptRetention":
|
||||
"Журнали моніторингу зловживань створюються для всіх випадків використання функцій API та зберігаються до 30 днів.",
|
||||
"go.faq.a5.catJamRetention": "Протягом безкоштовного періоду зібрані дані можуть використовуватися для покращення моделі.",
|
||||
"go.faq.a5.geminiRetention":
|
||||
"Ваші промпти та відповіді можуть використовуватися для навчання майбутніх моделей Google.",
|
||||
"go.faq.a5.learnMore": "Докладніше",
|
||||
|
||||
@@ -288,7 +288,6 @@ export const dict = {
|
||||
"ZDR 会禁用依赖所存储数据的重要 API 功能,包括有状态的 Responses API、Files and Collections 和 Batch API。",
|
||||
"go.faq.a5.gptRetention": "所有 API 功能的使用都会生成滥用监控日志,并最多保留 30 天。",
|
||||
"go.faq.a5.museRetention": "以允许使用你的提示词和补全结果训练未来的 Meta 模型为交换,token 价格可获得大幅折扣。",
|
||||
"go.faq.a5.catJamRetention": "在免费期间,收集的数据可能会用于改进模型。",
|
||||
"go.faq.a5.geminiRetention": "你的提示词和模型输出可能会被用于训练未来的 Google 模型。",
|
||||
"go.faq.a5.learnMore": "了解更多",
|
||||
"go.faq.a5.deepseekRetention": "ZDR 协议每月续签。当前协议有效期至 2026 年 10 月 31 日。",
|
||||
|
||||
@@ -287,7 +287,6 @@ export const dict = {
|
||||
"ZDR 會停用依賴儲存資料的重要 API 功能,包括具狀態的 Responses API、Files and Collections 與 Batch API。",
|
||||
"go.faq.a5.gptRetention": "所有 API 功能的使用都會產生濫用監控日誌,並保留最多 30 天。",
|
||||
"go.faq.a5.museRetention": "以允許使用您的提示詞和生成結果來訓練未來的 Meta 模型為交換,token 價格可享大幅折扣。",
|
||||
"go.faq.a5.catJamRetention": "在免費期間,收集的資料可能會用於改進模型。",
|
||||
"go.faq.a5.geminiRetention": "你的提示詞和模型輸出可能會被用於訓練未來的 Google 模型。",
|
||||
"go.faq.a5.learnMore": "了解更多",
|
||||
"go.faq.a5.deepseekRetention": "ZDR 協議每月續簽。目前的協議有效至 2026 年 10 月 31 日。",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { z } from "zod"
|
||||
|
||||
export const consoleAccountSchema = z.object({
|
||||
id: z.string().min(1).max(255),
|
||||
email: z.email(),
|
||||
name: z.string().nullable(),
|
||||
createdAt: z.iso.datetime(),
|
||||
})
|
||||
export type ConsoleAccount = z.infer<typeof consoleAccountSchema>
|
||||
|
||||
export async function lookupConsoleAccount(email: string): Promise<ConsoleAccount | null> {
|
||||
const response = await fetch(`${Resource.CONSOLE_CRM_LOOKUP_URL.value}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${Resource.CONSOLE_CRM_TOKEN.value}` },
|
||||
body: JSON.stringify({ email }),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Console account lookup failed (${response.status})`)
|
||||
const result = z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json())
|
||||
return result.account
|
||||
}
|
||||
|
||||
export function consoleAccountFields(account: ConsoleAccount | null) {
|
||||
if (!account) return { Has_Console_Account__c: false }
|
||||
return {
|
||||
Has_Console_Account__c: true,
|
||||
Console_Account_ID__c: account.id,
|
||||
Console_Signup_Date__c: account.createdAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { syncConsoleAccount, type SalesforceRequest } from "./salesforce"
|
||||
import { handleConsoleSignup } from "./console-signup"
|
||||
import { z } from "zod"
|
||||
|
||||
const account = {
|
||||
id: "user_console_test",
|
||||
email: "person@example.test",
|
||||
name: "Test Person",
|
||||
createdAt: "2026-10-01T10:00:00.000Z",
|
||||
}
|
||||
const servers = new Set<ReturnType<typeof Bun.serve>>()
|
||||
afterEach(async () => {
|
||||
await Promise.all([...servers].map((server) => server.stop(true)))
|
||||
servers.clear()
|
||||
})
|
||||
|
||||
function fixture(
|
||||
initial: { object: "Lead" | "Contact"; Id: string; LeadSource: string; Company?: string; AccountId?: string }[] = [],
|
||||
email = account.email,
|
||||
) {
|
||||
const people: Record<string, unknown>[] = initial.map((person) => ({ ...person }))
|
||||
const writes: { method: string; path: string; payload: Record<string, unknown> }[] = []
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.endsWith("/query")) {
|
||||
const query = url.searchParams.get("q") ?? ""
|
||||
expect(query).toContain("Console_Account_ID__c")
|
||||
expect(query).toContain(`Email = '${email.toLowerCase()}'`)
|
||||
const object = query.includes("FROM Contact") ? "Contact" : "Lead"
|
||||
if (object === "Lead") expect(query).toContain("IsConverted = false")
|
||||
return Response.json({
|
||||
done: true,
|
||||
records: people.filter((person) => person.object === object).map((person) => ({ Id: person.Id })),
|
||||
})
|
||||
}
|
||||
const payload = z.record(z.string(), z.unknown()).parse(await request.json())
|
||||
writes.push({ method: request.method, path: url.pathname, payload })
|
||||
if (request.method === "PATCH") {
|
||||
const person = people.find((person) => person.Id === url.pathname.split("/").at(-1))
|
||||
if (!person) return Response.json({ error: "Not found" }, { status: 404 })
|
||||
Object.assign(person, payload)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
const id = "00Q000000000001AAA"
|
||||
people.push({ object: "Lead", Id: id, ...payload })
|
||||
return Response.json({ success: true, id }, { status: 201 })
|
||||
},
|
||||
})
|
||||
servers.add(server)
|
||||
const request: SalesforceRequest = async (path, method = "GET", payload) => {
|
||||
const response = await fetch(new URL(path, server.url), {
|
||||
method,
|
||||
...(payload ? { body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } } : {}),
|
||||
})
|
||||
if (!response.ok) throw new Error("CRM request failed")
|
||||
return response.status === 204 ? null : response.json()
|
||||
}
|
||||
return { people, writes, request }
|
||||
}
|
||||
|
||||
test("a new signup creates one Console lead and an exact retry updates it", async () => {
|
||||
const crm = fixture()
|
||||
await syncConsoleAccount(account, crm.request)
|
||||
await syncConsoleAccount(account, crm.request)
|
||||
expect(crm.people).toHaveLength(1)
|
||||
expect(crm.writes.filter((write) => write.method === "POST")).toHaveLength(1)
|
||||
expect(crm.people[0]).toMatchObject({
|
||||
Company: "example.test",
|
||||
LeadSource: "console signup",
|
||||
Has_Console_Account__c: true,
|
||||
Console_Account_ID__c: account.id,
|
||||
Console_Signup_Date__c: account.createdAt,
|
||||
})
|
||||
expect(crm.writes[1].payload).not.toHaveProperty("LeadSource")
|
||||
expect(crm.writes[1].payload).not.toHaveProperty("Company")
|
||||
})
|
||||
|
||||
test.each([
|
||||
["hello@acme.com", "acme.com"],
|
||||
["jane@company.co.uk", "company.co.uk"],
|
||||
["HELLO@ACME.COM", "acme.com"],
|
||||
["person@gmail.com", "Individual"],
|
||||
["person+signup@GOOGLEMAIL.COM", "Individual"],
|
||||
["person@outlook.com", "Individual"],
|
||||
["person@hotmail.co.uk", "Individual"],
|
||||
["person@yahoo.com", "Individual"],
|
||||
["person@icloud.com", "Individual"],
|
||||
["person@proton.me", "Individual"],
|
||||
["person@protonmail.com", "Individual"],
|
||||
["person@pm.me", "Individual"],
|
||||
["person@aol.com", "Individual"],
|
||||
["person@gmail.com.example.test", "gmail.com.example.test"],
|
||||
])("sets Company for a new signup with %s to %s", async (email, company) => {
|
||||
const crm = fixture([], email)
|
||||
await syncConsoleAccount({ ...account, email }, crm.request)
|
||||
expect(crm.people).toHaveLength(1)
|
||||
expect(crm.people[0]).toMatchObject({ Company: company, Email: email.toLowerCase(), LeadSource: "console signup" })
|
||||
})
|
||||
|
||||
test("matching Leads and Contacts keep their source and receive the Console fields", async () => {
|
||||
const crm = fixture([
|
||||
{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Partner", AccountId: "001000000000001AAA" },
|
||||
{ object: "Lead", Id: "00Q000000000002AAA", LeadSource: "enterprise website form", Company: "Visitor's Company" },
|
||||
])
|
||||
await syncConsoleAccount(account, crm.request)
|
||||
expect(crm.writes.every((write) => write.method === "PATCH")).toBe(true)
|
||||
expect(crm.people.map((person) => person.LeadSource)).toEqual(["Partner", "enterprise website form"])
|
||||
expect(crm.people[0].AccountId).toBe("001000000000001AAA")
|
||||
expect(crm.people[1].Company).toBe("Visitor's Company")
|
||||
for (const write of crm.writes) {
|
||||
expect(write.payload).not.toHaveProperty("Company")
|
||||
expect(write.payload).not.toHaveProperty("AccountId")
|
||||
}
|
||||
for (const person of crm.people)
|
||||
expect(person).toMatchObject({
|
||||
Has_Console_Account__c: true,
|
||||
Console_Account_ID__c: account.id,
|
||||
Console_Signup_Date__c: account.createdAt,
|
||||
})
|
||||
})
|
||||
|
||||
test("a converted person's Contact is updated without creating another Lead", async () => {
|
||||
const crm = fixture([{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Website" }])
|
||||
await syncConsoleAccount(account, crm.request)
|
||||
expect(crm.people).toHaveLength(1)
|
||||
expect(crm.writes[0].path).toContain("/Contact/")
|
||||
expect(crm.people[0].LeadSource).toBe("Website")
|
||||
})
|
||||
|
||||
test("the signup endpoint requires its server credential before touching Salesforce", async () => {
|
||||
const crm = fixture()
|
||||
const response = await handleConsoleSignup(
|
||||
new Request("http://localhost/api/console-signup", { method: "POST", body: JSON.stringify(account) }),
|
||||
"test-shared-token",
|
||||
crm.request,
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
expect(crm.writes).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("the authenticated signup endpoint reports confirmed delivery", async () => {
|
||||
const crm = fixture()
|
||||
const response = await handleConsoleSignup(
|
||||
new Request("http://localhost/api/console-signup", {
|
||||
method: "POST",
|
||||
headers: { Authorization: "Bearer test-shared-token" },
|
||||
body: JSON.stringify(account),
|
||||
}),
|
||||
"test-shared-token",
|
||||
crm.request,
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ success: true })
|
||||
expect(crm.people).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { timingSafeEqual } from "node:crypto"
|
||||
import { consoleAccountSchema } from "~/lib/console-account"
|
||||
import { salesforceRequest, syncConsoleAccount, type SalesforceRequest } from "~/lib/salesforce"
|
||||
|
||||
export async function handleConsoleSignup(request: Request, token: string, salesforce?: SalesforceRequest) {
|
||||
const expected = Buffer.from(`Bearer ${token}`)
|
||||
const actual = Buffer.from(request.headers.get("authorization") ?? "")
|
||||
if (!token || expected.length !== actual.length || !timingSafeEqual(expected, actual))
|
||||
return new Response(null, { status: 401 })
|
||||
const data: unknown = await request.json().catch(() => null)
|
||||
const account = consoleAccountSchema.safeParse(data)
|
||||
if (!account.success) return Response.json({ error: "Invalid Console account" }, { status: 400 })
|
||||
return Promise.resolve(salesforce)
|
||||
.then(async (client) => syncConsoleAccount(account.data, client ?? (await salesforceRequest())))
|
||||
.then(
|
||||
(result) => Response.json(result),
|
||||
() => Response.json({ error: "Salesforce sync unavailable" }, { status: 503 }),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { sendEmail } from "~/lib/aws"
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
import { createLead } from "~/lib/salesforce"
|
||||
import { inferenceSpendOptions } from "~/lib/inference-spend"
|
||||
import { lookupConsoleAccount, type ConsoleAccount } from "~/lib/console-account"
|
||||
|
||||
interface EnterpriseFormData {
|
||||
name: string
|
||||
role: string
|
||||
company?: string
|
||||
email: string
|
||||
phone?: string
|
||||
inferenceSpend?: string
|
||||
alias?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
|
||||
|
||||
function splitFullName(fullName: string) {
|
||||
const parts = fullName
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((p) => p.length > 0)
|
||||
if (parts.length === 0) return { firstName: "", lastName: "" }
|
||||
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
|
||||
}
|
||||
|
||||
function subscribe(email: string, fullName: string) {
|
||||
const name = splitFullName(fullName)
|
||||
const fields: Record<string, string> = {}
|
||||
if (name.firstName) fields.FirstName = name.firstName
|
||||
if (name.lastName) fields.LastName = name.lastName
|
||||
|
||||
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
|
||||
if (Object.keys(fields).length) payload.fields = fields
|
||||
|
||||
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Resource.EMAILOCTOPUS_API_KEY.value}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).then(
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
console.error("EmailOctopus subscribe failed:", res.status, res.statusText, await res.text())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
(err) => {
|
||||
console.error("Failed to subscribe enterprise email:", err)
|
||||
return false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function handleEnterpriseRequest(
|
||||
request: Request,
|
||||
options: {
|
||||
salesforceSubmit?: (payload: Record<string, unknown>) => Promise<boolean>
|
||||
notifications?: boolean
|
||||
accountLookup?: (email: string) => Promise<ConsoleAccount | null>
|
||||
} = {},
|
||||
) {
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
try {
|
||||
const body = (await request.json()) as EnterpriseFormData
|
||||
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
|
||||
|
||||
if (trap) {
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
}
|
||||
|
||||
if (!body.name || !body.role || !body.email || !body.message) {
|
||||
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(body.email)) {
|
||||
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const inferenceSpend = inferenceSpendOptions.find((option) => option.value === body.inferenceSpend)
|
||||
if (body.inferenceSpend && !inferenceSpend) {
|
||||
return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const account = await (options.accountLookup ?? lookupConsoleAccount)(body.email).catch((err) => {
|
||||
console.error("Failed to look up Console account:", err)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const emailContent = `
|
||||
${body.message}<br><br>
|
||||
${inferenceSpend ? `Current monthly inference spend: ${inferenceSpend.salesforceValue}<br><br>` : ""}
|
||||
--<br>
|
||||
${body.name}<br>
|
||||
${body.role}<br>
|
||||
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
|
||||
${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||
|
||||
const [lead, mail, octopus] = await Promise.all([
|
||||
createLead(
|
||||
{
|
||||
name: body.name,
|
||||
role: body.role,
|
||||
company: body.company,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
inferenceSpend: inferenceSpend?.salesforceValue,
|
||||
message: body.message,
|
||||
consoleAccount: account,
|
||||
},
|
||||
options.salesforceSubmit,
|
||||
).catch((err) => {
|
||||
console.error("Failed to create Salesforce lead:", err)
|
||||
return false
|
||||
}),
|
||||
options.notifications === false
|
||||
? Promise.resolve(false)
|
||||
: sendEmail({
|
||||
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
console.error("Failed to send enterprise email:", err)
|
||||
return false
|
||||
},
|
||||
),
|
||||
options.notifications === false ? Promise.resolve(false) : subscribe(body.email, body.name),
|
||||
])
|
||||
|
||||
if (!lead && !mail && !octopus) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
}
|
||||
console.error("Enterprise inquiry delivery failed", { email: body.email })
|
||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||
}
|
||||
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error processing enterprise form:", error)
|
||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
|
||||
const leads: Record<string, unknown>[] = []
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request, instance) {
|
||||
const path = new URL(request.url).pathname
|
||||
if (path === "/services/oauth2/token") {
|
||||
return Response.json({ access_token: "local-test-token", instance_url: instance.url.origin })
|
||||
}
|
||||
if (path === "/services/data/v59.0/sobjects/Lead" && request.method === "POST") {
|
||||
leads.push(await request.json())
|
||||
return Response.json({ id: "local-test-lead", success: true }, { status: 201 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
const resources = {
|
||||
SST_RESOURCE_SALESFORCE_INSTANCE_URL: server.url.origin,
|
||||
SST_RESOURCE_SALESFORCE_CLIENT_ID: "local-test-client",
|
||||
SST_RESOURCE_SALESFORCE_CLIENT_SECRET: "local-test-secret",
|
||||
}
|
||||
const previous = Object.fromEntries(Object.keys(resources).map((key) => [key, process.env[key]]))
|
||||
Object.entries(resources).forEach(([key, value]) => {
|
||||
process.env[key] = JSON.stringify({ value })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server.stop(true)
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
return
|
||||
}
|
||||
process.env[key] = value
|
||||
})
|
||||
})
|
||||
|
||||
test("a fake entry keeps the visitor message, spend range, and enterprise lead source in their own fields", async () => {
|
||||
const { createLead } = await import("./salesforce")
|
||||
const accepted = await createLead({
|
||||
name: "Fake Console Visitor",
|
||||
role: "Engineering lead",
|
||||
company: "Example Test Company",
|
||||
email: "fake-visitor@example.test",
|
||||
inferenceSpend: "$10K–$50K",
|
||||
message: " We need SSO for our developers.\nPlease share the enterprise options. ",
|
||||
})
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(leads).toHaveLength(1)
|
||||
expect(leads[0]).toEqual({
|
||||
LastName: "Fake Console Visitor",
|
||||
Company: "Example Test Company",
|
||||
Email: "fake-visitor@example.test",
|
||||
Phone: null,
|
||||
Title: "Engineering lead",
|
||||
Description: " We need SSO for our developers.\nPlease share the enterprise options. ",
|
||||
Current_Monthly_Inference_Spend__c: "$10K–$50K",
|
||||
LeadSource: "enterprise website form",
|
||||
})
|
||||
})
|
||||
|
||||
test("the local Enterprise request handler applies the updated mapping to a form submission", async () => {
|
||||
const { handleEnterpriseRequest } = await import("./enterprise")
|
||||
const response = await handleEnterpriseRequest(
|
||||
new Request("http://localhost/api/enterprise", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "Fake Local Form Visitor",
|
||||
role: "Engineering lead",
|
||||
company: "Example Test Company",
|
||||
email: "fake-local-visitor@example.test",
|
||||
inferenceSpend: "50k-100k",
|
||||
message: " How are you bro!\nThis is a local form test. ",
|
||||
alias: "",
|
||||
}),
|
||||
}),
|
||||
{
|
||||
salesforceSubmit: async (payload) => {
|
||||
const response = await fetch(`${server.url.origin}/services/data/v59.0/sobjects/Lead`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return response.ok
|
||||
},
|
||||
notifications: false,
|
||||
accountLookup: async () => ({
|
||||
id: "user_test",
|
||||
email: "fake-local-visitor@example.test",
|
||||
name: null,
|
||||
createdAt: "2026-10-01T12:00:00.000Z",
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ success: true })
|
||||
expect(leads.at(-1)).toMatchObject({
|
||||
LastName: "Fake Local Form Visitor",
|
||||
Description: " How are you bro!\nThis is a local form test. ",
|
||||
Current_Monthly_Inference_Spend__c: "$50K–$100K",
|
||||
LeadSource: "enterprise website form",
|
||||
Has_Console_Account__c: true,
|
||||
Console_Account_ID__c: "user_test",
|
||||
Console_Signup_Date__c: "2026-10-01T12:00:00.000Z",
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,54 @@
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { z } from "zod"
|
||||
import { consoleAccountFields, type ConsoleAccount } from "./console-account"
|
||||
|
||||
// Match provider domains exactly; business domains hosted by these providers remain business addresses.
|
||||
const personalEmailDomains = new Set([
|
||||
"gmail.com",
|
||||
"googlemail.com",
|
||||
"outlook.com",
|
||||
"outlook.co.uk",
|
||||
"hotmail.com",
|
||||
"hotmail.co.uk",
|
||||
"hotmail.fr",
|
||||
"hotmail.de",
|
||||
"hotmail.it",
|
||||
"live.com",
|
||||
"live.co.uk",
|
||||
"live.ca",
|
||||
"live.com.au",
|
||||
"msn.com",
|
||||
"yahoo.com",
|
||||
"yahoo.co.uk",
|
||||
"yahoo.co.in",
|
||||
"yahoo.com.au",
|
||||
"yahoo.ca",
|
||||
"yahoo.fr",
|
||||
"yahoo.de",
|
||||
"yahoo.it",
|
||||
"yahoo.co.jp",
|
||||
"ymail.com",
|
||||
"rocketmail.com",
|
||||
"icloud.com",
|
||||
"me.com",
|
||||
"mac.com",
|
||||
"proton.me",
|
||||
"protonmail.com",
|
||||
"pm.me",
|
||||
"aol.com",
|
||||
"aim.com",
|
||||
"gmx.com",
|
||||
"gmx.de",
|
||||
"gmx.net",
|
||||
"mail.com",
|
||||
"fastmail.com",
|
||||
"fastmail.fm",
|
||||
"hey.com",
|
||||
"tuta.com",
|
||||
"tuta.io",
|
||||
"tutanota.com",
|
||||
"tutanota.de",
|
||||
])
|
||||
|
||||
async function login() {
|
||||
const url = Resource.SALESFORCE_INSTANCE_URL.value.replace(/\/$/, "")
|
||||
@@ -46,9 +96,25 @@ export interface SalesforceLeadInput {
|
||||
phone?: string
|
||||
inferenceSpend?: string
|
||||
message: string
|
||||
consoleAccount?: ConsoleAccount | null
|
||||
}
|
||||
|
||||
export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
|
||||
export async function createLead(
|
||||
input: SalesforceLeadInput,
|
||||
submit?: (payload: Record<string, unknown>) => Promise<boolean>,
|
||||
): Promise<boolean> {
|
||||
const payload = {
|
||||
LastName: input.name,
|
||||
Company: input.company?.trim() || "Website",
|
||||
Email: input.email,
|
||||
Phone: input.phone ?? null,
|
||||
Title: input.role,
|
||||
Description: input.message,
|
||||
Current_Monthly_Inference_Spend__c: input.inferenceSpend,
|
||||
LeadSource: "enterprise website form",
|
||||
...(input.consoleAccount === undefined ? {} : consoleAccountFields(input.consoleAccount)),
|
||||
}
|
||||
if (submit) return submit(payload)
|
||||
const auth = await login()
|
||||
if (!auth) return false
|
||||
|
||||
@@ -58,18 +124,7 @@ export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
LastName: input.name,
|
||||
Company: input.company?.trim() || "Website",
|
||||
Email: input.email,
|
||||
Phone: input.phone ?? null,
|
||||
Title: input.role,
|
||||
Description: input.inferenceSpend
|
||||
? `Current monthly inference spend: ${input.inferenceSpend}\n\n${input.message}`
|
||||
: input.message,
|
||||
Current_Monthly_Inference_Spend__c: input.inferenceSpend,
|
||||
LeadSource: "Website",
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
}).catch((err) => {
|
||||
console.error("Failed to create Salesforce lead:", err)
|
||||
})
|
||||
@@ -83,3 +138,65 @@ export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export type SalesforceRequest = (
|
||||
path: string,
|
||||
method?: "GET" | "POST" | "PATCH",
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<unknown>
|
||||
|
||||
export async function salesforceRequest(): Promise<SalesforceRequest> {
|
||||
const auth = await login()
|
||||
if (!auth) throw new Error("Salesforce authentication unavailable")
|
||||
return async (path, method = "GET", payload) => {
|
||||
const response = await fetch(`${auth.url}${path}`, {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" },
|
||||
...(payload ? { body: JSON.stringify(payload) } : {}),
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Salesforce request failed (${response.status})`)
|
||||
if (response.status === 204) return null
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
|
||||
/** Update existing people without changing their acquisition source or sales notes. */
|
||||
export async function syncConsoleAccount(account: ConsoleAccount, request: SalesforceRequest) {
|
||||
const quote = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")
|
||||
const matches = async (object: "Contact" | "Lead") => {
|
||||
const query = `SELECT Id FROM ${object} WHERE ${object === "Lead" ? "IsConverted = false AND " : ""}(Console_Account_ID__c = '${quote(account.id)}' OR Email = '${quote(account.email.toLowerCase())}')`
|
||||
const records: { Id: string }[] = []
|
||||
let path: string | undefined = `/services/data/v59.0/query?q=${encodeURIComponent(query)}`
|
||||
while (path) {
|
||||
const result = z
|
||||
.object({
|
||||
records: z.array(z.object({ Id: z.string().regex(/^[a-zA-Z0-9]{15,18}$/) })),
|
||||
done: z.boolean(),
|
||||
nextRecordsUrl: z.string().optional(),
|
||||
})
|
||||
.parse(await request(path))
|
||||
records.push(...result.records)
|
||||
if (result.done) break
|
||||
if (!result.nextRecordsUrl?.startsWith("/services/data/")) throw new Error("Salesforce query pagination missing")
|
||||
path = result.nextRecordsUrl
|
||||
}
|
||||
return records.map((record) => ({ object, id: record.Id }))
|
||||
}
|
||||
const people = [...(await matches("Contact")), ...(await matches("Lead"))]
|
||||
const fields = consoleAccountFields(account)
|
||||
for (const person of people)
|
||||
await request(`/services/data/v59.0/sobjects/${person.object}/${person.id}`, "PATCH", fields)
|
||||
if (people.length > 0) return { success: true, created: false, updated: people.length }
|
||||
const domain = account.email.slice(account.email.lastIndexOf("@") + 1).toLowerCase()
|
||||
const result = z.object({ success: z.literal(true), id: z.string() }).parse(
|
||||
await request("/services/data/v59.0/sobjects/Lead", "POST", {
|
||||
LastName: (account.name?.trim() || account.email).slice(0, 80),
|
||||
Company: personalEmailDomains.has(domain) ? "Individual" : domain,
|
||||
Email: account.email.toLowerCase(),
|
||||
LeadSource: "console signup",
|
||||
...fields,
|
||||
}),
|
||||
)
|
||||
return { success: true, created: true, id: result.id }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { handleConsoleSignup } from "~/lib/console-signup"
|
||||
|
||||
// SolidStart keeps only the method export from API routes, so the handler lives in lib.
|
||||
export async function POST(event: APIEvent) {
|
||||
return handleConsoleSignup(event.request, Resource.CONSOLE_CRM_TOKEN.value)
|
||||
}
|
||||
@@ -1,138 +1,7 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { sendEmail } from "~/lib/aws"
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { i18n } from "~/i18n"
|
||||
import { localeFromRequest } from "~/lib/language"
|
||||
import { createLead } from "~/lib/salesforce"
|
||||
import { inferenceSpendOptions } from "~/lib/inference-spend"
|
||||
|
||||
interface EnterpriseFormData {
|
||||
name: string
|
||||
role: string
|
||||
company?: string
|
||||
email: string
|
||||
phone?: string
|
||||
inferenceSpend?: string
|
||||
alias?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
|
||||
|
||||
function splitFullName(fullName: string) {
|
||||
const parts = fullName
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((p) => p.length > 0)
|
||||
if (parts.length === 0) return { firstName: "", lastName: "" }
|
||||
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
|
||||
}
|
||||
|
||||
function subscribe(email: string, fullName: string) {
|
||||
const name = splitFullName(fullName)
|
||||
const fields: Record<string, string> = {}
|
||||
if (name.firstName) fields.FirstName = name.firstName
|
||||
if (name.lastName) fields.LastName = name.lastName
|
||||
|
||||
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
|
||||
if (Object.keys(fields).length) payload.fields = fields
|
||||
|
||||
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Resource.EMAILOCTOPUS_API_KEY.value}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).then(
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
console.error("EmailOctopus subscribe failed:", res.status, res.statusText, await res.text())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
(err) => {
|
||||
console.error("Failed to subscribe enterprise email:", err)
|
||||
return false
|
||||
},
|
||||
)
|
||||
}
|
||||
import { handleEnterpriseRequest } from "~/lib/enterprise"
|
||||
|
||||
// SolidStart keeps only the method export from API routes, so the handler lives in lib.
|
||||
export async function POST(event: APIEvent) {
|
||||
const dict = i18n(localeFromRequest(event.request))
|
||||
try {
|
||||
const body = (await event.request.json()) as EnterpriseFormData
|
||||
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
|
||||
|
||||
if (trap) {
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
}
|
||||
|
||||
if (!body.name || !body.role || !body.email || !body.message) {
|
||||
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(body.email)) {
|
||||
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const inferenceSpend = inferenceSpendOptions.find((option) => option.value === body.inferenceSpend)
|
||||
if (body.inferenceSpend && !inferenceSpend) {
|
||||
return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 })
|
||||
}
|
||||
|
||||
const emailContent = `
|
||||
${body.message}<br><br>
|
||||
${inferenceSpend ? `Current monthly inference spend: ${inferenceSpend.salesforceValue}<br><br>` : ""}
|
||||
--<br>
|
||||
${body.name}<br>
|
||||
${body.role}<br>
|
||||
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
|
||||
${body.phone ? `${body.phone}<br>` : ""}`.trim()
|
||||
|
||||
const [lead, mail, octopus] = await Promise.all([
|
||||
createLead({
|
||||
name: body.name,
|
||||
role: body.role,
|
||||
company: body.company,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
inferenceSpend: inferenceSpend?.salesforceValue,
|
||||
message: body.message,
|
||||
}).catch((err) => {
|
||||
console.error("Failed to create Salesforce lead:", err)
|
||||
return false
|
||||
}),
|
||||
sendEmail({
|
||||
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
|
||||
subject: `Enterprise Inquiry from ${body.name}`,
|
||||
body: emailContent,
|
||||
replyTo: body.email,
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
console.error("Failed to send enterprise email:", err)
|
||||
return false
|
||||
},
|
||||
),
|
||||
subscribe(body.email, body.name),
|
||||
])
|
||||
|
||||
if (!lead && !mail && !octopus) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
}
|
||||
console.error("Enterprise inquiry delivery failed", { email: body.email })
|
||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||
}
|
||||
|
||||
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error processing enterprise form:", error)
|
||||
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
|
||||
}
|
||||
return handleEnterpriseRequest(event.request)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ const models = [
|
||||
{ name: "Space Bunny", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "LongCat 2.5 Preview Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "Step 5 Preview Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
|
||||
{ name: "Cat Jam Free", training: "go.faq.a5.used", retention: "go.faq.a5.notZdr" },
|
||||
{ name: "Cat Jam Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
|
||||
{ name: "Grok 4.7", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
|
||||
{ name: "Grok 4.6", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
|
||||
{ name: "GPT 6 Luna", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
|
||||
@@ -343,9 +343,6 @@ export default function Home() {
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Cat Jam Free:</strong> {i18n.t("go.faq.a5.catJamRetention")}
|
||||
</p>
|
||||
<p>
|
||||
<strong>DeepSeek:</strong> {i18n.t("go.faq.a5.deepseekRetention")}
|
||||
</p>
|
||||
|
||||
@@ -4,13 +4,14 @@ import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { sameDirectory } from "@opencode/util/path"
|
||||
import { dirname, join } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import path, { dirname, join } from "path"
|
||||
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/http"
|
||||
import { Config } from "../../config.js"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { InstructionDiscovery } from "../../instruction-discovery.js"
|
||||
import { Instructions } from "../../instructions/index.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
|
||||
type Loaded =
|
||||
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
|
||||
@@ -18,17 +19,19 @@ type Loaded =
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.instruction",
|
||||
effect: Effect.fn(function* () {
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const discovery = yield* InstructionDiscovery.Service
|
||||
// Nothing this plugin watches or loads can contribute when both scopes
|
||||
// are disabled; skip the resolves, the watcher fiber, and the transform.
|
||||
if (!discovery.project && !discovery.global) return
|
||||
// Activate even when both discovery scopes are disabled: configured `instructions`
|
||||
// entries are explicit requests, not discovery.
|
||||
yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const start = yield* fs.resolve(location.directory)
|
||||
const root = yield* fs.resolve(location.project.directory)
|
||||
@@ -37,6 +40,10 @@ export const Plugin = define({
|
||||
const stop = FSUtil.contains(home, start) ? home : root
|
||||
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
|
||||
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
|
||||
const configured: { sources: string[]; remote: Map<string, InstructionDiscovery.File> } = {
|
||||
sources: [],
|
||||
remote: new Map(),
|
||||
}
|
||||
|
||||
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
|
||||
// The ancestor walk can reach the global file when the location sits
|
||||
@@ -49,15 +56,15 @@ export const Plugin = define({
|
||||
.filter((file) => discovery.global || file !== globalFile)
|
||||
: []),
|
||||
]
|
||||
for (const path of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path, type: "file" })
|
||||
for (const file of new Set(candidates)) {
|
||||
const updates = yield* watcher.subscribe({ path: file, type: "file" })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
|
||||
}
|
||||
|
||||
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
|
||||
const content = yield* fs.readFileStringSafe(path)
|
||||
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
|
||||
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (filepath: string) {
|
||||
const content = yield* fs.readFileStringSafe(filepath)
|
||||
if (content !== undefined) return new InstructionDiscovery.File({ path: filepath, content })
|
||||
yield* Effect.logDebug("instruction file skipped", { path: filepath, reason: "unavailable" })
|
||||
})
|
||||
|
||||
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
|
||||
@@ -78,6 +85,83 @@ export const Plugin = define({
|
||||
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
|
||||
})
|
||||
|
||||
// Relative patterns search upward from the location to the project root. Without
|
||||
// project discovery they resolve against the global config directory instead.
|
||||
const match = Effect.fn("ConfigInstructionPlugin.match")(function* (source: string) {
|
||||
const expanded = source.startsWith("~/") ? path.join(global.home, source.slice(2)) : source
|
||||
const options = { absolute: true, include: "file", dot: true } as const
|
||||
if (path.isAbsolute(expanded))
|
||||
return yield* fs
|
||||
.scan(path.basename(expanded), { ...options, cwd: path.dirname(expanded) })
|
||||
.pipe(Effect.orElseSucceed(() => [] as string[]))
|
||||
if (project) return yield* fs.globUp(expanded, start, root)
|
||||
if (!discovery.global) return []
|
||||
return yield* fs
|
||||
.scan(expanded, { ...options, cwd: global.config })
|
||||
.pipe(Effect.orElseSucceed(() => [] as string[]))
|
||||
})
|
||||
|
||||
const configuredSource = Effect.fn("ConfigInstructionPlugin.configuredSource")(function* () {
|
||||
const files: InstructionDiscovery.File[] = []
|
||||
const watched = new Set<string>()
|
||||
for (const source of configured.sources) {
|
||||
if (isUrl(source)) {
|
||||
const file = configured.remote.get(source)
|
||||
if (file) files.push(file)
|
||||
continue
|
||||
}
|
||||
for (const item of (yield* match(source)).toSorted()) {
|
||||
const resolved = yield* fs.resolve(item)
|
||||
if (watched.has(resolved)) continue
|
||||
watched.add(resolved)
|
||||
const file = yield* read(resolved)
|
||||
if (file) files.push(file)
|
||||
}
|
||||
}
|
||||
for (const [key] of Array.from(watches)) if (!watched.has(key)) yield* FiberMap.remove(watches, key)
|
||||
for (const file of watched) {
|
||||
const updates = yield* watcher.subscribe({ path: file, type: "file" })
|
||||
yield* FiberMap.run(watches, file, updates.pipe(Stream.runForEach(publish)), {
|
||||
onlyIfMissing: true,
|
||||
startImmediately: true,
|
||||
})
|
||||
}
|
||||
return files
|
||||
})
|
||||
|
||||
// Remote instructions are fetched only when config loads, not on every file rescan.
|
||||
// A failed fetch keeps the last good copy or skips the URL, so an unreachable URL
|
||||
// never makes the whole instruction set unavailable.
|
||||
const reconfigure = Effect.fn("ConfigInstructionPlugin.reconfigure")(
|
||||
function* () {
|
||||
const entries = yield* config.entries()
|
||||
const sources = Array.from(
|
||||
new Set(entries.flatMap((entry) => (entry.type === "document" ? (entry.info.instructions ?? []) : []))),
|
||||
)
|
||||
const fetched = yield* Effect.forEach(
|
||||
sources.filter(isUrl),
|
||||
(url) =>
|
||||
HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((response) => response.text),
|
||||
Effect.timeout("5 seconds"),
|
||||
Effect.map((content) => new InstructionDiscovery.File({ path: url, content })),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to fetch instruction url", { url, cause }).pipe(
|
||||
Effect.as(configured.remote.get(url)),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ concurrency: 4 },
|
||||
)
|
||||
configured.sources = sources
|
||||
configured.remote = new Map(
|
||||
fetched.filter((file) => file !== undefined).map((file) => [file.path, file] as const),
|
||||
)
|
||||
},
|
||||
(effect) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
@@ -92,10 +176,11 @@ export const Plugin = define({
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
configured: isolate("configured", configuredSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project) && Array.isArray(sources.configured)
|
||||
? { type: "available", files: [...sources.global, ...sources.project, ...sources.configured] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
@@ -115,6 +200,13 @@ export const Plugin = define({
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
// Subscribe before the first config read so an update in between still triggers a reload.
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reconfigure().pipe(Effect.andThen(refresh()), Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* reconfigure()
|
||||
yield* refresh()
|
||||
yield* discovery.transform((editor) => {
|
||||
if (loaded.current.type === "unavailable") {
|
||||
@@ -141,3 +233,7 @@ function ancestorDirectories(start: string, stop: string): string[] {
|
||||
if (sameDirectory(start, stop)) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
}
|
||||
|
||||
function isUrl(source: string) {
|
||||
return URL.canParse(source) && /^https?:$/.test(new URL(source).protocol)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export const Plugin = define({
|
||||
})
|
||||
}
|
||||
integrations.update(integrationID, (integration) => {
|
||||
integration.name = provider.name ?? integration.name
|
||||
integration.name = provider.name || integration.name
|
||||
})
|
||||
if (provider.env !== undefined) {
|
||||
integrations.method.update({
|
||||
@@ -66,7 +66,7 @@ export const Plugin = define({
|
||||
})
|
||||
provider.activation = "enabled"
|
||||
if (item.canonical !== undefined) provider.canonical = item.canonical
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.name) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.compatibility !== undefined)
|
||||
provider.compatibility = { ...provider.compatibility, ...item.compatibility }
|
||||
@@ -111,7 +111,7 @@ export const Plugin = define({
|
||||
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
|
||||
}
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.name) model.name = config.name
|
||||
if (config.modelID !== undefined) model.modelID = config.modelID
|
||||
if (config.compatibility !== undefined)
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
|
||||
@@ -19,7 +19,6 @@ type ParseSource =
|
||||
|
||||
type SubstituteInput = ParseSource & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
@@ -56,27 +55,24 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
|
||||
const filePath = token.slice("{file:".length, -1)
|
||||
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
|
||||
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
|
||||
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (input.missing === "empty") return Effect.succeed("")
|
||||
const fileContent = yield* fs
|
||||
.readFileStringSafe(resolvedPath)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new InvalidError({ path: configSource, message: `bad file reference: "${token}"` }, { cause: error }),
|
||||
),
|
||||
)
|
||||
// Like a missing {env:VAR}, a missing or unreadable file substitutes empty so one stale secret
|
||||
// reference cannot take down every Location that loads this config.
|
||||
if (fileContent === undefined)
|
||||
yield* Effect.logWarning("config file reference is missing or unreadable", {
|
||||
source: configSource,
|
||||
reference: token,
|
||||
path: resolvedPath,
|
||||
})
|
||||
|
||||
const message = `bad file reference: "${token}"`
|
||||
return Effect.fail(
|
||||
new InvalidError(
|
||||
{
|
||||
path: configSource,
|
||||
message:
|
||||
error._tag === "PlatformError" && error.reason._tag === "NotFound"
|
||||
? `${message} ${resolvedPath} does not exist`
|
||||
: message,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
out += JSON.stringify(fileContent.trim()).slice(1, -1)
|
||||
out += JSON.stringify((fileContent ?? "").trim()).slice(1, -1)
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
|
||||
@@ -277,7 +277,7 @@ export class SQLiteEffectPreparedQuery<
|
||||
}
|
||||
|
||||
assertUnreachable(cacheStrat)
|
||||
}).pipe(Effect.mapError((e) => new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) })))
|
||||
}).pipe(Effect.mapError((e) => new EffectDrizzleQueryError({ query: queryString, params: params.map(() => "[redacted]"), cause: Cause.fail(e) })))
|
||||
}
|
||||
|
||||
getQuery(): Query {
|
||||
|
||||
@@ -5,11 +5,11 @@ import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { createPatch } from "diff"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Instructions } from "./instructions/index.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
|
||||
path: AbsolutePath,
|
||||
// An absolute file path, or the URL of a configured remote instruction.
|
||||
path: Schema.String,
|
||||
content: Schema.String,
|
||||
}) {}
|
||||
|
||||
@@ -21,7 +21,7 @@ export const Event = {
|
||||
}
|
||||
|
||||
export type Data = {
|
||||
files: Map<AbsolutePath, Types.DeepMutable<File>>
|
||||
files: Map<string, Types.DeepMutable<File>>
|
||||
available: boolean
|
||||
}
|
||||
|
||||
@@ -64,12 +64,12 @@ export const layer = (options?: Options) =>
|
||||
list: () => Array.from(editor.files.values()),
|
||||
add: (file) => editor.files.set(file.path, new File(file) as Types.DeepMutable<File>),
|
||||
update: (path, update) => {
|
||||
const current = editor.files.get(AbsolutePath.make(path))
|
||||
const current = editor.files.get(path)
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.path = AbsolutePath.make(path)
|
||||
current.path = path
|
||||
},
|
||||
remove: (path) => editor.files.delete(AbsolutePath.make(path)),
|
||||
remove: (path) => editor.files.delete(path),
|
||||
unavailable: () => {
|
||||
editor.available = false
|
||||
},
|
||||
|
||||
@@ -185,7 +185,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const client = new Client(clientInfo, {
|
||||
capabilities: {
|
||||
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
|
||||
...(elicitation ? { elicitation: { form: {}, url: {} } } : {}),
|
||||
// Legacy era only: roots are deprecated as of 2026-07-28 and modern servers cannot request them.
|
||||
// Some legacy servers refuse to run without one (https://github.com/anomalyco/opencode/issues/2308).
|
||||
roots: {},
|
||||
@@ -265,7 +265,8 @@ export const connect = Effect.fnUntraced(function* (
|
||||
// Servers that bundle their own Code Mode (Cloudflare and others) expose raw tools when asked
|
||||
// with ?codemode=false, which is what our Code Mode wants. The configured URL stays the OAuth identity.
|
||||
const url = new URL(config.url)
|
||||
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
|
||||
const addedCodemode =
|
||||
config.codemode !== false && !url.searchParams.has("codemode") && !rejectsUnknownQuery(url)
|
||||
if (addedCodemode) url.searchParams.set("codemode", "false")
|
||||
const open = Effect.fnUntraced(function* (url: URL) {
|
||||
session.transport = new StreamableHTTPClientTransport(url, {
|
||||
@@ -326,19 +327,35 @@ export const connect = Effect.fnUntraced(function* (
|
||||
Effect.mapError(failure),
|
||||
Effect.tapError((error) => Effect.logWarning(`failed to ${what}`, { server, error: error.message })),
|
||||
)
|
||||
// The SDK answers a list for an unadvertised capability with a console.debug line plus an empty
|
||||
// result, so refreshing a tools-only server prints to stdout on every catalog load. Negotiate
|
||||
// first and leave the request unmade; the result is the same empty list without the noise.
|
||||
const advertised = (capability: "tools" | "prompts" | "resources") =>
|
||||
Boolean(client.getServerCapabilities()?.[capability])
|
||||
|
||||
return {
|
||||
modern: client.getProtocolEra() === "modern",
|
||||
instructions: client.getInstructions()?.trim() || undefined,
|
||||
tools: () => list("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools)),
|
||||
tools: () =>
|
||||
advertised("tools")
|
||||
? list("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools))
|
||||
: Effect.succeed([]),
|
||||
prompts: () =>
|
||||
list("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts)),
|
||||
advertised("prompts")
|
||||
? list("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts))
|
||||
: Effect.succeed([]),
|
||||
resources: () =>
|
||||
list("list MCP resources", () => client.listResources(undefined, catalog)).pipe(Effect.map((r) => r.resources)),
|
||||
advertised("resources")
|
||||
? list("list MCP resources", () => client.listResources(undefined, catalog)).pipe(
|
||||
Effect.map((r) => r.resources),
|
||||
)
|
||||
: Effect.succeed([]),
|
||||
resourceTemplates: () =>
|
||||
list("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
|
||||
Effect.map((r) => r.resourceTemplates),
|
||||
),
|
||||
advertised("resources")
|
||||
? list("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
|
||||
Effect.map((r) => r.resourceTemplates),
|
||||
)
|
||||
: Effect.succeed([]),
|
||||
readResource: (input) => {
|
||||
if (!client.getServerCapabilities()?.resources) return Effect.succeed(undefined)
|
||||
return request("read MCP resource", (signal) =>
|
||||
@@ -389,6 +406,17 @@ export const connect = Effect.fnUntraced(function* (
|
||||
return yield* new ConnectError({ server, message: explain(error) })
|
||||
})
|
||||
|
||||
// These servers reject any unknown query parameter and have no Code Mode of their own, so adding
|
||||
// ?codemode=false only costs a failed request before the retry at the configured URL.
|
||||
function rejectsUnknownQuery(url: URL) {
|
||||
// Google Workspace MCP (gmailmcp, drivemcp, calendarmcp, ...) returns 404.
|
||||
if (url.hostname.endsWith(".googleapis.com")) return true
|
||||
// Zoho MCP gateways (zohomcp.com, zohomcp.eu, ...) return 400.
|
||||
if (/(^|\.)zohomcp\.[a-z.]+$/.test(url.hostname)) return true
|
||||
// Loopback servers run on this machine (Figma Desktop's Dev Mode MCP returns 404), so they are not hosted Code Mode servers.
|
||||
return url.hostname === "localhost" || url.hostname === "[::1]" || /^127(\.\d+){3}$/.test(url.hostname)
|
||||
}
|
||||
|
||||
// Absent config is legacy: the SDK sends the plain initialize handshake with no discover probe.
|
||||
function negotiation(protocol: ConfigMCP.Protocol | undefined): VersionNegotiationOptions | undefined {
|
||||
if (protocol === undefined || protocol === "legacy") return undefined
|
||||
|
||||
@@ -23,10 +23,9 @@ Use only <https://opencode.ai/v2/docs/> documentation as the source of truth for
|
||||
Do not use <https://opencode.ai/docs/>, which documents V1, and do not use
|
||||
general web search to resolve a V2 documentation question when the V2 docs or
|
||||
linked pages cover it. The schema served from
|
||||
<https://opencode.ai/config.json> may describe V1 even though V2 configuration
|
||||
files include that URL for editor integration. Never use it to infer V2 field
|
||||
names or shapes. If V2 documentation is missing or contradictory, state the
|
||||
uncertainty or ask for clarification instead of falling back to V1.
|
||||
<https://opencode.ai/config.json> accepts both V2 fields and deprecated V1
|
||||
fields. Never recommend a field it marks deprecated. If V2 documentation is
|
||||
missing or contradictory, state the uncertainty or ask for clarification instead of falling back to V1.
|
||||
|
||||
V1 documentation and syntax may be consulted only when the user explicitly
|
||||
asks about V1 or when needed as migration input. Outputs and recommendations
|
||||
|
||||
@@ -288,11 +288,17 @@ export const layer = Layer.effect(
|
||||
// template gets one reminder before it counts as a failure.
|
||||
const send = (request: LLMRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const prompted = LLMRequest.update(request, { messages: [...request.messages, Message.user(prompt)] })
|
||||
const prompted = fitOutput(
|
||||
LLMRequest.update(request, { messages: [...request.messages, Message.user(prompt)] }),
|
||||
context.model.limit,
|
||||
)
|
||||
const reply = yield* stream(context, prompted, prepared.options)
|
||||
if (filled(reply.text)) return { ...reply, recent: split.recent }
|
||||
|
||||
const nudged = LLMRequest.update(prompted, { messages: [...prompted.messages, Message.user(NUDGE)] })
|
||||
const nudged = fitOutput(
|
||||
LLMRequest.update(prompted, { messages: [...prompted.messages, Message.user(NUDGE)] }),
|
||||
context.model.limit,
|
||||
)
|
||||
const retry = yield* stream(context, nudged, prepared.options)
|
||||
if (filled(retry.text)) return { ...retry, recent: split.recent }
|
||||
return yield* Effect.fail<Failure>({
|
||||
@@ -922,6 +928,25 @@ const hasMeasuredPrompt = (message: SessionMessage.Info, model: SessionContext.L
|
||||
message.tokens !== undefined &&
|
||||
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0
|
||||
|
||||
/**
|
||||
* `prepare` sizes the output limit for a request of `budget` tokens, but `deliver` can send a larger one when it
|
||||
* cannot shrink the conversation further. Lower the limit to the room the request being sent leaves in the window,
|
||||
* so the input and the requested output never exceed the window by our own estimate.
|
||||
*
|
||||
* Like `budget` in `prepare`, the estimate counts as measured, without the margin primary requests add for estimates:
|
||||
* `deliver` aims at about 90% of the window, so that margin would cut every summary near its target to the minimum.
|
||||
*/
|
||||
const fitOutput = (request: LLMRequest, limit: SessionContext.Loaded["model"]["limit"]) => {
|
||||
const current = request.generation?.maxTokens
|
||||
if (current === undefined) return request
|
||||
const maxTokens = Math.min(
|
||||
current,
|
||||
SessionModelRequest.outputLimit(limit, "compaction", { measured: estimateRequest(request), estimated: 0 }),
|
||||
)
|
||||
if (maxTokens === current) return request
|
||||
return LLMRequest.update(request, { generation: { ...request.generation, maxTokens } })
|
||||
}
|
||||
|
||||
const estimateRequest = (request: Pick<LLMRequest, "system" | "tools" | "messages">) =>
|
||||
request.system.reduce((sum, part) => sum + Token.estimate(part.text), 0) +
|
||||
request.tools.reduce((sum, tool) => sum + estimateTool(tool), 0) +
|
||||
|
||||
@@ -85,7 +85,7 @@ export interface Input {
|
||||
}
|
||||
|
||||
/** The default output limit: the catalog limit, fitted to the room the prompt leaves in the context window. */
|
||||
const outputLimit = (
|
||||
export const outputLimit = (
|
||||
limit: Model.Info["limit"],
|
||||
kind: "primary" | "compaction",
|
||||
inputTokens?: Input["inputTokens"],
|
||||
|
||||
@@ -52,7 +52,7 @@ export const layer = Layer.effect(
|
||||
action: name(tool.server, tool.name),
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
metadata: { input: input ?? {} },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: {
|
||||
|
||||
@@ -1306,7 +1306,7 @@ describe("Config", () => {
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.jsonc"),
|
||||
`{
|
||||
// Ignored reference: {file:missing.txt}
|
||||
// Ignored reference: {file:.}
|
||||
"username": "user-{env:OPENCODE_TEST_MISSING}",
|
||||
"mcp": {
|
||||
"servers": {
|
||||
@@ -1315,7 +1315,8 @@ describe("Config", () => {
|
||||
"url": "https://example.com/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
|
||||
"X-Token": "{file:token.txt}"
|
||||
"X-Token": "{file:token.txt}",
|
||||
"X-Missing": "Bearer {file:~/.opencode-test-missing/key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1335,6 +1336,7 @@ describe("Config", () => {
|
||||
expect(remote.headers).toEqual({
|
||||
Authorization: "Bearer secret",
|
||||
"X-Token": 'file\n"token"',
|
||||
"X-Missing": "Bearer ",
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
|
||||
@@ -48,7 +48,7 @@ test("selects rows through Effect-yieldable query builders", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("maps query failures with query, params, and cause", async () => {
|
||||
test("maps query failures with query, redacted params, and cause", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
|
||||
@@ -56,7 +56,7 @@ test("maps query failures with query, params, and cause", async () => {
|
||||
|
||||
expect(error).toBeInstanceOf(EffectDrizzleQueryError)
|
||||
expect(error.query).toBe("select * from missing_table where id = ?")
|
||||
expect(error.params).toEqual([42])
|
||||
expect(error.params).toEqual(["[redacted]"])
|
||||
expect(Cause.isCause(error.cause)).toBe(true)
|
||||
if (!Cause.isCause(error.cause)) return
|
||||
expect(error.cause.reasons[0]?._tag).toBe("Fail")
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, PubSub, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/http"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ConfigInstructionPlugin } from "@opencode/core/config/plugin/instruction"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Document, Info } from "@opencode/schema/config"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
@@ -26,13 +30,25 @@ const instructionLayer = (input: {
|
||||
home?: string
|
||||
locationServiceLayer: Layer.Layer<Location.Service>
|
||||
filesystemLayer?: Layer.Layer<FSUtil.Service>
|
||||
instructions?: string[]
|
||||
http?: HttpClient.HttpClient
|
||||
project?: boolean
|
||||
}) => {
|
||||
const watcher = Watcher.testLayer
|
||||
const config = Config.testLayer(input.instructions ? [configured(input.instructions)] : [])
|
||||
return Layer.mergeAll(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
|
||||
LayerNode.group([
|
||||
InstructionDiscovery.node,
|
||||
Bus.node,
|
||||
Config.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
Location.node,
|
||||
Watcher.node,
|
||||
]),
|
||||
[
|
||||
Config.node.replace(config),
|
||||
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
|
||||
Global.node.replace(
|
||||
input.config || input.home
|
||||
@@ -48,20 +64,34 @@ const instructionLayer = (input: {
|
||||
],
|
||||
),
|
||||
watcher,
|
||||
config,
|
||||
Layer.succeed(HttpClient.HttpClient, input.http ?? respond(() => new Response(null, { status: 404 }))),
|
||||
)
|
||||
}
|
||||
|
||||
const start = Effect.fnUntraced(function* () {
|
||||
yield* ConfigInstructionPlugin.Plugin.effect(host())
|
||||
const start = Effect.fnUntraced(function* (ctx = host()) {
|
||||
yield* ConfigInstructionPlugin.Plugin.effect(ctx)
|
||||
return yield* InstructionDiscovery.Service
|
||||
})
|
||||
|
||||
const file = (path: string, content: string) =>
|
||||
new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
|
||||
const configured = (instructions: string[]) => new Document({ type: "document", info: new Info({ instructions }) })
|
||||
|
||||
const configUpdated = () => ({ id: Event.ID.create(), created: Date.now(), type: "config.updated" as const, data: {} })
|
||||
|
||||
const respond = (handler: (url: string) => Response) =>
|
||||
HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request.url))))
|
||||
|
||||
const file = (path: string, content: string) => new InstructionDiscovery.File({ path, content })
|
||||
|
||||
function emitAndWait(update: Watcher.Update) {
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* triggerAndWait(watcher.emit(update))
|
||||
})
|
||||
}
|
||||
|
||||
function triggerAndWait<E, R>(trigger: Effect.Effect<void, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const updated = yield* Deferred.make<void>()
|
||||
const fiber = yield* bus.subscribe(InstructionDiscovery.Event.Updated).pipe(
|
||||
@@ -69,7 +99,7 @@ function emitAndWait(update: Watcher.Update) {
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* watcher.emit(update)
|
||||
yield* trigger
|
||||
yield* Deferred.await(updated).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Fiber.interrupt(fiber)
|
||||
})
|
||||
@@ -250,6 +280,133 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads configured files, globs, home paths, and URLs alongside AGENTS.md", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const project = path.join(tmp.path, "repo")
|
||||
const directory = path.join(project, "packages", "core")
|
||||
const agents = path.join(project, "AGENTS.md")
|
||||
const first = path.join(project, "rules", "a.md")
|
||||
const second = path.join(project, "rules", "b.md")
|
||||
const absolute = path.join(tmp.path, "absolute.md")
|
||||
const homeFile = path.join(tmp.path, "home.md")
|
||||
const remote = "https://example.test/instructions.md"
|
||||
const requests: string[] = []
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(first), { recursive: true })
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await Promise.all([
|
||||
fs.writeFile(agents, "agents"),
|
||||
fs.writeFile(first, "first"),
|
||||
fs.writeFile(second, "second"),
|
||||
fs.writeFile(absolute, "absolute"),
|
||||
fs.writeFile(homeFile, "home"),
|
||||
])
|
||||
})
|
||||
|
||||
const discovery = yield* start()
|
||||
const initialized = yield* readInitial(yield* discovery.load())
|
||||
// The unreachable URL and missing file are skipped without making AGENTS.md unavailable.
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Instructions from: ${agents}\nagents`,
|
||||
`Instructions from: ${first}\nfirst`,
|
||||
`Instructions from: ${second}\nsecond`,
|
||||
`Instructions from: ${absolute}\nabsolute`,
|
||||
`Instructions from: ${homeFile}\nhome`,
|
||||
`Instructions from: ${remote}\nremote`,
|
||||
].join("\n\n"),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(first, "changed"))
|
||||
yield* emitAndWait({ type: "update", path: first })
|
||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
||||
`The instructions changed:\nInstructions from: ${first}\nchanged`,
|
||||
)
|
||||
// File rescans reuse fetched URLs instead of requesting them again.
|
||||
expect(requests).toHaveLength(2)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
home: tmp.path,
|
||||
instructions: [
|
||||
"rules/*.md",
|
||||
absolute,
|
||||
"~/home.md",
|
||||
remote,
|
||||
"missing.md",
|
||||
"https://example.test/missing.md",
|
||||
],
|
||||
http: respond((url) => {
|
||||
requests.push(url)
|
||||
return url === remote ? new Response("remote") : new Response(null, { status: 404 })
|
||||
}),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads configured sources on config updates and keeps the last fetched URL", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const first = path.join(tmp.path, "first.md")
|
||||
const second = path.join(tmp.path, "second.md")
|
||||
const remote = "https://example.test/instructions.md"
|
||||
const responses = [new Response("remote"), new Response(null, { status: 500 })]
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(first, "first")
|
||||
await fs.writeFile(second, "second")
|
||||
})
|
||||
const events = yield* PubSub.unbounded<ReturnType<typeof configUpdated>>()
|
||||
const config = yield* Config.Test
|
||||
const discovery = yield* start(host({ event: { subscribe: () => Stream.fromPubSub(events) } }))
|
||||
const initialized = yield* readInitial(yield* discovery.load())
|
||||
expect(initialized.text).toBe(
|
||||
[`Instructions from: ${first}\nfirst`, `Instructions from: ${remote}\nremote`].join("\n\n"),
|
||||
)
|
||||
|
||||
yield* config.setEntries([configured([second, remote])])
|
||||
yield* triggerAndWait(PubSub.publish(events, configUpdated()).pipe(Effect.asVoid))
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
||||
[`Instructions from: ${second}\nsecond`, `Instructions from: ${remote}\nremote`].join("\n\n"),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
instructions: [first, remote],
|
||||
http: respond(() => responses.shift() ?? new Response(null, { status: 500 })),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("keeps an empty AGENTS.md as available context", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -103,7 +103,7 @@ describe("LocationServiceMap", () => {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(directory, 0o755)))
|
||||
}
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "username.txt"), { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
|
||||
)
|
||||
@@ -124,6 +124,7 @@ describe("LocationServiceMap", () => {
|
||||
}
|
||||
if (failure === "permissions") yield* Effect.promise(() => fs.chmod(directory, 0o755))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.rm(path.join(directory, "username.txt"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
|
||||
}
|
||||
expect((yield* Effect.promise(() => fs.stat(directory))).isDirectory()).toBe(true)
|
||||
@@ -147,7 +148,7 @@ describe("LocationServiceMap", () => {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "username.txt"), { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
|
||||
)
|
||||
@@ -165,6 +166,7 @@ describe("LocationServiceMap", () => {
|
||||
if (failure === "file") yield* Effect.promise(() => fs.rm(directory))
|
||||
if (failure !== "config reference") yield* Effect.promise(() => fs.mkdir(directory))
|
||||
if (failure === "config reference") {
|
||||
yield* Effect.promise(() => fs.rm(path.join(directory, "username.txt"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
|
||||
}
|
||||
const repaired = yield* locations.contextEffect(ref)
|
||||
|
||||
+19
-100
@@ -991,15 +991,7 @@ test("applies configured MCP timeouts to resource operations", async () => {
|
||||
})
|
||||
|
||||
for (const entry of [
|
||||
{ name: "default", query: "", codemode: undefined, expected: "?codemode=false" },
|
||||
{ name: "explicit local code mode", query: "", codemode: true, expected: "?codemode=false" },
|
||||
{ name: "direct tools", query: "", codemode: false, expected: "" },
|
||||
{
|
||||
name: "existing query",
|
||||
query: "?source=opencode",
|
||||
codemode: undefined,
|
||||
expected: "?source=opencode&codemode=false",
|
||||
},
|
||||
{ name: "explicit remote code mode", query: "?codemode=true", codemode: undefined, expected: "?codemode=true" },
|
||||
{ name: "explicit remote opt-out", query: "?codemode=false", codemode: undefined, expected: "?codemode=false" },
|
||||
{ name: "portal opt-out", query: "?codemode=off", codemode: undefined, expected: "?codemode=off" },
|
||||
@@ -1024,42 +1016,7 @@ for (const entry of [
|
||||
)
|
||||
}
|
||||
|
||||
for (const { status, query } of [400, 404].flatMap((status) =>
|
||||
["", "?source=hello%20world&tag=a&tag=b"].map((query) => ({ status, query })),
|
||||
)) {
|
||||
testEffect(Layer.empty).live(`retries MCP initialization ${status} at the original URL: ${query || "no query"}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const headers: Array<string | null> = []
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => {
|
||||
headers.push(request.headers.get("x-mcp-test"))
|
||||
return new URL(request.url).searchParams.has("codemode") ? new Response(null, { status }) : undefined
|
||||
},
|
||||
})
|
||||
const config = new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url: server.url + query,
|
||||
headers: { "x-mcp-test": "preserved" },
|
||||
oauth: false,
|
||||
})
|
||||
const connection = yield* connect("resources", config, import.meta.dir)
|
||||
yield* connection.tools()
|
||||
yield* connection.resources()
|
||||
|
||||
expect(server.state.initializations).toBe(2)
|
||||
expect(new URL(server.state.urls[0]).searchParams.get("codemode")).toBe("false")
|
||||
expect(new Set(server.state.urls.slice(1))).toEqual(new Set([config.url]))
|
||||
expect(new Set(headers)).toEqual(new Set(["preserved"]))
|
||||
expect(server.state.toolLists).toBe(1)
|
||||
expect(server.state.resourceLists).toBe(1)
|
||||
expect(config.url).toBe(server.url + query)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const entry of [
|
||||
{ name: "second 404", status: 404, query: "", codemode: undefined, attempts: 2 },
|
||||
{ name: "second 400", status: 400, query: "", codemode: undefined, attempts: 2 },
|
||||
{ name: "401", status: 401, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "403", status: 403, query: "", codemode: undefined, attempts: 1 },
|
||||
{ name: "501", status: 501, query: "", codemode: undefined, attempts: 1 },
|
||||
@@ -1089,30 +1046,6 @@ for (const entry of [
|
||||
expect(server.state.initializations).toBe(entry.attempts)
|
||||
expect(server.state.urls).toHaveLength(entry.attempts)
|
||||
if (entry.query || entry.codemode === false) expect(server.state.urls).toEqual([config.url])
|
||||
if (entry.attempts === 2) expect(server.state.urls[1]).toBe(config.url)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const status of [400, 404]) {
|
||||
testEffect(Layer.empty).live(`does not strip codemode for an MCP ${status} after initialization`, () =>
|
||||
Effect.gen(function* () {
|
||||
let expired = false
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) => (expired && request.method === "POST" ? new Response(null, { status }) : undefined),
|
||||
})
|
||||
const config = new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false })
|
||||
const connection = yield* connect("resources", config, import.meta.dir)
|
||||
expired = true
|
||||
const error = yield* connection.tools().pipe(Effect.flip)
|
||||
|
||||
// A 404 against a live session is reported as an expiry for the lifecycle to recover; the
|
||||
// connection itself never re-initializes or changes URL.
|
||||
if (status === 404) expect(error).toBeInstanceOf(McpClient.SessionExpiredError)
|
||||
else expect(error).not.toBeInstanceOf(McpClient.SessionExpiredError)
|
||||
expect(server.state.initializations).toBe(1)
|
||||
expect(new Set(server.state.urls)).toEqual(new Set([server.url + "?codemode=false"]))
|
||||
expect(server.state.toolLists).toBe(0)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1575,6 +1508,8 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer({ modern: true, resources: false })
|
||||
const config = { type: "remote", url: server.url, oauth: false, protocol: "2026-07-28" } as const
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
|
||||
@@ -1582,25 +1517,12 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
expect(published).toContain(McpEvent.StatusChanged.type)
|
||||
expect(yield* service.connect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
||||
expect(yield* service.disconnect("missing").pipe(Effect.flip)).toBeInstanceOf(Mcp.NotFoundError)
|
||||
yield* service.add(
|
||||
"dynamic",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
}),
|
||||
)
|
||||
yield* service.add("dynamic", new ConfigMCP.Remote(config))
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||
status: "connected",
|
||||
})
|
||||
|
||||
yield* service.add(
|
||||
"dynamic",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
disabled: true,
|
||||
}),
|
||||
)
|
||||
yield* service.add("dynamic", new ConfigMCP.Remote({ ...config, disabled: true }))
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status).toEqual({
|
||||
status: "disabled",
|
||||
})
|
||||
@@ -1628,11 +1550,7 @@ test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer(
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
disabled: true,
|
||||
}),
|
||||
new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ published },
|
||||
@@ -1719,22 +1637,20 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
const server = yield* resourceServer({ modern: true, resources: false })
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((editor) => {
|
||||
editor.set("dynamic", {
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
})
|
||||
editor.set("dynamic", { type: "remote", url: server.url, oauth: false, protocol: "2026-07-28" })
|
||||
})
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
||||
"connected",
|
||||
)
|
||||
expect(yield* service.tools()).toHaveLength(2)
|
||||
expect(yield* service.tools()).toHaveLength(1)
|
||||
|
||||
yield* service.transform((editor) => editor.update("dynamic", (server) => (server.codemode = false)))
|
||||
expect((yield* service.tools()).map((tool) => tool.codemode)).toEqual([false, false])
|
||||
expect((yield* service.tools()).map((tool) => tool.codemode)).toEqual([false])
|
||||
|
||||
const settings = { disabled: true }
|
||||
yield* service.transform((editor) => {
|
||||
@@ -1750,7 +1666,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
||||
"connected",
|
||||
)
|
||||
expect(yield* service.tools()).toHaveLength(2)
|
||||
expect(yield* service.tools()).toHaveLength(1)
|
||||
|
||||
const removed = yield* service.transform((editor) => editor.remove("dynamic"))
|
||||
expect((yield* service.servers()).some((server) => server.name === "dynamic")).toBe(false)
|
||||
@@ -1760,7 +1676,7 @@ testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unus
|
||||
expect((yield* service.servers()).find((server) => server.name === "dynamic")?.status.status).toBe(
|
||||
"connected",
|
||||
)
|
||||
expect(yield* service.tools()).toHaveLength(2)
|
||||
expect(yield* service.tools()).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2088,6 +2004,7 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer({ modern: true, resources: false })
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
|
||||
@@ -2116,9 +2033,11 @@ test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
resourceMcpLayer(
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-output-schema.ts")],
|
||||
new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url: server.url,
|
||||
oauth: false,
|
||||
protocol: "2026-07-28",
|
||||
disabled: true,
|
||||
}),
|
||||
),
|
||||
@@ -2527,7 +2446,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
type: "tool-call",
|
||||
id: "call_mcp_permission",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.demo.search({})" },
|
||||
input: { code: 'return await tools.demo.search({ query: "open issues" })' },
|
||||
},
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
@@ -2535,7 +2454,7 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
action: "demo_search",
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
metadata: {},
|
||||
metadata: { input: { query: "open issues" } },
|
||||
sessionID: Session.ID.make("ses_mcp_permission"),
|
||||
agent: toolIdentity.agent,
|
||||
source: {
|
||||
|
||||
@@ -498,6 +498,46 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("compaction lowers the output limit when it sends a request it could not shrink", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const session = yield* insertSession(Session.ID.make("ses_unshrinkable_compaction"))
|
||||
// One exchange estimated at 250k tokens, past the 200k window, so no older exchange can be dropped.
|
||||
const messages = [
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "x".repeat(1_000_000),
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}),
|
||||
]
|
||||
|
||||
expect(yield* compactManually(session, messages)).toEqual({ status: "completed" })
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 1_024 }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("compaction keeps the output limit for a request that fits its target", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const session = yield* insertSession(Session.ID.make("ses_fitting_compaction"))
|
||||
// About 170k estimated tokens, under the 180k target, leaving 30k of the 200k window.
|
||||
const messages = [
|
||||
SessionMessage.User.make({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "x".repeat(680_000),
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}),
|
||||
]
|
||||
|
||||
expect(yield* compactManually(session, messages)).toEqual({ status: "completed" })
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 20_000 }))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("compaction hooks can supply the summary instead of the model", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
|
||||
@@ -215,7 +215,7 @@ describe("Session.move", () => {
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
|
||||
if (broken)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:./missing.txt}"] })),
|
||||
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:.}"] })),
|
||||
)
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
@@ -271,7 +271,7 @@ describe("Session.move", () => {
|
||||
const source = Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "source")) })
|
||||
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
|
||||
yield* Effect.promise(() => Promise.all([mkdir(source.directory), mkdir(destination)]))
|
||||
const config = JSON.stringify({ instructions: ["{file:./missing.txt}"] })
|
||||
const config = JSON.stringify({ instructions: ["{file:.}"] })
|
||||
if (!broken) yield* Effect.promise(() => Bun.write(path.join(source.directory, "opencode.json"), config))
|
||||
const selectedID = Session.ID.create()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Schema } from "effect"
|
||||
import { Config } from "../src/config.js"
|
||||
|
||||
interface JsonSchema {
|
||||
readonly [key: string]: unknown
|
||||
readonly properties?: Readonly<Record<string, JsonSchema>>
|
||||
}
|
||||
|
||||
// V1 and V2 read the same opencode.json, and V2 normalizes V1 fields at load time, so the published schema accepts
|
||||
// both. The V2 shape is generated from Config.Info; V1 is frozen in config-v1.json, the last schema the V1 runtime
|
||||
// published. Fields only V1 understands are marked deprecated.
|
||||
export async function generate() {
|
||||
const legacy: { $defs: Record<string, JsonSchema> } = await Bun.file(
|
||||
new URL("./config-v1.json", import.meta.url),
|
||||
).json()
|
||||
const { root: current, definitions } = document(Config.Info)
|
||||
const { Config: previous, ...legacyDefinitions } = legacy.$defs
|
||||
const shared = Object.keys(legacyDefinitions).filter((key) => key in definitions)
|
||||
if (shared.length) throw new Error(`V1 and V2 schema definitions share names: ${shared.join(", ")}`)
|
||||
|
||||
return {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "https://opencode.ai/config.json",
|
||||
type: "object",
|
||||
properties: merge(describe(current.properties ?? {}), previous.properties ?? {}),
|
||||
additionalProperties: false,
|
||||
$defs: { ...legacyDefinitions, ...definitions },
|
||||
allowComments: true,
|
||||
allowTrailingCommas: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Optional fields annotate their decoded side, which the encoded JSON Schema omits, so copy top-level descriptions
|
||||
// from the type-side document.
|
||||
function describe(properties: Readonly<Record<string, JsonSchema>>) {
|
||||
const type = document(Schema.toType(Config.Info)).root
|
||||
return Object.fromEntries(
|
||||
Object.entries(properties).map(([key, schema]) => [
|
||||
key,
|
||||
{ ...schema, description: type.properties?.[key]?.description ?? schema.description },
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
// Classes generate a root reference to their definition; plain structs generate the object inline.
|
||||
function document(schema: Schema.Top) {
|
||||
const result = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" })
|
||||
const all = result.definitions as Record<string, JsonSchema>
|
||||
if (typeof result.schema.$ref !== "string") return { root: result.schema as JsonSchema, definitions: all }
|
||||
const { [result.schema.$ref.replace("#/$defs/", "")]: root, ...definitions } = all
|
||||
return { root, definitions }
|
||||
}
|
||||
|
||||
function merge(current: Readonly<Record<string, JsonSchema>>, previous: Readonly<Record<string, JsonSchema>>) {
|
||||
return Object.fromEntries([
|
||||
...Object.entries(current).map(([key, schema]) => [key, previous[key] ? combine(schema, previous[key]) : schema]),
|
||||
...Object.entries(previous)
|
||||
.filter(([key]) => !current[key])
|
||||
.map(([key, schema]) => [key, { ...schema, deprecated: true }]),
|
||||
])
|
||||
}
|
||||
|
||||
// Shared keys keep the V2 shape. Shared objects also accept their V1 properties, and keys whose type changed accept
|
||||
// either shape.
|
||||
function combine(current: JsonSchema, previous: JsonSchema): JsonSchema {
|
||||
const description = current.description ?? previous.description
|
||||
if (current.type === "object" && previous.type === "object")
|
||||
return {
|
||||
...current,
|
||||
description,
|
||||
properties: merge(current.properties ?? {}, previous.properties ?? {}),
|
||||
additionalProperties:
|
||||
typeof current.additionalProperties === "object" || typeof previous.additionalProperties !== "object"
|
||||
? current.additionalProperties
|
||||
: { ...previous.additionalProperties, deprecated: true },
|
||||
}
|
||||
if (current.type !== undefined && previous.type !== undefined && current.type !== previous.type)
|
||||
return { description, anyOf: [current, { ...previous, deprecated: true }] }
|
||||
return { ...current, description }
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const target = process.argv[2]
|
||||
if (!target) throw new Error("A schema output path is required")
|
||||
await Bun.write(target, JSON.stringify(await generate(), null, 2) + "\n")
|
||||
}
|
||||
@@ -954,7 +954,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications"
|
||||
"description": "Check for new versions and show an update notice. Updates are never installed automatically; run /update or `opencode upgrade`. Set to false to disable update checks. true and 'notify' behave the same"
|
||||
},
|
||||
"disabled_providers": {
|
||||
"type": "array",
|
||||
@@ -47,15 +47,26 @@ story("renders every tool error outcome without leaking hidden tools", async ({
|
||||
for (const name of names) await expect(timeline.locator(`[data-timeline-part-id="tool_error_${name}"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
story("expands a failed execute to its full code and error", async ({ mount }) => {
|
||||
story("lists a failed execute's calls and keeps its error under the failed call", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-terminal-work--execute-failed")
|
||||
await timeline.getByRole("button", { name: /^Used 5 / }).click()
|
||||
// Executions count their calls: 1 (no calls) + 1 + 3 + 1, plus the shell.
|
||||
await timeline.getByRole("button", { name: /^Used 7 / }).click()
|
||||
const failed = timeline.locator('[data-timeline-part-id="tool_execute_failed_url"]')
|
||||
await failed.getByRole("button", { name: /Invalid browser URL/ }).click()
|
||||
await expect(failed.locator('[data-slot="tool-error-card-subtitle"]')).toContainText(
|
||||
"const shot = await tools.browser.screenshot({ tabID: tab.id, fullPage: true });",
|
||||
const call = failed.locator('[data-slot="execute-tool-call"]')
|
||||
// Executions open by default, so the calls show without expanding the row.
|
||||
await expect(call).toHaveText([/browser\.tabs\.open/])
|
||||
await expect(call).toHaveAttribute("data-status", "error")
|
||||
await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(0)
|
||||
|
||||
// The runtime error stays collapsed under the failed call until it is expanded.
|
||||
const error = failed.locator('[data-slot="execute-tool-error"]')
|
||||
await expect(error).toHaveCount(0)
|
||||
await call.click()
|
||||
await expect(call).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(failed.locator('[data-slot="execute-tool-call-input"]')).toContainText(
|
||||
"url:file:///C:/tmp/opencode/throne-of-glass-luke.html",
|
||||
)
|
||||
await expect(failed.locator('[data-slot="card-description"]')).toContainText(
|
||||
await expect(error).toContainText(
|
||||
"The connected server must be able to reach the address; localhost refers to that server.",
|
||||
)
|
||||
})
|
||||
@@ -155,3 +166,30 @@ story("combines adjacent edit calls and repeated files into one group", async ({
|
||||
await expect(group.locator('[data-scope="apply-patch"] button')).toContainText("+2")
|
||||
await expect(group.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
story("opens an execution's code and output from its View code action", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-terminal-work--execute-code")
|
||||
await timeline.getByRole("button", { name: "View code" }).first().click()
|
||||
const dialog = timeline.page().getByRole("dialog")
|
||||
await expect(dialog.getByRole("heading", { name: "Execute" })).toBeVisible()
|
||||
await expect(dialog).toContainText("Completed")
|
||||
await expect(dialog).toContainText("2 calls")
|
||||
await expect(dialog).toContainText('const tab = await tools.browser.tabs.open({ url: "http://localhost:4399" })')
|
||||
await expect(dialog).toContainText('"tab": "tab_4f1c"')
|
||||
await expect(dialog.getByRole("button", { name: "Copy code" })).toBeVisible()
|
||||
await expect(dialog.getByRole("button", { name: "Copy output" })).toBeVisible()
|
||||
})
|
||||
|
||||
story("shows a failed execution's error in place of its output", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-terminal-work--execute-failed")
|
||||
await timeline.getByRole("button", { name: /^Used 7 / }).click()
|
||||
await timeline
|
||||
.locator('[data-timeline-part-id="tool_execute_failed_url"]')
|
||||
.getByRole("button", { name: "View code" })
|
||||
.click()
|
||||
const dialog = timeline.page().getByRole("dialog")
|
||||
await expect(dialog).toContainText("Failed")
|
||||
await expect(dialog).toContainText("Invalid browser URL.")
|
||||
await expect(dialog.getByRole("button", { name: "Copy error" })).toBeVisible()
|
||||
await expect(dialog.getByRole("button", { name: "Copy output" })).toHaveCount(0)
|
||||
})
|
||||
@@ -170,6 +170,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The action sits on the trigger's line as a sibling, since buttons cannot nest inside the trigger button. */
|
||||
[data-component="collapsible"].tool-collapsible[data-has-action="true"] {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
column-gap: 2px;
|
||||
|
||||
> [data-slot="collapsible-trigger"] {
|
||||
flex: 0 1 auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
/* Leave room for the action so a long collapsed subtitle truncates instead of wrapping it. */
|
||||
max-width: calc(100% - 26px);
|
||||
|
||||
/* The trigger now sizes to its content, so the content's room for the arrow would clip the title. */
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
> [data-slot="collapsible-content"] {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-action"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-trigger"]:hover + [data-slot="basic-tool-action"],
|
||||
[data-slot="collapsible-trigger"]:focus-visible + [data-slot="basic-tool-action"],
|
||||
[data-slot="basic-tool-action"]:hover,
|
||||
[data-slot="basic-tool-action"]:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
[data-slot="basic-tool-action"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="collapsible"].tool-collapsible[data-compact="true"] > [data-slot="collapsible-trigger"] {
|
||||
height: 28px;
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface BasicToolProps {
|
||||
triggerAsLink?: boolean
|
||||
clickable?: boolean
|
||||
compact?: boolean
|
||||
/** Rendered beside the trigger rather than inside it, so it can be its own button. Shown on hover or focus. */
|
||||
action?: JSX.Element
|
||||
}
|
||||
|
||||
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
|
||||
@@ -303,6 +305,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
class="tool-collapsible"
|
||||
data-compact={props.compact ? "true" : undefined}
|
||||
data-rail={props.rail === false ? "false" : undefined}
|
||||
data-has-action={props.action ? "true" : undefined}
|
||||
>
|
||||
<Show
|
||||
when={!props.locked && (props.triggerAsLink || props.triggerHref)}
|
||||
@@ -340,6 +343,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
{trigger()}
|
||||
</Collapsible.Trigger>
|
||||
</Show>
|
||||
<Show when={props.action}>{(action) => <span data-slot="basic-tool-action">{action()}</span>}</Show>
|
||||
<Show when={props.animated && hasChildren() && !props.hideDetails}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
|
||||
@@ -135,6 +135,38 @@
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* Like the iOS app's Voice badge: on the bubble's top leading corner, ringed in the page background. */
|
||||
[data-slot="user-message-body"][data-voice="true"] {
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
[data-slot="user-message-voice"] {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
inset-inline-start: -8px;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 9999px;
|
||||
background: var(--v2-background-bg-layer-03);
|
||||
box-shadow: 0 0 0 2px var(--v2-background-bg-base);
|
||||
color: var(--v2-icon-icon-muted);
|
||||
|
||||
[data-component="tooltip-v2-trigger"] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="user-message-text"] {
|
||||
display: inline-block;
|
||||
white-space: pre-wrap;
|
||||
@@ -223,7 +255,8 @@
|
||||
}
|
||||
|
||||
&:hover [data-slot="user-message-copy-wrapper"],
|
||||
&:focus-within [data-slot="user-message-copy-wrapper"] {
|
||||
&:focus-within [data-slot="user-message-copy-wrapper"],
|
||||
[data-slot="user-message-copy-wrapper"]:has([data-expanded]) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -529,6 +562,206 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-component="execute-tool-calls"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-family: var(--v2-font-family-code);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-base);
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
|
||||
[data-slot="execute-tool-call"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-call-marker"] {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
|
||||
/* Centre on the lowercase code text rather than its line box. */
|
||||
[data-slot="icon-svg"] {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-call-name"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-call-args"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-call"][role="button"] {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover [data-slot="execute-tool-call-name"],
|
||||
&:hover [data-slot="execute-tool-call-args"] {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid var(--v2-text-text-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-call-input"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 4px 0 2px 4px;
|
||||
padding-inline-start: 13px;
|
||||
border-inline-start: 0.5px solid var(--v2-border-border-base);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-call-input-entry"] {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
dt {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
color: var(--v2-text-text-base);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-call-arrow"] {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-call"][aria-expanded="true"] [data-slot="execute-tool-call-arrow"] {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-call"][data-status="error"] [data-slot="execute-tool-call-marker"],
|
||||
[data-slot="execute-tool-error"] {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-error"] {
|
||||
margin-top: 4px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding-inline-start: 20px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="execute-tool-call-marker"] [data-slot="icon-svg"] {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
[data-slot="execute-details-title"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
[data-slot="execute-details-status"] {
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-slot="execute-details-status"][data-status="error"] {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
[data-slot="execute-details-meta"] {
|
||||
font-size: 12px;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="execute-details"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 16px 16px;
|
||||
|
||||
[data-slot="execute-details-section"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="execute-details-section-header"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 24px;
|
||||
|
||||
> :last-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="execute-details-section-label"] {
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="execute-details-section-meta"] {
|
||||
font-size: 12px;
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
[data-slot="execute-details-source"] {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 0.5px var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-slot="execute-details-error"] {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
font-family: var(--v2-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--v2-state-fg-danger);
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="exa-tool-output"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -22,6 +22,7 @@ export function SessionUserMessage(props: {
|
||||
displayText?: string
|
||||
comments?: SessionUserComment[]
|
||||
references?: SessionUserAttachmentReference[]
|
||||
voice?: boolean
|
||||
historicalAgent: string
|
||||
historicalModel: SessionMessageAssistant["model"]
|
||||
actions?: SessionUserActions
|
||||
@@ -33,6 +34,7 @@ export function SessionUserMessage(props: {
|
||||
text={props.displayText ?? props.message.text}
|
||||
comments={props.comments}
|
||||
references={props.references}
|
||||
voice={props.voice}
|
||||
agent={props.historicalAgent}
|
||||
model={props.historicalModel}
|
||||
actions={props.actions}
|
||||
|
||||
@@ -51,20 +51,42 @@ export function shellResultFailed(metadata: Record<string, unknown>) {
|
||||
|
||||
export function executeToolFailed(metadata: Record<string, unknown>) {
|
||||
// Code Mode can report failed nested calls in a completed tool result.
|
||||
return metadata.error === true || executeToolCalls(metadata).some((call) => call.status === "error")
|
||||
}
|
||||
|
||||
const decodeExecuteCall = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
tool: Schema.String,
|
||||
status: Schema.Literals(["running", "completed", "error"]),
|
||||
input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}),
|
||||
)
|
||||
|
||||
export type ExecuteToolCall = Option.Option.Value<ReturnType<typeof decodeExecuteCall>>
|
||||
|
||||
// Code Mode streams the tools a script calls through metadata rather than child tool parts.
|
||||
export function executeToolCalls(metadata: Record<string, unknown>): ExecuteToolCall[] {
|
||||
const calls = metadata.toolCalls
|
||||
|
||||
return (
|
||||
metadata.error === true ||
|
||||
(Array.isArray(calls) &&
|
||||
calls.some(
|
||||
(call) =>
|
||||
call !== null &&
|
||||
typeof call === "object" &&
|
||||
!Array.isArray(call) &&
|
||||
"status" in call &&
|
||||
call.status === "error",
|
||||
))
|
||||
)
|
||||
if (!Array.isArray(calls)) return []
|
||||
|
||||
return calls.flatMap((call) => Option.toArray(decodeExecuteCall(call)))
|
||||
}
|
||||
|
||||
export function executeToolCallArgs(call: ExecuteToolCall) {
|
||||
return Object.entries(call.input ?? {})
|
||||
.filter(([, value]) => typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
||||
.map(([key, value]) => `${key}=${String(value).replace(/\s+/g, " ")}`)
|
||||
}
|
||||
|
||||
// Collapsed rows name the failed call, or else the latest one.
|
||||
export function executeToolSubtitle(calls: readonly ExecuteToolCall[]) {
|
||||
const call = calls.find((item) => item.status === "error") ?? calls.at(-1)
|
||||
|
||||
if (!call) return undefined
|
||||
const args = executeToolCallArgs(call)
|
||||
|
||||
return args.length ? `${call.tool} [${args.join(", ")}]` : call.tool
|
||||
}
|
||||
|
||||
export function currentToolHasLoadedFiles(tool: SessionMessageAssistantTool) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { CommentCard } from "./comment-card"
|
||||
import { TimelineSeparator } from "../components/timeline-separator"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { TextReveal } from "@opencode/ui/text-reveal"
|
||||
@@ -265,11 +266,14 @@ export function CurrentUserMessageDisplay(props: {
|
||||
actions?: SessionUserActions
|
||||
comments?: SessionUserComment[]
|
||||
references?: SessionUserAttachmentReference[]
|
||||
voice?: boolean
|
||||
}) {
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const i18n = useI18n()
|
||||
|
||||
const [state, setState] = createStore({ copied: false, reverting: false, updating: false })
|
||||
|
||||
const status = createMemo(() => props.actions?.pending?.status(props.message.id))
|
||||
const pending = createMemo(() => status() !== undefined && status() !== "queued")
|
||||
const waiting = createMemo(() => status() === "steering" || status() === "stranded")
|
||||
@@ -297,8 +301,8 @@ export function CurrentUserMessageDisplay(props: {
|
||||
|
||||
const stamp = createMemo(() => timefmt().format(props.message.time.created))
|
||||
|
||||
const copy = async () => {
|
||||
if (!props.text || !(await writeClipboard(props.text))) return
|
||||
const copy = async (text: string) => {
|
||||
if (!text || !(await writeClipboard(text))) return
|
||||
setState("copied", true)
|
||||
setTimeout(() => setState("copied", false), 2000)
|
||||
}
|
||||
@@ -389,7 +393,14 @@ export function CurrentUserMessageDisplay(props: {
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div data-slot="user-message-body">
|
||||
<div data-slot="user-message-body" data-voice={props.voice ? "true" : undefined}>
|
||||
<Show when={props.voice}>
|
||||
<div data-slot="user-message-voice" role="img" aria-label={i18n.t("ui.message.voice")}>
|
||||
<Tooltip appearance="compact" value={i18n.t("ui.message.voice")} placement="top" gutter={4}>
|
||||
<Icon name="waveform" size="small" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="user-message-text" dir="auto" data-comments={comments().length > 0 ? "true" : undefined}>
|
||||
<CurrentHighlightedText text={props.text} files={inlineFiles()} agents={agents()} />
|
||||
<Show when={comments().length > 0}>
|
||||
@@ -453,17 +464,53 @@ export function CurrentUserMessageDisplay(props: {
|
||||
aria-label={i18n.t("ui.message.revertMessage")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.text}>
|
||||
<MessageActionButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void copy()
|
||||
}}
|
||||
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
/>
|
||||
{/* Like the iOS app, a Voice message's copy also offers the full prompt the session received, on right click. */}
|
||||
<Show
|
||||
when={props.voice}
|
||||
fallback={
|
||||
<Show when={props.text}>
|
||||
<MessageActionButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void copy(props.text)
|
||||
}}
|
||||
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Menu.Context modal={false}>
|
||||
<Tooltip
|
||||
appearance="compact"
|
||||
value={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<Menu.Context.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name={state.copied ? "check" : "outline-copy"} size="small" />}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void copy(props.text)
|
||||
}}
|
||||
aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Menu.Context.Portal>
|
||||
<Menu.Context.Content>
|
||||
<Menu.Item onSelect={() => void copy(props.text)}>{i18n.t("ui.message.copyMessage")}</Menu.Item>
|
||||
<Menu.Item onSelect={() => void copy(props.message.text)}>
|
||||
{i18n.t("ui.message.copyFullMessage")}
|
||||
</Menu.Item>
|
||||
</Menu.Context.Content>
|
||||
</Menu.Context.Portal>
|
||||
</Menu.Context>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -91,6 +91,10 @@ function runningTool(input: {
|
||||
output?: string
|
||||
metadata?: Record<string, JsonValue>
|
||||
}): SessionMessageAssistantTool {
|
||||
const metadata = { ...input.metadata }
|
||||
|
||||
if (input.output !== undefined) metadata.output = input.output
|
||||
|
||||
return {
|
||||
type: "tool",
|
||||
id: input.id,
|
||||
@@ -98,10 +102,7 @@ function runningTool(input: {
|
||||
state: {
|
||||
status: "running",
|
||||
input: input.args,
|
||||
metadata: {
|
||||
...input.metadata,
|
||||
...(input.output === undefined ? {} : { output: input.output }),
|
||||
},
|
||||
metadata,
|
||||
},
|
||||
time: { created: STORY_TIME + input.offset, ran: STORY_TIME + input.offset + 100 },
|
||||
}
|
||||
@@ -543,9 +544,23 @@ export const executeCodeDocument = document([
|
||||
name: "execute",
|
||||
offset: 52_300,
|
||||
args: {
|
||||
code: 'const greeting = "Code Mode execute completed"\nreturn { greeting, timestamp: new Date().toISOString() }',
|
||||
code: 'const tab = await tools.browser.tabs.open({ url: "http://localhost:4399" })\nconst usage = await tools.opencode_console.get_usage({ range: "24h" })\nreturn { tab: tab.id, cost: usage.cost_usd }',
|
||||
},
|
||||
output: '{\n "greeting": "Code Mode execute completed",\n "timestamp": "2026-08-17T09:01:43.590Z"\n}',
|
||||
output: '{\n "tab": "tab_4f1c",\n "cost": 0.42\n}',
|
||||
metadata: {
|
||||
toolCalls: [
|
||||
{ tool: "browser.tabs.open", status: "completed", input: { url: "http://localhost:4399" } },
|
||||
{ tool: "opencode_console.get_usage", status: "completed", input: { range: "24h" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
completedTool({
|
||||
id: "tool_execute_followup",
|
||||
name: "execute",
|
||||
offset: 52_600,
|
||||
args: { code: "return await tools.browser.tabs.list()" },
|
||||
output: "[]",
|
||||
metadata: { toolCalls: [{ tool: "browser.tabs.list", status: "completed" }] },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -1117,6 +1132,44 @@ export const attachmentsAndCommentsPresentation = {
|
||||
},
|
||||
} satisfies Record<string, SessionUserPresentation>
|
||||
|
||||
// The header and body as the OpenCode iOS app's `VoiceHandoff.format` writes them.
|
||||
const voiceHeader =
|
||||
"Voice delegation. The user is on a voice call with a voice agent, which hands their requests to you. Instruction is the user's own spoken words since the previous delegation, transcribed from speech, so it may contain recognition errors and disfluencies: resolve meaning from the complete utterance, not a disfluent prefix. Voice, when present, is how the voice agent framed the request. Transcript delta is the surrounding voice conversation, both sides, for context, not additional commands. Lead your reply with one or two plain sentences suitable for reading aloud, then any detail; answer only what was asked, with no unrequested tips. If this arrives while you are already working (a follow-up, nudge, or status check), answer it promptly: a clear status update, the finished result if you have enough, or the precise blocker or input you need; then continue on the fastest safe path."
|
||||
|
||||
const voiceInstruction =
|
||||
"okay so the upload test keeps failing on CI but only sometimes, can you look at the last few runs, figure out whether it's the retry timing or the fixture cleanup, and if it's the fixture just fix it and open a PR"
|
||||
|
||||
export const voiceDocument = document([
|
||||
user(
|
||||
"msg_user_voice",
|
||||
`${voiceHeader}\n${JSON.stringify({ instruction: "check the upload test", transcript_delta: ["user: check the upload test"], voice: "Checking the upload test." }, null, 2)}`,
|
||||
100_000,
|
||||
),
|
||||
assistant({
|
||||
id: "msg_assistant_voice",
|
||||
offset: 101_000,
|
||||
completed: 104_000,
|
||||
content: [{ type: "text", text: "The upload test is flaky on CI: it fails about one run in five." }],
|
||||
}),
|
||||
user(
|
||||
"msg_user_voice_continued",
|
||||
`Voice delegation (continued). Same fields as before.\n${JSON.stringify({ instruction: voiceInstruction, transcript_delta: [`user: ${voiceInstruction}`] }, null, 2)}`,
|
||||
110_000,
|
||||
),
|
||||
assistant({
|
||||
id: "msg_assistant_voice_continued",
|
||||
offset: 111_000,
|
||||
completed: 118_000,
|
||||
content: [{ type: "text", text: "It was the fixture cleanup. I fixed it and opened a pull request." }],
|
||||
}),
|
||||
user("msg_user_typed", "Thanks, merge it once CI is green.", 120_000),
|
||||
] satisfies SessionMessageInfo[])
|
||||
|
||||
export const voicePresentation = {
|
||||
msg_user_voice: { displayText: "Checking the upload test.", voice: true },
|
||||
msg_user_voice_continued: { displayText: voiceInstruction, voice: true },
|
||||
} satisfies Record<string, SessionUserPresentation>
|
||||
|
||||
export const revertDocument = document([
|
||||
user("msg_user_revert_base", "Add a compact Session summary.", 90_000),
|
||||
assistant({
|
||||
|
||||
@@ -56,6 +56,8 @@ export type SessionUserPresentation = {
|
||||
displayText?: string
|
||||
comments?: SessionUserComment[]
|
||||
references?: SessionUserAttachmentReference[]
|
||||
/** A voice call's agent sent it on the user's behalf. */
|
||||
voice?: boolean
|
||||
}
|
||||
|
||||
export function createSessionTimelineRowRenderer(input: {
|
||||
@@ -241,7 +243,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
}
|
||||
reasoningOpen={(id) => input.disclosure.value(id)}
|
||||
onReasoningOpenChange={(id, open) => input.disclosure.set(id, open)}
|
||||
toolDefaultOpen={(tool) => (input.timelineDetail ? contentDefaultOpen(tool) : false)}
|
||||
toolDefaultOpen={(tool) => (input.timelineDetail || tool.name === "execute" ? contentDefaultOpen(tool) : false)}
|
||||
toolOpen={(id) => input.disclosure.value(`${row().group.key}:tool:${id}`)}
|
||||
onToolOpenChange={(id, open) => input.disclosure.set(`${row().group.key}:tool:${id}`, open)}
|
||||
fileOpen={(path) =>
|
||||
@@ -380,6 +382,8 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
}
|
||||
|
||||
function contentDefaultOpen(item: SessionMessageAssistant["content"][number]) {
|
||||
if (item.type === "tool" && item.name === "execute") return true
|
||||
|
||||
if (input.timelineDetail) {
|
||||
const category = timelineCategory(item)
|
||||
|
||||
@@ -797,6 +801,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
displayText={presentation()?.displayText}
|
||||
comments={presentation()?.comments}
|
||||
references={presentation()?.references}
|
||||
voice={presentation()?.voice}
|
||||
historicalAgent={context()?.agent ?? ""}
|
||||
historicalModel={context()?.model ?? { id: "", providerID: "" }}
|
||||
actions={input.actions}
|
||||
|
||||
@@ -184,7 +184,7 @@ export const ExecuteCode = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Execute code"
|
||||
description="A Code Mode execution shares the shell treatment: the code and its result split into a two-tone card."
|
||||
description="Each Code Mode execution lists the tools its script called."
|
||||
document={executeCodeDocument}
|
||||
width="786px"
|
||||
shellToolDefaultOpen
|
||||
@@ -192,18 +192,29 @@ export const ExecuteCode = {
|
||||
),
|
||||
}
|
||||
|
||||
export const ExecuteGrouped = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Execute grouped"
|
||||
description="Executions inside a tool group open by default to the tools their scripts called."
|
||||
document={executeCodeDocument}
|
||||
width="786px"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const ExecuteFailed = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Execute failed"
|
||||
description="Failed Code Mode executions open to show the full code and the full error message."
|
||||
description="Failed Code Mode executions mark the failed call, which expands to show its input and the full error message."
|
||||
document={storyDocument([
|
||||
storyTool(
|
||||
"tool_execute_search",
|
||||
"execute",
|
||||
"completed",
|
||||
{ code: 'const found = search({ namespace: "browser", offset: 20, limit: 30 });\nreturn found;\n' },
|
||||
{ output: "[]" },
|
||||
{ output: "[]", metadata: { toolCalls: [] } },
|
||||
),
|
||||
storyTool(
|
||||
"tool_execute_failed_url",
|
||||
@@ -215,7 +226,16 @@ export const ExecuteFailed = {
|
||||
{
|
||||
output:
|
||||
"Invalid browser URL. Use an HTTP/HTTPS URL or about:blank without embedded credentials. Paths and file:// URLs are not browser URLs; use browser.preview to show a local file to the user. The connected server must be able to reach the address; localhost refers to that server.",
|
||||
metadata: { error: true },
|
||||
metadata: {
|
||||
error: true,
|
||||
toolCalls: [
|
||||
{
|
||||
tool: "browser.tabs.open",
|
||||
status: "error",
|
||||
input: { url: "file:///C:/tmp/opencode/throne-of-glass-luke.html", focus: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
{
|
||||
@@ -243,7 +263,22 @@ export const ExecuteFailed = {
|
||||
{
|
||||
output:
|
||||
"[browser.operation_failed] browser.screenshot failed. Screenshot needs a visible tab. Call browser.tabs.focus and keep its desktop window visible.",
|
||||
metadata: { error: true },
|
||||
metadata: {
|
||||
error: true,
|
||||
toolCalls: [
|
||||
{
|
||||
tool: "browser.tabs.open",
|
||||
status: "completed",
|
||||
input: { url: "http://localhost:4399", focus: true },
|
||||
},
|
||||
{
|
||||
tool: "browser.wait",
|
||||
status: "completed",
|
||||
input: { tabID: "tab_4f1c", condition: "load", timeoutMs: 5000 },
|
||||
},
|
||||
{ tool: "browser.screenshot", status: "error", input: { tabID: "tab_4f1c", fullPage: true } },
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
storyTool(
|
||||
@@ -251,7 +286,7 @@ export const ExecuteFailed = {
|
||||
"execute",
|
||||
"completed",
|
||||
{ code: "const list = await tools.browser.tabs.list();\nreturn list;\n" },
|
||||
{ output: "[]" },
|
||||
{ output: "[]", metadata: { toolCalls: [{ tool: "browser.tabs.list", status: "completed" }] } },
|
||||
),
|
||||
])}
|
||||
width="786px"
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
revertDocument,
|
||||
streamingDocument,
|
||||
thinkingDocument,
|
||||
voiceDocument,
|
||||
voicePresentation,
|
||||
} from "../storybook/current-session-fixtures"
|
||||
|
||||
export default {
|
||||
@@ -450,6 +452,18 @@ export const PromptWithAttachments = {
|
||||
),
|
||||
}
|
||||
|
||||
export const VoiceMessages = {
|
||||
render: () => (
|
||||
<CurrentSessionTimelineStory
|
||||
title="Voice messages"
|
||||
description="A voice call's agent prompts on the user's behalf: its framing, or the user's spoken words, with a Voice badge on the bubble."
|
||||
document={voiceDocument}
|
||||
presentation={voicePresentation}
|
||||
width="480px"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
export const MixedDirectionRtl = {
|
||||
globals: { direction: "rtl" },
|
||||
render: () => (
|
||||
|
||||
@@ -37,6 +37,8 @@ import { useMarkdown } from "../context/markdown"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import { checksum } from "@opencode/util/encode"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode/ui/dialog"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { changedFileDiff, patchFileGroups } from "../components/apply-patch-file"
|
||||
@@ -54,8 +56,11 @@ import {
|
||||
currentToolInput,
|
||||
currentToolMetadata,
|
||||
currentToolOutput,
|
||||
executeToolFailed,
|
||||
executeToolCallArgs,
|
||||
executeToolCalls,
|
||||
executeToolSubtitle,
|
||||
readImagePath,
|
||||
type ExecuteToolCall,
|
||||
} from "../message/current-tool-state"
|
||||
import { AssistantReasoningContent, writeClipboard } from "../message/message-content"
|
||||
import { followShellOutput } from "./shell-output"
|
||||
@@ -501,7 +506,14 @@ export function CurrentContextToolGroup(props: {
|
||||
}
|
||||
|
||||
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
|
||||
const count = props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
|
||||
// Like the TUI, an execution counts the calls its script made, or itself before it makes any.
|
||||
const count =
|
||||
props.parts.reduce((total, part) => {
|
||||
if (part.type === "tool" && part.name === "execute")
|
||||
return total + Math.max(1, executeToolCalls(currentToolMetadata(part)).length)
|
||||
|
||||
return part.type === "tool" || part.type === "shell" ? total + 1 : total
|
||||
}, 0) || thoughts
|
||||
const text = i18n.plural("ui.messagePart.tools.used", count, { tools: title })
|
||||
const index = text.indexOf(title)
|
||||
const before = text.slice(0, index).trim()
|
||||
@@ -1117,6 +1129,7 @@ function currentContextToolTrigger(tool: SessionMessageAssistantTool, i18n: Retu
|
||||
export interface ToolProps {
|
||||
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- see SAFETY above
|
||||
input: Record<string, unknown>
|
||||
error?: string
|
||||
// oxlint-disable-next-line anti-slop/no-unsafe-dictionary-type -- see SAFETY above
|
||||
metadata: Record<string, unknown>
|
||||
tool: string
|
||||
@@ -1362,7 +1375,7 @@ export function ToolDisplay(
|
||||
})
|
||||
|
||||
const errorSubtitle = createMemo(() => toolErrorSubtitle(props, i18n))
|
||||
const error = createMemo(() => toolDisplayError(props, i18n.t("ui.toolErrorCard.failed")))
|
||||
const error = createMemo(() => toolDisplayError(props))
|
||||
const render = createMemo(() => ToolRegistry.render(props.tool) ?? GenericTool)
|
||||
|
||||
return (
|
||||
@@ -1428,8 +1441,6 @@ function toolErrorSubtitle(props: ToolProps, i18n: UiI18n) {
|
||||
|
||||
if (props.tool === "shell") return input.command || metadata.command || undefined
|
||||
|
||||
if (props.tool === "execute") return input.code || undefined
|
||||
|
||||
if (props.tool === "read" || props.tool === "edit" || props.tool === "write") return getFilename(input.path ?? "")
|
||||
|
||||
if (props.tool === "list" || props.tool === "glob" || props.tool === "grep")
|
||||
@@ -1460,14 +1471,13 @@ function toolErrorSubtitle(props: ToolProps, i18n: UiI18n) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolDisplayError(props: ToolProps & { error?: string }, fallback: string) {
|
||||
function toolDisplayError(props: ToolProps & { error?: string }) {
|
||||
// Code Mode failures render inline in the execution's call list.
|
||||
if (props.tool === "execute") return undefined
|
||||
|
||||
if (props.status === "error") return props.error
|
||||
|
||||
if (props.tool !== "execute") return undefined
|
||||
|
||||
if (!executeToolFailed(props.metadata)) return undefined
|
||||
|
||||
return props.output || fallback
|
||||
return undefined
|
||||
}
|
||||
|
||||
ToolRegistry.register({
|
||||
@@ -1687,7 +1697,7 @@ ToolRegistry.register({
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending() && url()}>
|
||||
<Show when={props.status !== "streaming" && url()}>
|
||||
<a
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
class="webfetch-link"
|
||||
@@ -1915,17 +1925,46 @@ ToolRegistry.register({
|
||||
name: "execute",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const dialog = useDialog()
|
||||
const pending = () => props.status === "streaming" || props.status === "running"
|
||||
const code = createMemo(() => toolInput(props.input).code ?? "")
|
||||
const output = () => stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n")
|
||||
const calls = createMemo(() => executeToolCalls(props.metadata))
|
||||
const subtitle = createMemo(() => executeToolSubtitle(calls()))
|
||||
const code = createMemo(() => (toolInput(props.input).code ?? "").trimEnd())
|
||||
|
||||
// Like the TUI, only a runtime failure shows its output; a failed nested call is marked on its row.
|
||||
const error = createMemo(() => {
|
||||
if (props.status !== "error" && props.metadata.error !== true) return undefined
|
||||
const fallback = i18n.t("ui.toolErrorCard.failed")
|
||||
const text = stripAnsi((props.status === "error" ? props.error : undefined) || props.output || fallback)
|
||||
|
||||
return text.replace(/^Error:\s*/, "").trim() || fallback
|
||||
})
|
||||
|
||||
// A runtime failure belongs to the call that raised it; scripts can also fail outside any call.
|
||||
const owner = createMemo(() => calls().findLastIndex((call) => call.status === "error"))
|
||||
|
||||
const sawPending = pending()
|
||||
|
||||
// The script is complete once the call leaves streaming, so the dialog can show it while it runs.
|
||||
const viewable = () => props.status !== "streaming" && !!code()
|
||||
|
||||
const openDetails = () =>
|
||||
dialog.show(() => (
|
||||
<ExecuteDetails
|
||||
code={code()}
|
||||
output={stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n").trim()}
|
||||
error={error()}
|
||||
calls={calls().length}
|
||||
status={props.status}
|
||||
/>
|
||||
))
|
||||
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
rail={false}
|
||||
hasContent
|
||||
hasContent={calls().length > 0 || !!error()}
|
||||
compact
|
||||
allowOpenWhilePending
|
||||
trigger={(open) => (
|
||||
@@ -1934,22 +1973,230 @@ ToolRegistry.register({
|
||||
<span data-slot="basic-tool-tool-title">
|
||||
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
|
||||
</span>
|
||||
<Show when={!open() && code()}>
|
||||
<ShellSubmessage text={code().split("\n", 1)[0]} animate={sawPending} />
|
||||
</Show>
|
||||
<Show when={!open() && subtitle()}>{(text) => <ShellSubmessage text={text()} animate={sawPending} />}</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
action={
|
||||
viewable() ? (
|
||||
<Tooltip value={i18n.t("ui.tool.execute.viewCode")} placement="top">
|
||||
<IconButton
|
||||
icon={<Icon name="code-slash" size="small" />}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
aria-label={i18n.t("ui.tool.execute.viewCode")}
|
||||
aria-haspopup="dialog"
|
||||
onClick={openDetails}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ConsoleOutput copy={code()} variant="shell">
|
||||
<span data-slot="bash-command">{code()}</span>
|
||||
<Show when={output()}>{(value) => <span data-slot="bash-result">{value()}</span>}</Show>
|
||||
</ConsoleOutput>
|
||||
<div data-component="execute-tool-calls">
|
||||
<Index each={calls()}>
|
||||
{(call, index) => <ExecuteCallRow call={call()} error={index === owner() ? error() : undefined} />}
|
||||
</Index>
|
||||
<Show when={owner() === -1 && error()}>{(text) => <ExecuteCallRow error={text()} />}</Show>
|
||||
</div>
|
||||
</BasicTool>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
function ExecuteDetails(props: { code: string; output: string; error?: string; calls: number; status?: string }) {
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const lines = (text: string) => i18n.plural("ui.tool.execute.lines", text.split("\n").length)
|
||||
|
||||
return (
|
||||
<Dialog fit containerClass="!w-[min(calc(100vw_-_32px),720px)] max-h-[calc(100vh_-_92px)]">
|
||||
<DialogHeader>
|
||||
<div data-slot="execute-details-title">
|
||||
<DialogTitle>{i18n.t("ui.tool.execute")}</DialogTitle>
|
||||
<span data-slot="execute-details-status" data-status={props.error ? "error" : props.status}>
|
||||
{props.error
|
||||
? i18n.t("ui.toolErrorCard.failed")
|
||||
: props.status === "running"
|
||||
? i18n.t("ui.tool.execute.running")
|
||||
: i18n.t("ui.tool.execute.completed")}
|
||||
</span>
|
||||
<span data-slot="execute-details-meta">{i18n.plural("ui.tool.execute.calls", props.calls)}</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody class="min-h-0 flex-none overflow-y-auto">
|
||||
<div data-component="execute-details">
|
||||
<ExecuteDetailsSection
|
||||
label={i18n.t("ui.tool.execute.code")}
|
||||
meta={lines(props.code)}
|
||||
copy={props.code}
|
||||
copyLabel={i18n.t("ui.tool.execute.copyCode")}
|
||||
>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{ name: "execute.js", contents: props.code, cacheKey: checksum(props.code) }}
|
||||
overflow="scroll"
|
||||
/>
|
||||
</ExecuteDetailsSection>
|
||||
<Show
|
||||
when={props.error}
|
||||
fallback={
|
||||
<Show when={props.output}>
|
||||
{(output) => (
|
||||
<ExecuteDetailsSection
|
||||
label={i18n.t("ui.tool.execute.output")}
|
||||
meta={lines(output())}
|
||||
copy={output()}
|
||||
copyLabel={i18n.t("ui.tool.execute.copyOutput")}
|
||||
>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
// Code Mode returns structured results as JSON; highlight those, leave anything else plain.
|
||||
name: Option.isSome(decodeJson(output())) ? "output.json" : "output.txt",
|
||||
contents: output(),
|
||||
cacheKey: checksum(output()),
|
||||
}}
|
||||
overflow="scroll"
|
||||
/>
|
||||
</ExecuteDetailsSection>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(error) => (
|
||||
<ExecuteDetailsSection
|
||||
label={i18n.t("ui.tool.execute.error")}
|
||||
copy={error()}
|
||||
copyLabel={i18n.t("ui.toolErrorCard.copyError")}
|
||||
>
|
||||
<pre data-slot="execute-details-error">{error()}</pre>
|
||||
</ExecuteDetailsSection>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ExecuteDetailsSection(props: {
|
||||
label: string
|
||||
meta?: string
|
||||
copy: string
|
||||
copyLabel: string
|
||||
children: JSX.Element
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const copy = async () => {
|
||||
if (!(await writeClipboard(props.copy))) return
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-slot="execute-details-section">
|
||||
<header data-slot="execute-details-section-header">
|
||||
<span data-slot="execute-details-section-label">{props.label}</span>
|
||||
<Show when={props.meta}>{(meta) => <span data-slot="execute-details-section-meta">{meta()}</span>}</Show>
|
||||
<Tooltip value={copied() ? i18n.t("ui.message.copied") : props.copyLabel} placement="top">
|
||||
<IconButton
|
||||
icon={<Icon name={copied() ? "check" : "outline-copy"} size="small" />}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : props.copyLabel}
|
||||
onClick={copy}
|
||||
/>
|
||||
</Tooltip>
|
||||
</header>
|
||||
<div data-slot="execute-details-source">{props.children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ExecuteCallRow(props: { call?: ExecuteToolCall; error?: string }) {
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const args = createMemo(() => (props.call ? executeToolCallArgs(props.call) : []))
|
||||
const input = createMemo(() => Object.entries(props.call?.input ?? {}))
|
||||
const failed = () => !props.call || props.call.status === "error"
|
||||
const expandable = () => input().length > 0 || !!props.error
|
||||
const summary = () => (props.call ? args().join(", ") : props.error?.split("\n", 1)[0])
|
||||
|
||||
const toggle = () => {
|
||||
if (!expandable() || window.getSelection()?.toString()) return
|
||||
setOpen((value) => !value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="execute-tool-call-item">
|
||||
<div
|
||||
data-slot="execute-tool-call"
|
||||
data-status={failed() ? "error" : props.call?.status}
|
||||
data-expanded={open() ? "true" : undefined}
|
||||
dir="ltr"
|
||||
role={expandable() ? "button" : undefined}
|
||||
tabIndex={expandable() ? 0 : undefined}
|
||||
aria-expanded={expandable() ? open() : undefined}
|
||||
onClick={toggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
}}
|
||||
>
|
||||
<Show when={failed()} fallback={<span data-slot="execute-tool-call-marker">›</span>}>
|
||||
<span data-slot="execute-tool-call-marker">
|
||||
<Icon name="outline-hexagonal-warning" size="small" />
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.call}>
|
||||
{(call) => (
|
||||
<span data-slot="execute-tool-call-name">
|
||||
<TextShimmer text={call().tool} active={call().status === "running"} />
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!open() && summary()}>
|
||||
{(text) => (
|
||||
<span data-slot="execute-tool-call-args" title={text()}>
|
||||
{props.call ? `[${text()}]` : text()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.error}>
|
||||
<span data-slot="execute-tool-call-arrow">›</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={open()}>
|
||||
<Show when={input().length > 0}>
|
||||
<dl data-slot="execute-tool-call-input" dir="ltr">
|
||||
<For each={input()}>
|
||||
{([key, value]) => (
|
||||
<div data-slot="execute-tool-call-input-entry">
|
||||
<dt>{key}:</dt>
|
||||
<dd>{typeof value === "string" ? value : JSON.stringify(value, null, 2)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</dl>
|
||||
</Show>
|
||||
<Show when={props.error}>
|
||||
{(error) => (
|
||||
<div data-slot="execute-tool-error" dir="ltr">
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ToolRegistry.register({
|
||||
name: "shell",
|
||||
render(props) {
|
||||
|
||||
@@ -144,6 +144,7 @@ describe("inference stat normalization", () => {
|
||||
"deepseek-flash",
|
||||
"DEEPSEEK-FLASH-free:global",
|
||||
"deepseek-v4.1-flash",
|
||||
"deepseek-v4.1-flash-free",
|
||||
"deepseek-v4-1-flash-opencode-bo44an",
|
||||
].forEach((model) => {
|
||||
expect(statModel(model, "")).toBe("deepseek-v4.1-flash")
|
||||
|
||||
@@ -13,7 +13,13 @@ type Experiment = {
|
||||
// In-flight features anyone can opt into. Each entry is temporary: an
|
||||
// experiment either graduates (delete the entry, make the behavior
|
||||
// unconditional) or dies (delete the entry and the branch it gated).
|
||||
export const experiments: Experiment[] = []
|
||||
export const experiments: Experiment[] = [
|
||||
{
|
||||
id: "diff-panel",
|
||||
title: "Diff side panel",
|
||||
description: "Open /diff in a panel beside the session instead of full screen",
|
||||
},
|
||||
]
|
||||
|
||||
export function DialogExperiments() {
|
||||
const config = useConfig()
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { batch, createMemo, createSignal, For, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createSignal, For, on, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { splitAddedPatch, splitPatchHunks, type AddedPatchChunk } from "../util/diff"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
@@ -31,10 +31,12 @@ type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref"
|
||||
lineNumberBg: ColorInput
|
||||
ref?: (value: PatchDiffRef) => void
|
||||
scroll?: () => ScrollBoxRenderable | undefined
|
||||
/** Extra gutter columns before the line numbers, painted in each row's color. */
|
||||
gutterPadding?: number
|
||||
}
|
||||
|
||||
export function PatchDiff(props: Props) {
|
||||
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref", "scroll"])
|
||||
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref", "scroll", "gutterPadding"])
|
||||
const hunks = createMemo(() => splitPatchHunks(local.diff))
|
||||
const chunks = createMemo(() => {
|
||||
if (!local.scroll) return
|
||||
@@ -70,20 +72,32 @@ export function PatchDiff(props: Props) {
|
||||
...[...side.getLineSigns()].filter(([line]) => line >= 0).map(([, sign]) => stringWidth(sign.after ?? "")),
|
||||
),
|
||||
)
|
||||
// A diff creates its gutters after it mounts, so wait a few frames for them to appear.
|
||||
if (sides.length === 0 && attempt < 5) return syncGutters(attempt + 1)
|
||||
if (sides.length === 0) return
|
||||
const maxDigits = Math.max(...digits)
|
||||
const maxAfter = Math.max(...after)
|
||||
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
|
||||
if (!maxDigits) return
|
||||
const width = Math.max(maxDigits, minDigits())
|
||||
sides.forEach((side) => {
|
||||
const index = sides.indexOf(side)
|
||||
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
|
||||
signs.set(-1, { after: " ".repeat(maxAfter + width - digits[index]) })
|
||||
// Line -1 never renders; its sign only sets the gutter's padding columns.
|
||||
signs.set(-1, {
|
||||
before: " ".repeat(local.gutterPadding ?? 0) || undefined,
|
||||
after: " ".repeat(maxAfter + width - digits[index]),
|
||||
})
|
||||
side.setLineNumbers(lineNumbers[index])
|
||||
side.setLineSigns(signs)
|
||||
})
|
||||
})
|
||||
}
|
||||
createEffect(
|
||||
on(
|
||||
() => local.gutterPadding,
|
||||
() => syncGutters(),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const register = (index: number, node: DiffRenderable) => {
|
||||
nodes.set(index, node)
|
||||
onCleanup(() => nodes.delete(index))
|
||||
|
||||
@@ -85,6 +85,7 @@ export const Definitions = {
|
||||
"diff.single_patch": keybind("s", "Toggle single patch view"),
|
||||
"diff.switch_source": keybind("d", "Switch diff viewer source"),
|
||||
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
|
||||
"diff.toggle_fullscreen": keybind("f", "Toggle diff viewer full screen"),
|
||||
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
|
||||
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Plugin } from "@opencode/plugin/tui"
|
||||
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
|
||||
import { contextUsage, formatContextUsage } from "../../util/session"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useInteractivity } from "../../context/interactivity"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
|
||||
const money = new Intl.NumberFormat("en-US", {
|
||||
@@ -47,6 +49,8 @@ export function PromptFooter(props: {
|
||||
].filter((item): item is string => Boolean(item))
|
||||
})
|
||||
const live = createMemo(() => Boolean(subagents() || shells()))
|
||||
// Prompt hints read as disabled while the session does not own input, e.g. while a side panel is focused.
|
||||
const interactive = useInteractivity()
|
||||
const shortcut = (id: string) => props.context.keymap.shortcuts(id)[0]
|
||||
const layout = createMemo(() => {
|
||||
const command = shortcut("command.palette.show")
|
||||
@@ -93,14 +97,25 @@ export function PromptFooter(props: {
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={props.showDetails && layout().shortcuts}>
|
||||
<text fg={props.context.theme.text.base} flexShrink={0}>
|
||||
{shortcut("agent.cycle")} <span style={{ fg: props.context.theme.text.muted }}>agents</span>
|
||||
<text
|
||||
fg={interactive() ? props.context.theme.text.base : props.context.theme.text.action.primary.disabled}
|
||||
attributes={interactive() ? 0 : TextAttributes.DIM}
|
||||
flexShrink={0}
|
||||
>
|
||||
{shortcut("agent.cycle")}{" "}
|
||||
<span style={interactive() ? { fg: props.context.theme.text.muted } : {}}>agents</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={props.showDetails && layout().shortcuts}>
|
||||
<text fg={props.context.theme.text.base} wrapMode="none" flexShrink={0}>
|
||||
{shortcut("command.palette.show")} <span style={{ fg: props.context.theme.text.muted }}>commands</span>
|
||||
<text
|
||||
fg={interactive() ? props.context.theme.text.base : props.context.theme.text.action.primary.disabled}
|
||||
attributes={interactive() ? 0 : TextAttributes.DIM}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{shortcut("command.palette.show")}{" "}
|
||||
<span style={interactive() ? { fg: props.context.theme.text.muted } : {}}>commands</span>
|
||||
</text>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import type { FileDiffInfo, LocationRef } from "@opencode/client"
|
||||
import type { Vcs } from "@opencode/schema/vcs"
|
||||
import { Plugin } from "@opencode/plugin/tui"
|
||||
import type { KeymapCommand, Route } from "@opencode/plugin/tui/context"
|
||||
import type { KeymapCommand, PanelInput, Route } from "@opencode/plugin/tui/context"
|
||||
import {
|
||||
MouseButton,
|
||||
TextAttributes,
|
||||
type BoxRenderable,
|
||||
type RGBA,
|
||||
type MouseEvent,
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
@@ -23,7 +24,7 @@ import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { type DiffSource, useConfig } from "../../config"
|
||||
import { locationKey } from "../../context/data"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
@@ -81,26 +82,21 @@ const VCS_SOURCES = ["branch", "committed", "working"] as const
|
||||
|
||||
const needsBase = (mode: DiffMode) => mode === "branch" || mode === "committed"
|
||||
|
||||
function DiffViewer(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
function DiffViewer(props: {
|
||||
context: Plugin.Context
|
||||
sessionID?: string
|
||||
initialMode?: DiffMode
|
||||
panel?: PanelInput
|
||||
onClose: () => void
|
||||
}) {
|
||||
const config = useConfig()
|
||||
const [memory, updateMemory] = props.context.storage.memory<{
|
||||
source?: DiffMode
|
||||
bases: Record<string, string>
|
||||
}>("review", { initial: { bases: {} } })
|
||||
const params = () => {
|
||||
const route = props.context.ui.router.current()
|
||||
return (route.type === "plugin" ? route.data : undefined) as
|
||||
| {
|
||||
mode?: DiffMode
|
||||
sessionID?: string
|
||||
returnRoute?: Route
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
const sessionID = () => params()?.sessionID
|
||||
const sessionID = () => props.sessionID
|
||||
const sources = (): readonly DiffMode[] => (sessionID() ? [...VCS_SOURCES, "turn"] : VCS_SOURCES)
|
||||
const initialMode = params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch"
|
||||
const initialMode = props.initialMode ?? memory.source ?? config.data.diffs?.source ?? "branch"
|
||||
const [mode, setMode] = createSignal(sources().includes(initialMode) ? initialMode : "branch")
|
||||
const location = createMemo(
|
||||
() => {
|
||||
@@ -188,52 +184,67 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
if (!base) return "Base not reported"
|
||||
return `vs ${base.name}`
|
||||
}
|
||||
return (
|
||||
<DiffViewerContent
|
||||
context={props.context}
|
||||
files={result()?.files ?? []}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
mode={mode()}
|
||||
sources={sources()}
|
||||
sourceDetail={sourceDetail()}
|
||||
sourceBase={sourceBase()}
|
||||
unavailable={mode() === "committed" && !!result() && !result()?.base}
|
||||
preferences={config.data.diffs}
|
||||
panel={props.panel}
|
||||
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
|
||||
onPreferencesChange={(value) => {
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, ...value }
|
||||
})
|
||||
.catch(() => {})
|
||||
}}
|
||||
onClose={props.onClose}
|
||||
onSwitchSource={(mode) => {
|
||||
updateMemory((draft) => {
|
||||
draft.source = mode
|
||||
})
|
||||
setMode(mode)
|
||||
}}
|
||||
onChooseBase={() => {
|
||||
const target = { ...location() }
|
||||
const key = baseKey()
|
||||
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DiffBaseDialog
|
||||
context={props.context}
|
||||
location={target}
|
||||
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
|
||||
onSelect={(ref) =>
|
||||
updateMemory((draft) => {
|
||||
draft.bases[key] = ref
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffRoute(props: {
|
||||
context: Plugin.Context
|
||||
data?: { mode?: DiffMode; sessionID?: string; returnRoute?: Route }
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
return (
|
||||
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
||||
<DiffViewerContent
|
||||
<DiffViewer
|
||||
context={props.context}
|
||||
files={result()?.files ?? []}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
mode={mode()}
|
||||
sources={sources()}
|
||||
sourceDetail={sourceDetail()}
|
||||
sourceBase={sourceBase()}
|
||||
unavailable={mode() === "committed" && !!result() && !result()?.base}
|
||||
preferences={config.data.diffs}
|
||||
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
|
||||
onPreferencesChange={(value) => {
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, ...value }
|
||||
})
|
||||
.catch(() => {})
|
||||
}}
|
||||
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
|
||||
onSwitchSource={(mode) => {
|
||||
updateMemory((draft) => {
|
||||
draft.source = mode
|
||||
})
|
||||
setMode(mode)
|
||||
}}
|
||||
onChooseBase={() => {
|
||||
const target = { ...location() }
|
||||
const key = baseKey()
|
||||
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DiffBaseDialog
|
||||
context={props.context}
|
||||
location={target}
|
||||
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
|
||||
onSelect={(ref) =>
|
||||
updateMemory((draft) => {
|
||||
draft.bases[key] = ref
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
sessionID={props.data?.sessionID}
|
||||
initialMode={props.data?.mode}
|
||||
onClose={() => props.context.ui.router.navigate(props.data?.returnRoute ?? { type: "home" })}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
@@ -298,6 +309,7 @@ export function DiffViewerContent(props: {
|
||||
unavailable?: boolean
|
||||
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
|
||||
preferences?: DiffPreferences
|
||||
panel?: PanelInput
|
||||
onPreferencesChange?: (value: DiffPreferences) => void
|
||||
onClose: () => void
|
||||
onSwitchSource: (mode: DiffMode) => void
|
||||
@@ -307,19 +319,25 @@ export function DiffViewerContent(props: {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig()
|
||||
const dialog = props.context.ui.dialog
|
||||
const theme = useThemes().current
|
||||
const theme = useTheme()
|
||||
const currentSyntax = useThemes().currentSyntax
|
||||
const files = () => props.files
|
||||
const docked = () => props.panel?.presentation === "panel"
|
||||
// The side panel sits on the slot's raised surface; full screen keeps the base background.
|
||||
const surface = () => (docked() ? theme.background.raised.base : theme.background.base)
|
||||
// Cards are inset from the pane edges; the gaps between them run edge to edge.
|
||||
const inset = () => (docked() ? 0 : 2)
|
||||
const width = () => props.panel?.width ?? dimensions().width
|
||||
const mode = () => props.mode
|
||||
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true)
|
||||
const showFileTree = createMemo(
|
||||
() => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
|
||||
() => !docked() && width() >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
|
||||
)
|
||||
const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false)
|
||||
const fileTreeWidth = createMemo(() =>
|
||||
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))),
|
||||
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(width() / 4))),
|
||||
)
|
||||
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4)
|
||||
const patchPaneWidth = createMemo(() => width() - (showFileTree() ? fileTreeWidth() : 0) - inset() * 2)
|
||||
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
|
||||
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.preferences?.view))
|
||||
const view = createMemo(() =>
|
||||
@@ -339,8 +357,6 @@ export function DiffViewerContent(props: {
|
||||
const [selectedHunk, setSelectedHunk] = createSignal<SelectedHunk | undefined>()
|
||||
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
||||
|
||||
onCleanup(() => dialog.clear())
|
||||
|
||||
createEffect(() => {
|
||||
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
|
||||
setSelectedFileIndex(undefined)
|
||||
@@ -548,6 +564,7 @@ export function DiffViewerContent(props: {
|
||||
if (event.button !== MouseButton.RIGHT) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.panel?.focus()
|
||||
setFileMenu({ fileIndex, x: event.x, y: event.y })
|
||||
}
|
||||
|
||||
@@ -667,7 +684,9 @@ export function DiffViewerContent(props: {
|
||||
id: "diff.toggle_file_tree",
|
||||
title: "Toggle diff viewer file tree",
|
||||
group: "VCS",
|
||||
enabled: () => !docked(),
|
||||
run() {
|
||||
if (docked()) return
|
||||
const next = !fileTreeEnabled()
|
||||
setFileTreeEnabled(next)
|
||||
props.onPreferencesChange?.({ tree: next })
|
||||
@@ -715,6 +734,13 @@ export function DiffViewerContent(props: {
|
||||
props.onPreferencesChange?.({ view: next })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diff.toggle_fullscreen",
|
||||
title: "Toggle diff viewer full screen",
|
||||
group: "VCS",
|
||||
enabled: () => props.panel !== undefined,
|
||||
run: () => props.panel?.toggleFullscreen(),
|
||||
},
|
||||
{
|
||||
id: "diff.help",
|
||||
title: "Show more diff viewer shortcuts",
|
||||
@@ -767,16 +793,24 @@ export function DiffViewerContent(props: {
|
||||
}
|
||||
|
||||
const openHelpDialog = () => {
|
||||
dialog.show(() => <DiffViewerHelpDialog context={props.context} single={singlePatch()} />)
|
||||
dialog.show(() => (
|
||||
<DiffViewerHelpDialog
|
||||
context={props.context}
|
||||
single={singlePatch()}
|
||||
fullscreen={props.panel !== undefined}
|
||||
tree={!docked()}
|
||||
/>
|
||||
))
|
||||
dialog.set({ size: "medium", centered: true })
|
||||
}
|
||||
|
||||
const HelpShortcut = (props: { compact?: boolean }) => (
|
||||
const HelpShortcut = (props: { compact?: boolean; label?: string; muted?: boolean }) => (
|
||||
<Show when={helpShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text
|
||||
id="diff-help-shortcut"
|
||||
fg={theme.text.base}
|
||||
fg={props.muted ? theme.text.action.primary.disabled : theme.text.base}
|
||||
attributes={props.muted ? TextAttributes.DIM : 0}
|
||||
selectable={false}
|
||||
flexShrink={0}
|
||||
wrapMode="none"
|
||||
@@ -788,7 +822,7 @@ export function DiffViewerContent(props: {
|
||||
>
|
||||
{props.compact ? "?" : shortcut()}
|
||||
<Show when={!props.compact}>
|
||||
<span style={{ fg: theme.text.muted }}> help</span>
|
||||
<span style={props.muted ? {} : { fg: theme.text.muted }}> {props.label ?? "help"}</span>
|
||||
</Show>
|
||||
</text>
|
||||
)}
|
||||
@@ -800,13 +834,15 @@ export function DiffViewerContent(props: {
|
||||
}))
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" backgroundColor={theme.background.base}>
|
||||
<box width="100%" height="100%" backgroundColor={surface()}>
|
||||
<Show when={!showFileTree()}>
|
||||
<box
|
||||
id="diff-source-header"
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
height={1}
|
||||
// Docked, the header lines up with the card text below it; the card edge row supplies the gap underneath.
|
||||
paddingRight={docked() ? 1 : 2}
|
||||
paddingTop={docked() ? 1 : 0}
|
||||
height={docked() ? 2 : 1}
|
||||
flexShrink={0}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
@@ -845,12 +881,12 @@ export function DiffViewerContent(props: {
|
||||
<box flexGrow={1} minHeight={0}>
|
||||
<Switch>
|
||||
<Match when={props.loading}>
|
||||
<box flexGrow={1} padding={2}>
|
||||
<box flexGrow={1} padding={2} paddingTop={docked() ? 1 : 2}>
|
||||
<text fg={theme.text.muted}>Loading diff…</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!props.loading && props.error}>
|
||||
<box flexGrow={1} padding={2}>
|
||||
<box flexGrow={1} padding={2} paddingTop={docked() ? 1 : 2}>
|
||||
<text fg={theme.text.feedback.error.base}>
|
||||
{!props.sourceBase && needsBase(mode())
|
||||
? "Could not load diff. Choose a base branch from Diff source, or select Uncommitted."
|
||||
@@ -859,14 +895,14 @@ export function DiffViewerContent(props: {
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!props.loading && props.unavailable}>
|
||||
<box flexGrow={1} padding={2}>
|
||||
<box flexGrow={1} padding={2} paddingTop={docked() ? 1 : 2}>
|
||||
<text fg={theme.text.muted}>
|
||||
Committed comparison unavailable without base metadata. Choose a base branch from Diff source.
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!props.loading && files().length === 0}>
|
||||
<box flexGrow={1} padding={2}>
|
||||
<box flexGrow={1} padding={2} paddingTop={docked() ? 1 : 2}>
|
||||
<text fg={theme.text.muted}>No changes to show</text>
|
||||
</box>
|
||||
</Match>
|
||||
@@ -892,9 +928,11 @@ export function DiffViewerContent(props: {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<box flexGrow={1} minWidth={0} minHeight={0} paddingLeft={2} paddingRight={2}>
|
||||
<box flexGrow={1} minWidth={0} minHeight={0}>
|
||||
<box
|
||||
id="diff-patch-top-edge"
|
||||
marginLeft={inset()}
|
||||
marginRight={inset()}
|
||||
ref={(edge: BoxRenderable) => {
|
||||
// The fixed edge belongs to the visible card, not the tree selection.
|
||||
edge.onLifecyclePass = () => {
|
||||
@@ -936,16 +974,9 @@ export function DiffViewerContent(props: {
|
||||
return (
|
||||
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
|
||||
<Show when={index() > 0}>
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
border={["top"]}
|
||||
borderColor={background()}
|
||||
backgroundColor={theme.background.base}
|
||||
customBorderChars={{ ...EmptyBorder, horizontal: "▄" }}
|
||||
/>
|
||||
<CardGap glyph="▄" card={background()} surface={surface()} inset={inset()} />
|
||||
</Show>
|
||||
<box backgroundColor={background()}>
|
||||
<box backgroundColor={background()} marginLeft={inset()} marginRight={inset()}>
|
||||
<box
|
||||
id={`diff-file-header-${entry.fileIndex}`}
|
||||
onMouseDown={(event) => openFileMenu(entry.fileIndex, event)}
|
||||
@@ -970,7 +1001,7 @@ export function DiffViewerContent(props: {
|
||||
height={FILE_HEADER_HEIGHT}
|
||||
zIndex={1}
|
||||
backgroundColor={background()}
|
||||
paddingLeft={1}
|
||||
paddingLeft={docked() ? 2 : 1}
|
||||
paddingRight={1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
@@ -996,17 +1027,9 @@ export function DiffViewerContent(props: {
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={reviewed()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
bottom={0}
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["bottom"]}
|
||||
borderColor={background()}
|
||||
backgroundColor={theme.background.base}
|
||||
customBorderChars={{ ...EmptyBorder, horizontal: "▀" }}
|
||||
/>
|
||||
<box position="absolute" left={-inset()} right={-inset()} bottom={0} height={1}>
|
||||
<CardGap glyph="▀" card={background()} surface={surface()} inset={inset()} />
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={!reviewed()}>
|
||||
@@ -1047,6 +1070,7 @@ export function DiffViewerContent(props: {
|
||||
filetype={filetype(entry.file.file)}
|
||||
syntaxStyle={currentSyntax()}
|
||||
showLineNumbers={true}
|
||||
gutterPadding={docked() ? 1 : 0}
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
fg={theme.text.base}
|
||||
@@ -1075,7 +1099,42 @@ export function DiffViewerContent(props: {
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={!showFileTree()}>
|
||||
<Show when={docked()}>
|
||||
{/* The footer sits on the terminal background, eased in by a half-cell like the prompt. */}
|
||||
<box id="diff-footer" flexShrink={0} backgroundColor={theme.background.base}>
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
border={["bottom"]}
|
||||
borderColor={surface()}
|
||||
backgroundColor={theme.background.base}
|
||||
customBorderChars={{ ...EmptyBorder, horizontal: "▀" }}
|
||||
/>
|
||||
<box height={1} flexShrink={0} flexDirection="row" paddingRight={1}>
|
||||
<text
|
||||
fg={props.panel?.focused ? theme.background.action.primary.focused : theme.text.action.primary.disabled}
|
||||
attributes={props.panel?.focused ? 0 : TextAttributes.DIM}
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button !== MouseButton.LEFT) return
|
||||
if (!props.panel?.focused) props.panel?.focus()
|
||||
}}
|
||||
>
|
||||
<span>{props.context.keymap.shortcuts("pane.focus.left")[0] ?? ""}</span>
|
||||
<span style={props.panel?.focused ? { fg: theme.text.muted } : {}}> focus prompt</span>
|
||||
</text>
|
||||
<box flexShrink={0}>
|
||||
<HelpShortcut label="see all" muted={!props.panel?.focused} />
|
||||
</box>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} />
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!showFileTree() && !docked()}>
|
||||
<box position="absolute" top={0} right={0} width={1} height={1}>
|
||||
<HelpShortcut compact />
|
||||
</box>
|
||||
@@ -1095,7 +1154,30 @@ export function DiffViewerContent(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
|
||||
/** A half-row of base background between cards: the card's color over its width, the pane surface in the insets. */
|
||||
function CardGap(props: { glyph: "▄" | "▀"; card: RGBA; surface: RGBA; inset: number }) {
|
||||
const theme = useTheme()
|
||||
const half = (color: RGBA, width?: number) => (
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
{...(width === undefined ? { flexGrow: 1 } : { width })}
|
||||
border={[props.glyph === "▄" ? "top" : "bottom"]}
|
||||
borderColor={color}
|
||||
backgroundColor={theme.background.base}
|
||||
customBorderChars={{ ...EmptyBorder, horizontal: props.glyph }}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<box height={1} flexShrink={0} flexDirection="row">
|
||||
{half(props.surface, props.inset)}
|
||||
{half(props.card)}
|
||||
{half(props.surface, props.inset)}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean; fullscreen: boolean; tree: boolean }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.surface("dialog")
|
||||
const shortcut =
|
||||
@@ -1133,8 +1215,11 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
|
||||
rows: [
|
||||
{ shortcut: shortcut("diff.toggle_view"), label: "Split / unified" },
|
||||
{ shortcut: shortcut("diff.single_patch"), label: "All files / single file" },
|
||||
{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" },
|
||||
...(props.tree ? [{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" }] : []),
|
||||
{ shortcut: shortcut("diff.switch_source"), label: "Switch diff source" },
|
||||
...(props.fullscreen
|
||||
? [{ shortcut: shortcut("diff.toggle_fullscreen"), label: "Full screen / split view" }]
|
||||
: []),
|
||||
{ shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" },
|
||||
],
|
||||
},
|
||||
@@ -1192,6 +1277,7 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
|
||||
}
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
const config = useConfig()
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
@@ -1203,6 +1289,13 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
palette: true,
|
||||
run() {
|
||||
const route = props.context.ui.router.current()
|
||||
props.context.ui.dialog.clear()
|
||||
const current = props.context.ui.panel.current()
|
||||
if (route.type === "session" && current?.sessionID === route.sessionID && current.name === ROUTE) {
|
||||
props.context.ui.panel.close()
|
||||
return
|
||||
}
|
||||
if (config.data.experimental?.["diff-panel"] === true && props.context.ui.panel.open(ROUTE)) return
|
||||
const returnRoute: Route =
|
||||
route.type === "home"
|
||||
? { type: "home" }
|
||||
@@ -1222,7 +1315,6 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
returnRoute,
|
||||
},
|
||||
})
|
||||
props.context.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1233,9 +1325,17 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
export default Plugin.define({
|
||||
id: "opencode.diffs",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "session.panel",
|
||||
render: (panel) => (
|
||||
<Show when={panel.name === ROUTE}>
|
||||
<DiffViewer context={context} sessionID={panel.sessionID} panel={panel} onClose={panel.close} />
|
||||
</Show>
|
||||
),
|
||||
})
|
||||
context.ui.router.register({
|
||||
name: ROUTE,
|
||||
render: () => <DiffViewer context={context} />,
|
||||
render: ({ data }) => <DiffRoute context={context} data={data} />,
|
||||
})
|
||||
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
|
||||
},
|
||||
|
||||
@@ -69,6 +69,28 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test.each([80, 160])("standalone full-screen diff does not expose panel-only toggling at %i columns", async (width) => {
|
||||
const viewer = await renderDiffViewer([], {
|
||||
width,
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { sessionID: "session-1", returnRoute: startRoute, split: true },
|
||||
},
|
||||
})
|
||||
try {
|
||||
const command = viewer.commands.get("diff.toggle_fullscreen")
|
||||
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(false)
|
||||
const route = viewer.current()
|
||||
viewer.app.mockInput.pressKey("f")
|
||||
await viewer.app.flush()
|
||||
expect(viewer.current()).toEqual(route)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ctrl+c closes the diff viewer without exiting the application", async () => {
|
||||
const viewer = await renderDiffViewer([])
|
||||
|
||||
@@ -1032,7 +1054,9 @@ test.each(["dark", "light"] as const)("the pane edge matches the visible reviewe
|
||||
const scroll = findScrollBox(viewer.app.renderer.root)!
|
||||
const expectEdge = (file: string, collapsed = false) => {
|
||||
const frame = viewer.app.captureSpans()
|
||||
const edge = frame.lines[scroll.viewport.y - 1].spans.find((span) => span.width === scroll.viewport.width)!
|
||||
// Full-screen cards are inset two columns from each pane edge.
|
||||
const card = scroll.viewport.width - 4
|
||||
const edge = frame.lines[scroll.viewport.y - 1].spans.find((span) => span.width === card)!
|
||||
const page = frame.lines[scroll.viewport.y - 1].spans.at(-1)!.bg
|
||||
const title = frame.lines[scroll.viewport.y].spans.find((span) => span.text.includes(file))!
|
||||
expect(title, viewer.app.captureCharFrame()).toBeDefined()
|
||||
@@ -1040,12 +1064,14 @@ test.each(["dark", "light"] as const)("the pane edge matches the visible reviewe
|
||||
expect(edge.bg).toEqual(title.bg)
|
||||
expect(edge.bg).not.toEqual(page)
|
||||
expect(frame.lines[0].spans[0].bg).toEqual(frame.lines[1].spans.find((span) => span.text.includes("All"))!.bg)
|
||||
const bottom = frame.lines[scroll.viewport.y + 1].spans.findLast((span) => span.text.includes("▀"))
|
||||
const bottom = frame.lines[scroll.viewport.y + 1].spans.find(
|
||||
(span) => span.text.includes("▀") && span.width === card,
|
||||
)
|
||||
if (!collapsed) {
|
||||
expect(bottom).toBeUndefined()
|
||||
return
|
||||
}
|
||||
expect(bottom?.text.trim()).toBe("▀".repeat(scroll.viewport.width))
|
||||
expect(bottom?.text.trim()).toBe("▀".repeat(card))
|
||||
expect(bottom?.fg).toEqual(edge.bg)
|
||||
expect(bottom?.bg).toEqual(page)
|
||||
}
|
||||
@@ -1069,7 +1095,8 @@ test.each(["dark", "light"] as const)("the pane edge matches the visible reviewe
|
||||
expectEdge("file0.txt", true)
|
||||
const second = viewer.app.renderer.root.findDescendantById("diff-file-header-1")!
|
||||
const frame = viewer.app.captureSpans()
|
||||
expect(frame.lines[second.y - 1].spans.findLast((span) => span.text.includes("▄"))!.fg).toEqual(
|
||||
// The gap's card segment sits between the two inset segments.
|
||||
expect(frame.lines[second.y - 1].spans.find((span) => span.text.includes("▄") && span.width > 2)!.fg).toEqual(
|
||||
frame.lines[second.y].spans.find((span) => span.text.includes("file1.txt"))!.bg,
|
||||
)
|
||||
|
||||
@@ -1332,7 +1359,7 @@ test.each(["patch", "image", "fallback"] as const)(
|
||||
if (!(header instanceof BoxRenderable)) throw new Error("Missing reviewed file title")
|
||||
expect(header.parent?.height).toBe(header.height)
|
||||
expect(header.height).toBe(2)
|
||||
expect(viewer.app.captureCharFrame().split("\n")[header.y + 1].trim()).toBe("▀".repeat(header.width))
|
||||
expect(viewer.app.captureCharFrame().split("\n")[header.y + 1].trim()).toBe("▀".repeat(header.width + 4))
|
||||
expect(header.y).toBe(scroll.viewport.y)
|
||||
expect(viewer.app.captureCharFrame()).toContain(file.file)
|
||||
expect(viewer.app.captureCharFrame()).toContain("✓")
|
||||
@@ -2001,6 +2028,7 @@ async function renderDiffViewer(
|
||||
},
|
||||
ui: {
|
||||
dialog: createDialogApi(dialog, (render) => render()),
|
||||
panel: { open: () => false, close: () => {}, current: () => undefined },
|
||||
router: {
|
||||
register(page: Page) {
|
||||
if (page.name === "diff") renderDiff = page.render
|
||||
|
||||
@@ -243,6 +243,7 @@ test("centralizes named command defaults and resolves explicit none", () => {
|
||||
"diff.next_hunk": "]",
|
||||
"diff.previous_hunk": "[",
|
||||
"diff.mark_reviewed": "m",
|
||||
"diff.toggle_fullscreen": "f",
|
||||
"diff.help": "?,shift+?,shift+/",
|
||||
}
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
DiffRenderable,
|
||||
InputRenderable,
|
||||
MouseButton,
|
||||
type Renderable,
|
||||
ScrollBoxRenderable,
|
||||
TextareaRenderable,
|
||||
} from "@opentui/core"
|
||||
import type { FileDiffInfo, FormInfo, PermissionRequest, SessionInfo } from "@opencode/client"
|
||||
import type { Config } from "../src/config"
|
||||
import { createAppFixture } from "./fixture/app"
|
||||
import { directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("/diff opens the full-screen diff when the side panel experiment is off", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, { panel: false })
|
||||
await ready(setup)
|
||||
await setup.mockInput.typeText("/diff")
|
||||
await setup.waitForFrame((frame) => frame.includes("Open diff viewer"))
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame((frame) => frame.includes("file00.txt"), { maxPasses: 120 })
|
||||
expect(setup.renderer.root.findDescendantById("session-panel")).toBeUndefined()
|
||||
expect(setup.renderer.root.findDescendantById("diff-footer")).toBeUndefined()
|
||||
expect(setup.captureCharFrame()).not.toContain("Transcript item 11")
|
||||
setup.mockInput.pressKey("q")
|
||||
await setup.waitForFrame((frame) => frame.includes("Transcript item 11") && !frame.includes("file00.txt"))
|
||||
expect(setup.renderer.root.findDescendantById("session-panel")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("/diff focuses the production session panel without sending typing to the prompt", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path)
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
const transcript = node(setup, "msg_panel_11")
|
||||
|
||||
expect(setup.requests.base).toHaveLength(0)
|
||||
expect(setup.requests.diff).toHaveLength(0)
|
||||
await setup.mockInput.typeText("/diff")
|
||||
await setup.waitForFrame((frame) => frame.includes("Open diff viewer"))
|
||||
setup.mockInput.pressEnter()
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const patches = scroll(setup)
|
||||
expect(panel.width).toBeLessThan(setup.renderer.width)
|
||||
expect(setup.renderer.currentFocusedEditor).toBeNull()
|
||||
expect(node(setup, "msg_panel_11")).toBe(transcript)
|
||||
|
||||
await setup.mockInput.typeText("xyz")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.flush()
|
||||
expect(prompt.plainText).toBe("")
|
||||
expect(setup.requests.prompt).toHaveLength(0)
|
||||
|
||||
focus(setup, "left")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
await setup.mockInput.typeText("fjm")
|
||||
await setup.flush()
|
||||
expect(prompt.plainText).toBe("fjm")
|
||||
expect(panel.width).toBeLessThan(setup.renderer.width)
|
||||
expect(patches.scrollTop).toBe(0)
|
||||
expect(text(setup, "diff-review-count")).toBe("0/4")
|
||||
|
||||
focus(setup, "right")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedRenderable === panel)
|
||||
setup.mockInput.pressKey("j")
|
||||
await setup.waitFor(() => patches.scrollTop > 0)
|
||||
expect(prompt.plainText).toBe("fjm")
|
||||
setup.mockInput.pressKey("q")
|
||||
await setup.waitFor(() => !setup.renderer.root.findDescendantById("session-panel"))
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
expect(node(setup, "msg_panel_11")).toBe(transcript)
|
||||
expect(prompt.plainText).toBe("fjm")
|
||||
expect(setup.requests.prompt).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("repeated fullscreen toggles retain the viewer, transcript, scroll, and reviewed files", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path)
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
const transcript = node(setup, "msg_panel_11")
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const patches = scroll(setup)
|
||||
const splitWidth = panel.width
|
||||
const history = descendants(setup.renderer.root).find(
|
||||
(item) => item instanceof ScrollBoxRenderable && item.findDescendantById("msg_panel_11"),
|
||||
)
|
||||
if (!(history instanceof ScrollBoxRenderable)) throw new Error("Missing session transcript scrollbox")
|
||||
history.scrollTo(4)
|
||||
await setup.flush()
|
||||
const historyTop = history.scrollTop
|
||||
expect(historyTop).toBeGreaterThan(0)
|
||||
|
||||
setup.mockInput.pressKey("m")
|
||||
await setup.waitForFrame(() => text(setup, "diff-review-count") === "1/4")
|
||||
await setup.flush()
|
||||
setup.mockInput.pressKey("n")
|
||||
await setup.waitFor(() => patches.scrollTop > 0)
|
||||
setup.mockInput.pressKey("j")
|
||||
await setup.flush()
|
||||
const top = patches.scrollTop
|
||||
const header = node(setup, "diff-file-header-1")
|
||||
const diffs = descendants(patches).filter((item) => item instanceof DiffRenderable)
|
||||
expect(diffs).toHaveLength(3)
|
||||
expect(top).toBeGreaterThan(0)
|
||||
|
||||
for (const fullscreen of [true, false, true, false]) {
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => node(setup, "session-panel").width === (fullscreen ? setup.renderer.width : splitWidth))
|
||||
await setup.flush()
|
||||
expect(node(setup, "session-panel")).toBe(panel)
|
||||
expect(scroll(setup)).toBe(patches)
|
||||
expect(patches.scrollTop).toBe(top)
|
||||
expect(node(setup, "diff-file-header-1")).toBe(header)
|
||||
const current = descendants(patches).filter((item) => item instanceof DiffRenderable)
|
||||
expect(current).toHaveLength(diffs.length)
|
||||
current.forEach((item, index) => expect(item).toBe(diffs[index]))
|
||||
expect(text(setup, "diff-review-count")).toBe("1/4")
|
||||
expect(node(setup, "msg_panel_11")).toBe(transcript)
|
||||
expect(transcript.isDestroyed).toBe(false)
|
||||
expect(history.isDestroyed).toBe(false)
|
||||
expect(history.scrollTop).toBe(historyTop)
|
||||
expect(prompt.isDestroyed).toBe(false)
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
expect(setup.requests.base).toHaveLength(1)
|
||||
expect(setup.requests.diff).toHaveLength(1)
|
||||
}
|
||||
|
||||
setup.mockInput.pressKey("HOME")
|
||||
await setup.waitForFrame((frame) => frame.includes("file00.txt") && text(setup, "diff-file-header-0").includes("✓"))
|
||||
expect(text(setup, "diff-file-header-0")).toContain("✓")
|
||||
expect(setup.requests.diff[0].searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(setup.requests.diff[0].searchParams.get("base")).toBe("refs/heads/v2")
|
||||
})
|
||||
|
||||
test("docked tree commands leave the full-screen tree preference unchanged", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, { diffs: { tree: true } })
|
||||
await ready(setup)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
expect(setup.renderer.root.findDescendantById("diff-files")).toBeUndefined()
|
||||
setup.mockInput.pressKey("b")
|
||||
await setup.flush()
|
||||
expect(setup.renderer.root.findDescendantById("diff-files")).toBeUndefined()
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => setup.renderer.root.findDescendantById("diff-files") !== undefined)
|
||||
setup.mockInput.pressKey("b")
|
||||
await setup.waitFor(() => setup.renderer.root.findDescendantById("diff-files") === undefined)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => node(setup, "session-panel").width < setup.renderer.width)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => node(setup, "session-panel").width === setup.renderer.width)
|
||||
expect(setup.renderer.root.findDescendantById("diff-files")).toBeUndefined()
|
||||
expect(setup.requests.diff).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("right-clicking an inactive diff focuses its screen-relative file menu", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path)
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
focus(setup, "left")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
const header = node(setup, "diff-file-header-0")
|
||||
await setup.mockMouse.click(header.x + 1, header.y, MouseButton.RIGHT)
|
||||
await setup.waitFor(() => setup.renderer.root.findDescendantById("diff-file-menu") !== undefined)
|
||||
const menu = node(setup, "diff-file-menu")
|
||||
expect(menu.x).toBe(Math.min(header.x + 1, setup.renderer.width - menu.width))
|
||||
expect(menu.x + menu.width).toBeLessThanOrEqual(setup.renderer.width)
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame(() => text(setup, "diff-review-count") === "1/4")
|
||||
expect(setup.renderer.root.findDescendantById("diff-file-menu")).toBeUndefined()
|
||||
expect(prompt.plainText).toBe("")
|
||||
})
|
||||
|
||||
test.each([60, 80])("a panel opened at %i columns can split after growing without remounting", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, { width })
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
const transcript = node(setup, "msg_panel_11")
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const patches = scroll(setup)
|
||||
expect(panel.width).toBe(width)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.flush()
|
||||
expect(panel.width).toBe(width)
|
||||
expect(prompt.plainText).toBe("")
|
||||
|
||||
setup.resize(160, 36)
|
||||
await setup.flush()
|
||||
const grownWidth = panel.width
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => node(setup, "session-panel").width !== grownWidth)
|
||||
expect(node(setup, "session-panel")).toBe(panel)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width === grownWidth)
|
||||
if (panel.width === setup.renderer.width) {
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width < setup.renderer.width)
|
||||
}
|
||||
expect(panel.x).toBeGreaterThan(0)
|
||||
setup.resize(80, 36)
|
||||
await setup.waitFor(() => panel.width === 80)
|
||||
expect(scroll(setup)).toBe(patches)
|
||||
expect(node(setup, "msg_panel_11")).toBe(transcript)
|
||||
expect(prompt.isDestroyed).toBe(false)
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
expect(setup.requests.base).toHaveLength(1)
|
||||
expect(setup.requests.diff).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("split eligibility uses the columns remaining beside vertical session tabs", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, {
|
||||
width: 122,
|
||||
tabs: { enabled: true, layout: "vertical" },
|
||||
})
|
||||
await ready(setup)
|
||||
await setup.waitFor(() => editor(setup).x >= 42)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
expect(panel.x).toBe(0)
|
||||
expect(panel.width).toBe(122)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.flush()
|
||||
expect(panel.x).toBe(0)
|
||||
expect(panel.width).toBe(122)
|
||||
|
||||
setup.resize(180, 36)
|
||||
await setup.flush()
|
||||
const grownWidth = panel.width
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => node(setup, "session-panel").width !== grownWidth)
|
||||
expect(node(setup, "session-panel")).toBe(panel)
|
||||
expect(setup.requests.base).toHaveLength(1)
|
||||
expect(setup.requests.diff).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("panel focus, scrolling, and fullscreen use customized keys", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, {
|
||||
width: 240,
|
||||
keybinds: {
|
||||
leader: "ctrl+space",
|
||||
"pane.focus.left": "<leader>h",
|
||||
"pane.focus.right": "<leader>l",
|
||||
"diff.toggle_fullscreen": "f7",
|
||||
"diff.down": "x",
|
||||
"diff.up": "z",
|
||||
},
|
||||
})
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const patches = scroll(setup)
|
||||
const splitWidth = panel.width
|
||||
expect(text(setup, "diff-footer")).toContain("ctrl+space h focus prompt")
|
||||
expect(text(setup, "diff-footer")).toContain("? see all")
|
||||
setup.mockInput.pressKey("f")
|
||||
setup.mockInput.pressKey("j")
|
||||
focus(setup, "left")
|
||||
await setup.flush()
|
||||
expect(panel.width).toBe(splitWidth)
|
||||
expect(patches.scrollTop).toBe(0)
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
setup.mockInput.pressKey("x")
|
||||
await setup.waitForFrame(() => patches.scrollTop > 0)
|
||||
setup.mockInput.pressKey("F7")
|
||||
await setup.waitFor(() => panel.width === setup.renderer.width)
|
||||
setup.mockInput.pressKey("F7")
|
||||
await setup.waitFor(() => panel.width === splitWidth)
|
||||
|
||||
setup.mockInput.pressKey(" ", { ctrl: true })
|
||||
setup.mockInput.pressKey("h")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
setup.mockInput.pressKey(" ", { ctrl: true })
|
||||
setup.mockInput.pressKey("l")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedRenderable === panel)
|
||||
expect(prompt.plainText).toBe("")
|
||||
})
|
||||
|
||||
test.each(["none", false] as const)(
|
||||
"disabled panel keys (%s) have no behavioral or hint fallbacks",
|
||||
async (binding) => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, {
|
||||
width: 240,
|
||||
keybinds: {
|
||||
"pane.focus.left": binding,
|
||||
"pane.focus.right": binding,
|
||||
"diff.toggle_fullscreen": binding,
|
||||
"diff.down": binding,
|
||||
"diff.up": binding,
|
||||
"diff.next_file": binding,
|
||||
"diff.previous_file": binding,
|
||||
"diff.next_hunk": binding,
|
||||
"diff.previous_hunk": binding,
|
||||
"diff.mark_reviewed": binding,
|
||||
"diff.close": binding,
|
||||
"diff.help": binding,
|
||||
},
|
||||
})
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const splitWidth = panel.width
|
||||
const patches = scroll(setup)
|
||||
await setup.mockInput.typeText("fjknpm?[]q")
|
||||
focus(setup, "left")
|
||||
await setup.flush()
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
expect(panel.width).toBe(splitWidth)
|
||||
expect(patches.scrollTop).toBe(0)
|
||||
expect(text(setup, "diff-review-count")).toBe("0/4")
|
||||
expect(prompt.plainText).toBe("")
|
||||
expect(setup.renderer.root.findDescendantById("diff-help-shortcut")).toBeUndefined()
|
||||
expect(text(setup, "diff-footer")).not.toMatch(/ctrl\+x|see all/)
|
||||
|
||||
await setup.mockMouse.click(prompt.x + 1, prompt.y)
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
await setup.flush()
|
||||
focus(setup, "right")
|
||||
await setup.flush()
|
||||
expect(setup.renderer.currentFocusedEditor).toBe(prompt)
|
||||
},
|
||||
)
|
||||
|
||||
test("Enter in the diff panel cannot approve a pending permission in either presentation", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, { permission: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Permission required"))
|
||||
const permissionNode = node(setup, "session.permission")
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const splitWidth = panel.width
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.flush()
|
||||
expect(setup.requests.permission).toHaveLength(0)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width === setup.renderer.width)
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.flush()
|
||||
expect(setup.requests.permission).toHaveLength(0)
|
||||
expect(node(setup, "session.permission")).toBe(permissionNode)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width === splitWidth)
|
||||
|
||||
focus(setup, "left")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedRenderable !== panel)
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitFor(() => setup.requests.permission.length === 1)
|
||||
expect(setup.requests.permission).toEqual([{ decision: "once" }])
|
||||
})
|
||||
|
||||
test.each([true, false])("a form cannot capture diff keys (form already pending: %s)", async (pending) => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, { form: pending })
|
||||
await setup.waitForFrame((frame) => frame.includes(pending ? setup.form.title : "Transcript item 11"))
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
if (!pending) {
|
||||
setup.events.emit({
|
||||
id: "evt_panel_form",
|
||||
created: 20,
|
||||
type: "form.created",
|
||||
location: { directory },
|
||||
data: { form: setup.form },
|
||||
})
|
||||
await setup.waitForFrame((frame) => frame.includes(setup.form.title))
|
||||
}
|
||||
const panel = node(setup, "session-panel")
|
||||
const patches = scroll(setup)
|
||||
const splitWidth = panel.width
|
||||
setup.mockInput.pressKey("j")
|
||||
await setup.waitFor(() => patches.scrollTop > 0)
|
||||
setup.mockInput.pressKey("m")
|
||||
await setup.waitForFrame(() => text(setup, "diff-review-count") === "1/4")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.flush()
|
||||
expect(setup.requests.form).toHaveLength(0)
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width === setup.renderer.width)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width === splitWidth)
|
||||
expect(setup.requests.form).toHaveLength(0)
|
||||
|
||||
focus(setup, "left")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedRenderable !== panel)
|
||||
setup.mockInput.pressKey("x")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor?.plainText === "answerx")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitFor(() => setup.requests.form.length === 1)
|
||||
expect(setup.requests.form).toEqual([{ answer: { target: "answerx" } }])
|
||||
})
|
||||
|
||||
test("disabling the registered diff plugin closes its open panel and removes its bindings", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path)
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
const transcript = node(setup, "msg_panel_11")
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const patches = scroll(setup)
|
||||
focus(setup, "left")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
await setup.mockInput.typeText("/plugins")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("show internal"))
|
||||
setup.mockInput.pressKey("a", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("opencode.diffs"))
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
await setup.mockInput.typeText("opencode.diffs")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor?.plainText === "opencode.diffs")
|
||||
await setup.flush()
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitFor(() => !setup.renderer.root.findDescendantById("session-panel"))
|
||||
await setup.flush()
|
||||
if (setup.captureCharFrame().includes("Plugins")) setup.mockInput.pressEscape()
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
expect(patches.isDestroyed).toBe(true)
|
||||
expect(node(setup, "msg_panel_11")).toBe(transcript)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await setup.flush()
|
||||
expect(setup.renderer.root.findDescendantById("session-panel")).toBeUndefined()
|
||||
await setup.mockInput.typeText("fjm")
|
||||
expect(prompt.plainText).toBe("fjm")
|
||||
expect(setup.requests.base).toHaveLength(1)
|
||||
expect(setup.requests.diff).toHaveLength(1)
|
||||
})
|
||||
|
||||
const files = Array.from({ length: 4 }, (_, index) => ({
|
||||
file: `src/file0${index}.txt`,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: [
|
||||
`--- a/src/file0${index}.txt`,
|
||||
`+++ b/src/file0${index}.txt`,
|
||||
"@@ -1,30 +1,30 @@",
|
||||
`-before ${index}`,
|
||||
`+after ${index}`,
|
||||
...Array.from({ length: 29 }, (_, line) => ` context ${index}-${line}`),
|
||||
"",
|
||||
].join("\n"),
|
||||
})) satisfies FileDiffInfo[]
|
||||
|
||||
async function createSessionFixture(
|
||||
state: string,
|
||||
input: {
|
||||
width?: number
|
||||
tabs?: Config.Info["tabs"]
|
||||
diffs?: Config.Info["diffs"]
|
||||
keybinds?: Config.Info["keybinds"]
|
||||
permission?: boolean
|
||||
form?: boolean
|
||||
panel?: boolean
|
||||
} = {},
|
||||
) {
|
||||
// Drafts survive app disposal in memory, so each production app needs its own session identity.
|
||||
const session = {
|
||||
id: `ses_panel_${crypto.randomUUID()}`,
|
||||
title: "Panel fixture",
|
||||
projectID: "proj_panel",
|
||||
location: { directory },
|
||||
agent: "build",
|
||||
model: { providerID: "fixture", id: "model" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 12 },
|
||||
} satisfies SessionInfo
|
||||
const permission = {
|
||||
id: `per_panel_${crypto.randomUUID()}`,
|
||||
sessionID: session.id,
|
||||
action: "shell",
|
||||
resources: ["echo permission-fixture"],
|
||||
} satisfies PermissionRequest
|
||||
const form = {
|
||||
id: `frm_panel_${crypto.randomUUID()}`,
|
||||
sessionID: session.id,
|
||||
title: "Panel form fixture",
|
||||
fields: [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
title: "Target",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
default: "answer",
|
||||
},
|
||||
],
|
||||
} satisfies FormInfo
|
||||
const location = { directory, project: { id: session.projectID, directory, canonical: directory } }
|
||||
const requests = {
|
||||
base: [] as URL[],
|
||||
diff: [] as URL[],
|
||||
prompt: [] as unknown[],
|
||||
permission: [] as unknown[],
|
||||
form: [] as unknown[],
|
||||
}
|
||||
const setup = await createAppFixture({
|
||||
width: input.width ?? 160,
|
||||
height: 36,
|
||||
state,
|
||||
args: { sessionID: session.id },
|
||||
config: {
|
||||
animations: false,
|
||||
tabs: input.tabs ?? { enabled: false },
|
||||
session: { sidebar: "hide" },
|
||||
// Keep patch geometry stable while testing host presentation changes, not split/unified diff layout.
|
||||
diffs: { tree: false, view: "unified", ...input.diffs },
|
||||
experimental: { "diff-panel": input.panel ?? true },
|
||||
keybinds: { "diff.open": "f6", ...input.keybinds },
|
||||
},
|
||||
fetch: async (url, request) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
|
||||
if (url.pathname === "/api/model")
|
||||
return json({ location, data: [{ id: "model", providerID: "fixture", name: "Model", variants: [] }] })
|
||||
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "fixture", name: "Fixture" }] })
|
||||
if (url.pathname === "/api/vcs") return json({ location, data: { branch: { current: "panel", default: "v2" } } })
|
||||
if (url.pathname === "/api/vcs/base") {
|
||||
requests.base.push(url)
|
||||
return json({ location, data: { name: "v2", ref: "refs/heads/v2", source: "default" } })
|
||||
}
|
||||
if (url.pathname === "/api/vcs/diff") {
|
||||
requests.diff.push(url)
|
||||
return json({ location, data: files })
|
||||
}
|
||||
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
||||
if (url.pathname === `/api/session/${session.id}/message`)
|
||||
return json({
|
||||
data: Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `msg_panel_${String(index).padStart(2, "0")}`,
|
||||
type: "user",
|
||||
text: `Transcript item ${index}`,
|
||||
time: { created: index + 1 },
|
||||
})).toReversed(),
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${session.id}/permission`)
|
||||
return json({ data: input.permission ? [permission] : [] })
|
||||
if (url.pathname === `/api/session/${session.id}/form`) return json({ data: input.form ? [form] : [] })
|
||||
if (url.pathname === `/api/session/${session.id}/prompt`) {
|
||||
requests.prompt.push(await request.json())
|
||||
return json({ data: {} })
|
||||
}
|
||||
if (url.pathname === `/api/session/${session.id}/permission/${permission.id}/reply`) {
|
||||
requests.permission.push(await request.json())
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === `/api/session/${session.id}/form/${form.id}/reply`) {
|
||||
requests.form.push(await request.json())
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
return { ...setup, requests, form }
|
||||
}
|
||||
|
||||
type AppFixture = Awaited<ReturnType<typeof createAppFixture>>
|
||||
|
||||
async function ready(setup: AppFixture) {
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("Transcript item 11") && frame.includes("Build · Model Fixture"), {
|
||||
maxPasses: 120,
|
||||
})
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
||||
}
|
||||
|
||||
async function panelReady(setup: AppFixture) {
|
||||
await setup.waitForFrame(
|
||||
(frame) => frame.includes("file00.txt") && setup.renderer.currentFocusedRenderable?.id === "session-panel",
|
||||
{ maxPasses: 120 },
|
||||
)
|
||||
await setup.flush()
|
||||
}
|
||||
|
||||
function focus(setup: AppFixture, direction: "left" | "right") {
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressArrow(direction)
|
||||
}
|
||||
|
||||
function node(setup: AppFixture, id: string) {
|
||||
const result = setup.renderer.root.findDescendantById(id)
|
||||
if (!result) throw new Error(`Missing renderable: ${id}\n${setup.captureCharFrame()}`)
|
||||
return result
|
||||
}
|
||||
|
||||
function scroll(setup: AppFixture) {
|
||||
const result = node(setup, "diff-patches")
|
||||
if (!(result instanceof ScrollBoxRenderable)) throw new Error("Missing diff scrollbox")
|
||||
return result
|
||||
}
|
||||
|
||||
function editor(setup: AppFixture) {
|
||||
const result = setup.renderer.currentFocusedEditor
|
||||
if (!(result instanceof TextareaRenderable)) throw new Error("Missing focused session prompt")
|
||||
return result
|
||||
}
|
||||
|
||||
function text(setup: AppFixture, id: string) {
|
||||
const item = setup.renderer.root.findDescendantById(id)
|
||||
if (!item) return ""
|
||||
return setup
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.slice(item.y, item.y + item.height)
|
||||
.map((line) => line.slice(item.x, item.x + item.width).trim())
|
||||
.join("\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function descendants(root: Renderable): Renderable[] {
|
||||
return root.getChildren().flatMap((item) => [item, ...descendants(item)])
|
||||
}
|
||||
@@ -200,6 +200,18 @@ const source = {
|
||||
"ui.tool.shell.timeout": "Command timed out",
|
||||
"ui.tool.shell.cancelled": "Command cancelled",
|
||||
"ui.tool.execute": "Execute",
|
||||
"ui.tool.execute.viewCode": "View code",
|
||||
"ui.tool.execute.code": "Code",
|
||||
"ui.tool.execute.output": "Output",
|
||||
"ui.tool.execute.error": "Error",
|
||||
"ui.tool.execute.copyCode": "Copy code",
|
||||
"ui.tool.execute.copyOutput": "Copy output",
|
||||
"ui.tool.execute.running": "Running",
|
||||
"ui.tool.execute.completed": "Completed",
|
||||
"ui.tool.execute.calls.one": "{{count}} call",
|
||||
"ui.tool.execute.calls.other": "{{count}} calls",
|
||||
"ui.tool.execute.lines.one": "{{count}} line",
|
||||
"ui.tool.execute.lines.other": "{{count}} lines",
|
||||
"ui.tool.patch": "Patch",
|
||||
"ui.tool.questions": "Questions",
|
||||
"ui.tool.questions.numbered": "Questions {{number}}",
|
||||
@@ -241,10 +253,12 @@ const source = {
|
||||
"ui.message.collapse": "Collapse message",
|
||||
"ui.message.copy": "Copy",
|
||||
"ui.message.copyMessage": "Copy message",
|
||||
"ui.message.copyFullMessage": "Copy full message",
|
||||
"ui.message.forkMessage": "Fork to new session",
|
||||
"ui.message.revertMessage": "Revert message",
|
||||
"ui.message.moveToQueue": "Move to queue",
|
||||
"ui.message.pending": "Pending",
|
||||
"ui.message.voice": "Voice",
|
||||
"ui.message.deletePending": "Delete",
|
||||
"ui.message.copyResponse": "Copy response",
|
||||
"ui.message.copied": "Copied",
|
||||
|
||||
@@ -266,6 +266,10 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M5.37624 6.75194C5.18184 6.41861 5.42224 6 5.80814 6H10.1921C10.578 6 10.8184 6.41861 10.624 6.75194L8.43203 10.5096C8.23909 10.8404 7.76119 10.8404 7.56825 10.5096L5.37624 6.75194Z" fill="currentColor"/>`,
|
||||
},
|
||||
waveform: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2 7V9M4.5 5V11M7 2.5V13.5M9.5 4.5V11.5M12 6V10M14.5 7.25V8.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>`,
|
||||
},
|
||||
archive: {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.1112 13.5555V14.0555H13.6112V13.5555H13.1112ZM2.889 13.5555H2.389L2.389 14.0555H2.889V13.5555ZM3.38901 5.55546L3.38901 5.05546L2.38901 5.05546L2.38901 5.55546L2.88901 5.55546L3.38901 5.55546ZM14.4446 2.44434H14.9446V1.94434L14.4446 1.94434L14.4446 2.44434ZM14.4446 5.55545L14.4446 6.05545L14.9446 6.05545V5.55545H14.4446ZM1.55566 5.55546L1.05566 5.55545L1.05566 6.05546L1.55566 6.05546L1.55566 5.55546ZM1.5557 2.44436L1.5557 1.94436L1.05571 1.94436L1.0557 2.44435L1.5557 2.44436ZM13.1112 5.55546H12.6112V13.5555H13.1112H13.6112V5.55546H13.1112ZM2.889 13.5555H3.389L3.38901 5.55546L2.88901 5.55546L2.38901 5.55546L2.389 13.5555H2.889ZM14.4446 2.44434H13.9446V5.55545H14.4446H14.9446V2.44434H14.4446ZM1.55566 5.55546L2.05566 5.55547L2.0557 2.44436L1.5557 2.44436L1.0557 2.44435L1.05566 5.55545L1.55566 5.55546ZM6.22234 8.22213V8.72213H9.7779V8.22213V7.72213H6.22234V8.22213ZM13.1112 13.5555V13.0555H2.889V13.5555V14.0555H13.1112V13.5555ZM1.5557 2.44436L1.5557 2.94436L14.4446 2.94434L14.4446 2.44434L14.4446 1.94434L1.5557 1.94436L1.5557 2.44436ZM14.4446 5.55545L14.4446 5.05545L1.55566 5.05546L1.55566 5.55546L1.55566 6.05546L14.4446 6.05545L14.4446 5.55545Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -8,6 +8,7 @@ import config from "./config.mjs"
|
||||
import { rehypeHeadingIds } from "@astrojs/markdown-remark"
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings"
|
||||
import { copyFileSync } from "fs"
|
||||
import { execFileSync } from "child_process"
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
@@ -327,8 +328,8 @@ function configSchema() {
|
||||
name: "configSchema",
|
||||
hooks: {
|
||||
"astro:build:done": async () => {
|
||||
// V1 schemas are frozen in the console app since packages/opencode lives on the v1 branch.
|
||||
copyFileSync("../console/app/public/config.json", "./dist/config.json")
|
||||
execFileSync("bun", ["../schema/script/config-schema.ts", "./dist/config.json"], { stdio: "inherit" })
|
||||
// The V1 TUI schema is frozen in the console app since packages/opencode lives on the v1 branch.
|
||||
copyFileSync("../console/app/public/tui.json", "./dist/tui.json")
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..5eaa7b451c8c7e12e51f61c2a835ff6f7a8ea0ae 100644
|
||||
index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..3f4dfb779a55deaae2c188a442b78ca519d5c17d 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -517,7 +517,7 @@ function applyPublicAuth(clientId, params) {
|
||||
}
|
||||
/** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). */
|
||||
function isLoopbackHost(hostname) {
|
||||
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
+ return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
}
|
||||
/**
|
||||
* SEP-2207: refuse to send credentials to a non-TLS, non-loopback token endpoint.
|
||||
@@ -977,7 +977,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
|
||||
else {
|
||||
const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname);
|
||||
@@ -19,9 +28,18 @@ index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..5eaa7b451c8c7e12e51f61c2a835ff6f
|
||||
return {
|
||||
authorizationUrl,
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..73a93a066c3540e131fa0770a74b4a7e8d9343a0 100644
|
||||
index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..3508b8e841bb8d3d724fa5ff573902dbca81cf89 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -514,7 +514,7 @@ function applyPublicAuth(clientId, params) {
|
||||
}
|
||||
/** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). */
|
||||
function isLoopbackHost(hostname) {
|
||||
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
+ return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
|
||||
}
|
||||
/**
|
||||
* SEP-2207: refuse to send credentials to a non-TLS, non-loopback token endpoint.
|
||||
@@ -974,7 +974,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
|
||||
else {
|
||||
const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Console account identity in Salesforce
|
||||
|
||||
This metadata adds the `console signup` Lead Source and these fields on both Lead
|
||||
and Contact:
|
||||
|
||||
- `Has_Console_Account__c` (checkbox)
|
||||
- `Console_Account_ID__c` (non-unique external ID, so an inquiry Lead and a Contact may refer to the same user)
|
||||
- `Console_Signup_Date__c` (account creation timestamp)
|
||||
|
||||
It also adds a Console Account section to the existing Lead and Contact detail
|
||||
layouts, grants field permissions through `Console_Account_Sync`, and maps the three
|
||||
Lead fields to Contact on conversion. Existing records are not backfilled.
|
||||
|
||||
New signup Leads use the lowercased email domain as Company (for example, `acme.com`).
|
||||
Recognized personal-email domains use `Individual`. This is an exact-domain list in
|
||||
`packages/console/app/src/lib/salesforce.ts`, not a legal-company-name lookup.
|
||||
Signup sync preserves existing Lead companies and Contact account associations;
|
||||
Enterprise inquiries use the company supplied by the visitor.
|
||||
|
||||
Before applying to another org, retrieve and preserve that org's current LeadSource
|
||||
values, layouts, and LeadConvertSettings; these checked-in files are the additive
|
||||
changes prepared for the connected OpenCode org.
|
||||
|
||||
From this directory, deployment uses the existing Salesforce CLI authentication:
|
||||
|
||||
```sh
|
||||
sf project deploy start --source-dir force-app --target-org opencode --wait 10
|
||||
sf org assign permset --name Console_Account_Sync --target-org opencode
|
||||
```
|
||||
|
||||
The application integration uses the existing SST Salesforce credentials. Set the
|
||||
same `CONSOLE_CRM_TOKEN` on the SST website and Console API. The SST website
|
||||
links `CONSOLE_CRM_LOOKUP_URL` to the stage's private Console account lookup.
|
||||
|
||||
For local development, run `bun run dev:enterprise` in `packages/console/app` with
|
||||
`CONSOLE_CRM_TOKEN` set and `sf` on PATH. It serves the same Enterprise request and
|
||||
signup-sync handlers on `127.0.0.1:3101`, using the CLI's authenticated transport.
|
||||
It never sends local sales notifications or EmailOctopus subscriptions.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LeadConvertSettings xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<allowOwnerChange>true</allowOwnerChange>
|
||||
<objectMapping>
|
||||
<inputObject>Lead</inputObject>
|
||||
<mappingFields>
|
||||
<inputField>Has_Console_Account__c</inputField>
|
||||
<outputField>Has_Console_Account__c</outputField>
|
||||
</mappingFields>
|
||||
<mappingFields>
|
||||
<inputField>Console_Account_ID__c</inputField>
|
||||
<outputField>Console_Account_ID__c</outputField>
|
||||
</mappingFields>
|
||||
<mappingFields>
|
||||
<inputField>Console_Signup_Date__c</inputField>
|
||||
<outputField>Console_Signup_Date__c</outputField>
|
||||
</mappingFields>
|
||||
<outputObject>Contact</outputObject>
|
||||
</objectMapping>
|
||||
<opportunityCreationOptions>VisibleOptional</opportunityCreationOptions>
|
||||
</LeadConvertSettings>
|
||||
@@ -0,0 +1,294 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Layout xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<excludeButtons>DisableCustomerPortal</excludeButtons>
|
||||
<excludeButtons>DisablePartnerPortal</excludeButtons>
|
||||
<excludeButtons>DisablePrmSuperUser</excludeButtons>
|
||||
<excludeButtons>EnablePrmSuperUser</excludeButtons>
|
||||
<excludeButtons>Share</excludeButtons>
|
||||
<layoutSections>
|
||||
<customLabel>true</customLabel><detailHeading>true</detailHeading><editHeading>true</editHeading>
|
||||
<label>Console Account</label>
|
||||
<layoutColumns>
|
||||
<layoutItems><behavior>Readonly</behavior><field>Has_Console_Account__c</field></layoutItems>
|
||||
<layoutItems><behavior>Readonly</behavior><field>Console_Signup_Date__c</field></layoutItems>
|
||||
<layoutItems><behavior>Readonly</behavior><field>Console_Account_ID__c</field></layoutItems>
|
||||
</layoutColumns>
|
||||
<style>OneColumn</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>false</detailHeading>
|
||||
<editHeading>true</editHeading>
|
||||
<label>Contact Information</label>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<behavior>Required</behavior>
|
||||
<field>Name</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Required</behavior>
|
||||
<field>AccountId</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Title</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Email</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Phone</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>MobilePhone</field>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>OwnerId</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>ReportsToId</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Department</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Fax</field>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<style>TwoColumnsTopToBottom</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>true</detailHeading>
|
||||
<editHeading>true</editHeading>
|
||||
<label>Address Information</label>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>MailingAddress</field>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<layoutColumns/>
|
||||
<style>TwoColumnsTopToBottom</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>true</detailHeading>
|
||||
<editHeading>true</editHeading>
|
||||
<label>Additional Information</label>
|
||||
<layoutColumns/>
|
||||
<layoutColumns/>
|
||||
<style>TwoColumnsLeftToRight</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>false</detailHeading>
|
||||
<editHeading>true</editHeading>
|
||||
<label>Description Information</label>
|
||||
<layoutColumns/>
|
||||
<style>OneColumn</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>true</detailHeading>
|
||||
<editHeading>true</editHeading>
|
||||
<label>System Information</label>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<behavior>Readonly</behavior>
|
||||
<field>CreatedById</field>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<behavior>Readonly</behavior>
|
||||
<field>LastModifiedById</field>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<style>TwoColumnsTopToBottom</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>false</detailHeading>
|
||||
<editHeading>false</editHeading>
|
||||
<label>Custom Links</label>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<customLink>GoogleMaps</customLink>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<customLink>GoogleSearch</customLink>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<layoutColumns/>
|
||||
<style>CustomLinks</style>
|
||||
</layoutSections>
|
||||
<platformActionList>
|
||||
<actionListContext>Record</actionListContext>
|
||||
<platformActionListItems>
|
||||
<actionName>Edit</actionName>
|
||||
<actionType>StandardButton</actionType>
|
||||
<sortOrder>0</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>Delete</actionName>
|
||||
<actionType>StandardButton</actionType>
|
||||
<sortOrder>1</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>Clone</actionName>
|
||||
<actionType>StandardButton</actionType>
|
||||
<sortOrder>2</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>SendEmail</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>3</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>LogACall</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>4</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>NewTask</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>5</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>NewEvent</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>6</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>EmailHighlightAction</actionName>
|
||||
<actionType>ProductivityAction</actionType>
|
||||
<sortOrder>7</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>FeedItem.ContentNote</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>8</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>FeedItem.TextPost</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>9</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>FeedItem.ContentPost</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>10</sortOrder>
|
||||
</platformActionListItems>
|
||||
</platformActionList>
|
||||
<quickActionList>
|
||||
<quickActionListItems>
|
||||
<quickActionName>FeedItem.TextPost</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>FeedItem.ContentPost</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>LogACall</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>NewTask</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>NewEvent</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>FeedItem.ContentNote</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>FeedItem.LinkPost</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>FeedItem.PollPost</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>SendEmail</quickActionName>
|
||||
</quickActionListItems>
|
||||
</quickActionList>
|
||||
<relatedLists>
|
||||
<fields>OPPORTUNITY.NAME</fields>
|
||||
<fields>OPPORTUNITY.STAGE_NAME</fields>
|
||||
<fields>OPPORTUNITY.AMOUNT</fields>
|
||||
<fields>OPPORTUNITY.CLOSE_DATE</fields>
|
||||
<relatedList>RelatedOpportunityList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<fields>QUOTE.QUOTENUMBER</fields>
|
||||
<fields>QUOTE.NAME</fields>
|
||||
<fields>QUOTE.EXPIRATIONDATE</fields>
|
||||
<fields>QUOTE.ISSYNCING</fields>
|
||||
<fields>QUOTE.SUBTOTAL</fields>
|
||||
<fields>QUOTE.TOTALPRICE</fields>
|
||||
<fields>CreatedBy</fields>
|
||||
<relatedList>RelatedQuoteList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<fields>CASES.CASE_NUMBER</fields>
|
||||
<fields>CASES.STATUS</fields>
|
||||
<fields>CASES.CREATED_DATE_DATE_ONLY</fields>
|
||||
<fields>CASES.SUBJECT</fields>
|
||||
<relatedList>RelatedCaseList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<fields>CAMPAIGN.NAME</fields>
|
||||
<fields>CAMPAIGN.START_DATE</fields>
|
||||
<fields>CAMPAIGN.CAMPAIGN_TYPE</fields>
|
||||
<fields>CM.STATUS</fields>
|
||||
<fields>CM.RESPONDED</fields>
|
||||
<fields>CM.LAST_UPDATE</fields>
|
||||
<relatedList>RelatedCampaignList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<relatedList>RelatedFileList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<relatedList>RelatedContentNoteList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<fields>TASK.SUBJECT</fields>
|
||||
<fields>TASK.WHAT_NAME</fields>
|
||||
<fields>ACTIVITY.TASK</fields>
|
||||
<fields>TASK.DUE_DATE</fields>
|
||||
<fields>TASK.STATUS</fields>
|
||||
<fields>CORE.USERS.FULL_NAME</fields>
|
||||
<fields>EVENT.LOCATION</fields>
|
||||
<relatedList>RelatedActivityList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<fields>TASK.SUBJECT</fields>
|
||||
<fields>TASK.WHAT_NAME</fields>
|
||||
<fields>ACTIVITY.TASK</fields>
|
||||
<fields>TASK.DUE_DATE</fields>
|
||||
<fields>EVENT.LOCATION</fields>
|
||||
<fields>CORE.USERS.FULL_NAME</fields>
|
||||
<relatedList>RelatedHistoryList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedObjects>AccountId</relatedObjects>
|
||||
<showEmailCheckbox>false</showEmailCheckbox>
|
||||
<showHighlightsPanel>false</showHighlightsPanel>
|
||||
<showInteractionLogPanel>false</showInteractionLogPanel>
|
||||
<showRunAssignmentRulesCheckbox>false</showRunAssignmentRulesCheckbox>
|
||||
<showSubmitAndAttachButton>false</showSubmitAndAttachButton>
|
||||
<summaryLayout>
|
||||
<masterLabel>00hHn00000diPWw</masterLabel>
|
||||
<sizeX>4</sizeX>
|
||||
<sizeY>0</sizeY>
|
||||
<summaryLayoutStyle>Default</summaryLayoutStyle>
|
||||
</summaryLayout>
|
||||
</Layout>
|
||||
@@ -0,0 +1,293 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Layout xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<excludeButtons>Submit</excludeButtons>
|
||||
<layoutSections>
|
||||
<customLabel>true</customLabel><detailHeading>true</detailHeading><editHeading>true</editHeading>
|
||||
<label>Console Account</label>
|
||||
<layoutColumns>
|
||||
<layoutItems><behavior>Readonly</behavior><field>Has_Console_Account__c</field></layoutItems>
|
||||
<layoutItems><behavior>Readonly</behavior><field>Console_Signup_Date__c</field></layoutItems>
|
||||
<layoutItems><behavior>Readonly</behavior><field>Console_Account_ID__c</field></layoutItems>
|
||||
</layoutColumns>
|
||||
<style>OneColumn</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>false</detailHeading>
|
||||
<editHeading>false</editHeading>
|
||||
<label>Lead Information</label>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<behavior>Required</behavior>
|
||||
<field>Status</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Required</behavior>
|
||||
<field>Name</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Title</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Email</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Phone</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>MobilePhone</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Rating</field>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>OwnerId</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Website</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Required</behavior>
|
||||
<field>Company</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Industry</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>NumberOfEmployees</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>LeadSource</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Current_Monthly_Inference_Spend__c</field>
|
||||
</layoutItems>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Description</field>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<style>TwoColumnsTopToBottom</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>true</detailHeading>
|
||||
<editHeading>true</editHeading>
|
||||
<label>Address Information</label>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<behavior>Edit</behavior>
|
||||
<field>Address</field>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<layoutColumns/>
|
||||
<style>TwoColumnsTopToBottom</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>true</detailHeading>
|
||||
<editHeading>true</editHeading>
|
||||
<label>Additional Information</label>
|
||||
<layoutColumns/>
|
||||
<layoutColumns/>
|
||||
<style>TwoColumnsLeftToRight</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>false</detailHeading>
|
||||
<editHeading>true</editHeading>
|
||||
<label>Description Information</label>
|
||||
<layoutColumns/>
|
||||
<style>OneColumn</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>false</detailHeading>
|
||||
<editHeading>true</editHeading>
|
||||
<label>System Information</label>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<behavior>Readonly</behavior>
|
||||
<field>CreatedById</field>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<behavior>Readonly</behavior>
|
||||
<field>LastModifiedById</field>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<style>TwoColumnsTopToBottom</style>
|
||||
</layoutSections>
|
||||
<layoutSections>
|
||||
<customLabel>false</customLabel>
|
||||
<detailHeading>true</detailHeading>
|
||||
<editHeading>false</editHeading>
|
||||
<label>Custom Links</label>
|
||||
<layoutColumns>
|
||||
<layoutItems>
|
||||
<customLink>GoogleMaps</customLink>
|
||||
</layoutItems>
|
||||
</layoutColumns>
|
||||
<layoutColumns/>
|
||||
<layoutColumns/>
|
||||
<style>CustomLinks</style>
|
||||
</layoutSections>
|
||||
<platformActionList>
|
||||
<actionListContext>Record</actionListContext>
|
||||
<platformActionListItems>
|
||||
<actionName>Edit</actionName>
|
||||
<actionType>StandardButton</actionType>
|
||||
<sortOrder>0</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>Delete</actionName>
|
||||
<actionType>StandardButton</actionType>
|
||||
<sortOrder>1</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>Clone</actionName>
|
||||
<actionType>StandardButton</actionType>
|
||||
<sortOrder>2</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>Convert</actionName>
|
||||
<actionType>StandardButton</actionType>
|
||||
<sortOrder>3</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>FeedItem.ContentNote</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>4</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>LogACall</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>5</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>NewTask</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>6</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>NewEvent</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>7</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>FeedItem.TextPost</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>8</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>FeedItem.ContentPost</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>9</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>SendEmail</actionName>
|
||||
<actionType>QuickAction</actionType>
|
||||
<sortOrder>10</sortOrder>
|
||||
</platformActionListItems>
|
||||
<platformActionListItems>
|
||||
<actionName>EmailHighlightAction</actionName>
|
||||
<actionType>ProductivityAction</actionType>
|
||||
<sortOrder>11</sortOrder>
|
||||
</platformActionListItems>
|
||||
</platformActionList>
|
||||
<quickActionList>
|
||||
<quickActionListItems>
|
||||
<quickActionName>FeedItem.TextPost</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>FeedItem.ContentPost</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>LogACall</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>NewTask</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>NewEvent</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>FeedItem.LinkPost</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>FeedItem.PollPost</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>FeedItem.ContentNote</quickActionName>
|
||||
</quickActionListItems>
|
||||
<quickActionListItems>
|
||||
<quickActionName>SendEmail</quickActionName>
|
||||
</quickActionListItems>
|
||||
</quickActionList>
|
||||
<relatedContent>
|
||||
<relatedContentItems>
|
||||
<layoutItem>
|
||||
<behavior>Readonly</behavior>
|
||||
<field>CampaignId</field>
|
||||
</layoutItem>
|
||||
</relatedContentItems>
|
||||
</relatedContent>
|
||||
<relatedLists>
|
||||
<fields>CAMPAIGN.NAME</fields>
|
||||
<fields>CAMPAIGN.START_DATE</fields>
|
||||
<fields>CAMPAIGN.CAMPAIGN_TYPE</fields>
|
||||
<fields>CM.STATUS</fields>
|
||||
<fields>CM.RESPONDED</fields>
|
||||
<fields>CM.LAST_UPDATE</fields>
|
||||
<relatedList>RelatedCampaignList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<relatedList>RelatedFileList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<relatedList>RelatedContentNoteList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<fields>TASK.SUBJECT</fields>
|
||||
<fields>ACTIVITY.TASK</fields>
|
||||
<fields>TASK.DUE_DATE</fields>
|
||||
<fields>TASK.STATUS</fields>
|
||||
<fields>CORE.USERS.FULL_NAME</fields>
|
||||
<fields>EVENT.LOCATION</fields>
|
||||
<relatedList>RelatedActivityList</relatedList>
|
||||
</relatedLists>
|
||||
<relatedLists>
|
||||
<fields>TASK.SUBJECT</fields>
|
||||
<fields>ACTIVITY.TASK</fields>
|
||||
<fields>TASK.DUE_DATE</fields>
|
||||
<fields>EVENT.LOCATION</fields>
|
||||
<fields>CORE.USERS.FULL_NAME</fields>
|
||||
<relatedList>RelatedHistoryList</relatedList>
|
||||
</relatedLists>
|
||||
<runAssignmentRulesDefault>false</runAssignmentRulesDefault>
|
||||
<showEmailCheckbox>false</showEmailCheckbox>
|
||||
<showHighlightsPanel>false</showHighlightsPanel>
|
||||
<showInteractionLogPanel>false</showInteractionLogPanel>
|
||||
<showRunAssignmentRulesCheckbox>true</showRunAssignmentRulesCheckbox>
|
||||
<showSubmitAndAttachButton>false</showSubmitAndAttachButton>
|
||||
<summaryLayout>
|
||||
<masterLabel>00ha500000BShn4</masterLabel>
|
||||
<sizeX>4</sizeX>
|
||||
<sizeY>0</sizeY>
|
||||
<summaryLayoutStyle>Default</summaryLayoutStyle>
|
||||
</summaryLayout>
|
||||
</Layout>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
|
||||
<fullName>Console_Account_ID__c</fullName>
|
||||
<description>The stable OpenCode Console user ID. Identifies the person, not their workspace.</description>
|
||||
<externalId>true</externalId>
|
||||
<label>Console Account ID</label>
|
||||
<length>255</length>
|
||||
<required>false</required>
|
||||
<type>Text</type>
|
||||
<unique>false</unique>
|
||||
</CustomField>
|
||||
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user