mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-29 02:01:53 +00:00
fix(v2): stabilize follow-up delivery and location routing
Keep queued prompts at the transcript tail in native admission order, preserve escaped scroll state across history pagination, and pin every locally submitted prompt. Add a persisted steer/queue preference with inverse alternate delivery and expose native session backgrounding from the prompt controls. Preserve only ownership-validated native workspace selectors through the proxy, restore required V2 catalog and pending-state routes, and verify workspace/directory pairs against published or inventoried locations. Simplify the model selector and synchronize migration documentation with the shipped behavior and remaining gaps. Validated with UI, server, and Electron typechecks; 86 focused UI tests; 64 proxy/workspace tests; 121 Tauri tests; cargo check; production UI/server build; and git diff --check.
This commit is contained in:
parent
a2e203754d
commit
7fcfa44575
39 changed files with 458 additions and 96 deletions
|
|
@ -4,11 +4,11 @@
|
|||
|
||||
This review compares:
|
||||
|
||||
- CodeNomad `DEV-v2` at `aef4452b`, plus the fixes recorded below.
|
||||
- Official OpenCode Desktop V2 from `anomalyco/opencode` branch `upstream/v2` at `bc1f67e518` (2026-08-24).
|
||||
- CodeNomad follows the latest `@opencode-ai/client@beta` contract; the managed CLI updater independently resolves and installs the latest CLI beta.
|
||||
- CodeNomad `DEV-v2` through the fixes accompanying this document.
|
||||
- Official OpenCode Desktop V2 from `anomalyco/opencode` branch `upstream/beta` at `eb1ac54d73` (2026-08-25).
|
||||
- CodeNomad declares `@opencode-ai/client@beta`; the reviewed root lock resolves `0.0.0-beta-18219`. The managed CLI updater independently resolves and installs the latest CLI beta.
|
||||
|
||||
The official reference is `packages/desktop` for the Electron host, `packages/app` for the shared UI, and the V2 client, protocol, schema, server, and core packages for wire behavior. The old `upstream/opencode-2-0` branch and the intermediate `desktop-v2-*` branches are historical, not the current Desktop V2 reference.
|
||||
The official reference is `packages/desktop` for the Electron host, `packages/app` for the shared UI, and the V2 client, protocol, schema, server, and core packages for wire behavior. Older `v2`, `opencode-2-0`, and intermediate `desktop-v2-*` branches are historical, not the current Desktop V2 reference.
|
||||
|
||||
This is an incremental review. It does not repeat issues already closed by CodeNomad parity commits including `4b96f462`, `4359b4bf`, `249a96e7`, `ef70a8b7`, `affdb96f`, `e1987b9c`, `5484f9c9`, `b5f3fc6e`, `2c9ced63`, and `db0464f7`.
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ This is an incremental review. It does not repeat issues already closed by CodeN
|
|||
|
||||
CodeNomad implements the important V2 architecture rather than emulating the V1 desktop model. It uses the native client contract, locations, shared service, sessions, messages, Forms, permissions, providers, Shells, worktrees, and event stream. Its multi-window and cross-host restore implementation is broader than the official Electron-only desktop implementation.
|
||||
|
||||
The comparison found five concrete CodeNomad defects and one obsolete configuration block. All six are fixed in the commits accompanying this document. The remaining differences are scoped workflow defects, release hardening, or optional Desktop features. None requires restoring V1 code or replacing native V2 cursors.
|
||||
The comparison and subsequent beta-contract audit found concrete CodeNomad defects in pagination, navigation, model projection, inbox delivery, follow behavior, background control, location selectors, and proxy route coverage. Those defects are fixed in the commits accompanying this document. The remaining differences are scoped workflow defects, release hardening, or optional Desktop features. None requires restoring V1 code or replacing native V2 cursors.
|
||||
|
||||
## Closed Findings
|
||||
|
||||
|
|
@ -72,12 +72,14 @@ The review reconfirmed these areas and found no current incompatibility:
|
|||
- Multiple logical CodeNomad workspaces over one native location, including duplicate-folder instances.
|
||||
- Project-wide session inventory with directory scoping for native `global` projects, native session and message cursors, replace-in-place resident history windows, and ancestor hydration.
|
||||
- Optimistic prompt admission with client-minted identity and authoritative event reconciliation.
|
||||
- Native inbox queue/steer delivery with a persisted primary preference, inverse alternate shortcut, cancellation, and pending prompts ordered at the transcript tail.
|
||||
- User-controlled follow state across older-page positioning and remote updates, with bottom pinning reserved for a local send.
|
||||
- Native Forms, permissions, provider authentication, commands, agents, variants, attachments, and instructions.
|
||||
- One upstream event stream with reconnect generation fencing and targeted authoritative refresh.
|
||||
- Native background Shell listing, bounded output, removal, and ownership-checked Shell/PTY proxy routes.
|
||||
- Native `session.background` control plus background Shell listing, bounded output, removal, and ownership-checked Shell/PTY proxy routes.
|
||||
- Root/worktree location ownership, physical-identity mutation fencing and session evacuation before worktree deletion, and WSL translation.
|
||||
- Independent multi-window tabs and content-addressed restore state across Electron and Tauri.
|
||||
- Strict proxy route allowlisting, traversal protection, selector stripping, authentication isolation, and location ownership checks.
|
||||
- Strict proxy route allowlisting, traversal protection, validated native workspace selectors, authentication isolation, and location ownership checks.
|
||||
|
||||
Service stop removal is intentional: CodeNomad does not own the shared daemon. Upstream session sharing is disabled, so its absence is not a parity gap. Upstream's temporary SSE heartbeat change was reverted and requires no CodeNomad change.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ This branch replaces CodeNomad's OpenCode V1 SDK, custom plugin, and per-workspa
|
|||
|
||||
The work grew beyond an SDK swap. It also introduces location-based ownership, native Forms and Shell resources, project-wide session pagination, reconnect reconciliation, bounded virtualized timelines, multi-window desktop state, and a content-addressed restore format.
|
||||
|
||||
Server and UI follow the latest `@opencode-ai/client@beta` contract. The selected `opencode2` CLI is managed independently: CodeNomad's updater resolves and installs the latest published CLI beta, while startup accepts another healthy CLI instead of enforcing an exact version.
|
||||
Server and UI declare `@opencode-ai/client@beta`; the root lock currently resolves `0.0.0-beta-18219`. The selected `opencode2` CLI is managed independently: CodeNomad's updater resolves and installs the latest published CLI beta, while startup accepts another healthy CLI instead of enforcing an exact version.
|
||||
|
||||
The incremental comparison with official OpenCode Desktop V2, including closed findings and remaining gaps, is recorded in [`DESKTOP_V2_COMPARISON.md`](DESKTOP_V2_COMPARISON.md).
|
||||
|
||||
|
|
@ -62,6 +62,7 @@ The incremental comparison with official OpenCode Desktop V2, including closed f
|
|||
- Bound instance logs and validate restore-state counts, IDs, paths, snapshots, string budgets, partition sizes, and graph sizes.
|
||||
- Reconcile only affected resources after native events or reconnects instead of periodically reloading full message history.
|
||||
- Keep optimistic prompts visible before native admission and replace temporary parts with authoritative native parts without duplicating output.
|
||||
- Keep every pending inbox prompt after delivered transcript messages in native inbox admission order. A local send pins the new prompt to the bottom, while pagination and remote updates preserve a user's escaped follow state.
|
||||
- Reduce every native compaction delta in order while keeping intermediate chunks off the renderer path; project accumulated content at the next boundary and refresh immediately when compaction starts, ends, or fails.
|
||||
|
||||
## Forms, Permissions, and Providers
|
||||
|
|
@ -94,7 +95,7 @@ Git status, diff, stage, unstage, commit, worktree creation, and worktree remova
|
|||
|
||||
- Expose only reviewed method/path pairs; new upstream APIs are unavailable until explicitly allowlisted.
|
||||
- Verify workspace ownership for native locations, sessions, projects, cursors, Shell/PTY CWDs, imported session locations, and prompt file URIs before forwarding.
|
||||
- Reject encoded path traversal, foreign locations/projects, forged cursors, and browser-supplied workspace selectors.
|
||||
- Reject encoded path traversal, foreign locations/projects, forged cursors, and mismatched workspace selectors. Preserve validated native location workspace identities, including global Form headers, across the proxy.
|
||||
- Translate host/WSL paths only after ownership validation.
|
||||
- Strip CodeNomad cookies, browser authorization, forwarding headers, and incoming `x-opencode-*` headers; inject shared-service authentication server-side.
|
||||
- Block upstream cookies and authentication challenges and avoid logging unredacted secret-bearing request bodies.
|
||||
|
|
@ -143,8 +144,8 @@ During stabilization, CodeNomad implements the public native V2 contract and rem
|
|||
| Delete-to-boundary / undo | `session.revert.stage` and `session.revert.clear` use V2 staged-revert semantics instead of arbitrary deletion. | Undo uses `revert.stage`. Redo/clear remains a CodeNomad UI gap, not a reason to restore V1 deletion. | Expose `revert.clear` through the existing native contract. |
|
||||
| Compaction | Native checkpoint compaction summarizes the older head and retains a server-selected recent tail controlled by `compaction.keep.tokens`. | Uses `session.compact`; there is no message-level selective compaction. CodeNomad displays the terminal summary without rendering every streamed delta. | Add scoped controls only if V2 defines scoped compaction semantics. |
|
||||
| Full-session search | Native message cursors exist, but there is no server search endpoint that returns message identity and position. | Retained through bounded cursor traversal while keeping only the 200-message resident window and collected matches. Large searches still fetch every page. | V2 exposes server search plus a rank/cursor navigation target. |
|
||||
| Queued prompt management | Native inbox list, cancel, steer, and queue operations are available. | Implemented with authoritative steering, cancellation, safe edit replacement, draft preservation, and queue admission for follow-up prompts. | Add in-place editing and reordering when V2 exposes atomic inbox mutations; replacement currently moves an edited prompt to the queue tail. |
|
||||
| Background execution | Native Shell resources support listing, bounded output, and removal, but do not reproduce every custom V1 process-manager control. | Replaced the custom manager with native Shells and removed unsupported controls such as rename. | Add controls only when native Shell APIs support them. |
|
||||
| Queued prompt management | Native inbox list, cancel, steer, and queue operations are available. | Implements a persisted primary `steer`/`queue` preference, inverse alternate shortcut, delivery switching, cancellation with draft restoration, and ordered timeline projection. | Add in-place editing or reordering only when V2 exposes an atomic inbox mutation; neither is currently implemented. |
|
||||
| Background execution | Native `session.background` moves blocking tools out of the foreground; Shell resources separately support listing, bounded output, and removal. | Exposes `session.background` through the proxy and UI with `Ctrl/Cmd+B`, and replaces the custom process manager with native Shells. Unsupported controls such as rename remain removed. | Add controls only when the native session or Shell APIs support them. |
|
||||
| Service lifecycle | V2 uses one shared externally owned service rather than one runtime per workspace. | CodeNomad discovers or starts the service but never exposes workspace stop or stops the daemon on shutdown. | No parity work planned unless V2 changes service ownership semantics. |
|
||||
|
||||
This table is release-facing and must remain synchronized with the open migration pull request description whenever a capability is removed, restored, or becomes available in the current V2 client.
|
||||
|
|
|
|||
|
|
@ -135,7 +135,9 @@ async function harness(
|
|||
return pathMappings[candidate] ?? candidate
|
||||
},
|
||||
getSharedServiceClient: async () => client,
|
||||
ownsLocationWorkspace: (_id, workspaceID) => workspaceID === "owned-location",
|
||||
ownsLocation: async (_id, location) => owned.has(location.directory)
|
||||
&& (!location.workspaceID || location.workspaceID === (location.directory.includes("worktree") ? "worktree-location" : "owned-location")),
|
||||
ownsLocationWorkspace: async (_id, workspaceID) => workspaceID === "owned-location" || workspaceID === "worktree-location",
|
||||
ownsDirectory: async (_id, directory) => owned.has(directory),
|
||||
ownsPath: async (_id, candidate) => {
|
||||
pathOwnershipChecks.push(candidate)
|
||||
|
|
@ -191,6 +193,37 @@ describe("instance proxy location enforcement", () => {
|
|||
assert.doesNotMatch(bodyResponse.body, /internal-secret/)
|
||||
})
|
||||
|
||||
it("preserves owned native workspace selectors and rejects mismatched pairs", async () => {
|
||||
const { app, requestCount } = await harness()
|
||||
const query = await app.inject({
|
||||
method: "GET",
|
||||
url: "/workspaces/workspace/instance/api/model?location%5Bdirectory%5D=%2Frepo%2Fworktree&location%5Bworkspace%5D=worktree-location",
|
||||
})
|
||||
assert.equal(query.statusCode, 200)
|
||||
assert.match(JSON.parse(query.body).url, /location%5Bworkspace%5D=worktree-location/)
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session",
|
||||
payload: { location: { directory: "/repo", workspaceID: "owned-location" } },
|
||||
})
|
||||
assert.equal(create.statusCode, 200)
|
||||
assert.equal(JSON.parse(create.body).body.location.workspaceID, "owned-location")
|
||||
|
||||
const foreign = await app.inject({
|
||||
method: "GET",
|
||||
url: "/workspaces/workspace/instance/api/model?location%5Bdirectory%5D=%2Frepo%2Fworktree&location%5Bworkspace%5D=owned-location",
|
||||
})
|
||||
assert.equal(foreign.statusCode, 403)
|
||||
|
||||
const unsupported = await app.inject({
|
||||
method: "GET",
|
||||
url: "/workspaces/workspace/instance/api/model?workspace=owned-location",
|
||||
})
|
||||
assert.equal(unsupported.statusCode, 400)
|
||||
assert.equal(requestCount(), 2)
|
||||
})
|
||||
|
||||
it("rejects session admission while a worktree deletion is pending", async () => {
|
||||
const { app, worktreeDeletionFence, requestCount } = await harness()
|
||||
let release!: () => void
|
||||
|
|
@ -357,12 +390,13 @@ describe("instance proxy location enforcement", () => {
|
|||
const ownedCursor = cursor({ directory: "/repo/worktree", anchor: { id: "session-1", time: 1, direction: "next" } })
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/workspaces/workspace/instance/api/session?cursor=${ownedCursor}&directory=%2Fother`,
|
||||
url: `/workspaces/workspace/instance/api/session?cursor=${ownedCursor}&directory=%2Fother&workspace=foreign-location`,
|
||||
})
|
||||
assert.equal(response.statusCode, 200)
|
||||
const upstreamUrl = JSON.parse(response.body).url as string
|
||||
assert.match(upstreamUrl, new RegExp(`cursor=${ownedCursor}`))
|
||||
assert.doesNotMatch(upstreamUrl, /directory=/)
|
||||
assert.doesNotMatch(upstreamUrl, /workspace=/)
|
||||
|
||||
const forgedCursor = cursor({ directory: "/other", anchor: { id: "session-1", time: 1, direction: "next" } })
|
||||
assert.equal((await app.inject({
|
||||
|
|
@ -503,14 +537,14 @@ describe("instance proxy location enforcement", () => {
|
|||
assert.deepEqual(sessionGets, ["global"])
|
||||
})
|
||||
|
||||
it("forwards a validated global Form root location instead of browser routing headers", async () => {
|
||||
it("forwards a validated global Form root location and workspace", async () => {
|
||||
const { app } = await harness("/repo/worktree", {}, {}, "/repo", "/srv/repo")
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session/global/form/form-1/reply",
|
||||
headers: {
|
||||
"x-opencode-directory": encodeURIComponent("/repo"),
|
||||
"x-opencode-workspace": "untrusted-workspace",
|
||||
"x-opencode-workspace": "owned-location",
|
||||
},
|
||||
payload: { answers: {} },
|
||||
})
|
||||
|
|
@ -518,7 +552,7 @@ describe("instance proxy location enforcement", () => {
|
|||
assert.equal(response.statusCode, 200)
|
||||
const upstream = JSON.parse(response.body)
|
||||
assert.equal(upstream.headers["x-opencode-directory"], encodeURIComponent("/srv/repo"))
|
||||
assert.equal(upstream.headers["x-opencode-workspace"], undefined)
|
||||
assert.equal(upstream.headers["x-opencode-workspace"], "owned-location")
|
||||
})
|
||||
|
||||
it("translates and forwards a validated global Form worktree location", async () => {
|
||||
|
|
@ -553,6 +587,15 @@ describe("instance proxy location enforcement", () => {
|
|||
headers: { "x-opencode-directory": "%ZZ" },
|
||||
payload: { answers: {} },
|
||||
})).statusCode, 400)
|
||||
assert.equal((await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/workspace/instance/api/session/global/form/form-1/reply",
|
||||
headers: {
|
||||
"x-opencode-directory": encodeURIComponent("/repo"),
|
||||
"x-opencode-workspace": "foreign-location",
|
||||
},
|
||||
payload: { answers: {} },
|
||||
})).statusCode, 403)
|
||||
assert.equal(requestCount(), 0)
|
||||
})
|
||||
|
||||
|
|
@ -617,10 +660,17 @@ describe("instance proxy location enforcement", () => {
|
|||
assert.equal(requestCount(), 0)
|
||||
})
|
||||
|
||||
it("allows native inbox management for owned sessions", async () => {
|
||||
it("allows native pending-state management for owned sessions", async () => {
|
||||
const { app } = await harness()
|
||||
const requests = [
|
||||
["GET", "/workspaces/workspace/instance/api/session/owned/inbox"],
|
||||
["GET", "/workspaces/workspace/instance/api/session/owned/permission"],
|
||||
["GET", "/workspaces/workspace/instance/api/session/owned/form"],
|
||||
["POST", "/workspaces/workspace/instance/api/session/owned/background"],
|
||||
["GET", "/workspaces/workspace/instance/api/skill"],
|
||||
["GET", "/workspaces/workspace/instance/api/reference"],
|
||||
["GET", "/workspaces/workspace/instance/api/mcp/resource"],
|
||||
["GET", "/workspaces/workspace/instance/api/websearch/provider"],
|
||||
["DELETE", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1"],
|
||||
["POST", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1/steer"],
|
||||
["POST", "/workspaces/workspace/instance/api/session/owned/inbox/prompt-1/queue"],
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { connect as connectTls, type TLSSocket } from "tls"
|
|||
import { fetch, type Headers } from "undici"
|
||||
import type { Logger } from "../logger"
|
||||
import { WorkspaceManager } from "../workspaces/manager"
|
||||
import { isPtyNotFoundError, isSessionNotFoundError, isShellNotFoundError, type OpenCodeClient } from "@opencode-ai/client"
|
||||
import { isPtyNotFoundError, isSessionNotFoundError, isShellNotFoundError, type LocationRef, type OpenCodeClient } from "@opencode-ai/client"
|
||||
|
||||
import type { SettingsService } from "../settings/service"
|
||||
import { FileSystemBrowser } from "../filesystem/browser"
|
||||
|
|
@ -371,7 +371,8 @@ export interface InstanceProxyWorkspaceManager {
|
|||
getWorktreeIdentityForPath(id: string, directory: string): Promise<string | undefined>
|
||||
getServicePathForPath?(id: string, candidate: string): Promise<string | undefined>
|
||||
getSharedServiceClient(): Promise<OpenCodeClient>
|
||||
ownsLocationWorkspace(id: string, workspaceID: string): boolean
|
||||
ownsLocation(id: string, location: LocationRef): ReturnType<WorkspaceManager["ownsLocation"]>
|
||||
ownsLocationWorkspace(id: string, workspaceID: string): ReturnType<WorkspaceManager["ownsLocationWorkspace"]>
|
||||
ownsDirectory(id: string, directory: string): Promise<boolean>
|
||||
ownsPath(id: string, candidate: string): Promise<boolean>
|
||||
}
|
||||
|
|
@ -628,32 +629,44 @@ async function proxyWorkspaceRequest(args: {
|
|||
}
|
||||
const sessionListHasScope = request.method === "GET"
|
||||
&& pathname.replace(/\/+$/, "") === "/api/session"
|
||||
&& (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project"))
|
||||
&& (targetUrl.searchParams.has("cursor") || targetUrl.searchParams.has("project") || targetUrl.searchParams.has("workspace"))
|
||||
const sessionListScope = await authorizeSessionList(targetUrl, request.method, workspaceManager, workspaceId)
|
||||
if (sessionListScope !== "allowed") {
|
||||
reply.code(sessionListScope === "invalid" ? 400 : 403).send({ error: "Session list does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
const serviceDirectory = workspaceManager.getServiceDirectory?.(workspaceId) ?? workspace.path
|
||||
let globalFormDirectory: string | undefined
|
||||
let globalFormLocation: LocationRef | undefined
|
||||
if (isGlobalFormAction(pathname, request.method)) {
|
||||
const header = request.headers["x-opencode-directory"]
|
||||
if (Array.isArray(header)) {
|
||||
const directoryHeader = request.headers["x-opencode-directory"]
|
||||
const workspaceHeader = request.headers["x-opencode-workspace"]
|
||||
if (Array.isArray(directoryHeader) || Array.isArray(workspaceHeader)) {
|
||||
reply.code(400).send({ error: "Invalid Form location" })
|
||||
return
|
||||
}
|
||||
if (header !== undefined) {
|
||||
let directory: string | undefined
|
||||
if (directoryHeader !== undefined) {
|
||||
try {
|
||||
globalFormDirectory = decodeURIComponent(header)
|
||||
directory = decodeURIComponent(directoryHeader)
|
||||
} catch {
|
||||
reply.code(400).send({ error: "Invalid Form location" })
|
||||
return
|
||||
}
|
||||
if (!globalFormDirectory.trim()) {
|
||||
if (!directory.trim()) {
|
||||
reply.code(400).send({ error: "Invalid Form location" })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (workspaceHeader !== undefined && !workspaceHeader.trim()) {
|
||||
reply.code(400).send({ error: "Invalid Form location" })
|
||||
return
|
||||
}
|
||||
if (directory !== undefined || workspaceHeader !== undefined) {
|
||||
globalFormLocation = {
|
||||
directory: directory ?? workspace.path,
|
||||
...(workspaceHeader ? { workspaceID: workspaceHeader } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
const imported = prepareSessionImport(
|
||||
pathname,
|
||||
|
|
@ -661,13 +674,19 @@ async function proxyWorkspaceRequest(args: {
|
|||
stripLocationSelectors(targetUrl, request.body, workspace.path, serviceDirectory),
|
||||
serviceDirectory,
|
||||
)
|
||||
const requestLocations = readRequestDirectories(targetUrl, imported.body)
|
||||
if (globalFormDirectory) requestLocations.directories.push(globalFormDirectory)
|
||||
const requestLocations = readRequestDirectories(targetUrl, imported.body, workspace.path)
|
||||
if (globalFormLocation) {
|
||||
requestLocations.directories.push(globalFormLocation.directory)
|
||||
requestLocations.locations.push(globalFormLocation)
|
||||
}
|
||||
requestLocations.directories.push(...imported.directories)
|
||||
requestLocations.locations.push(...imported.locations)
|
||||
requestLocations.invalid ||= imported.invalid
|
||||
readNativeCwd(targetUrl, imported.body, requestLocations)
|
||||
const promptFiles = readPromptFilePaths(pathname, request.method, imported.body)
|
||||
if (requestLocations.invalid || !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories))) {
|
||||
if (requestLocations.invalid
|
||||
|| !(await allDirectoriesOwned(workspaceManager, workspaceId, requestLocations.directories))
|
||||
|| !(await allLocationsOwned(workspaceManager, workspaceId, requestLocations.locations))) {
|
||||
reply.code(requestLocations.invalid ? 400 : 403).send({ error: "Location does not belong to workspace" })
|
||||
return
|
||||
}
|
||||
|
|
@ -801,8 +820,9 @@ async function proxyWorkspaceRequest(args: {
|
|||
...(body !== request.body ? { body } : {}),
|
||||
rewriteRequestHeaders: (_originalRequest, headers) => {
|
||||
const outgoingHeaders = sanitizeInstanceProxyRequestHeaders(headers, instanceAuthHeader)
|
||||
if (globalFormDirectory) {
|
||||
outgoingHeaders["x-opencode-directory"] = encodeURIComponent(translatedDirectories.get(globalFormDirectory)!)
|
||||
if (globalFormLocation) {
|
||||
outgoingHeaders["x-opencode-directory"] = encodeURIComponent(translatedDirectories.get(globalFormLocation.directory)!)
|
||||
if (globalFormLocation.workspaceID) outgoingHeaders["x-opencode-workspace"] = globalFormLocation.workspaceID
|
||||
}
|
||||
|
||||
if (logger.isLevelEnabled("trace")) {
|
||||
|
|
@ -857,8 +877,13 @@ function appendIncomingQuery(targetUrl: URL, incomingUrl: string): URL {
|
|||
return targetUrl
|
||||
}
|
||||
|
||||
function readRequestDirectories(targetUrl: URL, body: unknown): { directories: string[]; invalid: boolean } {
|
||||
function readRequestDirectories(
|
||||
targetUrl: URL,
|
||||
body: unknown,
|
||||
defaultDirectory: string,
|
||||
): { directories: string[]; locations: LocationRef[]; invalid: boolean } {
|
||||
const directories: string[] = []
|
||||
const locations: LocationRef[] = []
|
||||
let invalid = false
|
||||
for (const key of ["location[directory]", "directory"]) {
|
||||
for (const value of targetUrl.searchParams.getAll(key)) {
|
||||
|
|
@ -866,6 +891,16 @@ function readRequestDirectories(targetUrl: URL, body: unknown): { directories: s
|
|||
else invalid = true
|
||||
}
|
||||
}
|
||||
const queryWorkspaces = targetUrl.searchParams.getAll("location[workspace]")
|
||||
const queryDirectories = targetUrl.searchParams.getAll("location[directory]")
|
||||
if (queryWorkspaces.length > 1 || queryDirectories.length > 1) invalid = true
|
||||
if (queryWorkspaces.length === 1) {
|
||||
const workspaceID = queryWorkspaces[0]
|
||||
if (!workspaceID.trim()) invalid = true
|
||||
else locations.push({ directory: queryDirectories[0] ?? defaultDirectory, workspaceID })
|
||||
}
|
||||
if (targetUrl.pathname.replace(/\/+$/, "") !== "/api/session" && targetUrl.searchParams.has("workspace")) invalid = true
|
||||
if (targetUrl.searchParams.has("workspaceID") || targetUrl.searchParams.has("location[workspaceID]")) invalid = true
|
||||
|
||||
if (body && typeof body === "object" && !Array.isArray(body) && !Buffer.isBuffer(body)) {
|
||||
const input = body as Record<string, unknown>
|
||||
|
|
@ -876,21 +911,35 @@ function readRequestDirectories(targetUrl: URL, body: unknown): { directories: s
|
|||
if ("location" in input) {
|
||||
const location = input.location
|
||||
if (location && typeof location === "object" && !Array.isArray(location)) {
|
||||
const directory = (location as Record<string, unknown>).directory
|
||||
const source = location as Record<string, unknown>
|
||||
const directory = source.directory
|
||||
if (typeof directory === "string" && directory.trim()) directories.push(directory)
|
||||
else invalid = true
|
||||
if (source.workspace !== undefined) invalid = true
|
||||
if (source.workspaceID !== undefined) {
|
||||
if (typeof source.workspaceID === "string" && source.workspaceID.trim() && typeof directory === "string" && directory.trim()) {
|
||||
locations.push({ directory, workspaceID: source.workspaceID })
|
||||
} else invalid = true
|
||||
}
|
||||
} else if (location !== null && location !== undefined) {
|
||||
invalid = true
|
||||
}
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/move\/?$/.test(targetUrl.pathname) && "workspaceID" in input) {
|
||||
if (typeof input.workspaceID === "string" && input.workspaceID.trim() && typeof input.directory === "string" && input.directory.trim()) {
|
||||
locations.push({ directory: input.directory, workspaceID: input.workspaceID })
|
||||
} else invalid = true
|
||||
} else if ("workspaceID" in input) {
|
||||
invalid = true
|
||||
}
|
||||
}
|
||||
return { directories, invalid }
|
||||
return { directories, locations, invalid }
|
||||
}
|
||||
|
||||
function readNativeCwd(
|
||||
targetUrl: URL,
|
||||
body: unknown,
|
||||
locations: { directories: string[]; invalid: boolean },
|
||||
locations: { directories: string[]; locations: LocationRef[]; invalid: boolean },
|
||||
) {
|
||||
if (!/^\/api\/(?:shell|pty)\/?$/.test(targetUrl.pathname) || !body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return
|
||||
const input = body as Record<string, unknown>
|
||||
|
|
@ -941,6 +990,11 @@ async function allDirectoriesOwned(manager: InstanceProxyWorkspaceManager, works
|
|||
return (await Promise.all(directories.map((directory) => manager.ownsDirectory(workspaceId, directory)))).every(Boolean)
|
||||
}
|
||||
|
||||
async function allLocationsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, locations: LocationRef[]) {
|
||||
const unique = new Map(locations.map((location) => [`${location.directory}\0${location.workspaceID ?? ""}`, location]))
|
||||
return (await Promise.all([...unique.values()].map((location) => manager.ownsLocation(workspaceId, location)))).every(Boolean)
|
||||
}
|
||||
|
||||
async function allPathsOwned(manager: InstanceProxyWorkspaceManager, workspaceId: string, paths: string[]) {
|
||||
return (await Promise.all(paths.map((candidate) => manager.ownsPath(workspaceId, candidate)))).every(Boolean)
|
||||
}
|
||||
|
|
@ -990,18 +1044,30 @@ async function authorizeSessionList(
|
|||
if (cursors.length === 1) {
|
||||
const scope = decodeSessionListCursor(cursors[0])
|
||||
if (!scope) return "invalid"
|
||||
for (const key of ["directory", "location[directory]", "project", "subpath"]) targetUrl.searchParams.delete(key)
|
||||
for (const key of ["directory", "workspace", "location[directory]", "location[workspace]", "project", "subpath"]) {
|
||||
targetUrl.searchParams.delete(key)
|
||||
}
|
||||
return ownsSessionListScope(manager, workspaceId, scope)
|
||||
}
|
||||
|
||||
const projects = targetUrl.searchParams.getAll("project")
|
||||
const subpaths = targetUrl.searchParams.getAll("subpath")
|
||||
if (projects.length > 1 || subpaths.length > 1 || (subpaths.length && !projects.length)) return "invalid"
|
||||
if (!projects.length) return "allowed"
|
||||
const workspaces = targetUrl.searchParams.getAll("workspace")
|
||||
const directories = targetUrl.searchParams.getAll("directory")
|
||||
if (projects.length > 1 || subpaths.length > 1 || workspaces.length > 1 || directories.length > 1
|
||||
|| (subpaths.length && !projects.length)) return "invalid"
|
||||
const workspace = workspaces[0]
|
||||
const directory = directories[0]
|
||||
if ((workspace !== undefined && !workspace.trim()) || (directory !== undefined && !directory.trim())) return "invalid"
|
||||
if (!projects.length) {
|
||||
if (workspace && directory) return ownsSessionListScope(manager, workspaceId, { workspace, directory })
|
||||
if (workspace) return ownsSessionListScope(manager, workspaceId, { workspace })
|
||||
return "allowed"
|
||||
}
|
||||
const project = projects[0]
|
||||
const subpath = subpaths[0]
|
||||
if (!project || (subpath !== undefined && !isSafeRelativePath(subpath))) return "invalid"
|
||||
return ownsSessionListScope(manager, workspaceId, { project, subpath })
|
||||
return ownsSessionListScope(manager, workspaceId, { ...(workspace ? { workspace } : {}), project, subpath })
|
||||
}
|
||||
|
||||
type SessionListScope = {
|
||||
|
|
@ -1053,9 +1119,13 @@ async function ownsSessionListScope(
|
|||
workspaceId: string,
|
||||
scope: SessionListScope,
|
||||
): Promise<"allowed" | "foreign"> {
|
||||
if (scope.workspace && !manager.ownsLocationWorkspace(workspaceId, scope.workspace)) return "foreign"
|
||||
if (scope.directory) return await manager.ownsDirectory(workspaceId, scope.directory) ? "allowed" : "foreign"
|
||||
if (!scope.project) return scope.workspace ? "allowed" : "foreign"
|
||||
if (scope.directory) {
|
||||
const owned = scope.workspace
|
||||
? await manager.ownsLocation(workspaceId, { directory: scope.directory, workspaceID: scope.workspace })
|
||||
: await manager.ownsDirectory(workspaceId, scope.directory)
|
||||
return owned ? "allowed" : "foreign"
|
||||
}
|
||||
if (!scope.project) return scope.workspace && await manager.ownsLocationWorkspace(workspaceId, scope.workspace) ? "allowed" : "foreign"
|
||||
const project = (await (await manager.getSharedServiceClient()).project.list()).find((candidate) => candidate.id === scope.project)
|
||||
if (!project) return "foreign"
|
||||
const directory = scope.subpath === undefined
|
||||
|
|
@ -1063,7 +1133,10 @@ async function ownsSessionListScope(
|
|||
: /^[A-Za-z]:[\\/]|^\\\\/.test(project.canonical)
|
||||
? path.win32.resolve(project.canonical, scope.subpath)
|
||||
: path.posix.resolve(project.canonical, scope.subpath)
|
||||
return await manager.ownsDirectory(workspaceId, directory) ? "allowed" : "foreign"
|
||||
const owned = scope.workspace
|
||||
? await manager.ownsLocation(workspaceId, { directory, workspaceID: scope.workspace })
|
||||
: await manager.ownsDirectory(workspaceId, directory)
|
||||
return owned ? "allowed" : "foreign"
|
||||
}
|
||||
|
||||
function getPtyRouteId(pathname: string): string | null {
|
||||
|
|
@ -1105,7 +1178,8 @@ function hasDotSegment(value: string): boolean {
|
|||
function isAllowedInstanceApiRoute(method: string, pathname: string): boolean {
|
||||
const route = pathname.replace(/\/+$/, "")
|
||||
const allowed: Array<[string, RegExp]> = [
|
||||
["GET", /^\/api\/(?:agent|command|config|integration|location|mcp|model|plugin|provider)$/],
|
||||
["GET", /^\/api\/(?:agent|command|config|integration|location|mcp|model|plugin|provider|reference|skill)$/],
|
||||
["GET", /^\/api\/(?:mcp\/resource|websearch\/provider)$/],
|
||||
["GET", /^\/api\/agent\/[^/]+$/],
|
||||
["GET", /^\/api\/model\/default$/],
|
||||
["GET", /^\/api\/(?:permission|question)\/request$/],
|
||||
|
|
@ -1130,9 +1204,10 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean {
|
|||
["POST", /^\/api\/session(?:\/import)?$/],
|
||||
["GET", /^\/api\/session\/[^/]+(?:\/message(?:\/[^/]+)?)?$/],
|
||||
["GET", /^\/api\/session\/[^/]+\/inbox$/],
|
||||
["GET", /^\/api\/session\/[^/]+\/(?:permission|form)$/],
|
||||
["DELETE", /^\/api\/session\/[^/]+$/],
|
||||
["DELETE", /^\/api\/session\/[^/]+\/inbox\/[^/]+$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/(?:agent|model|rename|move|prompt|command|shell|compact|interrupt|fork)$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/(?:agent|model|rename|move|prompt|command|shell|compact|interrupt|background|fork)$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/inbox\/[^/]+\/(?:steer|queue)$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/revert\/stage$/],
|
||||
["PUT", /^\/api\/session\/[^/]+\/instructions\/entries\/[^/]+$/],
|
||||
|
|
@ -1202,9 +1277,6 @@ function stripLocationSelectors(targetUrl: URL, body: unknown, workspaceDirector
|
|||
for (const value of values) targetUrl.searchParams.append(key, value === workspaceDirectory ? serviceDirectory : value)
|
||||
}
|
||||
}
|
||||
for (const key of ["workspace", "workspaceID", "location[workspace]", "location[workspaceID]"]) {
|
||||
targetUrl.searchParams.delete(key)
|
||||
}
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) return body
|
||||
const input = body as Record<string, unknown>
|
||||
const canonicalInput = { ...input }
|
||||
|
|
@ -1213,7 +1285,7 @@ function stripLocationSelectors(targetUrl: URL, body: unknown, workspaceDirector
|
|||
}
|
||||
const location = input.location
|
||||
if (!location || typeof location !== "object" || Array.isArray(location) || Buffer.isBuffer(location)) return canonicalInput
|
||||
const { workspace: _workspace, workspaceID: _workspaceID, ...canonicalLocation } = location as Record<string, unknown>
|
||||
const canonicalLocation = { ...(location as Record<string, unknown>) }
|
||||
if (canonicalLocation.directory === workspaceDirectory) canonicalLocation.directory = serviceDirectory
|
||||
return { ...canonicalInput, location: canonicalLocation }
|
||||
}
|
||||
|
|
@ -1285,7 +1357,7 @@ function replacePromptFileUris(body: unknown, replacements: ReadonlyMap<string,
|
|||
}
|
||||
|
||||
function prepareSessionImport(pathname: string, method: string, body: unknown, directory: string) {
|
||||
const result = { body, directories: [] as string[], invalid: false }
|
||||
const result = { body, directories: [] as string[], locations: [] as LocationRef[], invalid: false }
|
||||
if (pathname !== "/api/session/import" || method !== "POST") return result
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) || Buffer.isBuffer(body)) {
|
||||
result.invalid = true
|
||||
|
|
@ -1306,8 +1378,17 @@ function prepareSessionImport(pathname: string, method: string, body: unknown, d
|
|||
}
|
||||
const location = value as Record<string, unknown>
|
||||
if (location.directory === null || location.directory === undefined) location.directory = directory
|
||||
if (typeof location.directory === "string" && location.directory.trim()) result.directories.push(location.directory)
|
||||
else result.invalid = true
|
||||
if (typeof location.directory !== "string" || !location.directory.trim()) {
|
||||
result.invalid = true
|
||||
return
|
||||
}
|
||||
result.directories.push(location.directory)
|
||||
if (location.workspace !== undefined) result.invalid = true
|
||||
if (location.workspaceID !== undefined) {
|
||||
if (typeof location.workspaceID === "string" && location.workspaceID.trim()) {
|
||||
result.locations.push({ directory: location.directory, workspaceID: location.workspaceID })
|
||||
} else result.invalid = true
|
||||
}
|
||||
}
|
||||
|
||||
addLocation(input, "location")
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class ControlledSharedService {
|
|||
afterValidation?: () => void
|
||||
headerFailures = 0
|
||||
validationCalls: Array<{ location: LocationRef; options?: OpenCodeSharedServiceOptions }> = []
|
||||
debugLocations: LocationRef[] = []
|
||||
shutdownCalls = 0
|
||||
shutdownGate?: ReturnType<typeof deferred<void>>
|
||||
shutdownTimeouts: number[] = []
|
||||
|
|
@ -44,7 +45,7 @@ class ControlledSharedService {
|
|||
}
|
||||
|
||||
async client() {
|
||||
return {} as OpenCodeClient
|
||||
return { debug: { location: { list: async () => this.debugLocations } } } as OpenCodeClient
|
||||
}
|
||||
|
||||
async headers() {
|
||||
|
|
@ -118,6 +119,21 @@ function createHarness(service = new ControlledSharedService(), overrides: Recor
|
|||
}
|
||||
|
||||
describe("workspace manager shared service lifecycle", () => {
|
||||
it("validates native workspace identity against an owned directory", async () => {
|
||||
const service = new ControlledSharedService()
|
||||
service.debugLocations = [
|
||||
{ directory: process.cwd(), workspaceID: "worktree-location" },
|
||||
{ directory: path.join(path.parse(process.cwd()).root, "__codenomad_foreign__"), workspaceID: "mismatch-location" },
|
||||
]
|
||||
const { manager } = createHarness(service)
|
||||
const workspace = await manager.create(process.cwd())
|
||||
|
||||
assert.equal(await manager.ownsLocation(workspace.workspace.id, { directory: process.cwd(), workspaceID: "location-1" }), true)
|
||||
assert.equal(await manager.ownsLocation(workspace.workspace.id, { directory: process.cwd(), workspaceID: "foreign" }), false)
|
||||
assert.equal(await manager.ownsLocation(workspace.workspace.id, { directory: process.cwd(), workspaceID: "mismatch-location" }), false)
|
||||
assert.equal(await manager.ownsLocationWorkspace(workspace.workspace.id, "worktree-location"), true)
|
||||
})
|
||||
|
||||
it("distinguishes WSL service paths from Windows host paths", () => {
|
||||
assert.equal(isWindowsHostPath("/mnt/d/repo"), false)
|
||||
assert.equal(isWindowsHostPath("D:\\repo"), true)
|
||||
|
|
|
|||
|
|
@ -222,9 +222,41 @@ export class WorkspaceManager {
|
|||
return Boolean(hostDirectory && await this.ownsHostDirectory(record, hostDirectory))
|
||||
}
|
||||
|
||||
ownsLocationWorkspace(id: string, workspaceID: string): boolean {
|
||||
async ownsLocation(id: string, location: LocationRef): Promise<boolean> {
|
||||
const record = this.workspaces.get(id)
|
||||
return Boolean(record?.[WORKSPACE_STATE].published && record.location?.workspaceID === workspaceID)
|
||||
if (!record?.[WORKSPACE_STATE].published) return false
|
||||
const requested = await this.resolveOwnedWorktree(record, location.directory)
|
||||
if (!requested) return false
|
||||
if (!location.workspaceID) return true
|
||||
try {
|
||||
const candidates = record.location?.workspaceID === location.workspaceID
|
||||
? [record.location]
|
||||
: (await (await this.sharedService.client(record[WORKSPACE_STATE].serviceOptions)).debug.location.list())
|
||||
.filter((candidate) => candidate.workspaceID === location.workspaceID)
|
||||
for (const candidate of candidates) {
|
||||
const resolved = await this.resolveOwnedWorktree(record, candidate.directory)
|
||||
if (resolved && canonicalWorktreeIdentity(resolved.worktreeDirectory, this.options.platform)
|
||||
=== canonicalWorktreeIdentity(requested.worktreeDirectory, this.options.platform)) return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async ownsLocationWorkspace(id: string, workspaceID: string): Promise<boolean> {
|
||||
const record = this.workspaces.get(id)
|
||||
if (!record?.[WORKSPACE_STATE].published) return false
|
||||
if (record.location?.workspaceID === workspaceID) return true
|
||||
try {
|
||||
const locations = await (await this.sharedService.client(record[WORKSPACE_STATE].serviceOptions)).debug.location.list()
|
||||
for (const location of locations) {
|
||||
if (location.workspaceID === workspaceID && await this.ownsDirectory(id, location.directory)) return true
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getServiceDirectoryForPath(id: string, directory: string): Promise<string | undefined> {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,25 @@ describe("message history pagination", () => {
|
|||
assert.notEqual(getMessageWindowPageKey(older), getMessageWindowPageKey(next))
|
||||
})
|
||||
|
||||
it("keeps follow escaped after positioning an older resident page", () => {
|
||||
const source = fs.readFileSync(new URL("./message-section.tsx", import.meta.url), "utf8")
|
||||
const start = source.indexOf("async function pageWindow(")
|
||||
const paging = source.slice(start, source.indexOf("function messageWindowPageKey", start))
|
||||
const position = paging.indexOf("after(api)")
|
||||
|
||||
assert.ok(position >= 0)
|
||||
assert.ok(paging.indexOf('api.setAutoScroll(direction === "latest")', position) > position)
|
||||
})
|
||||
|
||||
it("keeps native inbox prompts after delivered transcript messages", () => {
|
||||
const source = fs.readFileSync(new URL("./message-section.tsx", import.meta.url), "utf8")
|
||||
const start = source.indexOf("const visibleMessageIds")
|
||||
const projection = source.slice(start, source.indexOf("const sessionRevision", start))
|
||||
|
||||
assert.match(projection, /visible\.filter\(\(messageId\) => !props\.pendingPrompts!\.has\(messageId\)\)/)
|
||||
assert.match(projection, /Array\.from\(props\.pendingPrompts\.keys\(\)\)\.filter\(\(messageId\) => visibleSet\.has\(messageId\)\)/)
|
||||
})
|
||||
|
||||
it("uses a finite ceiling and leaves failed pages retryable", async () => {
|
||||
let attempts = 0
|
||||
const options = {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||
const visibleMessageIds = createMemo(() => {
|
||||
const resolvedStore = store()
|
||||
return messageIds().filter((messageId) => {
|
||||
const visible = messageIds().filter((messageId) => {
|
||||
const record = resolvedStore.getMessage(messageId)
|
||||
if (!record) return false
|
||||
|
||||
|
|
@ -103,6 +103,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
const timeInfo = info.time as { created: number; completed?: number } | undefined
|
||||
return Boolean(timeInfo && (timeInfo.completed === undefined || timeInfo.completed === 0))
|
||||
})
|
||||
if (!props.pendingPrompts?.size) return visible
|
||||
const visibleSet = new Set(visible)
|
||||
return [
|
||||
...visible.filter((messageId) => !props.pendingPrompts!.has(messageId)),
|
||||
...Array.from(props.pendingPrompts.keys()).filter((messageId) => visibleSet.has(messageId)),
|
||||
]
|
||||
})
|
||||
|
||||
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
|
||||
|
|
@ -723,6 +729,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||
await waitTwoFrames()
|
||||
if (!isCurrent()) return
|
||||
after(api)
|
||||
api.setAutoScroll(direction === "latest")
|
||||
api.notifyContentRendered()
|
||||
} catch (error) {
|
||||
if (isCurrent()) {
|
||||
|
|
|
|||
|
|
@ -292,12 +292,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
setManualAll(false)
|
||||
}
|
||||
|
||||
const showAllModels = () => {
|
||||
setManualAll(true)
|
||||
setExplicitFavorites(false)
|
||||
setAutoFavoritesEligibleAtOpen(false)
|
||||
setTimeout(() => searchInputRef?.focus(), 0)
|
||||
}
|
||||
const favoritesToggleLabel = () => t(favoritesOnlyEnabled()
|
||||
? "modelSelector.favoritesOnly.showAll"
|
||||
: "modelSelector.favoritesOnly.toggle.ariaLabel")
|
||||
|
||||
return (
|
||||
<div class="sidebar-selector">
|
||||
|
|
@ -435,7 +432,8 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
<button
|
||||
type="button"
|
||||
class="selector-favorites-toggle"
|
||||
aria-label={t("modelSelector.favoritesOnly.toggle.ariaLabel")}
|
||||
aria-label={favoritesToggleLabel()}
|
||||
title={favoritesToggleLabel()}
|
||||
aria-pressed={favoritesOnlyEnabled()}
|
||||
disabled={!hasFavorites() || searchActive()}
|
||||
data-active={favoritesOnlyEnabled()}
|
||||
|
|
@ -472,26 +470,6 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||
<PlugZap class="w-4 h-4" />
|
||||
<span class="selector-option-label">{t("modelSelector.manageProviders")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-option selector-option-action w-full"
|
||||
style={{ display: favoritesOnlyEnabled() && !searchActive() ? "flex" : "none" }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
showAllModels()
|
||||
}}
|
||||
>
|
||||
<span class="selector-option-label">{t("modelSelector.favoritesOnly.showAll")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js"
|
||||
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Paperclip, Volume2, X } from "lucide-solid"
|
||||
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Minimize2, Paperclip, Volume2, X } from "lucide-solid"
|
||||
import ExpandButton from "./expand-button"
|
||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
|
||||
import { preparePromptSubmission } from "./prompt-input/submitPrompt"
|
||||
import { preparePromptSubmission, resolvePromptDelivery } from "./prompt-input/submitPrompt"
|
||||
import { focusConversationStream } from "./focus-conversation"
|
||||
import Kbd from "./kbd"
|
||||
import { getActiveInstance } from "../stores/instances"
|
||||
|
|
@ -459,10 +459,17 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
resizeDragState = undefined
|
||||
})
|
||||
|
||||
const promptDelivery = (alternate = false) => resolvePromptDelivery(
|
||||
Boolean(props.isSessionBusy || submissionsInFlight > 0),
|
||||
preferences().followUpBehavior,
|
||||
alternate,
|
||||
)
|
||||
|
||||
async function handleSend(delivery?: PromptDelivery) {
|
||||
const text = prompt().trim()
|
||||
const currentAttachments = attachments()
|
||||
if (props.disabled || (!text && currentAttachments.length === 0)) return
|
||||
const resolvedDelivery = delivery ?? promptDelivery()
|
||||
|
||||
const isShellMode = mode() === "shell"
|
||||
|
||||
|
|
@ -535,7 +542,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
|
||||
}
|
||||
} else {
|
||||
await props.onSend(submitPrompt, currentAttachments, delivery ?? "steer")
|
||||
await props.onSend(submitPrompt, currentAttachments, resolvedDelivery)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to send message:", error)
|
||||
|
|
@ -558,6 +565,14 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
void props.onAbortSession()
|
||||
}
|
||||
|
||||
const canBackground = () => Boolean(props.isSessionBusy && props.onBackgroundSession)
|
||||
|
||||
function handleBackground() {
|
||||
if (!canBackground()) return false
|
||||
void props.onBackgroundSession?.()
|
||||
return true
|
||||
}
|
||||
|
||||
function handleExpandToggle(nextState: "normal" | "expanded") {
|
||||
setInputHeight(null)
|
||||
setExpandState(nextState)
|
||||
|
|
@ -741,7 +756,8 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
getAttachments: attachments,
|
||||
removeAttachment: (attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId),
|
||||
submitOnEnter,
|
||||
onSend: (alternate) => void handleSend(alternate && (props.isSessionBusy || submissionsInFlight > 0) ? "queue" : "steer"),
|
||||
onSend: (alternate) => void handleSend(promptDelivery(Boolean(alternate))),
|
||||
onBackground: handleBackground,
|
||||
selectPreviousHistory: (force) =>
|
||||
selectPreviousHistory({ force, isPickerOpen: showPicker(), getTextarea: () => textareaRef ?? null }),
|
||||
selectNextHistory: (force) =>
|
||||
|
|
@ -1069,13 +1085,24 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
<rect x="4" y="4" width="12" height="12" rx="2" />
|
||||
</svg>
|
||||
</button>
|
||||
<Show when={canBackground()}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-voice-button"
|
||||
onClick={() => handleBackground()}
|
||||
aria-label={t("promptInput.background.title")}
|
||||
title={t("promptInput.background.title")}
|
||||
>
|
||||
<Minimize2 class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
onClick={() => void handleSend()}
|
||||
disabled={!canSend()}
|
||||
aria-label={t(props.isSessionBusy && mode() === "normal" ? "promptInput.send.queueAriaLabel" : "promptInput.send.ariaLabel")}
|
||||
title={t(props.isSessionBusy && mode() === "normal" ? "promptInput.send.queueAriaLabel" : "promptInput.send.ariaLabel")}
|
||||
aria-label={t(mode() === "normal" && promptDelivery() === "queue" ? "promptInput.send.queueAriaLabel" : "promptInput.send.ariaLabel")}
|
||||
title={t(mode() === "normal" && promptDelivery() === "queue" ? "promptInput.send.queueAriaLabel" : "promptInput.send.ariaLabel")}
|
||||
>
|
||||
<Show
|
||||
when={mode() === "shell"}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,17 @@ import assert from "node:assert/strict"
|
|||
import { describe, it } from "node:test"
|
||||
|
||||
import { createTextAttachment } from "../../types/attachment"
|
||||
import { preparePromptSubmission } from "./submitPrompt"
|
||||
import { preparePromptSubmission, resolvePromptDelivery } from "./submitPrompt"
|
||||
|
||||
describe("resolvePromptDelivery", () => {
|
||||
it("uses steer at rest and flips the configured delivery for the busy-session alternate shortcut", () => {
|
||||
assert.equal(resolvePromptDelivery(false, "queue"), "steer")
|
||||
assert.equal(resolvePromptDelivery(true, "steer"), "steer")
|
||||
assert.equal(resolvePromptDelivery(true, "steer", true), "queue")
|
||||
assert.equal(resolvePromptDelivery(true, "queue"), "queue")
|
||||
assert.equal(resolvePromptDelivery(true, "queue", true), "steer")
|
||||
})
|
||||
})
|
||||
|
||||
describe("preparePromptSubmission", () => {
|
||||
it("keeps placeholder-backed pasted text intact for message submission while resolving history text", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { resolvePastedPlaceholders } from "../../lib/prompt-placeholders"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { PromptDelivery } from "./types"
|
||||
|
||||
export type PromptSubmissionMode = "message" | "shell" | "slash"
|
||||
|
||||
|
|
@ -9,6 +10,16 @@ export interface PromptSubmissionResult {
|
|||
resolvedCommandArgs: string
|
||||
}
|
||||
|
||||
export function resolvePromptDelivery(
|
||||
working: boolean,
|
||||
preferred: PromptDelivery,
|
||||
alternate = false,
|
||||
): PromptDelivery {
|
||||
if (!working) return "steer"
|
||||
if (!alternate) return preferred
|
||||
return preferred === "steer" ? "queue" : "steer"
|
||||
}
|
||||
|
||||
export function preparePromptSubmission(input: {
|
||||
mode: PromptSubmissionMode
|
||||
text: string
|
||||
|
|
|
|||
|
|
@ -33,5 +33,6 @@ export interface PromptInputProps {
|
|||
escapeInDebounce?: boolean
|
||||
isSessionBusy?: boolean
|
||||
onAbortSession?: () => Promise<void>
|
||||
onBackgroundSession?: () => Promise<void>
|
||||
registerPromptInputApi?: (api: PromptInputApi) => void | (() => void)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { usePromptKeyDown } from "./usePromptKeyDown.ts"
|
|||
function setup(submitOnEnter = true) {
|
||||
let prompt = "hello"
|
||||
const sends: Array<boolean | undefined> = []
|
||||
let backgrounds = 0
|
||||
const textarea = {
|
||||
selectionStart: prompt.length,
|
||||
selectionEnd: prompt.length,
|
||||
|
|
@ -30,6 +31,10 @@ function setup(submitOnEnter = true) {
|
|||
removeAttachment: () => {},
|
||||
submitOnEnter: () => submitOnEnter,
|
||||
onSend: (alternate) => sends.push(alternate),
|
||||
onBackground: () => {
|
||||
backgrounds += 1
|
||||
return true
|
||||
},
|
||||
selectPreviousHistory: () => false,
|
||||
selectNextHistory: () => false,
|
||||
})
|
||||
|
|
@ -39,7 +44,7 @@ function setup(submitOnEnter = true) {
|
|||
stopPropagation() {},
|
||||
...input,
|
||||
} as KeyboardEvent)
|
||||
return { press, sends, prompt: () => prompt }
|
||||
return { press, sends, backgrounds: () => backgrounds, prompt: () => prompt }
|
||||
}
|
||||
|
||||
describe("prompt submit shortcuts", () => {
|
||||
|
|
@ -62,4 +67,11 @@ describe("prompt submit shortcuts", () => {
|
|||
input.press({ ctrlKey: true, shiftKey: true })
|
||||
assert.deepEqual(input.sends, [true])
|
||||
})
|
||||
|
||||
it("moves blocking session work to the background on Mod+B", () => {
|
||||
const input = setup()
|
||||
input.press({ key: "b", ctrlKey: true })
|
||||
assert.equal(input.backgrounds(), 1)
|
||||
assert.deepEqual(input.sends, [])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export type UsePromptKeyDownOptions = {
|
|||
|
||||
submitOnEnter: Accessor<boolean>
|
||||
onSend: (alternate?: boolean) => void
|
||||
onBackground?: () => boolean
|
||||
|
||||
selectPreviousHistory: (force?: boolean) => boolean
|
||||
selectNextHistory: (force?: boolean) => boolean
|
||||
|
|
@ -56,6 +57,12 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
|
|||
const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
const isShellMode = options.mode() === "shell"
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "b" && options.onBackground?.()) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !textarea.disabled) {
|
||||
e.preventDefault()
|
||||
options.setMode("shell")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import assert from "node:assert/strict"
|
||||
import fs from "node:fs"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent } from "./session-bottom-pin-intent.ts"
|
||||
|
||||
describe("session bottom pin intent", () => {
|
||||
it("starts a bottom pin for every locally submitted prompt, including queue delivery", () => {
|
||||
const source = fs.readFileSync(new URL("./session-view.tsx", import.meta.url), "utf8")
|
||||
const start = source.indexOf("async function handleSendMessage(")
|
||||
const submit = source.slice(start, source.indexOf("function showQueueError", start))
|
||||
|
||||
assert.match(submit, /const initialPinIntent = forceSubmittedExchangeToBottom/)
|
||||
assert.doesNotMatch(submit, /delivery === "queue"/)
|
||||
})
|
||||
|
||||
it("targets only the queued user prompt when submitting during active streaming", () => {
|
||||
assert.equal(getSubmitBottomPinTargetCount(10, true), 11)
|
||||
assert.equal(getSubmitBottomPinTargetCount(10, false), 12)
|
||||
|
|
|
|||
|
|
@ -403,12 +403,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
}
|
||||
const messageCount = visibleMessageCount()
|
||||
const submittedExchangeTargetCount = getSubmitBottomPinTargetCount(messageCount, sessionStreamingActive())
|
||||
const initialPinIntent = delivery === "queue"
|
||||
? undefined
|
||||
: forceSubmittedExchangeToBottom(submittedExchangeTargetCount, { createdMessageCount: messageCount })
|
||||
const initialPinIntent = forceSubmittedExchangeToBottom(submittedExchangeTargetCount, { createdMessageCount: messageCount })
|
||||
try {
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments, { delivery })
|
||||
if (!initialPinIntent) return
|
||||
const latestMessageCount = visibleMessageCount()
|
||||
if (latestMessageCount < submittedExchangeTargetCount && !sessionStreamingActive()) {
|
||||
setSubmitBottomPinIntent(null)
|
||||
|
|
@ -419,7 +416,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (initialPinIntent) setSubmitBottomPinIntent(null)
|
||||
setSubmitBottomPinIntent(null)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
|
@ -472,6 +469,20 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBackgroundSession() {
|
||||
const client = instances().get(props.instanceId)?.client
|
||||
if (!client) return
|
||||
try {
|
||||
await client.session.background({ sessionID: props.sessionId })
|
||||
} catch (error) {
|
||||
log.error("Failed to move blocking tools to background", error)
|
||||
showAlertDialog(t("promptInput.background.error.message"), {
|
||||
title: t("promptInput.background.error.title"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getUserMessageText(messageId: string): string | null {
|
||||
|
||||
|
|
@ -632,6 +643,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||
isSessionBusy={sessionBusy()}
|
||||
disabled={sessionNeedsInput()}
|
||||
onAbortSession={handleAbortSession}
|
||||
onBackgroundSession={handleBackgroundSession}
|
||||
registerPromptInputApi={registerPromptInputApi}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const GeneralSettingsSection: Component = () => {
|
|||
setting.id === "behavior.messageTimeline" ||
|
||||
setting.id === "behavior.timelineToolCalls" ||
|
||||
setting.id === "behavior.diffViewMode" ||
|
||||
setting.id === "behavior.followUpBehavior" ||
|
||||
setting.id === "behavior.promptSubmitOnEnter",
|
||||
),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -186,6 +186,9 @@ export const messagingMessages = {
|
|||
"promptInput.overlay.againToAbort": "erneut zum Abbrechen der Sitzung",
|
||||
"promptInput.stopSession.ariaLabel": "Sitzung stoppen",
|
||||
"promptInput.stopSession.title": "Sitzung stoppen",
|
||||
"promptInput.background.title": "Blockierende Werkzeuge in den Hintergrund verschieben (Cmd/Ctrl+B)",
|
||||
"promptInput.background.error.title": "Hintergrundaktion fehlgeschlagen",
|
||||
"promptInput.background.error.message": "Blockierende Werkzeuge konnten nicht in den Hintergrund verschoben werden",
|
||||
"promptInput.attachFiles.ariaLabel": "Dateien anhängen",
|
||||
"promptInput.attachFiles.title": "Dateien anhängen",
|
||||
"promptInput.attachFiles.dialogTitle": "Dateien anhängen",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,10 @@ export const settingsMessages = {
|
|||
"settings.behavior.promptVoiceInput.subtitle": "Mikrofon-Steuerung für Sprache-zu-Text anzeigen, wenn Sprache konfiguriert ist.",
|
||||
"settings.behavior.promptSubmit.title": "Enter zum Absenden",
|
||||
"settings.behavior.promptSubmit.subtitle": "Enter zum Absenden von Prompts verwenden; Cmd/Ctrl+Enter fügt eine neue Zeile ein.",
|
||||
"settings.behavior.followUpBehavior.title": "Folgeprompt-Verhalten",
|
||||
"settings.behavior.followUpBehavior.subtitle": "Legt fest, wie Prompts während einer aktiven Aufgabe zugestellt werden; Cmd/Ctrl+Umschalt+Enter nutzt die andere Option.",
|
||||
"settings.behavior.followUpBehavior.option.steer": "Steuern",
|
||||
"settings.behavior.followUpBehavior.option.queue": "Warteschlange",
|
||||
"settings.speech.title": "Sprache",
|
||||
"settings.speech.subtitle": "Konfigurieren Sie Sprache-zu-Text und Text-zu-Sprache.",
|
||||
"settings.speech.provider.title": "Anbieter",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,9 @@ export const messagingMessages = {
|
|||
"promptInput.overlay.againToAbort": "again to abort session",
|
||||
"promptInput.stopSession.ariaLabel": "Stop session",
|
||||
"promptInput.stopSession.title": "Stop session",
|
||||
"promptInput.background.title": "Move blocking tools to background (Cmd/Ctrl+B)",
|
||||
"promptInput.background.error.title": "Background action failed",
|
||||
"promptInput.background.error.message": "Unable to move blocking tools to the background",
|
||||
"promptInput.attachFiles.ariaLabel": "Attach files",
|
||||
"promptInput.attachFiles.title": "Attach files",
|
||||
"promptInput.attachFiles.dialogTitle": "Attach files",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,10 @@ export const settingsMessages = {
|
|||
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
|
||||
"settings.behavior.promptSubmit.title": "Enter to submit",
|
||||
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
|
||||
"settings.behavior.followUpBehavior.title": "Follow-up behavior",
|
||||
"settings.behavior.followUpBehavior.subtitle": "Choose how prompts are delivered while a task is active; Cmd/Ctrl+Shift+Enter uses the other behavior.",
|
||||
"settings.behavior.followUpBehavior.option.steer": "Steer",
|
||||
"settings.behavior.followUpBehavior.option.queue": "Queue",
|
||||
"settings.speech.title": "Speech",
|
||||
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
|
||||
"settings.speech.provider.title": "Provider",
|
||||
|
|
|
|||
|
|
@ -189,6 +189,9 @@ export const messagingMessages = {
|
|||
"promptInput.overlay.againToAbort": "otra vez para abortar la sesión",
|
||||
"promptInput.stopSession.ariaLabel": "Detener sesión",
|
||||
"promptInput.stopSession.title": "Detener sesión",
|
||||
"promptInput.background.title": "Mover herramientas bloqueantes a segundo plano (Cmd/Ctrl+B)",
|
||||
"promptInput.background.error.title": "Error al mover a segundo plano",
|
||||
"promptInput.background.error.message": "No se pudieron mover las herramientas bloqueantes a segundo plano",
|
||||
"promptInput.attachFiles.ariaLabel": "Adjuntar archivos",
|
||||
"promptInput.attachFiles.title": "Adjuntar archivos",
|
||||
"promptInput.attachFiles.dialogTitle": "Adjuntar archivos",
|
||||
|
|
|
|||
|
|
@ -340,6 +340,10 @@ export const settingsMessages = {
|
|||
"settings.behavior.promptVoiceInput.subtitle": "Muestra el control del micrófono para la entrada de voz a texto del prompt cuando la voz esté configurada.",
|
||||
"settings.behavior.promptSubmit.title": "Enter para enviar",
|
||||
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva línea.",
|
||||
"settings.behavior.followUpBehavior.title": "Comportamiento de seguimiento",
|
||||
"settings.behavior.followUpBehavior.subtitle": "Elige cómo enviar prompts durante una tarea activa; Cmd/Ctrl+Mayús+Enter usa el otro comportamiento.",
|
||||
"settings.behavior.followUpBehavior.option.steer": "Dirigir",
|
||||
"settings.behavior.followUpBehavior.option.queue": "Poner en cola",
|
||||
"settings.speech.title": "Voz",
|
||||
"settings.speech.subtitle": "Configura ahora el reconocimiento de voz y prepara la base de texto a voz para funciones futuras.",
|
||||
"settings.speech.provider.title": "Proveedor",
|
||||
|
|
|
|||
|
|
@ -189,6 +189,9 @@ export const messagingMessages = {
|
|||
"promptInput.overlay.againToAbort": "à nouveau pour interrompre la session",
|
||||
"promptInput.stopSession.ariaLabel": "Arrêter la session",
|
||||
"promptInput.stopSession.title": "Arrêter la session",
|
||||
"promptInput.background.title": "Passer les outils bloquants en arrière-plan (Cmd/Ctrl+B)",
|
||||
"promptInput.background.error.title": "Échec du passage en arrière-plan",
|
||||
"promptInput.background.error.message": "Impossible de passer les outils bloquants en arrière-plan",
|
||||
"promptInput.attachFiles.ariaLabel": "Joindre des fichiers",
|
||||
"promptInput.attachFiles.title": "Joindre des fichiers",
|
||||
"promptInput.attachFiles.dialogTitle": "Joindre des fichiers",
|
||||
|
|
|
|||
|
|
@ -340,6 +340,10 @@ export const settingsMessages = {
|
|||
"settings.behavior.promptVoiceInput.subtitle": "Afficher le contrôle du microphone pour la saisie vocale du prompt lorsque la voix est configurée.",
|
||||
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
|
||||
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
|
||||
"settings.behavior.followUpBehavior.title": "Comportement des messages de suivi",
|
||||
"settings.behavior.followUpBehavior.subtitle": "Choisir comment envoyer les prompts pendant une tâche active; Cmd/Ctrl+Maj+Entrée utilise l'autre comportement.",
|
||||
"settings.behavior.followUpBehavior.option.steer": "Orienter",
|
||||
"settings.behavior.followUpBehavior.option.queue": "Mettre en file",
|
||||
"settings.speech.title": "Voix",
|
||||
"settings.speech.subtitle": "Configurez dès maintenant la reconnaissance vocale et préparez la synthèse vocale pour de futures fonctionnalités.",
|
||||
"settings.speech.provider.title": "Fournisseur",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,9 @@ export const messagingMessages = {
|
|||
"promptInput.overlay.againToAbort": "שוב כדי לבטל את הסשן",
|
||||
"promptInput.stopSession.ariaLabel": "עצור סשן",
|
||||
"promptInput.stopSession.title": "עצור סשן",
|
||||
"promptInput.background.title": "העבר כלים חוסמים לרקע (Cmd/Ctrl+B)",
|
||||
"promptInput.background.error.title": "העברה לרקע נכשלה",
|
||||
"promptInput.background.error.message": "לא ניתן להעביר את הכלים החוסמים לרקע",
|
||||
"promptInput.attachFiles.ariaLabel": "צרף קבצים",
|
||||
"promptInput.attachFiles.title": "צרף קבצים",
|
||||
"promptInput.attachFiles.dialogTitle": "צרף קבצים",
|
||||
|
|
|
|||
|
|
@ -340,6 +340,10 @@ export const settingsMessages = {
|
|||
"settings.behavior.promptVoiceInput.subtitle": "הצג את כפתור המיקרופון לקלט דיבור-לטקסט כאשר תכונת הקול מוגדרת.",
|
||||
"settings.behavior.promptSubmit.title": "Enter לשליחה",
|
||||
"settings.behavior.promptSubmit.subtitle": "השתמש ב-Enter לשליחת פקודות; Cmd/Ctrl+Enter מוסיף שורה חדשה.",
|
||||
"settings.behavior.followUpBehavior.title": "התנהגות הודעת המשך",
|
||||
"settings.behavior.followUpBehavior.subtitle": "בחר כיצד לשלוח הנחיות בזמן שמשימה פעילה; Cmd/Ctrl+Shift+Enter משתמש באפשרות השנייה.",
|
||||
"settings.behavior.followUpBehavior.option.steer": "כוון",
|
||||
"settings.behavior.followUpBehavior.option.queue": "הוסף לתור",
|
||||
"settings.speech.title": "קול",
|
||||
"settings.speech.subtitle": "הגדר כעת דיבור-לטקסט והכן תשתית לטקסט-לדיבור עבור יכולות עתידיות.",
|
||||
"settings.speech.provider.title": "ספק",
|
||||
|
|
|
|||
|
|
@ -189,6 +189,9 @@ export const messagingMessages = {
|
|||
"promptInput.overlay.againToAbort": "もう一度押すとセッションを中断",
|
||||
"promptInput.stopSession.ariaLabel": "セッションを停止",
|
||||
"promptInput.stopSession.title": "セッションを停止",
|
||||
"promptInput.background.title": "ブロック中のツールをバックグラウンドへ移動 (Cmd/Ctrl+B)",
|
||||
"promptInput.background.error.title": "バックグラウンド移動に失敗しました",
|
||||
"promptInput.background.error.message": "ブロック中のツールをバックグラウンドへ移動できませんでした",
|
||||
"promptInput.attachFiles.ariaLabel": "ファイルを添付",
|
||||
"promptInput.attachFiles.title": "ファイルを添付",
|
||||
"promptInput.attachFiles.dialogTitle": "ファイルを添付",
|
||||
|
|
|
|||
|
|
@ -340,6 +340,10 @@ export const settingsMessages = {
|
|||
"settings.behavior.promptVoiceInput.subtitle": "音声が設定されている場合、音声文字起こし用のマイク操作を表示します。",
|
||||
"settings.behavior.promptSubmit.title": "Enterで送信",
|
||||
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
|
||||
"settings.behavior.followUpBehavior.title": "フォローアップの動作",
|
||||
"settings.behavior.followUpBehavior.subtitle": "タスク実行中のプロンプト送信方法を選択します。Cmd/Ctrl+Shift+Enterで逆の方法を使用します。",
|
||||
"settings.behavior.followUpBehavior.option.steer": "割り込み",
|
||||
"settings.behavior.followUpBehavior.option.queue": "キュー",
|
||||
"settings.speech.title": "音声",
|
||||
"settings.speech.subtitle": "今すぐ音声入力を設定し、今後の機能のために音声合成の基盤も準備します。",
|
||||
"settings.speech.provider.title": "プロバイダー",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,9 @@ export const messagingMessages = {
|
|||
"promptInput.overlay.againToAbort": "सत्र रद्द गर्न फेरि",
|
||||
"promptInput.stopSession.ariaLabel": "सत्र रोक्नुहोस्",
|
||||
"promptInput.stopSession.title": "सत्र रोक्नुहोस्",
|
||||
"promptInput.background.title": "अवरुद्ध गर्ने उपकरणहरू पृष्ठभूमिमा सार्नुहोस् (Cmd/Ctrl+B)",
|
||||
"promptInput.background.error.title": "पृष्ठभूमि कार्य असफल भयो",
|
||||
"promptInput.background.error.message": "अवरुद्ध गर्ने उपकरणहरू पृष्ठभूमिमा सार्न सकिएन",
|
||||
"promptInput.attachFiles.ariaLabel": "फाइलहरू संलग्न गर्नुहोस्",
|
||||
"promptInput.attachFiles.title": "फाइलहरू संलग्न गर्नुहोस्",
|
||||
"promptInput.attachFiles.dialogTitle": "फाइलहरू संलग्न गर्नुहोस्",
|
||||
|
|
|
|||
|
|
@ -341,6 +341,10 @@ export const settingsMessages = {
|
|||
"settings.behavior.promptVoiceInput.subtitle": "वाचन कन्फिगर हुँदा स्पीच-टु-टेक्स्ट प्रम्प्ट इनपुटको लागि माइक्रोफोन नियन्त्रण देखाउनुहोस्।",
|
||||
"settings.behavior.promptSubmit.title": "Enter थिचेर बुझाउनुहोस्",
|
||||
"settings.behavior.promptSubmit.subtitle": "प्रम्प्ट बुझाउन Enter प्रयोग गर्नुहोस्; Cmd/Ctrl+Enter ले नयाँ लाइन घुसाउँछ।",
|
||||
"settings.behavior.followUpBehavior.title": "फलो-अप व्यवहार",
|
||||
"settings.behavior.followUpBehavior.subtitle": "कार्य चलिरहेको बेला प्रम्प्ट कसरी पठाउने छान्नुहोस्; Cmd/Ctrl+Shift+Enter ले अर्को विकल्प प्रयोग गर्छ।",
|
||||
"settings.behavior.followUpBehavior.option.steer": "निर्देशन दिनुहोस्",
|
||||
"settings.behavior.followUpBehavior.option.queue": "लाममा राख्नुहोस्",
|
||||
"settings.speech.title": "वाचन (Speech)",
|
||||
"settings.speech.subtitle": "स्पीच-टु-टेक्स्ट र टेक्स्ट-टु-स्पीच कन्फिगर गर्नुहोस्।",
|
||||
"settings.speech.provider.title": "प्रदायक",
|
||||
|
|
|
|||
|
|
@ -189,6 +189,9 @@ export const messagingMessages = {
|
|||
"promptInput.overlay.againToAbort": "еще раз, чтобы прервать сессию",
|
||||
"promptInput.stopSession.ariaLabel": "Остановить сессию",
|
||||
"promptInput.stopSession.title": "Остановить сессию",
|
||||
"promptInput.background.title": "Переместить блокирующие инструменты в фон (Cmd/Ctrl+B)",
|
||||
"promptInput.background.error.title": "Не удалось переместить в фон",
|
||||
"promptInput.background.error.message": "Не удалось переместить блокирующие инструменты в фоновый режим",
|
||||
"promptInput.attachFiles.ariaLabel": "Прикрепить файлы",
|
||||
"promptInput.attachFiles.title": "Прикрепить файлы",
|
||||
"promptInput.attachFiles.dialogTitle": "Прикрепить файлы",
|
||||
|
|
|
|||
|
|
@ -340,6 +340,10 @@ export const settingsMessages = {
|
|||
"settings.behavior.promptVoiceInput.subtitle": "Показывать микрофон для ввода промпта через распознавание речи, когда речь настроена.",
|
||||
"settings.behavior.promptSubmit.title": "Enter для отправки",
|
||||
"settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.",
|
||||
"settings.behavior.followUpBehavior.title": "Поведение последующих запросов",
|
||||
"settings.behavior.followUpBehavior.subtitle": "Выберите способ отправки во время активной задачи; Cmd/Ctrl+Shift+Enter использует другой вариант.",
|
||||
"settings.behavior.followUpBehavior.option.steer": "Направить",
|
||||
"settings.behavior.followUpBehavior.option.queue": "В очередь",
|
||||
"settings.speech.title": "Речь",
|
||||
"settings.speech.subtitle": "Настройте преобразование речи в текст сейчас и подготовьте основу для синтеза речи в будущих функциях.",
|
||||
"settings.speech.provider.title": "Провайдер",
|
||||
|
|
|
|||
|
|
@ -188,6 +188,9 @@ export const messagingMessages = {
|
|||
"promptInput.overlay.againToAbort": "再次按下以中止会话",
|
||||
"promptInput.stopSession.ariaLabel": "停止会话",
|
||||
"promptInput.stopSession.title": "停止会话",
|
||||
"promptInput.background.title": "将阻塞工具移至后台 (Cmd/Ctrl+B)",
|
||||
"promptInput.background.error.title": "后台操作失败",
|
||||
"promptInput.background.error.message": "无法将阻塞工具移至后台",
|
||||
"promptInput.attachFiles.ariaLabel": "附加文件",
|
||||
"promptInput.attachFiles.title": "附加文件",
|
||||
"promptInput.attachFiles.dialogTitle": "附加文件",
|
||||
|
|
|
|||
|
|
@ -340,6 +340,10 @@ export const settingsMessages = {
|
|||
"settings.behavior.promptVoiceInput.subtitle": "语音已配置时,显示用于语音转文字提示词输入的麦克风控件。",
|
||||
"settings.behavior.promptSubmit.title": "回车发送",
|
||||
"settings.behavior.promptSubmit.subtitle": "使用回车发送;Cmd/Ctrl+回车插入新行。",
|
||||
"settings.behavior.followUpBehavior.title": "后续提示行为",
|
||||
"settings.behavior.followUpBehavior.subtitle": "选择任务运行时提示的发送方式;Cmd/Ctrl+Shift+Enter 使用另一种方式。",
|
||||
"settings.behavior.followUpBehavior.option.steer": "引导",
|
||||
"settings.behavior.followUpBehavior.option.queue": "排队",
|
||||
"settings.speech.title": "语音",
|
||||
"settings.speech.subtitle": "立即配置语音转文字,并为后续功能预留文字转语音基础。",
|
||||
"settings.speech.provider.title": "提供商",
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ test("model picker delegates keyboard selection to its accessible Kobalte input"
|
|||
const search = source.slice(source.indexOf('<div class="selector-search-container">'), source.indexOf("<Combobox.Listbox"))
|
||||
assert.match(search, /<Combobox\.Input[\s\S]{0,300}value=\{inputValue\(\)\}/)
|
||||
assert.match(search, /class="selector-input-group"[\s\S]*class="selector-favorites-toggle"/)
|
||||
assert.match(search, /title=\{favoritesToggleLabel\(\)\}/)
|
||||
assert.match(search, /aria-pressed=\{favoritesOnlyEnabled\(\)\}[\s\S]*disabled=\{!hasFavorites\(\) \|\| searchActive\(\)\}[\s\S]*data-active=\{favoritesOnlyEnabled\(\)\}/)
|
||||
assert.doesNotMatch(search, /<input\b/)
|
||||
assert.match(source, /onKeyDown=\{\(event\) => \{\s*if \(event\.key === "Escape"\) queueMicrotask\(restoreSelectedInput\)/)
|
||||
|
|
@ -105,6 +106,7 @@ test("model picker delegates keyboard selection to its accessible Kobalte input"
|
|||
assert.match(source, /closePicker\(\)\s*setProvidersModalOpen\(true\)/)
|
||||
const footer = source.slice(source.indexOf('<div class="selector-footer">'), source.indexOf("</Combobox.Content>"))
|
||||
assert.doesNotMatch(footer, /toggleFavoritesOnly/)
|
||||
assert.doesNotMatch(footer, /favoritesOnly\.showAll/)
|
||||
})
|
||||
|
||||
test("provider auth keeps its catalog location across deferred operation steps", () => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Accessor } from "solid-js"
|
|||
import type {
|
||||
Preferences,
|
||||
ExpansionPreference,
|
||||
FollowUpBehavior,
|
||||
ToolInputsVisibilityPreference,
|
||||
VisibilityPreference,
|
||||
} from "../../stores/preferences"
|
||||
|
|
@ -344,6 +345,18 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
|
|||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "enum",
|
||||
id: "behavior.followUpBehavior",
|
||||
titleKey: "settings.behavior.followUpBehavior.title",
|
||||
subtitleKey: "settings.behavior.followUpBehavior.subtitle",
|
||||
get: (p) => p.followUpBehavior,
|
||||
set: (next) => updatePreferences?.({ followUpBehavior: next as FollowUpBehavior }),
|
||||
options: [
|
||||
{ value: "steer", labelKey: "settings.behavior.followUpBehavior.option.steer" },
|
||||
{ value: "queue", labelKey: "settings.behavior.followUpBehavior.option.queue" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
id: "behavior.promptSubmitOnEnter",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export interface ModelPreference {
|
|||
}
|
||||
|
||||
export type DiffViewMode = "split" | "unified"
|
||||
export type FollowUpBehavior = "steer" | "queue"
|
||||
export type ExpansionPreference = "expanded" | "collapsed"
|
||||
export type VisibilityPreference = "hidden" | ExpansionPreference
|
||||
export type ToolCallExpansionPreset = "minimal" | "balanced" | "detailed" | "everything"
|
||||
|
|
@ -94,6 +95,7 @@ export interface UiSettings {
|
|||
showTimelineTools: boolean
|
||||
holdLongAssistantReplies: boolean
|
||||
promptSubmitOnEnter: boolean
|
||||
followUpBehavior: FollowUpBehavior
|
||||
showPromptVoiceInput: boolean
|
||||
locale?: string
|
||||
diffViewMode: DiffViewMode
|
||||
|
|
@ -187,6 +189,7 @@ const defaultUiSettings: UiSettings = {
|
|||
showTimelineTools: true,
|
||||
holdLongAssistantReplies: true,
|
||||
promptSubmitOnEnter: true,
|
||||
followUpBehavior: "steer",
|
||||
showPromptVoiceInput: true,
|
||||
diffViewMode: "split",
|
||||
toolCallExpansionDefaults: defaultToolCallExpansionDefaults,
|
||||
|
|
@ -284,6 +287,10 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
|||
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
|
||||
holdLongAssistantReplies: sanitized.holdLongAssistantReplies ?? defaultUiSettings.holdLongAssistantReplies,
|
||||
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
|
||||
followUpBehavior:
|
||||
sanitized.followUpBehavior === "queue" || sanitized.followUpBehavior === "steer"
|
||||
? sanitized.followUpBehavior
|
||||
: defaultUiSettings.followUpBehavior,
|
||||
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
|
||||
locale: sanitized.locale ?? defaultUiSettings.locale,
|
||||
diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue