Compare commits

..
Author SHA1 Message Date
usrnk1 1f1361a149 Merge branch 'v2' into background-button 2026-09-04 17:19:17 +02:00
usrnk1 2516d8e976 feat(desktop): simplify move to background action 2026-09-04 14:15:51 +02:00
8 changed files with 8 additions and 171 deletions
@@ -113,8 +113,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
responseID = created
return { type: "frame", frame }
}
// Keepalives and provider notifications carry no response state and may precede response.created.
if (!event.type.startsWith("response.")) return { type: "frame", frame }
// Keepalives carry no response state and may arrive before response.created.
if (event.type === "keepalive") return { type: "frame", frame }
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
@@ -526,7 +526,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("tolerates keepalive and provider notifications before response.created", () =>
it.effect("tolerates keepalive frames before response.created", () =>
Effect.gen(function* () {
const webSocket = WebSocketTransport.makeDirect({
open: () =>
@@ -534,7 +534,6 @@ describe("OpenAI Responses route", () => {
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
ProviderShared.encodeJson({
type: "response.completed",
+1 -1
View File
@@ -676,7 +676,7 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.moveRunning": "Move to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
@@ -58,7 +58,6 @@ export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => v
type="button"
variant="ghost-faint"
size="small"
icon="outline-arrow-to-corner-top-right"
class="max-w-full"
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
onClick={() => props.onMove?.()}
+2 -6
View File
@@ -273,13 +273,9 @@ const layer = Layer.effect(
}),
)
// A tool is hidden from the model only when no resource could get past `deny`. Each rule's resource
// pattern matches itself as a literal, so the patterns for this action are a complete set of probes:
// a later broader rule overrides a probe exactly when it also covers every resource the probe covers.
// The extra "*" probe stands for resources no narrow rule covers, which fall back to the default `ask`.
const whollyDisabled = (action: string, rules: Permission.Ruleset) => {
const probes = rules.filter((rule) => Wildcard.match(action, rule.action)).map((rule) => rule.resource)
return [...probes, "*"].every((resource) => Permission.evaluate(action, resource, rules).effect === "deny")
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny"
}
const formatSchemaIssue = SchemaIssue.makeFormatterDefault()
-49
View File
@@ -706,55 +706,6 @@ describe("Tool", () => {
}),
)
it.effect("hides tools whose narrower trailing rules cannot get past deny", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
yield* transform(service, { bash: make() }, { codemode: false })
const names = (permissions: Permission.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
// trailing narrow deny rules leave every call denied
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
{ action: "bash", resource: "git *", effect: "deny" },
]),
).toEqual([])
// without a catch-all, uncovered resources fall back to ask
expect(yield* names([{ action: "bash", resource: "rm*", effect: "deny" }])).toEqual(["bash", "execute"])
// a narrow ask superseded by the same narrow deny admits nothing
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
{ action: "bash", resource: "rm*", effect: "ask" },
{ action: "bash", resource: "rm*", effect: "deny" },
]),
).toEqual([])
// a trailing narrow ask or allow still admits some calls
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
{ action: "bash", resource: "rm*", effect: "ask" },
]),
).toEqual(["bash"])
// a narrow allow that a later broader deny supersedes admits nothing
expect(
yield* names([
{ action: "bash", resource: "git *", effect: "allow" },
{ action: "bash", resource: "*", effect: "deny" },
]),
).toEqual(["execute"])
// a later narrower deny does not swallow the broader allow before it
expect(
yield* names([
{ action: "bash", resource: "*", effect: "deny" },
{ action: "bash", resource: "git *", effect: "allow" },
{ action: "bash", resource: "git push*", effect: "deny" },
]),
).toEqual(["bash", "execute"])
}),
)
it.effect("keeps permission options isolated between registrations", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+2 -18
View File
@@ -2522,24 +2522,12 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme()
const [seconds, setSeconds] = createSignal(0)
createEffect(() => {
const at = props.retry?.at
if (at === undefined) return
const update = () => setSeconds(Math.max(0, Math.ceil((at - Date.now()) / 1_000)))
if (update() === 0) return
const timer = setInterval(() => {
if (update() === 0) clearInterval(timer)
}, 1_000)
onCleanup(() => clearInterval(timer))
})
return (
<Show when={props.retry}>
{(retry) => (
<box paddingLeft={3}>
<text fg={theme.text.feedback.warning.default}>
{seconds() > 0 ? `Retrying in ${seconds()}s` : "Retry due"} · attempt {retry().attempt} ·{" "}
{retry().error.message}
Retry attempt {retry().attempt} scheduled: {retry().error.message}
</text>
</box>
)}
@@ -2938,7 +2926,6 @@ function InlineTool(props: {
pending: string
failure?: string
spinner?: boolean
running?: boolean
status?: JSX.Element
children: JSX.Element
part: SessionMessageAssistantTool
@@ -2950,9 +2937,7 @@ function InlineTool(props: {
const [errorExpanded, setErrorExpanded] = createSignal(false)
const permission = useToolPermission(() => props.part)
const error = createMemo(() =>
!props.running && props.part.state.status === "error" ? props.part.state.error.message : undefined,
)
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
const denied = createMemo(
() =>
@@ -3497,7 +3482,6 @@ function Subagent(props: ToolProps) {
<InlineTool
icon={continuation() ? "↳" : isRunning() ? "│" : props.part.state.status === "completed" ? "✓" : "│"}
spinner={!continuation() && isRunning()}
running={isRunning()}
complete={description()}
pending="Delegating…"
part={props.part}
-92
View File
@@ -1529,95 +1529,3 @@ test("server plugin failures share one notice and use source names before an ID
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
expect(setup.captureCharFrame()).toContain("Open plugins")
})
test.each([44, 100])(
"retry countdown updates and clears with the retry lifecycle at width %s",
async (width) => {
await using state = await tmpdir()
const session = {
id: "ses_countdown",
projectID: "proj_test",
location: { directory },
title: "Retry countdown",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
const model = { id: "model", providerID: "provider" }
const error = { type: "provider.transport" as const, message: "Provider unavailable" }
await using setup = await createAppFixture({
width,
state: state.path,
args: { sessionID: session.id },
config: { animations: false, tabs: { enabled: false } },
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_countdown",
type: "assistant",
agent: "build",
model,
content: [],
error,
retry: { attempt: 2, at: Date.now() + 2_500, error },
time: { created: 1 },
},
],
cursor: {},
})
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
return json({ data: [] })
return undefined
},
})
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Retrying in 3s"))
expect(setup.captureCharFrame()).toContain("attempt 2")
expect(setup.captureCharFrame()).toContain("Provider unavailable")
expect(setup.captureCharFrame()).not.toContain("Error:")
await setup.waitForFrame((frame) => frame.includes("Retrying in 2s"), { maxPasses: 200 })
await setup.waitForFrame((frame) => frame.includes("Retrying in 1s"), { maxPasses: 200 })
await setup.waitForFrame((frame) => frame.includes("Retry due"), { maxPasses: 200 })
expect(setup.captureCharFrame()).not.toContain("in 0s")
setup.events.emit({
id: "evt_countdown_rescheduled",
created: 2,
type: "session.retry.scheduled",
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 3, at: Date.now() + 10_500, error },
})
await setup.waitForFrame((frame) => frame.includes("Retrying in 11s") && frame.includes("attempt 3"))
setup.events.emit({
id: "evt_countdown_started",
created: 3,
type: "session.step.started",
durable: { aggregateID: session.id, seq: 2, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", agent: "build", model },
})
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
setup.events.emit({
id: "evt_countdown_expired",
created: 4,
type: "session.retry.scheduled",
durable: { aggregateID: session.id, seq: 3, version: 1 },
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 4, at: Date.now() - 1_000, error },
})
await setup.waitForFrame((frame) => frame.includes("Retry due") && frame.includes("attempt 4"))
expect(setup.captureCharFrame()).not.toContain("in -")
setup.events.emit({
id: "evt_countdown_interrupted",
created: 5,
type: "session.execution.interrupted",
durable: { aggregateID: session.id, seq: 4, version: 1 },
data: { sessionID: session.id, reason: "shutdown" },
})
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
},
15_000,
)