mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-20 19:44:34 +00:00
feat(chat): add native /btw side questions
Reserve /btw in the composer and autocomplete, using session.generate to answer from the current native context in a non-modal Markdown window. Bare /btw opens the question form; answers can be copied or retried without adding transcript, inbox or child-session records. Keep side-request cancellation independent of the main execution, fence late results on cancellation, replacement, session changes and disposal, and preserve unsent file/image attachments. Authorize only the reviewed session-scoped generation route through existing ownership and worktree-deletion checks. Add all ten locales and shared square window styling. Cover routing, Markdown, attachments, concurrent main prompts, cancellation races and narrow layouts with ten browser scenarios, plus proxy regressions. Verify native idle and busy behavior with isolated local-provider fixtures on OpenCode 2.0.7, 2.0.11 and 2.0.12; include the fixture in minimum/latest CI. UI/server typechecks, twelve adjacent unit tests and diff checks pass.
This commit is contained in:
parent
0e4a0d3d72
commit
da696ed3ba
23 changed files with 822 additions and 4 deletions
2
.github/workflows/pr-build.yml
vendored
2
.github/workflows/pr-build.yml
vendored
|
|
@ -176,6 +176,8 @@ jobs:
|
|||
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
||||
node scripts/test-session-fork-native.mjs $cli
|
||||
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
||||
node scripts/test-session-aside-native.mjs $cli
|
||||
if ($LASTEXITCODE) { exit $LASTEXITCODE }
|
||||
|
||||
tests:
|
||||
needs: authorize
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
- Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere.
|
||||
- Use the shared `.window-*` primitives from `src/styles/components/window.css` for dialog, popover, and floating-window headers, toolbars, bodies, footers, titles, and actions.
|
||||
- Persistent command/search utility windows use `components/dismissible-window.tsx`: non-modal, no scrim, outside interactions keep them open, and explicit toggles use `.icon-toggle` with `aria-expanded`/`aria-controls`. Keep search state scoped to its instance/session and close it when that view becomes inactive.
|
||||
- The composer reserves `/btw` for native `session.generate`, outside ordinary prompt/command submission. Its ephemeral question/answer window uses `DismissibleWindow` and `styles/components/session-aside.css`; cancellation and inactive/session transitions fence late results without interrupting the main session.
|
||||
- OpenCode settings show a compact runtime summary with update availability and a direct install/update action; additional management actions live in the shared setup dialog, also mounted by the native preferences window. Keep diagnostics, executable selection and configuration reload in explicit disclosure sections styled in `styles/components/opencode-setup.css`.
|
||||
- Full-history search/counts use the bundled pruning plugin's bounded queries, outside the transcript store. Current-session results and the global timeline navigate through bounded anchor windows; other-session previews fetch one native message only. Search/results/progress styles live in `styles/components/history-search.css`. See `dev-docs/SESSION_HISTORY_QUERIES.md` for scope, snapshot and cleanup semantics.
|
||||
- Keep agent, model, and thinking controls in the composer footer via `PromptContextControls`; adapt that footer with the named `prompt-composer` container rather than viewport-only breakpoints.
|
||||
|
|
|
|||
|
|
@ -28,6 +28,19 @@ The isolated 2.0.3→2.0.11 seed confirms native workspace-selector collapse whi
|
|||
preserving session IDs and complete history. See the
|
||||
[transition register](OPENCODE_V2_POST_BETA.md) for actual coverage and release gates.
|
||||
|
||||
### Side questions (`/btw`)
|
||||
|
||||
The composer-owned command calls native `session.generate({ sessionID, prompt })`
|
||||
through the ownership-checked session proxy. It does not use `session.command`,
|
||||
create a fork, replace session environment, or add transcript/inbox records.
|
||||
The native API returns one text answer; there is no client-side context assembly
|
||||
or tool loop. Closing/cancelling the ephemeral window aborts that request only.
|
||||
`scripts/test-session-aside-native.mjs` uses a private daemon and local provider
|
||||
to verify context reuse, unchanged idle history and generation during an active
|
||||
main turn. It passes on 2.0.7 (technical minimum), 2.0.11 and 2.0.12, and runs in
|
||||
the minimum/latest-stable CI matrix. These targeted results do not change the
|
||||
global minimum, dependency pins or recommended release-tested version.
|
||||
|
||||
The audit and implementation record below is historical context, not a runtime
|
||||
qualification matrix to maintain.
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,38 @@ async function harness(
|
|||
}
|
||||
|
||||
describe("instance proxy location enforcement", () => {
|
||||
it("forwards side generation for an owned busy session without mutating its environment", async () => {
|
||||
const { app, manager, sessionGets, requestCount } = await harness("/repo/worktree", { owned: { type: "running" } })
|
||||
manager.getSessionEnvironment = async () => { throw new Error("Side generation must not replace the session environment") }
|
||||
const response = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/owned/generate",
|
||||
payload: { prompt: "Explain the current approach" } })
|
||||
assert.equal(response.statusCode, 200)
|
||||
assert.deepEqual(sessionGets, ["owned"])
|
||||
assert.deepEqual(response.json().body, { prompt: "Explain the current approach" })
|
||||
assert.equal(response.json().url, "/api/session/owned/generate")
|
||||
assert.equal(requestCount(), 1)
|
||||
// Only the reviewed session-scoped route is opened, never generic generation.
|
||||
const generic = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/experimental/generate", payload: { prompt: "test" } })
|
||||
assert.equal(generic.statusCode, 403)
|
||||
assert.equal(requestCount(), 1)
|
||||
})
|
||||
|
||||
it("rejects side generation for a foreign session and during worktree deletion", async () => {
|
||||
const { app, requestCount, worktreeDeletionFence } = await harness("/repo/worktree", {}, { foreign: "/other" })
|
||||
const foreign = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/foreign/generate", payload: { prompt: "test" } })
|
||||
assert.equal(foreign.statusCode, 403)
|
||||
assert.equal(requestCount(), 0)
|
||||
let release!: () => void
|
||||
const deletion = worktreeDeletionFence.run("workspace:worktree", ["workspace:worktree"], () => (
|
||||
new Promise<void>(resolve => { release = resolve })
|
||||
))
|
||||
try {
|
||||
const deleting = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session/owned/generate", payload: { prompt: "test" } })
|
||||
assert.equal(deleting.statusCode, 409)
|
||||
assert.equal(requestCount(), 0)
|
||||
} finally { release(); await deletion }
|
||||
})
|
||||
|
||||
it("authorizes transcript reads without resolving the mutation checkout identity", async () => {
|
||||
const { app, manager, sessionGets } = await harness()
|
||||
manager.getWorktreeIdentityForPath = async () => { throw new Error("Unexpected Git mutation identity read") }
|
||||
|
|
|
|||
|
|
@ -1271,7 +1271,7 @@ function isAllowedInstanceApiRoute(method: string, pathname: string): boolean {
|
|||
["DELETE", /^\/api\/session\/[^/]+$/],
|
||||
["DELETE", /^\/api\/session\/[^/]+\/inbox\/[^/]+$/],
|
||||
["PATCH", /^\/api\/session\/[^/]+$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/(?:agent|model|move|prompt|command|shell|compact|interrupt|background|fork)$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/(?:agent|model|move|prompt|command|shell|compact|interrupt|background|fork|generate)$/],
|
||||
["POST", /^\/api\/experimental\/session\/[^/]+\/wait$/],
|
||||
["PATCH", /^\/api\/session\/[^/]+\/inbox\/[^/]+$/],
|
||||
["POST", /^\/api\/session\/[^/]+\/revert\/stage$/],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js"
|
||||
import { Suspense, createEffect, createSignal, createUniqueId, lazy, on, onCleanup, onMount, Show } from "solid-js"
|
||||
import { Loader2, Mic, Paperclip, Volume2, X } from "lucide-solid"
|
||||
import { addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
|
||||
|
|
@ -12,6 +12,7 @@ import { showAlertDialog } from "../stores/alerts"
|
|||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getOpencodeErrorMessage } from "../lib/opencode-api"
|
||||
import { createAttachmentPlaceholderRegex, getAttachmentPlaceholder } from "../lib/attachment-placeholders"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { isDesktopHost, isLocalWindow } from "../lib/runtime-env"
|
||||
import { preferences } from "../stores/preferences"
|
||||
|
|
@ -24,6 +25,8 @@ import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
|||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
|
||||
import { usePromptAside } from "./prompt-input/usePromptAside"
|
||||
import PromptAsideWindow from "./prompt-input/PromptAsideWindow"
|
||||
import {
|
||||
initializePromptInputHeight,
|
||||
persistPromptInputHeight,
|
||||
|
|
@ -86,6 +89,17 @@ function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachmen
|
|||
|
||||
export default function PromptInput(props: PromptInputProps) {
|
||||
const { t } = useI18n()
|
||||
const asideId = createUniqueId()
|
||||
const aside = usePromptAside({
|
||||
instanceId: () => props.instanceId,
|
||||
sessionId: () => props.sessionId,
|
||||
active: () => props.isActive !== false,
|
||||
})
|
||||
// /btw is a local UI command, like OpenCode's TUI command of the same name.
|
||||
const promptCommands = () => [
|
||||
{ name: "btw", description: t("promptInput.btw.commandDescription") },
|
||||
...getCommands(props.instanceId).filter(command => command.name !== "btw"),
|
||||
]
|
||||
initializePromptInputHeight()
|
||||
const [, setIsFocused] = createSignal(false)
|
||||
const [mode, setMode] = createSignal<PromptMode>("normal")
|
||||
|
|
@ -320,7 +334,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
setPrompt,
|
||||
getTextarea: () => textareaRef ?? null,
|
||||
instanceAgents,
|
||||
commands: () => getCommands(props.instanceId),
|
||||
commands: promptCommands,
|
||||
})
|
||||
|
||||
const {
|
||||
|
|
@ -522,6 +536,28 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
|
||||
const isShellMode = mode() === "shell"
|
||||
|
||||
const asideMatch = !isShellMode && /^\/btw(?:\s+([\s\S]*))?$/.exec(text)
|
||||
if (asideMatch) {
|
||||
const question = preparePromptSubmission({ mode: "slash", text, attachments: currentAttachments,
|
||||
commandToken: "/btw", commandArgs: asideMatch[1] ?? "" }).resolvedCommandArgs
|
||||
if (aside.launch(question)) {
|
||||
// Pasted text is consumed by the side question; image/file attachments
|
||||
// are not sent. Retain image tokens so normal attachment cleanup keeps them.
|
||||
const imageTokens = currentAttachments.flatMap(attachment => {
|
||||
if (attachment.source.type !== "file") return []
|
||||
const placeholder = getAttachmentPlaceholder(attachment.display)
|
||||
return placeholder?.kind === "image"
|
||||
? draftText.match(createAttachmentPlaceholderRegex("image", placeholder.counter)) ?? [] : []
|
||||
})
|
||||
if (imageTokens.length) setPrompt(imageTokens.join(" "))
|
||||
else clearPrompt()
|
||||
clearHistoryDraft()
|
||||
setShowPicker(false)
|
||||
restoredQueuedPayload = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Slash command routing (match OpenCode TUI): only run if the command exists.
|
||||
const isSlashCandidate = !isShellMode && text.startsWith("/")
|
||||
const firstSpace = isSlashCandidate ? text.indexOf(" ") : -1
|
||||
|
|
@ -973,7 +1009,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
void handleSend()
|
||||
}}
|
||||
agents={instanceAgents()}
|
||||
commands={getCommands(props.instanceId)}
|
||||
commands={promptCommands()}
|
||||
searchQuery={searchQuery()}
|
||||
textareaRef={textareaRef}
|
||||
workspaceId={props.instanceId}
|
||||
|
|
@ -1119,6 +1155,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<PromptAsideWindow id={asideId} controller={aside} />
|
||||
<DirectoryBrowserDialog
|
||||
open={isFileBrowserOpen()}
|
||||
mode="files"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { createEffect, createSignal, Show } from "solid-js"
|
||||
import { Copy, Loader2, X } from "lucide-solid"
|
||||
import DismissibleWindow from "../dismissible-window"
|
||||
import { Markdown } from "../markdown"
|
||||
import { copyToClipboard } from "../../lib/clipboard"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import type { PromptAsideController } from "./usePromptAside"
|
||||
|
||||
export default function PromptAsideWindow(props: { id: string; controller: PromptAsideController }) {
|
||||
const { t } = useI18n()
|
||||
const aside = props.controller
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [copyFailed, setCopyFailed] = createSignal(false)
|
||||
let input: HTMLTextAreaElement | undefined
|
||||
let closeButton: HTMLButtonElement | undefined
|
||||
createEffect(() => {
|
||||
aside.answer()
|
||||
aside.open()
|
||||
setCopied(false)
|
||||
setCopyFailed(false)
|
||||
})
|
||||
|
||||
return (
|
||||
<DismissibleWindow id={props.id} open={aside.open()} onClose={aside.close}
|
||||
title={t("promptInput.btw.title")} description={t("promptInput.btw.description")}
|
||||
class="session-aside-window" initialFocus={() => aside.pending() ? closeButton : input}>
|
||||
<div class="window-header">
|
||||
<h2 class="window-title">{t("promptInput.btw.title")}</h2>
|
||||
<button ref={closeButton} type="button" class="window-icon-button" aria-label={t("promptInput.btw.close")} onClick={aside.close}><X /></button>
|
||||
</div>
|
||||
<div class="window-body">
|
||||
<p class="session-aside-description">{t("promptInput.btw.description")}</p>
|
||||
<form onSubmit={event => { event.preventDefault(); void aside.ask() }}>
|
||||
<label class="session-aside-label" for={`${props.id}-question`}>{t("promptInput.btw.question")}</label>
|
||||
<textarea ref={input} id={`${props.id}-question`} class="session-aside-question" rows={3}
|
||||
value={aside.question()} disabled={aside.pending()}
|
||||
onInput={event => aside.setQuestion(event.currentTarget.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === "Enter" && (event.ctrlKey || event.metaKey) && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
void aside.ask()
|
||||
}
|
||||
}} />
|
||||
<div class="session-aside-actions">
|
||||
<Show when={aside.pending()} fallback={
|
||||
<button type="submit" class="window-action" disabled={!aside.question().trim()}>{t("promptInput.btw.ask")}</button>
|
||||
}>
|
||||
<span role="status" class="session-aside-loading"><Loader2 class="animate-spin" size={14} />{t("promptInput.btw.loading")}</span>
|
||||
<button type="button" class="window-action" onClick={aside.cancel}>{t("promptInput.btw.cancel")}</button>
|
||||
</Show>
|
||||
</div>
|
||||
</form>
|
||||
<Show when={aside.error()}><p role="alert">{aside.error()}</p></Show>
|
||||
<Show when={aside.answer()}>
|
||||
<section class="session-aside-answer" aria-label={t("promptInput.btw.answer")} tabindex="0">
|
||||
<Markdown part={{ type: "text", text: aside.answer() }} escapeRawHtml />
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={aside.answer()}>
|
||||
<div class="window-footer">
|
||||
<Show when={copyFailed()}><span role="alert">{t("promptInput.btw.copyFailed")}</span></Show>
|
||||
<button type="button" class="window-action" onClick={async () => {
|
||||
const answer = aside.answer()
|
||||
const success = await copyToClipboard(answer)
|
||||
if (aside.answer() !== answer) return
|
||||
setCopied(success)
|
||||
setCopyFailed(!success)
|
||||
}}><Copy size={14} />{t(copied() ? "promptInput.btw.copied" : "promptInput.btw.copy")}</button>
|
||||
</div>
|
||||
</Show>
|
||||
</DismissibleWindow>
|
||||
)
|
||||
}
|
||||
96
packages/ui/src/components/prompt-input/usePromptAside.ts
Normal file
96
packages/ui/src/components/prompt-input/usePromptAside.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { useI18n } from "../../lib/i18n"
|
||||
import { getOpencodeErrorMessage } from "../../lib/opencode-api"
|
||||
import { getRootClient } from "../../stores/opencode-client"
|
||||
|
||||
// Match OpenCode's native /btw instructions. session.generate performs a single
|
||||
// generation with session context; it does not run a tool loop or append messages.
|
||||
const instructions = [
|
||||
"The user is asking a quick side question about the conversation so far.",
|
||||
"Answer directly and concisely in markdown from what you already know.",
|
||||
"Do not call any tools and do not take any actions.",
|
||||
].join(" ")
|
||||
|
||||
export function usePromptAside(options: {
|
||||
instanceId: Accessor<string>
|
||||
sessionId: Accessor<string>
|
||||
active: Accessor<boolean>
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [question, setQuestion] = createSignal("")
|
||||
const [answer, setAnswer] = createSignal("")
|
||||
const [error, setError] = createSignal("")
|
||||
const [pending, setPending] = createSignal(false)
|
||||
let request: AbortController | undefined
|
||||
|
||||
function cancel() {
|
||||
const previous = request
|
||||
request = undefined
|
||||
previous?.abort()
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
function close() {
|
||||
cancel()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
createEffect(on(
|
||||
[options.instanceId, options.sessionId, options.active],
|
||||
() => {
|
||||
close()
|
||||
setQuestion("")
|
||||
setAnswer("")
|
||||
setError("")
|
||||
},
|
||||
))
|
||||
onCleanup(cancel)
|
||||
|
||||
async function ask() {
|
||||
const text = question().trim()
|
||||
if (!text || pending() || !options.active()) return
|
||||
const controller = new AbortController()
|
||||
const instanceId = options.instanceId()
|
||||
const sessionId = options.sessionId()
|
||||
request = controller
|
||||
const current = () => request === controller && !controller.signal.aborted
|
||||
&& options.active() && options.instanceId() === instanceId && options.sessionId() === sessionId
|
||||
setPending(true)
|
||||
setAnswer("")
|
||||
setError("")
|
||||
try {
|
||||
const result = await getRootClient(instanceId).session.generate({
|
||||
sessionID: sessionId,
|
||||
prompt: `${instructions}\n\n${text}`,
|
||||
}, { signal: controller.signal })
|
||||
if (!current()) return
|
||||
const textAnswer = result.text.trim()
|
||||
if (!textAnswer) setError(t("promptInput.btw.empty"))
|
||||
else setAnswer(textAnswer)
|
||||
} catch (cause) {
|
||||
if (current()) setError(getOpencodeErrorMessage(cause, t("promptInput.btw.failed")))
|
||||
} finally {
|
||||
if (current()) {
|
||||
request = undefined
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return false when a second question cannot be accepted yet, so the composer
|
||||
// keeps its draft. Bare /btw reopens the window without another model request.
|
||||
function launch(text: string): boolean {
|
||||
if (!options.active()) return false
|
||||
setOpen(true)
|
||||
if (!text.trim()) return true
|
||||
if (pending()) return false
|
||||
setQuestion(text.trim())
|
||||
void ask()
|
||||
return true
|
||||
}
|
||||
|
||||
return { open, question, setQuestion, answer, error, pending, launch, ask, cancel, close }
|
||||
}
|
||||
|
||||
export type PromptAsideController = ReturnType<typeof usePromptAside>
|
||||
|
|
@ -1,4 +1,18 @@
|
|||
export const messagingMessages = {
|
||||
"promptInput.btw.title": "/btw — Zwischenfrage",
|
||||
"promptInput.btw.commandDescription": "Eine Zwischenfrage stellen, ohne sie zum Gespräch hinzuzufügen",
|
||||
"promptInput.btw.description": "Verwendet den Sitzungskontext in einer separaten Modellanfrage. Frage und Antwort werden nicht zum Gespräch hinzugefügt. Anhänge bleiben im Eingabefeld.",
|
||||
"promptInput.btw.question": "Ihre Frage",
|
||||
"promptInput.btw.answer": "Antwort",
|
||||
"promptInput.btw.ask": "Fragen",
|
||||
"promptInput.btw.loading": "Denkt nach…",
|
||||
"promptInput.btw.cancel": "Frage abbrechen",
|
||||
"promptInput.btw.close": "Zwischenfrage schließen",
|
||||
"promptInput.btw.empty": "Das Modell hat keinen Text zurückgegeben. Formulieren Sie Ihre Frage neu.",
|
||||
"promptInput.btw.failed": "Die Zwischenfrage konnte nicht beantwortet werden.",
|
||||
"promptInput.btw.copy": "Antwort kopieren",
|
||||
"promptInput.btw.copied": "Kopiert",
|
||||
"promptInput.btw.copyFailed": "Die Antwort konnte nicht kopiert werden.",
|
||||
"history.navigation.outlineLoading": "Vollständige Zeitleiste wird geladen… {loaded} / {total}",
|
||||
"history.navigation.loading": "Abschnitt wird geladen…",
|
||||
"history.navigation.outlineUnavailable": "Vollständige Zeitleiste nicht verfügbar",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
export const messagingMessages = {
|
||||
"promptInput.btw.title": "/btw — Side question",
|
||||
"promptInput.btw.commandDescription": "Ask a side question without adding to the conversation",
|
||||
"promptInput.btw.description": "Uses this session's context in a separate model request. Questions and answers are not added to the conversation. Attachments stay in the composer.",
|
||||
"promptInput.btw.question": "Your question",
|
||||
"promptInput.btw.answer": "Answer",
|
||||
"promptInput.btw.ask": "Ask",
|
||||
"promptInput.btw.loading": "Thinking…",
|
||||
"promptInput.btw.cancel": "Cancel question",
|
||||
"promptInput.btw.close": "Close side question",
|
||||
"promptInput.btw.empty": "The model returned no text. Try rephrasing your question.",
|
||||
"promptInput.btw.failed": "Unable to answer the side question.",
|
||||
"promptInput.btw.copy": "Copy answer",
|
||||
"promptInput.btw.copied": "Copied",
|
||||
"promptInput.btw.copyFailed": "Unable to copy the answer.",
|
||||
"history.navigation.outlineLoading": "Loading full timeline… {loaded} / {total}",
|
||||
"history.navigation.loading": "Loading passage…",
|
||||
"history.navigation.outlineUnavailable": "Full timeline unavailable",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
export const messagingMessages = {
|
||||
"promptInput.btw.title": "/btw — Pregunta aparte",
|
||||
"promptInput.btw.commandDescription": "Hacer una pregunta aparte sin añadirla a la conversación",
|
||||
"promptInput.btw.description": "Usa el contexto de esta sesión en una solicitud independiente al modelo. La pregunta y la respuesta no se añaden a la conversación. Los adjuntos permanecen en el editor.",
|
||||
"promptInput.btw.question": "Tu pregunta",
|
||||
"promptInput.btw.answer": "Respuesta",
|
||||
"promptInput.btw.ask": "Preguntar",
|
||||
"promptInput.btw.loading": "Pensando…",
|
||||
"promptInput.btw.cancel": "Cancelar pregunta",
|
||||
"promptInput.btw.close": "Cerrar pregunta aparte",
|
||||
"promptInput.btw.empty": "El modelo no devolvió texto. Intenta reformular la pregunta.",
|
||||
"promptInput.btw.failed": "No se pudo responder a la pregunta aparte.",
|
||||
"promptInput.btw.copy": "Copiar respuesta",
|
||||
"promptInput.btw.copied": "Copiado",
|
||||
"promptInput.btw.copyFailed": "No se pudo copiar la respuesta.",
|
||||
"history.navigation.outlineLoading": "Cargando la cronología completa… {loaded} / {total}",
|
||||
"history.navigation.loading": "Cargando el pasaje…",
|
||||
"history.navigation.outlineUnavailable": "Cronología completa no disponible",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
export const messagingMessages = {
|
||||
"promptInput.btw.title": "/btw — Question à part",
|
||||
"promptInput.btw.commandDescription": "Poser une question à part sans l'ajouter à la conversation",
|
||||
"promptInput.btw.description": "Utilise le contexte de cette session dans une requête distincte au modèle. La question et la réponse ne sont pas ajoutées à la conversation. Les pièces jointes restent dans le composeur.",
|
||||
"promptInput.btw.question": "Votre question",
|
||||
"promptInput.btw.answer": "Réponse",
|
||||
"promptInput.btw.ask": "Demander",
|
||||
"promptInput.btw.loading": "Réflexion…",
|
||||
"promptInput.btw.cancel": "Annuler la question",
|
||||
"promptInput.btw.close": "Fermer la question à part",
|
||||
"promptInput.btw.empty": "Le modèle n'a renvoyé aucun texte. Essayez de reformuler votre question.",
|
||||
"promptInput.btw.failed": "Impossible de répondre à la question à part.",
|
||||
"promptInput.btw.copy": "Copier la réponse",
|
||||
"promptInput.btw.copied": "Copié",
|
||||
"promptInput.btw.copyFailed": "Impossible de copier la réponse.",
|
||||
"history.navigation.outlineLoading": "Chargement de la timeline complète… {loaded} / {total}",
|
||||
"history.navigation.loading": "Chargement du passage…",
|
||||
"history.navigation.outlineUnavailable": "Timeline complète indisponible",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
export const messagingMessages = {
|
||||
"promptInput.btw.title": "/btw — שאלה צדדית",
|
||||
"promptInput.btw.commandDescription": "שאל שאלה צדדית בלי להוסיף אותה לשיחה",
|
||||
"promptInput.btw.description": "משתמש בהקשר ההפעלה בבקשה נפרדת למודל. השאלה והתשובה אינן מתווספות לשיחה. הקבצים המצורפים נשארים בעורך ההודעה.",
|
||||
"promptInput.btw.question": "השאלה שלך",
|
||||
"promptInput.btw.answer": "תשובה",
|
||||
"promptInput.btw.ask": "שאל",
|
||||
"promptInput.btw.loading": "חושב…",
|
||||
"promptInput.btw.cancel": "בטל שאלה",
|
||||
"promptInput.btw.close": "סגור שאלה צדדית",
|
||||
"promptInput.btw.empty": "המודל לא החזיר טקסט. נסה לנסח מחדש את השאלה.",
|
||||
"promptInput.btw.failed": "לא ניתן לענות על השאלה הצדדית.",
|
||||
"promptInput.btw.copy": "העתק תשובה",
|
||||
"promptInput.btw.copied": "הועתק",
|
||||
"promptInput.btw.copyFailed": "לא ניתן להעתיק את התשובה.",
|
||||
"history.navigation.outlineLoading": "טוען את ציר הזמן המלא… {loaded} / {total}",
|
||||
"history.navigation.loading": "טוען קטע…",
|
||||
"history.navigation.outlineUnavailable": "ציר הזמן המלא אינו זמין",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
export const messagingMessages = {
|
||||
"promptInput.btw.title": "/btw — 補足の質問",
|
||||
"promptInput.btw.commandDescription": "会話に追加せずに補足の質問をする",
|
||||
"promptInput.btw.description": "このセッションのコンテキストを使い、モデルに個別に問い合わせます。質問と回答は会話に追加されません。添付ファイルは入力欄に残ります。",
|
||||
"promptInput.btw.question": "質問",
|
||||
"promptInput.btw.answer": "回答",
|
||||
"promptInput.btw.ask": "質問する",
|
||||
"promptInput.btw.loading": "考え中…",
|
||||
"promptInput.btw.cancel": "質問をキャンセル",
|
||||
"promptInput.btw.close": "補足の質問を閉じる",
|
||||
"promptInput.btw.empty": "モデルからテキストが返されませんでした。質問を言い換えてみてください。",
|
||||
"promptInput.btw.failed": "補足の質問に回答できませんでした。",
|
||||
"promptInput.btw.copy": "回答をコピー",
|
||||
"promptInput.btw.copied": "コピーしました",
|
||||
"promptInput.btw.copyFailed": "回答をコピーできませんでした。",
|
||||
"history.navigation.outlineLoading": "タイムライン全体を読み込み中… {loaded} / {total}",
|
||||
"history.navigation.loading": "該当箇所を読み込み中…",
|
||||
"history.navigation.outlineUnavailable": "全体のタイムラインを取得できません",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
export const messagingMessages = {
|
||||
"promptInput.btw.title": "/btw — छुट्टै प्रश्न",
|
||||
"promptInput.btw.commandDescription": "कुराकानीमा नथपी छुट्टै प्रश्न सोध्नुहोस्",
|
||||
"promptInput.btw.description": "यस सत्रको सन्दर्भ प्रयोग गरी मोडेललाई छुट्टै अनुरोध पठाउँछ। प्रश्न र उत्तर कुराकानीमा थपिँदैनन्। संलग्न फाइलहरू लेखन क्षेत्रमा रहन्छन्।",
|
||||
"promptInput.btw.question": "तपाईंको प्रश्न",
|
||||
"promptInput.btw.answer": "उत्तर",
|
||||
"promptInput.btw.ask": "सोध्नुहोस्",
|
||||
"promptInput.btw.loading": "सोच्दै…",
|
||||
"promptInput.btw.cancel": "प्रश्न रद्द गर्नुहोस्",
|
||||
"promptInput.btw.close": "छुट्टै प्रश्न बन्द गर्नुहोस्",
|
||||
"promptInput.btw.empty": "मोडेलले कुनै पाठ फर्काएन। प्रश्नलाई अर्को तरिकाले सोध्नुहोस्।",
|
||||
"promptInput.btw.failed": "छुट्टै प्रश्नको उत्तर दिन सकिएन।",
|
||||
"promptInput.btw.copy": "उत्तर प्रतिलिपि गर्नुहोस्",
|
||||
"promptInput.btw.copied": "प्रतिलिपि गरियो",
|
||||
"promptInput.btw.copyFailed": "उत्तर प्रतिलिपि गर्न सकिएन।",
|
||||
"history.navigation.outlineLoading": "पूरा समयरेखा लोड हुँदैछ… {loaded} / {total}",
|
||||
"history.navigation.loading": "अंश लोड हुँदैछ…",
|
||||
"history.navigation.outlineUnavailable": "पूरा टाइमलाइन उपलब्ध छैन",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
export const messagingMessages = {
|
||||
"promptInput.btw.title": "/btw — Отдельный вопрос",
|
||||
"promptInput.btw.commandDescription": "Задать отдельный вопрос, не добавляя его в разговор",
|
||||
"promptInput.btw.description": "Использует контекст сессии в отдельном запросе к модели. Вопрос и ответ не добавляются в разговор. Вложения остаются в поле ввода.",
|
||||
"promptInput.btw.question": "Ваш вопрос",
|
||||
"promptInput.btw.answer": "Ответ",
|
||||
"promptInput.btw.ask": "Спросить",
|
||||
"promptInput.btw.loading": "Думает…",
|
||||
"promptInput.btw.cancel": "Отменить вопрос",
|
||||
"promptInput.btw.close": "Закрыть отдельный вопрос",
|
||||
"promptInput.btw.empty": "Модель не вернула текст. Попробуйте переформулировать вопрос.",
|
||||
"promptInput.btw.failed": "Не удалось ответить на отдельный вопрос.",
|
||||
"promptInput.btw.copy": "Копировать ответ",
|
||||
"promptInput.btw.copied": "Скопировано",
|
||||
"promptInput.btw.copyFailed": "Не удалось скопировать ответ.",
|
||||
"history.navigation.outlineLoading": "Загрузка полной шкалы времени… {loaded} / {total}",
|
||||
"history.navigation.loading": "Загрузка фрагмента…",
|
||||
"history.navigation.outlineUnavailable": "Полная шкала времени недоступна",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
export const messagingMessages = {
|
||||
"promptInput.btw.title": "/btw — Yan soru",
|
||||
"promptInput.btw.commandDescription": "Konuşmaya eklemeden yan bir soru sorun",
|
||||
"promptInput.btw.description": "Bu oturumun bağlamını ayrı bir model isteğinde kullanır. Soru ve yanıt konuşmaya eklenmez. Ekler yazma alanında kalır.",
|
||||
"promptInput.btw.question": "Sorunuz",
|
||||
"promptInput.btw.answer": "Yanıt",
|
||||
"promptInput.btw.ask": "Sor",
|
||||
"promptInput.btw.loading": "Düşünüyor…",
|
||||
"promptInput.btw.cancel": "Soruyu iptal et",
|
||||
"promptInput.btw.close": "Yan soruyu kapat",
|
||||
"promptInput.btw.empty": "Model metin döndürmedi. Sorunuzu yeniden ifade etmeyi deneyin.",
|
||||
"promptInput.btw.failed": "Yan soru yanıtlanamadı.",
|
||||
"promptInput.btw.copy": "Yanıtı kopyala",
|
||||
"promptInput.btw.copied": "Kopyalandı",
|
||||
"promptInput.btw.copyFailed": "Yanıt kopyalanamadı.",
|
||||
"history.navigation.loading": "Bölüm yükleniyor…",
|
||||
"history.navigation.outlineUnavailable": "Tam zaman çizelgesi kullanılamıyor",
|
||||
"history.scope": "Arama kapsamı",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
export const messagingMessages = {
|
||||
"promptInput.btw.title": "/btw — 旁支问题",
|
||||
"promptInput.btw.commandDescription": "提出旁支问题,不添加到对话中",
|
||||
"promptInput.btw.description": "使用此会话的上下文单独请求模型。问题和回答不会添加到对话中。附件保留在输入框中。",
|
||||
"promptInput.btw.question": "你的问题",
|
||||
"promptInput.btw.answer": "回答",
|
||||
"promptInput.btw.ask": "提问",
|
||||
"promptInput.btw.loading": "思考中…",
|
||||
"promptInput.btw.cancel": "取消提问",
|
||||
"promptInput.btw.close": "关闭旁支问题",
|
||||
"promptInput.btw.empty": "模型未返回文本。请尝试换一种方式提问。",
|
||||
"promptInput.btw.failed": "无法回答旁支问题。",
|
||||
"promptInput.btw.copy": "复制回答",
|
||||
"promptInput.btw.copied": "已复制",
|
||||
"promptInput.btw.copyFailed": "无法复制回答。",
|
||||
"history.navigation.outlineLoading": "正在加载完整时间线… {loaded} / {total}",
|
||||
"history.navigation.loading": "正在加载所选段落…",
|
||||
"history.navigation.outlineUnavailable": "完整时间线暂不可用",
|
||||
|
|
|
|||
61
packages/ui/src/styles/components/session-aside.css
Normal file
61
packages/ui/src/styles/components/session-aside.css
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
.session-aside-window {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
inset-block-start: 15vh;
|
||||
inset-inline-end: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(36rem, calc(100vw - 2 * var(--space-md)));
|
||||
max-height: 75dvh;
|
||||
}
|
||||
|
||||
.session-aside-description {
|
||||
margin: 0 0 var(--space-md);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.session-aside-label {
|
||||
display: block;
|
||||
margin-block-end: var(--space-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.session-aside-question {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 4rem;
|
||||
max-height: 25dvh;
|
||||
resize: vertical;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-aside-question:focus-visible {
|
||||
outline: 1px solid var(--focus-ring-color);
|
||||
}
|
||||
|
||||
.session-aside-actions,
|
||||
.session-aside-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.session-aside-actions {
|
||||
justify-content: flex-end;
|
||||
margin-block: var(--space-sm);
|
||||
}
|
||||
|
||||
.session-aside-loading {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.session-aside-answer {
|
||||
border-top: 1px solid var(--border-divider);
|
||||
padding-block-start: var(--space-md);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
@import "./components/buttons.css";
|
||||
@import "./components/switches.css";
|
||||
@import "./components/window.css";
|
||||
@import "./components/session-aside.css";
|
||||
@import "./components/history-search.css";
|
||||
@import "./components/native-titlebar.css";
|
||||
@import "./components/badges.css";
|
||||
|
|
|
|||
45
packages/ui/tests/browser/fixtures/session-aside.tsx
Normal file
45
packages/ui/tests/browser/fixtures/session-aside.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { createSignal, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import PromptInput from "../../../src/components/prompt-input"
|
||||
import { ConfigProvider } from "../../../src/stores/preferences"
|
||||
import { I18nProvider } from "../../../src/lib/i18n"
|
||||
import { ThemeProvider } from "../../../src/lib/theme"
|
||||
import { getRootClient } from "../../../src/stores/opencode-client"
|
||||
import { addInstance, setActiveInstanceId } from "../../../src/stores/instances"
|
||||
import { fetchCommands } from "../../../src/stores/commands"
|
||||
import { addAttachment, getAttachments } from "../../../src/stores/attachments"
|
||||
import { createFileAttachment, createTextAttachment } from "../../../src/types/attachment"
|
||||
import "../../../src/index.css"
|
||||
|
||||
const instanceId = "aside-instance"
|
||||
const [session, setSession] = createSignal("source")
|
||||
const [active, setActive] = createSignal(true)
|
||||
const [mounted, setMounted] = createSignal(true)
|
||||
const sends: unknown[] = [], commands: unknown[] = []
|
||||
let interrupts = 0
|
||||
const client = getRootClient(instanceId)
|
||||
addInstance({ id: instanceId, folder: "/fixture", port: 0, pid: 0, proxyPath: "", status: "ready", client })
|
||||
setActiveInstanceId(instanceId)
|
||||
await fetchCommands(instanceId, client)
|
||||
render(() => <ConfigProvider><I18nProvider><ThemeProvider>
|
||||
<button id="outside">Outside control</button>
|
||||
<Show when={mounted()}><PromptInput instanceId={instanceId} instanceFolder="/fixture" sessionId={session()} isActive={active()}
|
||||
isSessionBusy={true} onAbortSession={async () => { interrupts++ }}
|
||||
onSend={async (text, attachments, delivery) => { sends.push({ text, attachments, delivery }) }}
|
||||
onCommand={async (name, text) => { commands.push({ name, text }) }} /></Show>
|
||||
</ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
|
||||
;(window as any).fixture = {
|
||||
switch: setSession, active: setActive, unmount: () => setMounted(false),
|
||||
attach: () => {
|
||||
addAttachment(instanceId, session(), createFileAttachment("/fixture/notes.txt", "notes.txt"))
|
||||
},
|
||||
paste: () => {
|
||||
addAttachment(instanceId, session(), createTextAttachment("PASTED_QUESTION", "[Pasted #1]", "paste.txt"))
|
||||
},
|
||||
image: () => {
|
||||
const image = createFileAttachment("image.png", "image.png", "image/png")
|
||||
image.display = "[Image #1]"
|
||||
addAttachment(instanceId, session(), image)
|
||||
},
|
||||
snapshot: () => ({ sends, commands, interrupts, session: session(), attachments: getAttachments(instanceId, session()).length }),
|
||||
}
|
||||
199
packages/ui/tests/browser/session-aside.test.ts
Normal file
199
packages/ui/tests/browser/session-aside.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { after, before, test } from "node:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { chromium, type Browser, type Page, type Route } from "playwright"
|
||||
import { createServer, type ViteDevServer } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
let server: ViteDevServer, browser: Browser, url: string
|
||||
before(async () => {
|
||||
server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error",
|
||||
plugins: [solid(), { name: "aside-fixture", configureServer(s) {
|
||||
s.middlewares.use("/aside-fixture", async (_req, res) => {
|
||||
res.setHeader("Content-Type", "text/html")
|
||||
res.end(await s.transformIndexHtml("/aside-fixture", '<html><body><div id="root" style="margin:24px;max-width:1100px"></div><script type="module" src="/tests/browser/fixtures/session-aside.tsx"></script></body></html>'))
|
||||
})
|
||||
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] },
|
||||
server: { host: "127.0.0.1", port: 0, hmr: false, watch: null },
|
||||
})
|
||||
await server.listen()
|
||||
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/aside-fixture`
|
||||
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
|
||||
})
|
||||
after(async () => { await browser?.close(); await server?.close() })
|
||||
|
||||
async function setup(respond?: (route: Route) => Promise<void>, width = 1200) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 850 }, locale: "en-US" })
|
||||
const requests: { url: string; body: any }[] = [], errors: string[] = []
|
||||
page.on("pageerror", error => errors.push(error.message))
|
||||
await page.addInitScript(`Object.defineProperty(navigator, 'clipboard', {value: {writeText: async (text) => {window.copiedText = text}}})`)
|
||||
await page.route("**/api/**", async route => {
|
||||
const request = route.request()
|
||||
if (request.url().endsWith("/generate")) {
|
||||
requests.push({ url: request.url(), body: request.postDataJSON() })
|
||||
return respond ? respond(route) : route.fulfill({ json: { data: { text: "## Side answer\n\n**Only in this window.**" } } })
|
||||
}
|
||||
if (request.url().endsWith("/api/command")) return route.fulfill({ json: { data: [{ name: "btw", description: "Server collision" }, { name: "review", description: "Review code" }] } })
|
||||
return route.fulfill({ json: {} })
|
||||
})
|
||||
await page.goto(url)
|
||||
await page.waitForFunction(() => Boolean((window as any).fixture))
|
||||
return { page, requests, errors }
|
||||
}
|
||||
const composer = (page: Page) => page.locator(".prompt-input-container textarea").first()
|
||||
async function send(page: Page, text: string) {
|
||||
await composer(page).fill(text)
|
||||
// Clicking Send also covers routing while the slash picker is still open.
|
||||
await page.locator(".send-button").click()
|
||||
}
|
||||
|
||||
test("/btw is discoverable, uses native generation, renders Markdown and leaves the conversation untouched", async () => {
|
||||
const { page, requests, errors } = await setup()
|
||||
try {
|
||||
await composer(page).fill("/btw")
|
||||
await page.getByText("Ask a side question without adding to the conversation", { exact: true }).waitFor()
|
||||
assert.equal(await page.getByText("Server collision", { exact: true }).count(), 0)
|
||||
await send(page, "/btw\nWhy this approach?")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("heading", { name: "Side answer", exact: true }).waitFor({ timeout: 10000 }).catch(async cause => {
|
||||
throw new Error(JSON.stringify({ requests, errors, body: await page.locator("body").innerText(), cause: String(cause) }))
|
||||
})
|
||||
assert.equal(requests.length, 1)
|
||||
assert.ok(requests[0].url.endsWith("/api/session/source/generate"))
|
||||
assert.ok(requests[0].body.prompt.endsWith("\n\nWhy this approach?"))
|
||||
assert.match(requests[0].body.prompt, /Do not call any tools/)
|
||||
assert.deepEqual(Object.keys(requests[0].body), ["prompt"])
|
||||
await dialog.getByRole("button", { name: "Copy answer", exact: true }).click()
|
||||
assert.equal(await page.evaluate(() => (window as any).copiedText), "## Side answer\n\n**Only in this window.**")
|
||||
await page.locator("#outside").click()
|
||||
assert.equal(await dialog.isVisible(), true)
|
||||
const snapshot = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.deepEqual(snapshot.sends, [])
|
||||
assert.deepEqual(snapshot.commands, [])
|
||||
assert.equal(snapshot.interrupts, 0)
|
||||
if (process.env.CODENOMAD_ASIDE_CAPTURE) await page.screenshot({ path: process.env.CODENOMAD_ASIDE_CAPTURE })
|
||||
assert.deepEqual(errors, [])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("bare /btw opens a question form; attachments and normal command routing survive", async () => {
|
||||
const { page, requests } = await setup()
|
||||
try {
|
||||
await page.evaluate(() => (window as any).fixture.attach())
|
||||
await send(page, "/btw")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.waitFor()
|
||||
assert.equal(requests.length, 0)
|
||||
assert.equal(await dialog.getByRole("button", { name: "Ask", exact: true }).isDisabled(), true)
|
||||
await dialog.getByLabel("Your question", { exact: true }).fill("What happened?")
|
||||
await dialog.getByRole("button", { name: "Ask", exact: true }).click()
|
||||
await dialog.getByRole("heading", { name: "Side answer" }).waitFor()
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).attachments, 1)
|
||||
await dialog.getByRole("button", { name: "Close side question" }).click()
|
||||
await composer(page).fill("/btw [Pasted #1] [Image #1]")
|
||||
await page.evaluate(() => { (window as any).fixture.paste(); (window as any).fixture.image() })
|
||||
await page.locator(".send-button").click()
|
||||
await page.getByRole("heading", { name: "Side answer" }).waitFor()
|
||||
assert.ok(requests[1].body.prompt.includes("PASTED_QUESTION"))
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).attachments, 2)
|
||||
assert.equal(await composer(page).inputValue(), "[Image #1]")
|
||||
await page.getByRole("button", { name: "Close side question" }).click()
|
||||
await send(page, "/review module")
|
||||
await send(page, "/btwExtra is normal text")
|
||||
const snapshot = await page.evaluate(() => (window as any).fixture.snapshot())
|
||||
assert.deepEqual(snapshot.commands, [{ name: "review", text: "module" }])
|
||||
assert.equal(snapshot.sends[0].text, "/btwExtra is normal text")
|
||||
assert.equal(requests.length, 2)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("pending side questions do not block normal prompts; a second side question keeps its draft", async () => {
|
||||
let release!: () => Promise<void>
|
||||
const { page, requests } = await setup(route => new Promise<void>(resolve => {
|
||||
release = async () => { await route.fulfill({ json: { data: { text: "Delayed answer" } } }); resolve() }
|
||||
}))
|
||||
try {
|
||||
await send(page, "/btw First question")
|
||||
await page.getByRole("status").filter({ hasText: "Thinking" }).waitFor()
|
||||
await send(page, "/btw Second question")
|
||||
assert.equal(await composer(page).inputValue(), "/btw Second question")
|
||||
assert.equal(requests.length, 1)
|
||||
await send(page, "Continue the main task")
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).sends.length, 1)
|
||||
await release()
|
||||
await page.getByText("Delayed answer", { exact: true }).waitFor()
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
for (const exit of ["close", "cancel", "switch", "inactive", "unmount"] as const) {
|
||||
test(`${exit} fences pending results without interrupting the main session`, async () => {
|
||||
const { page } = await setup()
|
||||
try {
|
||||
// A deliberately non-cooperative transport resolves even after abort.
|
||||
// This exercises the result fence, not just the browser's fetch cancellation.
|
||||
await page.evaluate(async () => {
|
||||
const { getRootClient } = await import("/src/stores/opencode-client.ts")
|
||||
getRootClient("aside-instance").session.generate = (_input: any, options: any) => new Promise(resolve => {
|
||||
;(window as any).asideSignal = options.signal
|
||||
;(window as any).resolveAside = resolve
|
||||
})
|
||||
})
|
||||
await send(page, "/btw delayed")
|
||||
await page.getByRole("status").filter({ hasText: "Thinking" }).waitFor()
|
||||
if (exit === "close") await page.keyboard.press("Escape")
|
||||
if (exit === "cancel") await page.getByRole("button", { name: "Cancel question" }).click()
|
||||
if (exit === "switch") await page.evaluate(() => (window as any).fixture.switch("other"))
|
||||
if (exit === "inactive") await page.evaluate(() => (window as any).fixture.active(false))
|
||||
if (exit === "unmount") await page.evaluate(() => (window as any).fixture.unmount())
|
||||
await page.waitForFunction(() => (window as any).asideSignal.aborted)
|
||||
await page.evaluate(() => (window as any).resolveAside({ text: "STALE_RESPONSE" }))
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame))
|
||||
assert.equal(await page.getByText("STALE_RESPONSE", { exact: true }).count(), 0)
|
||||
assert.equal((await page.evaluate(() => (window as any).fixture.snapshot())).interrupts, 0)
|
||||
assert.equal(await page.getByRole("dialog").count(), exit === "cancel" ? 1 : 0)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
}
|
||||
|
||||
test("a cancelled request cannot overwrite or settle a newer side question", async () => {
|
||||
const { page } = await setup()
|
||||
try {
|
||||
await page.evaluate(async () => {
|
||||
const { getRootClient } = await import("/src/stores/opencode-client.ts")
|
||||
;(window as any).asideResolvers = []
|
||||
getRootClient("aside-instance").session.generate = () => new Promise(resolve => (window as any).asideResolvers.push(resolve))
|
||||
})
|
||||
await send(page, "/btw Old question")
|
||||
await page.getByRole("button", { name: "Cancel question" }).click()
|
||||
await page.getByLabel("Your question").fill("New question")
|
||||
await page.getByLabel("Your question").press("Control+Enter")
|
||||
await page.waitForFunction(() => (window as any).asideResolvers.length === 2)
|
||||
await page.evaluate(() => (window as any).asideResolvers[0]({ text: "STALE_RESPONSE" }))
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame))
|
||||
assert.equal(await page.getByRole("button", { name: "Cancel question" }).isVisible(), true)
|
||||
assert.equal(await page.getByText("STALE_RESPONSE", { exact: true }).count(), 0)
|
||||
await page.evaluate(() => (window as any).asideResolvers[1]({ text: "NEW_RESPONSE" }))
|
||||
await page.getByText("NEW_RESPONSE", { exact: true }).waitFor()
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
test("errors and empty answers remain retryable; the window fits a narrow screen", async () => {
|
||||
let calls = 0
|
||||
const { page } = await setup(route => {
|
||||
calls++
|
||||
return calls === 1 ? route.fulfill({ status: 503, json: { message: "Provider unavailable" } })
|
||||
: route.fulfill({ json: { data: { text: calls === 2 ? " " : "Recovered answer" } } })
|
||||
}, 360)
|
||||
try {
|
||||
await send(page, "/btw Try this question")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("alert").waitFor()
|
||||
assert.equal(await dialog.getByLabel("Your question").inputValue(), "Try this question")
|
||||
await dialog.getByRole("button", { name: "Ask", exact: true }).click()
|
||||
await dialog.getByText(/The model returned no text/).waitFor()
|
||||
await dialog.getByRole("button", { name: "Ask", exact: true }).click()
|
||||
await dialog.getByText("Recovered answer", { exact: true }).waitFor()
|
||||
const bounds = await dialog.boundingBox()
|
||||
assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= 360 && bounds.height < 850)
|
||||
} finally { await page.close() }
|
||||
})
|
||||
117
scripts/test-session-aside-native.mjs
Normal file
117
scripts/test-session-aside-native.mjs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { spawn } from "node:child_process"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises"
|
||||
import { createServer } from "node:http"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { setTimeout as delay } from "node:timers/promises"
|
||||
import { OpenCode } from "@opencode/client"
|
||||
import { stopFixtureChild } from "./native-fixture-guards.mjs"
|
||||
|
||||
// Explicit CLI, private home/database, local fake provider. Never discovers or
|
||||
// connects to the shared daemon and never uses real provider credentials.
|
||||
const cli = process.argv[2]
|
||||
assert(cli && path.isAbsolute(cli), "Pass an absolute isolated CLI executable path")
|
||||
const parent = path.join(os.tmpdir(), "opencode")
|
||||
await mkdir(parent, { recursive: true })
|
||||
const root = await mkdtemp(path.join(parent, "aside-native-"))
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_)/i.test(key)))
|
||||
for (const key of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
|
||||
const password = randomUUID()
|
||||
Object.assign(env, { HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root,
|
||||
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "fixture.db"),
|
||||
OPENCODE_CONFIG_PROJECT_DISABLE: "1", OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
||||
OPENCODE_SERVER_PASSWORD: password })
|
||||
await mkdir(env.OPENCODE_CONFIG_DIR)
|
||||
let finishMain, providerError
|
||||
const sideRequests = []
|
||||
const provider = createServer(async (req, res) => {
|
||||
try {
|
||||
let raw = ""
|
||||
for await (const chunk of req) raw += chunk
|
||||
const body = JSON.parse(raw)
|
||||
const last = JSON.stringify(body.messages.at(-1))
|
||||
const side = last.includes("SIDE_QUESTION")
|
||||
if (side) sideRequests.push(body)
|
||||
if (!body.stream) {
|
||||
res.setHeader("Content-Type", "application/json")
|
||||
res.end(JSON.stringify({ id: "fixture", choices: [{ message: { role: "assistant", content: last.includes("SIDE_QUESTION") ? "SIDE_ANSWER" : "Fixture" }, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }))
|
||||
return
|
||||
}
|
||||
res.setHeader("Content-Type", "text/event-stream")
|
||||
const chunk = (delta, finish_reason = null) => res.write("data: " + JSON.stringify({
|
||||
id: "fixture", object: "chat.completion.chunk", model: "fixture", choices: [{ index: 0, delta, finish_reason }],
|
||||
...(finish_reason ? { usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } } : {}),
|
||||
}) + "\n\n")
|
||||
chunk({ role: "assistant" })
|
||||
const finish = () => { chunk({ content: side ? "SIDE_ANSWER" : "MAIN_ANSWER" }); chunk({}, "stop"); res.end("data: [DONE]\n\n") }
|
||||
if (last.includes("MAIN_PENDING")) finishMain = finish
|
||||
else finish()
|
||||
} catch (error) { providerError = error; res.destroy(error) }
|
||||
})
|
||||
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
|
||||
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ model: "fixture/fixture", providers: { fixture: {
|
||||
package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only" }, models: { fixture: {} },
|
||||
} } })
|
||||
let output = "", spawnError
|
||||
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true })
|
||||
const stopped = new Promise(resolve => child.once("close", resolve))
|
||||
child.on("error", error => { spawnError = error })
|
||||
child.stdout.on("data", data => { output += data })
|
||||
child.stderr.on("data", data => { output += data })
|
||||
async function until(predicate) {
|
||||
const deadline = Date.now() + 30_000
|
||||
while (Date.now() < deadline) {
|
||||
if (providerError) throw providerError
|
||||
if (spawnError) throw spawnError
|
||||
if (child.exitCode !== null) throw new Error(output)
|
||||
if (await predicate()) return
|
||||
await delay(25)
|
||||
}
|
||||
throw new Error(`Timed out: ${output.slice(-3000)}`)
|
||||
}
|
||||
try {
|
||||
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output))
|
||||
const client = OpenCode.make({ baseUrl: output.match(/http:\/\/127\.0\.0\.1:\d+/)[0],
|
||||
headers: { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` },
|
||||
fetch: (url, init) => fetch(url, { ...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) }),
|
||||
})
|
||||
const session = await client.session.create({ location: { directory: root } })
|
||||
await client.session.prompt({ sessionID: session.id, text: "CONTEXT_CANARY: explain the repository" })
|
||||
await client.session.wait({ sessionID: session.id })
|
||||
const messages = () => client.message.list({ sessionID: session.id, order: "asc", limit: 100 })
|
||||
const before = await messages()
|
||||
const answer = await client.session.generate({ sessionID: session.id, prompt: "SIDE_QUESTION: summarize the context without tools" })
|
||||
assert.equal(answer.text, "SIDE_ANSWER")
|
||||
assert.deepEqual(await messages(), before, "Side generation must not mutate history")
|
||||
assert.equal(sideRequests.length, 1)
|
||||
assert.match(JSON.stringify(sideRequests[0]), /CONTEXT_CANARY/)
|
||||
assert.match(JSON.stringify(sideRequests[0]), /MAIN_ANSWER/)
|
||||
console.log("PASS idle: native context reused, history unchanged")
|
||||
|
||||
await client.session.prompt({ sessionID: session.id, text: "MAIN_PENDING: keep working" })
|
||||
await until(() => Boolean(finishMain))
|
||||
const activeBefore = await client.session.active()
|
||||
assert.ok(activeBefore[session.id], "Main session should still be active")
|
||||
assert.equal((await client.session.generate({ sessionID: session.id, prompt: "SIDE_QUESTION: what are you doing?" })).text, "SIDE_ANSWER")
|
||||
assert.ok((await client.session.active())[session.id], "Side generation must not interrupt the main session")
|
||||
assert.equal(sideRequests.length, 2)
|
||||
assert.match(JSON.stringify(sideRequests[1]), /MAIN_PENDING/)
|
||||
finishMain()
|
||||
finishMain = undefined
|
||||
await client.session.wait({ sessionID: session.id })
|
||||
assert.doesNotMatch(JSON.stringify(await messages()), /SIDE_QUESTION|SIDE_ANSWER/)
|
||||
const sessions = await client.session.list({ location: { directory: root } })
|
||||
assert.equal(sessions.data.length, 1, "No temporary or child session should be created")
|
||||
assert.equal((await client.session.inbox.list({ sessionID: session.id })).length, 0)
|
||||
console.log("PASS busy: main execution continues, no side messages, inbox entries or child sessions")
|
||||
} finally {
|
||||
finishMain?.()
|
||||
try { await stopFixtureChild(child, stopped) } finally {
|
||||
provider.closeAllConnections()
|
||||
await new Promise(resolve => provider.close(resolve))
|
||||
await writeFile(path.join(root, "daemon.log"), output)
|
||||
console.log(`Isolated fixture: ${root}`)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue