From 3f8656d9b7955c7b5157a73b7d607ede8f4e6f7f Mon Sep 17 00:00:00 2001 From: sulthonzh <4921059+sulthonzh@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:32:59 +0700 Subject: [PATCH 1/2] fix(tui): default animations off inside herdr panes Multiplexer panes re-composite pane output at the multiplexer's own cadence, so the TUI's animated repaint stream (tab pulse, marquee, shimmer, tween ticks) turns into continuous full-region recomposites on the multiplexer client, visible as flicker and shaking on remote or slow links (#57462, #57676). Resolve animations in resolve() the same way tabs.enabled is already environment-aware: default off when HERDR_ENV=1, on otherwise; an explicit animations setting always wins. The mini footer queue test pinned its expected statusline glyph via the test harness coercing unresolved animations to false; resolve() now always defines the field, so the test declares animations: false explicitly for its still-glyph assertion. --- packages/tui/src/config/index.tsx | 2 ++ packages/tui/test/config-v2.test.tsx | 9 +++++++++ packages/tui/test/mini/footer.view.test.tsx | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index ec23025336d4..b604f1abbe52 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -267,6 +267,7 @@ export type Resolved = Omit { ).toMatchObject({ mode: "on", enabled: true }) }) +test("resolves animations off by default inside herdr panes", () => { + expect(resolve({}, { terminalSuspend: true, environment: {} }).animations).toBe(true) + expect(resolve({}, { terminalSuspend: true, environment: { HERDR_ENV: "1" } }).animations).toBe(false) + expect(resolve({ animations: true }, { terminalSuspend: true, environment: { HERDR_ENV: "1" } }).animations).toBe( + true, + ) + expect(resolve({ animations: false }, { terminalSuspend: true, environment: {} }).animations).toBe(false) +}) + test("shows resolved tab defaults in settings", () => { expect(settings.find((setting) => setting.path.join(".") === "tabs.mode")).toMatchObject({ default: "auto", diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 3960fda1b138..df50aab002da 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -1930,7 +1930,7 @@ test.each([8, 12])("production footer grows for wrapped instructions in %i rows" test("an oversized queue shortcut prevents backfilling shorter work and identity hints", async () => { const app = await renderFooter({ width: 32, - tuiConfig: createTuiResolvedConfig({ keybinds: { "session.queued_prompts": "ctrl+shift+alt+q" } }), + tuiConfig: createTuiResolvedConfig({ animations: false, keybinds: { "session.queued_prompts": "ctrl+shift+alt+q" } }), queuedPrompts: [{ messageID: "queued", prompt: { text: "later", parts: [] }, delivery: "queue" }], subagents: { tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect" })], From b1a06158a771a9f8f613b47609fecc59d8d37145 Mon Sep 17 00:00:00 2001 From: sulthonzh <4921059+sulthonzh@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:36:17 +0700 Subject: [PATCH 2/2] fix(core): cancel a session's background shell jobs on interrupt Interrupting a session only cancelled the job keyed by the session's own id, which covers a session running as a subagent job. Background shell and image jobs are keyed by their shell/image id and only carry the owning session in job metadata, so jobs.cancel(sessionID) missed them and the work kept running; the completion notice then woke the interrupted session for a new turn (#60107). Add Job.cancelAll({ sessionID, type? }) that cancels running jobs owned by the session (metadata.sessionID match, mirroring backgroundAll's shape) and call it next to the existing cancel when the run coordinator settles on a non-shutdown interrupt. --- packages/core/src/job.ts | 29 +++++++++++++ packages/core/src/session/execution.ts | 7 +++- packages/core/test/job.test.ts | 57 ++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/packages/core/src/job.ts b/packages/core/src/job.ts index 4eb0ab39d498..7d9030d7ff3c 100644 --- a/packages/core/src/job.ts +++ b/packages/core/src/job.ts @@ -130,6 +130,11 @@ export type BackgroundAllInput = { type?: string } +export type CancelAllInput = { + sessionID: SessionSchema.ID + type?: string +} + export interface Interface { readonly get: (id: string) => Effect.Effect readonly start: (input: StartInput) => Effect.Effect @@ -138,6 +143,7 @@ export interface Interface { readonly background: (id: string) => Effect.Effect readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect readonly cancel: (id: string) => Effect.Effect + readonly cancelAll: (input: CancelAllInput) => Effect.Effect readonly pendingBackground: Effect.Effect readonly runningBackground: Effect.Effect readonly completeBackground: (notificationID: SessionMessage.ID) => Effect.Effect @@ -445,6 +451,28 @@ export const make = Effect.gen(function* () { return result.info }) + const cancelAll: Interface["cancelAll"] = Effect.fn("Job.cancelAll")(function* (input) { + const owned = yield* SynchronizedRef.modifyEffect( + state.jobs, + Effect.fnUntraced(function* (jobs): Effect.fn.Return]> { + const ids: string[] = [] + for (const [id, job] of jobs) { + if (job.info.status !== "running") continue + if (input.type !== undefined && job.info.type !== input.type) continue + if (job.info.metadata?.sessionID !== input.sessionID) continue + ids.push(id) + } + return [ids, jobs] + }), + ) + const cancelled: Info[] = [] + for (const id of owned) { + const info = yield* cancel(id) + if (info) cancelled.push(info) + } + return cancelled + }) + const pendingBackground: Interface["pendingBackground"] = Effect.gen(function* () { const recovered: Background[] = [] let after: string | undefined @@ -486,6 +514,7 @@ export const make = Effect.gen(function* () { background, backgroundAll, cancel, + cancelAll, pendingBackground, runningBackground, completeBackground, diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index ce11a7a0f97d..87fd796314b7 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -125,7 +125,12 @@ export const layer = Layer.effect( } if (outcome.type === "interrupted") { // Deliberate stops release the claim; shutdown keeps it for restart continuity. - if (outcome.reason !== "shutdown") yield* jobs.cancel(sessionID) + if (outcome.reason !== "shutdown") { + // A session running as a subagent job is keyed by its own id; owned + // background work (shell, image) carries the session in job metadata. + yield* jobs.cancel(sessionID) + yield* jobs.cancelAll({ sessionID }) + } yield* bus.publish( SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason }, diff --git a/packages/core/test/job.test.ts b/packages/core/test/job.test.ts index 400a2adb4aaa..842056d017a4 100644 --- a/packages/core/test/job.test.ts +++ b/packages/core/test/job.test.ts @@ -450,4 +450,61 @@ describe("Job", () => { expect((yield* jobs.get(job.id))?.status).toBe("running") }), ) + + it.live("cancels running jobs owned by the session and leaves foreign work alone", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const owner = SessionSchema.ID.make("ses_owner") + const other = SessionSchema.ID.make("ses_other") + const latch = yield* Deferred.make() + const finish = Deferred.await(latch).pipe(Effect.as("done")) + const owned = yield* jobs.start({ + id: "job_owned_shell", + type: "shell", + metadata: { sessionID: owner }, + run: finish, + }) + const foreign = yield* jobs.start({ + id: "job_foreign_shell", + type: "shell", + metadata: { sessionID: other }, + run: finish, + }) + const unowned = yield* jobs.start({ id: "job_unowned", type: "test", run: finish }) + + const cancelled = yield* jobs.cancelAll({ sessionID: owner }) + + expect(cancelled.map((info) => info.id)).toEqual([owned.id]) + expect(yield* jobs.get(owned.id)).toMatchObject({ status: "cancelled" }) + expect(yield* jobs.get(foreign.id)).toMatchObject({ status: "running" }) + expect(yield* jobs.get(unowned.id)).toMatchObject({ status: "running" }) + yield* Deferred.succeed(latch, undefined) + expect((yield* jobs.wait({ id: foreign.id })).info).toMatchObject({ status: "completed" }) + expect((yield* jobs.wait({ id: unowned.id })).info).toMatchObject({ status: "completed" }) + expect((yield* jobs.wait({ id: owned.id })).info).toMatchObject({ status: "cancelled" }) + }), + ) + + it.live("filters cancelAll by job type", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const owner = SessionSchema.ID.make("ses_filter") + const latch = yield* Deferred.make() + const finish = Deferred.await(latch).pipe(Effect.as("done")) + yield* jobs.start({ id: "job_filter_shell", type: "shell", metadata: { sessionID: owner }, run: finish }) + const image = yield* jobs.start({ + id: "job_filter_image", + type: "image", + metadata: { sessionID: owner }, + run: finish, + }) + + const cancelled = yield* jobs.cancelAll({ sessionID: owner, type: "image" }) + + expect(cancelled.map((info) => info.id)).toEqual([image.id]) + expect(yield* jobs.get("job_filter_shell")).toMatchObject({ status: "running" }) + yield* Deferred.succeed(latch, undefined) + expect((yield* jobs.wait({ id: "job_filter_shell" })).info).toMatchObject({ status: "completed" }) + }), + ) })