You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/15 11:00:01 UTC

(maka) 02/02: fix(workhub): align composer controls and apply Code Mode per turn (#5345)

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 0d9ea7576a49c2f3173aa2cbe99ded53f82deaab
Author: Wang <m4...@gmail.com>
AuthorDate: Tue Sep 15 18:59:45 2026 +0800

    fix(workhub): align composer controls and apply Code Mode per turn (#5345)
    
    * fix(workhub): align composer controls and live Code Mode policy
    
    Generated-by: OpenAI Codex
    
    * fix(desktop): share session inspector across feature boundaries
    
    Generated-by: OpenAI Codex
    
    * fix(workhub): close review findings and refresh CI inventories
    
    Remove the obsolete Code Mode missing-tool bridge, require an explicit thinking setting, and align the shared inspector inventory and exports.
    
    Generated-by: OpenAI Codex
---
 apps/desktop/e2e-budget.json                       |   2 +-
 apps/desktop/e2e/workhub-layout.spec.ts            |  24 +++-
 apps/desktop/renderer-architecture.json            |   1 +
 .../src/main/__tests__/use-session-trace.test.ts   |  40 ++----
 .../main/__tests__/workhub-send-visibility.test.ts |  29 +++-
 .../contracts/session-inspector/copy.ts            | 157 +++++++++++++++++++++
 .../session-inspector/latest-request-usage.ts      |  68 +++++++++
 .../session-inspector}/live-context-usage.ts       |   0
 .../contracts/session-inspector/service.ts         |  49 +++++++
 .../session-inspector-overview-model.ts            |   0
 .../session-inspector-panel-model.ts               |   0
 .../session-inspector}/session-inspector-panel.tsx |  10 +-
 .../session-inspector}/session-trace-refresh.ts    |   0
 .../session-inspector}/use-live-context-usage.ts   |   5 +-
 .../session-inspector}/use-session-trace.ts        |  25 ++--
 apps/desktop/src/renderer/chat-composer-region.tsx |  51 +------
 .../desktop/src/renderer/features/workbar/ports.ts |  33 +----
 .../src/renderer/features/workbar/testing.ts       |  16 +--
 .../tools/inspector/live-context-usage-probe.tsx   |   7 +-
 .../features/workbar/ui/workbar-surface.tsx        |   6 +-
 .../workhub/controller/use-workhub-controller.ts   |  18 ++-
 .../features/workhub/locales/workhub-live-copy.ts  |   3 +
 .../desktop/src/renderer/features/workhub/ports.ts |   1 +
 .../renderer/features/workhub/ui/workhub-root.tsx  |  35 ++++-
 .../src/renderer/locales/conversation-copy.ts      | 142 +------------------
 .../renderer/locales/settings-preferences-copy.ts  |   6 +-
 .../platform/desktop/create-workbar-services.ts    |  20 +--
 .../platform/desktop/create-workhub-services.ts    |   3 +
 apps/desktop/src/renderer/styles/workhub.css       |  15 ++
 apps/desktop/stories/session-workbar.stories.tsx   |   6 +-
 apps/desktop/stories/workhub.stories.tsx           |  36 ++++-
 docs/astryx-surface-file-inventory.md              |   2 +-
 docs/astryx-surface-file-inventory.paths           |   2 +-
 .../__tests__/execution-model-composition.test.ts  |  43 ++++++
 .../hosted-execution-tool-profile.test.ts          | 119 +++++++++++++++-
 .../__tests__/session-catalog-coordinator.test.ts  |  38 +++++
 .../workhub-coordination-protocol.test.ts          |  12 +-
 packages/runtime-host/src/protocol/index.ts        |   2 +-
 .../src/protocol/workhub-coordination.ts           |  14 +-
 .../src/server/execution-composition.ts            |   9 ++
 .../src/server/session-catalog-coordinator.ts      |   5 +-
 .../src/__tests__/code-mode-backend.test.ts        |   4 +-
 packages/runtime/src/__tests__/code-mode.test.ts   |   4 +-
 .../runtime/src/__tests__/session-manager.test.ts  |  57 ++++++++
 packages/runtime/src/runtime-kernel.ts             |   3 +
 packages/runtime/src/session-manager.ts            |   4 +-
 packages/ui/src/chat-model-switcher.tsx            |   2 +-
 .../@maka+runtime++@ai-sdk+code-mode+1.0.56.patch  | 117 ++++++++++++++-
 48 files changed, 926 insertions(+), 319 deletions(-)

diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json
index a1249147b0..b3b4c5e562 100644
--- a/apps/desktop/e2e-budget.json
+++ b/apps/desktop/e2e-budget.json
@@ -75,7 +75,7 @@
     },
     "workhub-layout.spec.ts": {
       "tests": 3,
-      "electron": "WorkHub model configuration and attachment sending cross the Host boundary; the Project New task action must dismiss the native dock owned by a separate WebContentsView; the compact native window grows only with the inline composer wheel and keeps its bottom anchor, and pointer dragging scrolls the no-drag wheel instead of moving the frameless window; native floating preserves macOS Dock visibility; a held-open Host Turn verifies immediate prompt visibility, durable tr [...]
+      "electron": "WorkHub model configuration and attachment sending cross the Host boundary; the Project New task action must dismiss the native dock owned by a separate WebContentsView; the compact native window grows only with the inline composer wheel and keeps its bottom anchor; opening context details expands the native window, while the thinking menu stays within the compact bounds, and pointer dragging scrolls the no-drag wheel instead of moving the frameless window; native floa [...]
     },
     "workhub-reconstruction.spec.ts": {
       "tests": 1,
diff --git a/apps/desktop/e2e/workhub-layout.spec.ts b/apps/desktop/e2e/workhub-layout.spec.ts
index 85bba7036d..a0541662d5 100644
--- a/apps/desktop/e2e/workhub-layout.spec.ts
+++ b/apps/desktop/e2e/workhub-layout.spec.ts
@@ -28,7 +28,7 @@ test('WorkHub uses its coordination model and shared attachment composer', async
     const { connections } = await window.maka.connections.getSnapshot();
     const connection = connections.find((entry) => entry.slug === 'e2e')!;
     const ids = ['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001', 'claude-opus-4-5-20251101'];
-    await window.maka.connections.update({ connectionId: connection.connectionId, slug: connection.slug }, { enabledModelIds: ids, models: ids.map((id) => ({ id })) });
+    await window.maka.connections.update({ connectionId: connection.connectionId, slug: connection.slug }, { enabledModelIds: ids, models: ids.map((id) => ({ id })), defaultModel: ids[2] });
   });
   await page.locator(COMPOSER_INPUT).fill('WorkHub navigation regression');
   await awaitSendReady(page);
@@ -139,6 +139,7 @@ test('WorkHub uses its coordination model and shared attachment composer', async
     const session = await window.maka.workHub.getSession(id);
     return window.maka.workHub.configureModel(id, {
       expectedRevision: session.revision,
+      thinkingLevel: session.thinkingLevel ?? null,
       modelTarget: { kind: 'explicit', connectionId: session.llmConnectionId!, connectionSlug: session.llmConnectionSlug, model: session.model },
     });
   }, sessionId);
@@ -193,6 +194,27 @@ test('WorkHub uses its coordination model and shared attachment composer', async
   await expect(workhub.getByRole('button', { name: /^(隐藏|Hide)$/ })).toHaveCount(0);
   await expect.poll(() => workhub.evaluate(() => innerHeight === Math.ceil(document.querySelector('.workHubComposerSurface')!.getBoundingClientRect().height))).toBe(true);
   await expect.poll(floatingBottom).toBe(anchoredBottom);
+  // Opening context details must expand the native compact window as well as the renderer.
+  await workhub.getByRole('button', { name: /打开用量追踪|Open usage trace/ }).click();
+  await expect(workhub.getByRole('button', { name: /返回对话|Back to conversation/ })).toBeVisible();
+  await expect.poll(() => workhub.evaluate(() => innerHeight)).toBe(expandedHeight);
+  await workhub.getByRole('button', { name: /返回对话|Back to conversation/ }).click();
+  await workhub.getByRole('button', { name: /收起对话|Collapse conversation/ }).click();
+  await expect.poll(() => workhub.evaluate(() => innerHeight === Math.ceil(document.querySelector('.workHubComposerSurface')!.getBoundingClientRect().height))).toBe(true);
+  const thinking = workhub.getByRole('button', { name: /思考级别|Thinking level/ });
+  await expect(thinking).toBeEnabled();
+  await thinking.click();
+  const thinkingMenu = workhub.getByRole('menu');
+  await expect(thinkingMenu).toBeVisible();
+  await workhub.screenshot({ animations: 'disabled', path: testInfo.outputPath('floating-thinking-levels.png') });
+  await expect.poll(() => thinkingMenu.evaluate((element) => {
+    const rect = element.getBoundingClientRect();
+    return rect.top >= 0 && rect.bottom <= innerHeight;
+  })).toBe(true);
+  await workhub.getByRole('menuitemradio', { name: /^(高|High)$/ }).click();
+  await expect(thinking).toContainText(/高|High/);
+  await expect.poll(() => workhub.evaluate(async (id) => (await window.maka.workHub.getSession(id)).thinkingLevel, sessionId)).toBe('high');
+  await workhub.screenshot({ animations: 'disabled', path: testInfo.outputPath('floating-composer-controls.png') });
   const compactHeight = await workhub.evaluate(() => innerHeight);
   const screenLayout = async () => {
     const origin = await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'WorkHub')!.getContentBounds());
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 496cde3d7a..b5538c2ba6 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -1085,6 +1085,7 @@
         "unresolvedDependencies": 0,
         "actionFactories": [],
         "dependencyPaths": {
+          "./application/contracts/session-inspector/latest-request-usage.js": 1,
           "./composer-mentions.js": 1,
           "./new-task-reload-intent.js": 1,
           "@maka/ui": 1,
diff --git a/apps/desktop/src/main/__tests__/use-session-trace.test.ts b/apps/desktop/src/main/__tests__/use-session-trace.test.ts
index 75c1437194..ff7b1728f3 100644
--- a/apps/desktop/src/main/__tests__/use-session-trace.test.ts
+++ b/apps/desktop/src/main/__tests__/use-session-trace.test.ts
@@ -31,9 +31,8 @@ import {
   createFakeWorkbarServices,
   TRACE_REFRESH_DEBOUNCE_MS,
   useSessionTrace,
-  WorkbarServicesProvider,
-  type WorkbarSessionTracePage,
-  type WorkbarSessionUsageSummary,
+  type SessionTracePage,
+  type SessionUsageSummary,
   type WorkbarServices,
 } from '../../renderer/features/workbar/testing.js';
 
@@ -62,7 +61,7 @@ function trace(sessionId: string): SessionTrace {
 function usageSummary(
   totalRequests = 0,
   totalCostUsd = 0,
-): WorkbarSessionUsageSummary {
+): SessionUsageSummary {
   return {
     range: { from: 0, to: 1 },
     totalRequests,
@@ -112,15 +111,15 @@ interface TraceHarness {
 
 function createTraceHarness(
   options: {
-    tracePages?: WorkbarSessionTracePage[];
+    tracePages?: SessionTracePage[];
     trace?: (
       sessionId: string,
       cursor?: string,
-    ) => Promise<Result<WorkbarSessionTracePage>>;
+    ) => Promise<Result<SessionTracePage>>;
     summary?: (
       sessionId: string,
       readIndex: number,
-    ) => Promise<Result<WorkbarSessionUsageSummary>>;
+    ) => Promise<Result<SessionUsageSummary>>;
   } = {},
 ): TraceHarness {
   const handlers = new Set<(event: SessionEvent) => void>();
@@ -147,7 +146,7 @@ function createTraceHarness(
       trace: async (
         sessionId: string,
         cursor?: string,
-      ): Promise<Result<WorkbarSessionTracePage>> => {
+      ): Promise<Result<SessionTracePage>> => {
         harness.reads.push(sessionId);
         harness.traceRequests.push({ sessionId, ...(cursor ? { cursor } : {}) });
         if (options.trace) return options.trace(sessionId, cursor);
@@ -213,20 +212,7 @@ function Probe(props: {
   onSnapshot?: (trace: SessionTrace | undefined) => void;
   onHookSnapshot?: (snapshot: ReturnType<typeof useSessionTrace>) => void;
 }) {
-  return createElement(
-    WorkbarServicesProvider,
-    { services: props.services },
-    createElement(TraceProbe, props),
-  );
-}
-
-function TraceProbe(props: {
-  sessionId?: string;
-  active: boolean;
-  onSnapshot?: (trace: SessionTrace | undefined) => void;
-  onHookSnapshot?: (snapshot: ReturnType<typeof useSessionTrace>) => void;
-}) {
-  const snapshot = useSessionTrace(props.sessionId, props.active, COPY);
+  const snapshot = useSessionTrace(props.sessionId, props.active, COPY, props.services.inspector);
   props.onSnapshot?.(snapshot.trace);
   props.onHookSnapshot?.(snapshot);
   return null;
@@ -236,7 +222,7 @@ function tracePage(
   sessionId: string,
   runId: string,
   nextCursor: string | null,
-): WorkbarSessionTracePage {
+): SessionTracePage {
   const startedAt = Number(runId.replace(/\D/g, ''));
   return {
     trace: {
@@ -543,8 +529,8 @@ describe('useSessionTrace', () => {
 
   it('keeps the requested page depth when a head refresh supersedes load-earlier', async () => {
     const { root } = installReactRenderer();
-    let resolveEarlierPage: ((result: Result<WorkbarSessionTracePage>) => void) | undefined;
-    const earlierPage = new Promise<Result<WorkbarSessionTracePage>>((resolve) => {
+    let resolveEarlierPage: ((result: Result<SessionTracePage>) => void) | undefined;
+    const earlierPage = new Promise<Result<SessionTracePage>>((resolve) => {
       resolveEarlierPage = resolve;
     });
     let startReads = 0;
@@ -599,8 +585,8 @@ describe('useSessionTrace', () => {
 
   it('settles loading when load-earlier supersedes an in-flight retry', async () => {
     const { root } = installReactRenderer();
-    let resolveRetry: ((result: Result<WorkbarSessionTracePage>) => void) | undefined;
-    const retryPage = new Promise<Result<WorkbarSessionTracePage>>((resolve) => {
+    let resolveRetry: ((result: Result<SessionTracePage>) => void) | undefined;
+    const retryPage = new Promise<Result<SessionTracePage>>((resolve) => {
       resolveRetry = resolve;
     });
     let headReads = 0;
diff --git a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
index db85aec741..885afa2c35 100644
--- a/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
+++ b/apps/desktop/src/main/__tests__/workhub-send-visibility.test.ts
@@ -154,7 +154,7 @@ async function mountController(failFirstRead = false, overrides: Partial<WorkHub
   };
 }
 
-test('WorkHub model selection settles with its new revision and cannot be undone by a delayed catalog read', async () => {
+test('WorkHub model and thinking selection share versioned saves and reject stale reads', async () => {
   type Session = Awaited<ReturnType<WorkHubServices['getSession']>>;
   const initial = {
     id: JSON.stringify(['host-1', 'workhub-coordination']),
@@ -162,6 +162,7 @@ test('WorkHub model selection settles with its new revision and cannot be undone
     runningTurnIds: [],
   } as unknown as Session;
   let snapshot = initial;
+  let failSave = false;
   let notify!: () => void;
   let nextRead: Promise<Session> | undefined;
   const requests: Array<Parameters<WorkHubServices['configureModel']>[1]> = [];
@@ -174,7 +175,8 @@ test('WorkHub model selection settles with its new revision and cannot be undone
     subscribeSessions: (handler) => { notify = handler; return () => {}; },
     configureModel: async (_id, input) => {
       requests.push(input);
-      snapshot = { ...snapshot, model: input.modelTarget.model, revision: snapshot.revision + 1 };
+      if (failSave) throw new Error('configuration failed');
+      snapshot = { ...snapshot, model: input.modelTarget.model, thinkingLevel: input.thinkingLevel ?? undefined, revision: snapshot.revision + 1 };
       return { kind: 'committed', session: snapshot } as unknown as Awaited<ReturnType<WorkHubServices['configureModel']>>;
     },
   });
@@ -190,6 +192,9 @@ test('WorkHub model selection settles with its new revision and cannot be undone
     void change.then(() => { settled = true; });
   });
   assert.equal(settled, false, 'the wheel must remain pending until the saved session is available');
+  assert.equal(h.controller.configuringModel, true);
+  await act(async () => { await h.controller.changeThinkingLevel('high'); });
+  assert.equal(requests.length, 1, 'model and thinking saves cannot overlap');
   await act(async () => { confirmation.resolve(snapshot); await change; });
   assert.equal(h.controller.session?.model, 'B');
   assert.equal(h.controller.session?.revision, 2);
@@ -200,6 +205,26 @@ test('WorkHub model selection settles with its new revision and cannot be undone
   });
   assert.equal(requests[1]?.expectedRevision, 2, 'the next pick uses the committed revision');
   assert.equal(h.controller.session?.model, 'C');
+  await act(async () => { await h.controller.changeThinkingLevel('high'); });
+  assert.equal(h.controller.session?.thinkingLevel, 'high');
+  assert.equal(requests.at(-1)?.expectedRevision, 3);
+  assert.equal(requests.at(-1)?.modelTarget.model, 'C', 'thinking changes preserve model identity');
+  failSave = true;
+  await act(async () => { await h.controller.changeThinkingLevel('low'); });
+  assert.equal(h.controller.session?.thinkingLevel, 'high', 'failed writes retain the saved level');
+  assert.equal(h.controller.error, 'configuration failed');
+  assert.equal(h.controller.configuringModel, false);
+  failSave = false;
+  await act(async () => { await h.controller.changeThinkingLevel(undefined); });
+  assert.equal(requests.at(-1)?.thinkingLevel, null, 'default explicitly clears the stored override');
+  assert.equal(h.controller.session?.thinkingLevel, undefined);
+  await act(async () => { await h.controller.changeThinkingLevel('high'); });
+  await act(async () => { await h.controller.changeModel({ llmConnectionId: 'connection', llmConnectionSlug: 'provider', model: 'D' }); });
+  assert.equal(h.controller.session?.thinkingLevel, undefined, 'changing models clears the old model level');
+  const count = requests.length;
+  await act(async () => { h.admit('busy-turn'); });
+  await act(async () => { await h.controller.changeThinkingLevel('high'); });
+  assert.equal(requests.length, count, 'running turns cannot change their thinking level');
 });
 
 test('WorkHub stops presenting execution on observation loss while retaining the Stop target', async () => {
diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/copy.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/copy.ts
new file mode 100644
index 0000000000..e60bffa1e7
--- /dev/null
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/copy.ts
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export interface InspectorCopy {
+  ariaLabel: string;
+  /** Copy action and success copy for an unpriced model call's exact Pricing key. */
+  copyPricingKey: string;
+  pricingKeyCopied: string;
+  unpricedPricingKey: string;
+  /** Toast title when the clipboard write is denied or unavailable. */
+  copyFailed: string;
+  copyFailedDetail: string;
+  loadFailed: string;
+  retry: string;
+  empty: string;
+  /** The panel-empty (tier 2) sentence under `empty`. */
+  emptyHelp: string;
+  costUnavailable: string;
+  costEstimateHelp: string;
+  loadEarlier: string;
+  hideEarlier: string;
+  loadingEarlier: string;
+  loadingTrace: string;
+  loadingSummary: string;
+  summaryUnavailable: string;
+  /** Label for the complete Session cost estimate. */
+  totals: {
+    cost: string;
+  };
+  /**
+   * The session-wide metered-token split, read like a bill: what the
+   * provider's cache served, what was paid as uncached input, what was paid
+   * as output. Names the bands of the token track, in the track's order.
+   */
+  tokenUsage: {
+    title: string;
+    segment: { cacheRead: string; cacheMiss: string; output: string };
+  };
+  /**
+   * Where the session's recorded time went. Names the bands of the duration
+   * track; each row also states how many times its kind ran.
+   */
+  durationUsage: {
+    title: string;
+    /** Label under the ring's total figure. */
+    center: string;
+    segment: {
+      model: (count: number) => string;
+      tool: (count: number) => string;
+    };
+  };
+  /**
+   * The coverage notice, composed with its own breakdown: the separators
+   * belong to the language, not to the layout, so a Chinese sentence gets
+   * `:` and `、` where an English one gets `:` and `,`.
+   */
+  coveragePartial: (parts: readonly string[]) => string;
+  coverageAbsent: (parts: readonly string[]) => string;
+  /** Each states its own count, so English can say "1 turn" and not "1 turns". */
+  unreadable: (count: number) => string;
+  oversizedRuns: (count: number) => string;
+  turnsMissing: (count: number) => string;
+  turnsShort: (count: number) => string;
+  /**
+   * Names a step whose kind IS its identity — a compaction, an error, a
+   * permission prompt with no tool attached. Rows that carry a real
+   * identifier (a model id, a tool name) print that instead.
+   */
+  stepKind: { permission: string; compaction: string; error: string };
+  /** Why a model was called, when the reason was not the turn itself. */
+  callKind: (kind: string) => string;
+  /** How a permission request was answered. */
+  permissionDecision: (decision: string) => string;
+  /** What a tool that failed was recovered as. */
+  recoveredAs: (disposition: string) => string;
+  /** Attempts beyond the first, in words rather than as `×N`. */
+  retries: (count: number) => string;
+  /**
+   * What ended the turn badly, in words. The trace's codes are engineering
+   * vocabulary (`tool_failed`, `turn_aborted`); this is the sentence a
+   * reader gets, with a plain fallback for a code nobody has named yet.
+   */
+  turnFailure: (code: string) => string;
+  /** Stable display name of one turn, qualified by its recorded start time. */
+  turnLabel: (startedAt: string) => string;
+  /** Summary above the raw timeline. */
+  overview: {
+    context: string;
+    /** Names the bands of the context bar, in the bar's own order. */
+    segment: {
+      cacheRead: string;
+      fresh: string;
+      used: string;
+      free: string;
+    };
+    /** The three figures a reader opens this tab for, as headline stats. */
+    cacheHit: string;
+    /** Heading over the causal record. */
+    timelineTab: string;
+    /**
+     * What filled the context, under the bar that says how full it is.
+     *
+     * Kept verbally separate from the bar on purpose: these are estimates
+     * over serialized bytes and do not sum to the provider-reported prompt
+     * (#2323), so the heading says estimate and every figure carries a `≈`.
+     */
+    composition: {
+      title: string;
+      /** States the unit and its authority, once, under the heading. */
+      basis: string;
+      part: {
+        system_instructions: string;
+        tool_definitions: string;
+        messages: string;
+        other: string;
+      };
+      /** Heading over the per-tool rows. */
+      tools: string;
+      /** The tools below the visible rows, folded into one. */
+      remainingTools: (count: number) => string;
+      /** Tool schemas the payload never named. */
+      unlabelled: string;
+      /** The metered call carried no capture — a gap, not an empty prompt. */
+      unrecorded: string;
+    };
+  };
+}
+
+/**
+ * The name a step falls back to when it has no identifier of its own. A model
+ * call and a tool call always carry one, so they never reach here.
+ *
+ * It lives beside the words rather than in the panel so fallback labels stay
+ * part of the locale's vocabulary instead of being reconstructed by the view.
+ */
+export function inspectorStepKindLabel(copy: InspectorCopy, kind: string): string {
+  if (kind === 'permission') return copy.stepKind.permission;
+  if (kind === 'compaction') return copy.stepKind.compaction;
+  if (kind === 'error') return copy.stepKind.error;
+  return kind;
+}
diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts
new file mode 100644
index 0000000000..62635a5cee
--- /dev/null
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/latest-request-usage.ts
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * The session's latest provider-counted request, or nothing.
+ *
+ * A token count belongs to one request on one route: it is a number in that
+ * model's tokenizer, and it is only the session's latest if nothing newer
+ * exists. The runtime enforces both when it reads an anchor back, refusing one
+ * whose run header names another model or connection. A control that shows the
+ * number has to enforce the same two facts or it will display a precise-looking
+ * figure about a request the user is not making — model A's tokens against
+ * model B's window, or a historical range's usage presented as current.
+ *
+ * So this refuses rather than approximates, and the three refusals are the
+ * three normal states that break the pairing:
+ *
+ * - the loaded transcript range is not the session tail, so a newer request may
+ *   exist that this range cannot see;
+ * - the newest usage row carries no anchor, which is what manual `/compact`
+ *   writes, so the scan continues past it exactly as the runtime's does;
+ * - the anchor names a different route than the active one, or names none at
+ *   all because it was written before anchors carried their route.
+ */
+export interface LatestRequestUsageAnchor {
+  inputTokens: number;
+  outputTokens?: number;
+  modelId?: string;
+  connectionId?: string;
+}
+
+export function selectLatestRequestUsage(
+  messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[],
+  /** `hasNewer` means the loaded range is not the session tail. */
+  range: { hasNewer?: boolean } | undefined,
+  model: string | undefined,
+  route: { llmConnectionId?: string } | undefined,
+): number | undefined {
+  const connectionId = route?.llmConnectionId;
+  if (range?.hasNewer || model === undefined || connectionId === undefined) return undefined;
+  for (let index = messages.length - 1; index >= 0; index -= 1) {
+    const message = messages[index];
+    if (message?.type !== 'token_usage') continue;
+    const anchor = message.lastRequestAnchor;
+    if (!anchor) continue;
+    if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined;
+    if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined;
+    const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0;
+    return anchor.inputTokens + output;
+  }
+  return undefined;
+}
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts
similarity index 100%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/live-context-usage.ts
diff --git a/apps/desktop/src/renderer/application/contracts/session-inspector/service.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/service.ts
new file mode 100644
index 0000000000..ae99910b5e
--- /dev/null
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/service.ts
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { SessionEvent } from '@maka/core/events';
+import type { SessionTrace } from '@maka/core/session-trace';
+import type { Result } from '@maka/core/result';
+import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol';
+import type { MergedUsageSummary } from '@maka/core/usage-ledger-merge';
+
+export interface SessionTracePage {
+  readonly trace: SessionTrace;
+  readonly nextCursor: string | null;
+}
+
+export type SessionUsageSummary = MergedUsageSummary;
+
+export interface SessionInspectorService {
+  trace(
+    sessionId: string,
+    cursor?: string,
+  ): Promise<Result<SessionTracePage>>;
+  summary(sessionId: string): Promise<Result<SessionUsageSummary>>;
+  context(sessionId: string): Promise<Result<ContextDiagnosticsResult>>;
+  subscribeSessionEvents(
+    sessionId: string,
+    handler: (event: SessionEvent) => void,
+  ): () => void;
+  subscribeUsageChanges(
+    sessionId: string,
+    handler: () => void,
+  ): () => void;
+}
+
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-overview-model.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-overview-model.ts
similarity index 100%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-overview-model.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-overview-model.ts
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel-model.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel-model.ts
similarity index 100%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel-model.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel-model.ts
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx b/apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx
similarity index 99%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx
rename to apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx
index 9b6c940269..dd6f2824b1 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+import type { SessionInspectorService } from './service.js';
 import { type ReactNode, useMemo, useState } from 'react';
 import { Banner } from '@astryxdesign/core/Banner';
 import { Button } from '@astryxdesign/core/Button';
@@ -30,10 +31,9 @@ import { traceTurnIdentityKey } from '@maka/core/session-trace';
 import { useToast, useUiLocale } from '@maka/ui';
 import { ICON_SIZE, Activity, AlertTriangle, Copy } from '@maka/ui/icons';
 import {
-  getDesktopConversationCopy,
   type InspectorCopy,
   inspectorStepKindLabel,
-} from '../../../../locales/conversation-copy.js';
+} from './copy.js';
 import {
   deriveInspectorOverviewModel,
   estimatedSessionCost,
@@ -63,14 +63,14 @@ import { useSessionTrace } from './use-session-trace.js';
  * like every other empty surface (EmptyState) rather than like a stray
  * paragraph.
  */
-export function SessionInspectorPanel(props: { sessionId: string; active: boolean }) {
+export function SessionInspectorPanel(props: { sessionId: string; active: boolean; inspector: SessionInspectorService; copy: InspectorCopy }) {
   const locale = useUiLocale();
-  const copy = getDesktopConversationCopy(locale).inspector;
+  const { copy } = props;
   const toast = useToast();
   const snapshot = useSessionTrace(props.sessionId, props.active, {
     loadFailed: copy.loadFailed,
     locale,
-  });
+  }, props.inspector);
   const model = useMemo(() => deriveInspectorPanelModel(snapshot.trace), [snapshot.trace]);
   const overview = useMemo(
     () => deriveInspectorOverviewModel(snapshot.context, snapshot.summary),
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/session-trace-refresh.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/session-trace-refresh.ts
similarity index 100%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/session-trace-refresh.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/session-trace-refresh.ts
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-live-context-usage.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts
similarity index 95%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/use-live-context-usage.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts
index a8e9ef4d0f..1933b5e423 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-live-context-usage.ts
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/use-live-context-usage.ts
@@ -18,7 +18,7 @@
  */
 
 import { useEffect, useState } from 'react';
-import { useWorkbarServices } from '../../services-context.js';
+import type { SessionInspectorService } from './service.js';
 import {
   createLiveContextUsageTracker,
   type LiveContextUsage,
@@ -38,11 +38,12 @@ import { TRACE_REFRESH_DEBOUNCE_MS } from './session-trace-refresh.js';
  * hook says nothing, and the caller falls back to the per-turn anchor.
  */
 export function useLiveContextUsage(input: {
+  readonly inspector: SessionInspectorService;
   readonly sessionId: string | undefined;
   readonly model: string | undefined;
   readonly providerType: string | undefined;
 }): LiveContextUsage | undefined {
-  const { inspector } = useWorkbarServices();
+  const { inspector } = input;
   const [usage, setUsage] = useState<LiveContextUsage | undefined>(undefined);
   const { sessionId, model, providerType } = input;
   useEffect(() => {
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts b/apps/desktop/src/renderer/application/contracts/session-inspector/use-session-trace.ts
similarity index 95%
rename from apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts
rename to apps/desktop/src/renderer/application/contracts/session-inspector/use-session-trace.ts
index 560ff4f47f..e7896bde04 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/inspector/use-session-trace.ts
+++ b/apps/desktop/src/renderer/application/contracts/session-inspector/use-session-trace.ts
@@ -26,20 +26,19 @@ import {
 } from '@maka/core/session-trace';
 import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol';
 import type {
-  WorkbarInspectorService,
-  WorkbarSessionTracePage,
-  WorkbarSessionUsageSummary,
-} from '../../ports.js';
+  SessionInspectorService,
+  SessionTracePage,
+  SessionUsageSummary,
+} from './service.js';
 import {
   createRefreshCoalescer,
   createTraceRefreshCoalescer,
   TRACE_REFRESH_DEBOUNCE_MS,
 } from './session-trace-refresh.js';
-import { useWorkbarServices } from '../../services-context.js';
 
 interface SessionTraceState {
   sessionId?: string;
-  tracePages?: readonly WorkbarSessionTracePage[];
+  tracePages?: readonly SessionTracePage[];
   /**
    * What the context is made of right now, from the Host operation that owns
    * that question (#2323). Read on the same signal as the trace but kept
@@ -47,7 +46,7 @@ interface SessionTraceState {
    * answer it must not blank the causal record beside it.
    */
   context?: ContextDiagnosticsResult;
-  summary?: WorkbarSessionUsageSummary;
+  summary?: SessionUsageSummary;
   loading: boolean;
   summaryLoading?: boolean;
   summaryError?: boolean;
@@ -82,19 +81,19 @@ export function useSessionTrace(
   // whose comment once outran its code — is renderable in a test without the
   // UI package behind it.
   copy: { loadFailed: string; locale: UiLocale },
+  inspector: SessionInspectorService,
 ): SessionTraceSnapshot & {
   canHideEarlier: boolean;
   retry: () => void;
   loadEarlier: () => void;
   hideEarlier: () => void;
 } {
-  const { inspector } = useWorkbarServices();
   const traceRevisionRef = useRef(0);
   const summaryRevisionRef = useRef(0);
   const contextRevisionRef = useRef(0);
   const desiredPageCountRef = useRef<{ sessionId: string; count: number } | undefined>(undefined);
   const traceWindowRef = useRef<
-    { sessionId: string; pages: readonly WorkbarSessionTracePage[] } | undefined
+    { sessionId: string; pages: readonly SessionTracePage[] } | undefined
   >(undefined);
   const [state, setState] = useState<SessionTraceState>(EMPTY_STATE);
 
@@ -375,13 +374,13 @@ export function useSessionTrace(
 }
 
 async function readTracePageWindow(
-  inspector: WorkbarInspectorService,
+  inspector: SessionInspectorService,
   sessionId: string,
   pageCount: number,
   loadFailed: string,
   isCurrent: () => boolean,
-): Promise<WorkbarSessionTracePage[]> {
-  const pages: WorkbarSessionTracePage[] = [];
+): Promise<SessionTracePage[]> {
+  const pages: SessionTracePage[] = [];
   const seen = new Set<string>();
   let requestCursor: string | null = null;
   while (pages.length < pageCount) {
@@ -401,6 +400,6 @@ async function readTracePageWindow(
   return pages;
 }
 
-function mergeSessionTracePages(pages: readonly WorkbarSessionTracePage[]): SessionTrace {
+function mergeSessionTracePages(pages: readonly SessionTracePage[]): SessionTrace {
   return mergeSessionTraces(pages.map((page) => page.trace));
 }
diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx
index 18dfaca848..6098164b44 100644
--- a/apps/desktop/src/renderer/chat-composer-region.tsx
+++ b/apps/desktop/src/renderer/chat-composer-region.tsx
@@ -30,6 +30,7 @@ import {
   UserQuestionPrompt,
 } from '@maka/ui';
 import type { ComposerHandle } from '@maka/ui';
+export { selectLatestRequestUsage } from './application/contracts/session-inspector/latest-request-usage.js';
 import { useComposerMentionsContext } from './composer-mentions.js';
 import {
   readNewTaskReloadDraft,
@@ -153,56 +154,6 @@ interface ChatComposerRegionProps
   directoryPickerEnabled: boolean;
 }
 
-/**
- * The session's latest provider-counted request, or nothing.
- *
- * A token count belongs to one request on one route: it is a number in that
- * model's tokenizer, and it is only the session's latest if nothing newer
- * exists. The runtime enforces both when it reads an anchor back, refusing one
- * whose run header names another model or connection. A control that shows the
- * number has to enforce the same two facts or it will display a precise-looking
- * figure about a request the user is not making — model A's tokens against
- * model B's window, or a historical range's usage presented as current.
- *
- * So this refuses rather than approximates, and the three refusals are the
- * three normal states that break the pairing:
- *
- * - the loaded transcript range is not the session tail, so a newer request may
- *   exist that this range cannot see;
- * - the newest usage row carries no anchor, which is what manual `/compact`
- *   writes, so the scan continues past it exactly as the runtime's does;
- * - the anchor names a different route than the active one, or names none at
- *   all because it was written before anchors carried their route.
- */
-export interface LatestRequestUsageAnchor {
-  inputTokens: number;
-  outputTokens?: number;
-  modelId?: string;
-  connectionId?: string;
-}
-
-export function selectLatestRequestUsage(
-  messages: readonly { type: string; lastRequestAnchor?: LatestRequestUsageAnchor }[],
-  /** `hasNewer` means the loaded range is not the session tail. */
-  range: { hasNewer?: boolean } | undefined,
-  model: string | undefined,
-  route: { llmConnectionId?: string } | undefined,
-): number | undefined {
-  const connectionId = route?.llmConnectionId;
-  if (range?.hasNewer || model === undefined || connectionId === undefined) return undefined;
-  for (let index = messages.length - 1; index >= 0; index -= 1) {
-    const message = messages[index];
-    if (message?.type !== 'token_usage') continue;
-    const anchor = message.lastRequestAnchor;
-    if (!anchor) continue;
-    if (anchor.modelId !== model || anchor.connectionId !== connectionId) return undefined;
-    if (!Number.isFinite(anchor.inputTokens) || anchor.inputTokens <= 0) return undefined;
-    const output = Number.isFinite(anchor.outputTokens ?? 0) ? Math.max(0, anchor.outputTokens ?? 0) : 0;
-    return anchor.inputTokens + output;
-  }
-  return undefined;
-}
-
 export function ChatComposerRegion({
   composerRef,
   active,
diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts
index 6a31ce2d94..409a8e4a8a 100644
--- a/apps/desktop/src/renderer/features/workbar/ports.ts
+++ b/apps/desktop/src/renderer/features/workbar/ports.ts
@@ -17,6 +17,9 @@
  * under the License.
  */
 
+import type { SessionInspectorService } from '../../application/contracts/session-inspector/service.js';
+export type { SessionInspectorService, SessionTracePage, SessionUsageSummary } from '../../application/contracts/session-inspector/service.js';
+
 import type {
   MessageQueuePlacement,
   QuoteRef,
@@ -42,16 +45,12 @@ import type {
   StoredMessage,
   TurnRecord,
 } from '@maka/core/session';
-import type { SessionTrace } from '@maka/core/session-trace';
 import type { UserQuestionResponse } from '@maka/core/user-question';
 import type { InteractionFormResponse } from '@maka/core/interaction';
-import type { Result } from '@maka/core/result';
 import type {
   ContextCompactResult,
-  ContextDiagnosticsResult,
   TurnMessageExecutionQueryResult,
 } from '@maka/runtime-host/protocol';
-import type { MergedUsageSummary } from '@maka/core/usage-ledger-merge';
 import type {
   ShellRunPtyDataEvent,
   ShellRunPtySnapshot,
@@ -150,30 +149,6 @@ export interface WorkbarArtifactsService {
   saveAs(sessionId: string, artifactId: string): Promise<ArtifactSaveResult>;
 }
 
-export interface WorkbarSessionTracePage {
-  readonly trace: SessionTrace;
-  readonly nextCursor: string | null;
-}
-
-export type WorkbarSessionUsageSummary = MergedUsageSummary;
-
-export interface WorkbarInspectorService {
-  trace(
-    sessionId: string,
-    cursor?: string,
-  ): Promise<Result<WorkbarSessionTracePage>>;
-  summary(sessionId: string): Promise<Result<WorkbarSessionUsageSummary>>;
-  context(sessionId: string): Promise<Result<ContextDiagnosticsResult>>;
-  subscribeSessionEvents(
-    sessionId: string,
-    handler: (event: SessionEvent) => void,
-  ): WorkbarUnsubscribe;
-  subscribeUsageChanges(
-    sessionId: string,
-    handler: () => void,
-  ): WorkbarUnsubscribe;
-}
-
 export interface WorkbarAttachmentsService {
   readBytes(sessionId: string, artifactId: string): Promise<ArtifactBinaryReadResult>;
   pickFiles(): Promise<
@@ -312,7 +287,7 @@ export interface WorkbarServices {
   readonly terminal: WorkbarTerminalService;
   readonly browser: WorkbarBrowserService;
   readonly artifacts: WorkbarArtifactsService;
-  readonly inspector: WorkbarInspectorService;
+  readonly inspector: SessionInspectorService;
   readonly attachments: WorkbarAttachmentsService;
   readonly workBoard?: WorkbarWorkBoardService;
   readonly sideChat: SideChatSessionPort;
diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts
index f64361735c..026f3986d1 100644
--- a/apps/desktop/src/renderer/features/workbar/testing.ts
+++ b/apps/desktop/src/renderer/features/workbar/testing.ts
@@ -22,8 +22,8 @@ import type { WorkbarServices } from './ports.js';
 export { WorkbarServicesProvider } from './services-context.js';
 export type {
   WorkbarServices,
-  WorkbarSessionTracePage,
-  WorkbarSessionUsageSummary,
+  SessionTracePage,
+  SessionUsageSummary,
   WorkbarIngestInput,
 } from './ports.js';
 
@@ -32,17 +32,17 @@ export * from './model/workbar-layout.js';
 export * from './model/workbar-tool-definitions.js';
 export * from './tools/artifacts/artifact-list-keyboard.js';
 export * from './tools/artifacts/artifact-visibility.js';
-export * from './tools/inspector/session-inspector-panel-model.js';
+export * from '../../application/contracts/session-inspector/session-inspector-panel-model.js';
 export {
   compactNumberFormatter,
   InspectorCompositionSection,
   RING_ACTIVE_MIN_SWEEP,
   RING_MIN_SWEEP,
   usageRingArcs,
-} from './tools/inspector/session-inspector-panel.js';
-export * from './tools/inspector/session-inspector-overview-model.js';
-export * from './tools/inspector/session-trace-refresh.js';
-export * from './tools/inspector/live-context-usage.js';
+} from '../../application/contracts/session-inspector/session-inspector-panel.js';
+export * from '../../application/contracts/session-inspector/session-inspector-overview-model.js';
+export * from '../../application/contracts/session-inspector/session-trace-refresh.js';
+export * from '../../application/contracts/session-inspector/live-context-usage.js';
 export * from './tools/side-chat/quote-companion-panel-state.js';
 export * from './tools/side-chat/quote-companion-core.js';
 export * from './tools/side-chat/quote-companion-context-compaction.js';
@@ -53,7 +53,7 @@ export {
 export * from './tools/terminal/session-terminal-hydration.js';
 export * from './tools/terminal/session-terminal-query.js';
 export * from './tools/terminal/session-terminal-frame.js';
-export * from './tools/inspector/use-session-trace.js';
+export * from '../../application/contracts/session-inspector/use-session-trace.js';
 export * from './controller/use-workbar-controller.js';
 export { SideChatCloseConfirmation } from './ui/side-chat-close-confirmation.js';
 
diff --git a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
index 14f3a032b7..e43864ef2d 100644
--- a/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
+++ b/apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
@@ -17,9 +17,10 @@
  * under the License.
  */
 
+import { useWorkbarServices } from '../../services-context.js';
 import type { ReactElement, ReactNode } from 'react';
-import type { LiveContextUsage } from './live-context-usage.js';
-import { useLiveContextUsage } from './use-live-context-usage.js';
+import type { LiveContextUsage } from '../../../../application/contracts/session-inspector/live-context-usage.js';
+import { useLiveContextUsage } from '../../../../application/contracts/session-inspector/use-live-context-usage.js';
 
 /**
  * Render-prop boundary for the composer context gauge (#4717).
@@ -37,7 +38,9 @@ export function LiveContextUsageProbe(props: {
   readonly providerType: string | undefined;
   readonly children: (usage: LiveContextUsage | undefined) => ReactNode;
 }): ReactElement {
+  const { inspector } = useWorkbarServices();
   const usage = useLiveContextUsage({
+    inspector,
     sessionId: props.sessionId,
     model: props.model,
     providerType: props.providerType,
diff --git a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
index 6ca910c759..62cfdc4a91 100644
--- a/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
+++ b/apps/desktop/src/renderer/features/workbar/ui/workbar-surface.tsx
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+import { useWorkbarServices } from '../services-context.js';
 import { lazy, Suspense, useState, type ReactNode } from 'react';
 import { Composer, useUiLocale, type ChatModelChoice } from '@maka/ui';
 import {
@@ -78,7 +79,7 @@ const BrowserPanel = lazy(() =>
   import('../tools/browser/browser-panel').then((module) => ({ default: module.BrowserPanel })),
 );
 const SessionInspectorPanel = lazy(() =>
-  import('../tools/inspector/session-inspector-panel').then((module) => ({
+  import('../../../application/contracts/session-inspector/session-inspector-panel.js').then((module) => ({
     default: module.SessionInspectorPanel,
   })),
 );
@@ -419,6 +420,7 @@ export function WorkbarSurface(props: {
   workBoardStartTaskEnabled?: boolean;
   confirmBypass: () => Promise<boolean>;
 }) {
+  const { inspector } = useWorkbarServices();
   const locale = useUiLocale();
   const copy = getDesktopConversationCopy(locale).workbar;
   const [artifactCount, setArtifactCount] = useState({ sessionId: props.sessionId, count: 0 });
@@ -559,6 +561,8 @@ export function WorkbarSurface(props: {
           content = (
             <Suspense fallback={<WorkbarPanelLoading label={copy.inspector} />}>
               <SessionInspectorPanel
+                inspector={inspector}
+                copy={getDesktopConversationCopy(locale).inspector}
                 key={props.sessionId}
                 sessionId={props.sessionId!}
                 active={!props.hidden && active}
diff --git a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
index 58b6be323c..195700e263 100644
--- a/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
+++ b/apps/desktop/src/renderer/features/workhub/controller/use-workhub-controller.ts
@@ -34,6 +34,7 @@ import {
 } from '@maka/ui';
 import type { WorkHubAnswerInput, WorkHubAnswerResult } from '../../../../shared/workhub-conversation.js';
 import type { AttachmentRef, FollowUpMode, MessageQueuePlacement } from '@maka/core/events';
+import type { ThinkingLevel } from '@maka/core/model-thinking';
 import type { ChatModelChoice } from '@maka/core/chat-model-choice';
 import { startWorkHubCoordinationLifecycle } from './coordination-lifecycle.js';
 import { useWorkHubServices } from '../services.js';
@@ -62,6 +63,8 @@ export function useWorkHubController(onSubmit?: () => void) {
   const [sessions, setSessions] = useState<Awaited<ReturnType<WorkHubServices['listSessions']>>>(
     [],
   );
+  const [configuringModel, setConfiguringModel] = useState(false);
+  const configuringModelRef = useRef(false);
   const [choices, setChoices] = useState<ChatModelChoice[]>([]);
   const [transcript, setTranscript] = useState(emptyTranscript);
   // Reconciliation reads the published view, never a source page held by input.
@@ -524,11 +527,14 @@ export function useWorkHubController(onSubmit?: () => void) {
     llmConnectionId: string;
     llmConnectionSlug: string;
     model: string;
-  }) {
-    if (!sessionId || !session || busy) return;
+  }, thinkingLevel: ThinkingLevel | null = null) {
+    if (!sessionId || !session || busy || configuringModelRef.current) return;
+    configuringModelRef.current = true;
+    setConfiguringModel(true);
     try {
       const result = await services.configureModel(sessionId, {
         expectedRevision: session.revision,
+        thinkingLevel,
         modelTarget: {
           kind: 'explicit',
           connectionId: input.llmConnectionId,
@@ -550,6 +556,9 @@ export function useWorkHubController(onSubmit?: () => void) {
       if (currentSessionId.current !== sessionId) return;
       refreshSessions.current();
       report(reason);
+    } finally {
+      configuringModelRef.current = false;
+      setConfiguringModel(false);
     }
   }
   async function mutateQueue(action: (target: string) => Promise<void>) {
@@ -596,6 +605,11 @@ export function useWorkHubController(onSubmit?: () => void) {
     send,
     stop,
     changeModel,
+    configuringModel,
+    changeThinkingLevel: async (level: ThinkingLevel | undefined) => {
+      if (!session?.llmConnectionId || !session.llmConnectionSlug || !session.model) return;
+      await changeModel({ llmConnectionId: session.llmConnectionId, llmConnectionSlug: session.llmConnectionSlug, model: session.model }, level ?? null);
+    },
     retry: () => {
       const attempt = pendingSend.current;
       if (readError && sessionId) {
diff --git a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts
index 54f2e272c2..3659db9256 100644
--- a/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts
+++ b/apps/desktop/src/renderer/features/workhub/locales/workhub-live-copy.ts
@@ -29,6 +29,7 @@ export const workHubLiveCopy = {
     controlFailed: 'The action could not be completed. Review the conversation and try again.',
     progressTitle: 'WorkHub progress', progressWorking: 'Working', progressPaused: 'Paused', progressError: 'Needs attention', progressDone: 'Finished', progressHint: 'You can keep working in Maka.', progressOpen: 'View conversation', progressClose: 'Hide progress for this task',
     title: 'WorkHub',
+    backToConversation: 'Back to conversation',
     progressInput: 'Add a message…',
     float: 'Float WorkHub',
     dock: 'Return to Maka',
@@ -55,6 +56,7 @@ export const workHubLiveCopy = {
     controlFailed: '操作未完成,请查看对话后重试。',
     progressTitle: 'WorkHub 进度', progressWorking: '正在处理', progressPaused: '已暂停', progressError: '需要查看', progressDone: '本轮已结束', progressHint: '你可以继续在 Maka 中操作。', progressOpen: '查看对话', progressClose: '本轮不再显示进度',
     title: '工作台',
+    backToConversation: '返回对话',
     progressInput: '补充一句…',
     float: '浮出工作台',
     dock: '收回 Maka',
@@ -81,6 +83,7 @@ export const workHubLiveCopy = {
     controlFailed: '操作未完成,請查看對話後重試。',
     progressTitle: 'WorkHub 進度', progressWorking: '正在處理', progressPaused: '已暫停', progressError: '需要查看', progressDone: '本輪已結束', progressHint: '你可以繼續在 Maka 中操作。', progressOpen: '查看對話', progressClose: '本輪不再顯示進度',
     title: '工作台',
+    backToConversation: '返回對話',
     progressInput: '補充一句…',
     float: '浮出工作台',
     dock: '收回 Maka',
diff --git a/apps/desktop/src/renderer/features/workhub/ports.ts b/apps/desktop/src/renderer/features/workhub/ports.ts
index 190a7210da..453791b3d2 100644
--- a/apps/desktop/src/renderer/features/workhub/ports.ts
+++ b/apps/desktop/src/renderer/features/workhub/ports.ts
@@ -49,6 +49,7 @@ export interface WorkHubTranscript {
   close(): Promise<void>;
 }
 export interface WorkHubServices {
+  readonly inspector: import('../../application/contracts/session-inspector/service.js').SessionInspectorService;
   readonly surface: 'main' | 'workhub';
   readonly initialLocale: UiLocale;
   subscribeAppearance(handler: (locale: UiLocale) => void): () => void;
diff --git a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
index 1088b8212b..59786210cf 100644
--- a/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
+++ b/apps/desktop/src/renderer/features/workhub/ui/workhub-root.tsx
@@ -21,6 +21,10 @@ import { useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } fro
 import { ChatSurfaceLayout, UserQuestionPrompt, MakaWordmark, useUiLocale, type ComposerHandle } from '@maka/ui';
 import { Button, IconButton } from '@astryxdesign/core';
 import { ChevronDown, PictureInPicture2, Undo2, X } from '@maka/ui/icons';
+import { useLiveContextUsage } from '../../../application/contracts/session-inspector/use-live-context-usage.js';
+import { SessionInspectorPanel } from '../../../application/contracts/session-inspector/session-inspector-panel.js';
+import { selectLatestRequestUsage } from '../../../application/contracts/session-inspector/latest-request-usage.js';
+import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js';
 import { WorkHubProgressCard } from './workhub-progress-card.js';
 import { WorkHubComposer } from './workhub-composer.js';
 import { WorkHubConversation } from './workhub-conversation.js';
@@ -74,6 +78,13 @@ function WorkHubContents() {
   const { selectWork } = useContext(WorkHubHighlightContext);
   const controller = useWorkHubController(() => selectWork(undefined));
   const { services, session, transcript, busy } = controller;
+  const modelChoice = controller.choices.find((choice) =>
+    choice.connectionId === session?.llmConnectionId && choice.connectionSlug === session?.llmConnectionSlug && choice.model === session?.model,
+  );
+  const thinkingLevels = modelChoice?.thinkingLevels ?? [];
+  const liveContextUsage = useLiveContextUsage({ inspector: services.inspector, sessionId: controller.sessionId, model: session?.model, providerType: modelChoice?.providerType });
+  const [inspectingContext, setInspectingContext] = useState(false);
+  const thinkingLevel = session?.thinkingLevel && thinkingLevels.includes(session.thinkingLevel) ? session.thinkingLevel : undefined;
   const locale = useUiLocale();
   const t = workHubLiveCopy[locale];
   const shortcutLabel = navigator.platform.toLowerCase().includes('mac') ? '⌘⇧K' : 'Ctrl+Shift+K';
@@ -324,11 +335,28 @@ function WorkHubContents() {
               onStop={controller.stop}
               activeSession={session}
               activeModel={session?.model}
+              activeModelLabel={modelChoice?.label}
+              activeProviderType={modelChoice?.providerType}
               activeModelConnectionId={session?.llmConnectionId}
               activeModelConnectionSlug={session?.llmConnectionSlug}
               modelChoices={controller.choices}
               maxInputRows={progress && !editingProgress ? 1 : showConversation ? undefined : 6}
               onModelChange={controller.changeModel}
+              modelSwitchAvailability={controller.configuringModel ? { available: false, pending: true, reason: 'pending' } : undefined}
+              contextUsage={session ? {
+                usageTokens: liveContextUsage?.usageTokens ?? selectLatestRequestUsage(transcript.messages, transcript, session.model, session),
+                declaredContextWindow: modelChoice?.declaredContextWindow,
+                meteredContextWindow: liveContextUsage?.contextWindow,
+                metadataContextWindow: modelChoice?.contextWindow,
+                onOpen: () => {
+                  setInspectingContext(true);
+                  setConversationExpanded(true);
+                  if (progress) call(services.presentation.expandProgress(presentation.progressRequest));
+                },
+              } : undefined}
+              activeThinkingLevels={thinkingLevels}
+              activeThinkingLevel={thinkingLevel}
+              onThinkingLevelChange={controller.changeThinkingLevel}
               modelSwitchHasHistory={transcript.messages.length > 0}
               footerAccessory={
                 <div className="workHubComposerActions">
@@ -348,7 +376,10 @@ function WorkHubContents() {
         <div className="workhub-body">
         <WorkHubNavigationRail locale={locale} sessions={tasks} delegatedSessionIds={delegatedSessionIds} copy={getWorkHubRailCopy(locale)} />
         <div className="workhub-conversation-shell">
-        <WorkHubConversation
+        {inspectingContext && session ? <div className="workHubContextInspector">
+          <Button variant="ghost" size="sm" label={t.backToConversation} onClick={() => setInspectingContext(false)} />
+          <SessionInspectorPanel sessionId={session.id} active={showConversation} inspector={services.inspector} copy={getDesktopConversationCopy(locale).inspector} />
+        </div> : <WorkHubConversation
           promptStates={promptStates}
           workLinks={linksWithFeedback}
           onReadAttachmentBytes={services.readAttachmentBytes}
@@ -374,7 +405,7 @@ function WorkHubContents() {
               <p>{t.hint}</p>
             </div>
           }
-        />
+        />}
         </div></div>
         </div>
       </ChatSurfaceLayout>
diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts
index 555595b70f..d1654f37ba 100644
--- a/apps/desktop/src/renderer/locales/conversation-copy.ts
+++ b/apps/desktop/src/renderer/locales/conversation-copy.ts
@@ -17,6 +17,8 @@
  * under the License.
  */
 
+import type { InspectorCopy } from '../application/contracts/session-inspector/copy.js';
+
 import type { ChatConfigurationReason } from '@maka/core/connection-readiness';
 import type { SessionSendProjection } from '@maka/core/session-send-projection';
 
@@ -176,130 +178,7 @@ export interface DesktopConversationCopy {
     writeFailed: string;
     stopFailed: string;
   };
-  inspector: {
-    ariaLabel: string;
-    /** Copy action and success copy for an unpriced model call's exact Pricing key. */
-    copyPricingKey: string;
-    pricingKeyCopied: string;
-    unpricedPricingKey: string;
-    /** Toast title when the clipboard write is denied or unavailable. */
-    copyFailed: string;
-    copyFailedDetail: string;
-    loadFailed: string;
-    retry: string;
-    empty: string;
-    /** The panel-empty (tier 2) sentence under `empty`. */
-    emptyHelp: string;
-    costUnavailable: string;
-    costEstimateHelp: string;
-    loadEarlier: string;
-    hideEarlier: string;
-    loadingEarlier: string;
-    loadingTrace: string;
-    loadingSummary: string;
-    summaryUnavailable: string;
-    /** Label for the complete Session cost estimate. */
-    totals: {
-      cost: string;
-    };
-    /**
-     * The session-wide metered-token split, read like a bill: what the
-     * provider's cache served, what was paid as uncached input, what was paid
-     * as output. Names the bands of the token track, in the track's order.
-     */
-    tokenUsage: {
-      title: string;
-      segment: { cacheRead: string; cacheMiss: string; output: string };
-    };
-    /**
-     * Where the session's recorded time went. Names the bands of the duration
-     * track; each row also states how many times its kind ran.
-     */
-    durationUsage: {
-      title: string;
-      /** Label under the ring's total figure. */
-      center: string;
-      segment: {
-        model: (count: number) => string;
-        tool: (count: number) => string;
-      };
-    };
-    /**
-     * The coverage notice, composed with its own breakdown: the separators
-     * belong to the language, not to the layout, so a Chinese sentence gets
-     * `:` and `、` where an English one gets `:` and `,`.
-     */
-    coveragePartial: (parts: readonly string[]) => string;
-    coverageAbsent: (parts: readonly string[]) => string;
-    /** Each states its own count, so English can say "1 turn" and not "1 turns". */
-    unreadable: (count: number) => string;
-    oversizedRuns: (count: number) => string;
-    turnsMissing: (count: number) => string;
-    turnsShort: (count: number) => string;
-    /**
-     * Names a step whose kind IS its identity — a compaction, an error, a
-     * permission prompt with no tool attached. Rows that carry a real
-     * identifier (a model id, a tool name) print that instead.
-     */
-    stepKind: { permission: string; compaction: string; error: string };
-    /** Why a model was called, when the reason was not the turn itself. */
-    callKind: (kind: string) => string;
-    /** How a permission request was answered. */
-    permissionDecision: (decision: string) => string;
-    /** What a tool that failed was recovered as. */
-    recoveredAs: (disposition: string) => string;
-    /** Attempts beyond the first, in words rather than as `×N`. */
-    retries: (count: number) => string;
-    /**
-     * What ended the turn badly, in words. The trace's codes are engineering
-     * vocabulary (`tool_failed`, `turn_aborted`); this is the sentence a
-     * reader gets, with a plain fallback for a code nobody has named yet.
-     */
-    turnFailure: (code: string) => string;
-    /** Stable display name of one turn, qualified by its recorded start time. */
-    turnLabel: (startedAt: string) => string;
-    /** Summary above the raw timeline. */
-    overview: {
-      context: string;
-      /** Names the bands of the context bar, in the bar's own order. */
-      segment: {
-        cacheRead: string;
-        fresh: string;
-        used: string;
-        free: string;
-      };
-      /** The three figures a reader opens this tab for, as headline stats. */
-      cacheHit: string;
-      /** Heading over the causal record. */
-      timelineTab: string;
-      /**
-       * What filled the context, under the bar that says how full it is.
-       *
-       * Kept verbally separate from the bar on purpose: these are estimates
-       * over serialized bytes and do not sum to the provider-reported prompt
-       * (#2323), so the heading says estimate and every figure carries a `≈`.
-       */
-      composition: {
-        title: string;
-        /** States the unit and its authority, once, under the heading. */
-        basis: string;
-        part: {
-          system_instructions: string;
-          tool_definitions: string;
-          messages: string;
-          other: string;
-        };
-        /** Heading over the per-tool rows. */
-        tools: string;
-        /** The tools below the visible rows, folded into one. */
-        remainingTools: (count: number) => string;
-        /** Tool schemas the payload never named. */
-        unlabelled: string;
-        /** The metered call carried no capture — a gap, not an empty prompt. */
-        unrecorded: string;
-      };
-    };
-  };
+  inspector: InspectorCopy;
   quoteCompanion: {
     /** Prefix for the companion fork's session name (followed by the excerpt). */
     namePrefix: string;
@@ -1177,18 +1056,3 @@ export function getDesktopConversationCopy(locale: UiLocale): DesktopConversatio
   return COPY[locale];
 }
 
-export type InspectorCopy = DesktopConversationCopy['inspector'];
-
-/**
- * The name a step falls back to when it has no identifier of its own. A model
- * call and a tool call always carry one, so they never reach here.
- *
- * It lives beside the words rather than in the panel so fallback labels stay
- * part of the locale's vocabulary instead of being reconstructed by the view.
- */
-export function inspectorStepKindLabel(copy: InspectorCopy, kind: string): string {
-  if (kind === 'permission') return copy.stepKind.permission;
-  if (kind === 'compaction') return copy.stepKind.compaction;
-  if (kind === 'error') return copy.stepKind.error;
-  return kind;
-}
diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
index 9941054271..09fc2c183f 100644
--- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts
@@ -339,7 +339,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
       removeErrors: { invalid_id: '宠物 ID 无效。', remove_failed: '无法删除本地宠物包。' },
     },
     general: {
-      codeModeHelp: '开启后,新任务通过代码编排和调用工具。仅影响新建任务,已有任务保持不变。',
+      codeModeHelp: '开启后,新任务通过代码编排和调用工具。WorkHub 从下一轮起采用新设置,其他已有任务保持不变。',
       incognito: '隐身模式', incognitoHelp: '开启后暂停本地记忆读写、联网搜索和定时任务触发。', enableIncognito: '启用隐身模式', incognitoFailed: '隐身模式切换失败', notifications: '完成时发送系统通知', notificationsHelp: '窗口不在前台时,在回答完成或出错后发送桌面通知。', notificationsFailed: '通知设置切换失败', workspaceInstructions: '遵循项目指令', workspaceInstructionsHelp: '自动读取每个项目中已有的 AGENTS.md、CLAUDE.md 或 GEMINI.md;文件仍由各自项目管理。', workspaceInstructionsFailed: '项目指令设置切换失败', workHub: '启用 WorkHub', workHubHelp: 'WorkHub 目前仍不可用。此开关仅供开发测试,开启后也不能保证正常使用。', workHubFailed: 'Wor [...]
       defaultModel: '默认模型', defaultModelHelp: '新任务默认使用的模型。', notSet: '未设置', saveDefaultModelFailed: '保存默认模型失败', defaultPermission: '默认权限模式', defaultPermissionHelp: '新任务默认使用的权限模式;可在任务内随时切换。', saveDefaultPermissionFailed: '保存默认权限模式失败', defaultThinking: '默认思考级别', defaultThinkingHelp: '新任务的思考级别;当前模型不支持所选级别时用模型默认。', followModelDefault: '跟随模型默认', saveDefaultThinkingFailed: '保存默认思考级别失败',
       shellPreference: 'Bash 工具 shell', shellPreferenceHelp: '自动模式保持 Windows 的 PowerShell 优先规则;Git Bash 是仅对当前 Runtime Host 生效的显式覆盖。', shellAuto: '自动(推荐)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash 可执行文件', shellExecutableHelp: '填写 Runtime Host 所在 Windows 机器上 bash.exe 的绝对路径。也支持该机器上的旧版 System32 WSL Bash;保存时会验证 GNU Bash。', saveShell: '保存 shell 设置', savingShell: '正在保存…', shellSaved: '已保存', saveShellFailed: '保存 shell 设置失败', shellExecutableRejected: '当前 Runtime Host 无法把该路径作为 GNU Bash [...]
@@ -444,7 +444,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
       removeErrors: { invalid_id: '寵物 ID 無效。', remove_failed: '無法刪除本機寵物包。' },
     },
     general: {
-      codeModeHelp: '開啟後,新任務透過程式碼編排和呼叫工具。僅影響新建任務,既有任務保持不變。',
+      codeModeHelp: '開啟後,新任務透過程式碼編排和呼叫工具。WorkHub 從下一輪起套用新設定,其他既有任務保持不變。',
       incognito: '隱身模式', incognitoHelp: '開啟後暫停本地記憶讀寫、聯網搜尋和定時任務觸發。', enableIncognito: '啟用隱身模式', incognitoFailed: '隱身模式切換失敗', notifications: '完成時傳送系統通知', notificationsHelp: '視窗不在前臺時,在回答完成或出錯後傳送桌面通知。', notificationsFailed: '通知設定切換失敗', workspaceInstructions: '遵循專案指令', workspaceInstructionsHelp: '自動讀取每個專案中已有的 AGENTS.md、CLAUDE.md 或 GEMINI.md;檔案仍由各自專案管理。', workspaceInstructionsFailed: '專案指令設定切換失敗', workHub: '啟用 WorkHub', workHubHelp: '在一個入口檢視已有工作,並將新輸入保守地送往普通任務。', workHubFailed: 'WorkHub 設定切換失敗 [...]
       defaultModel: '預設模型', defaultModelHelp: '新任務預設使用的模型。', notSet: '未設定', saveDefaultModelFailed: '儲存預設模型失敗', defaultPermission: '預設權限模式', defaultPermissionHelp: '新任務預設使用的權限模式;可在任務內隨時切換。', saveDefaultPermissionFailed: '儲存預設權限模式失敗', defaultThinking: '預設思考級別', defaultThinkingHelp: '新任務的思考級別;目前模型不支援所選級別時用模型預設。', followModelDefault: '跟隨模型預設', saveDefaultThinkingFailed: '儲存預設思考級別失敗',
       shellPreference: 'Bash 工具 shell', shellPreferenceHelp: '自動模式保持 Windows 的 PowerShell 優先規則;Git Bash 是僅對目前 Runtime Host 生效的顯式覆蓋。', shellAuto: '自動(推薦)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash 執行檔', shellExecutableHelp: '填寫 Runtime Host 所在 Windows 機器上 bash.exe 的絕對路徑。也支援該機器上的舊版 System32 WSL Bash;儲存時會驗證 GNU Bash。', saveShell: '儲存 shell 設定', savingShell: '正在儲存…', shellSaved: '已儲存', saveShellFailed: '儲存 shell 設定失敗', shellExecutableRejected: '目前 Runtime Host 無法把該路徑作為 GNU Bash 執 [...]
@@ -506,7 +506,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = {
       removeErrors: { invalid_id: 'The pet ID is invalid.', remove_failed: 'The local pet pack could not be removed.' },
     },
     general: {
-      codeModeHelp: 'New tasks use code to compose and call tools. Changes apply only to new tasks; existing tasks keep their mode.',
+      codeModeHelp: 'New tasks use code to compose and call tools. WorkHub applies changes on its next turn; other existing tasks keep their mode.',
       incognito: 'Incognito mode', incognitoHelp: 'Pause local memory, web search, and scheduled task triggers.', enableIncognito: 'Enable incognito mode', incognitoFailed: 'Could not change incognito mode', notifications: 'Send a system notification when finished', notificationsHelp: 'Notify when a response finishes or fails while the window is in the background.', notificationsFailed: 'Could not change notification settings', workspaceInstructions: 'Follow project instructions', worksp [...]
       shellPreference: 'Bash tool shell', shellPreferenceHelp: 'Automatic keeps the PowerShell-first Windows default. Git Bash is an explicit override for the current Runtime Host.', shellAuto: 'Automatic (recommended)', shellGitBash: 'Git Bash', shellExecutable: 'Git Bash executable', shellExecutableHelp: 'Enter the absolute path to bash.exe on the Windows machine running the Runtime Host. The legacy System32 WSL Bash shim is also recognized; Maka verifies GNU Bash before saving.', save [...]
       passwordSavedPlaceholder: 'Password saved; enter a new password to replace it',
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
index bd1e2d637f..f6886fee5e 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts
@@ -55,6 +55,16 @@ function isDesktopTerminal(update: ShellRunUpdate): boolean {
 }
 
 /** The only Desktop-to-Workbar adapter. It narrows the preload bridge by tool. */
+export function createDesktopInspectorService(bridge: Pick<MakaBridge, 'inspector' | 'sessions'>) {
+  return {
+    trace: (sessionId: string, cursor?: string) => bridge.inspector.trace(sessionId, cursor),
+    summary: (sessionId: string) => bridge.inspector.summary(sessionId),
+    context: (sessionId: string) => bridge.inspector.context(sessionId),
+    subscribeSessionEvents: (sessionId: string, handler: Parameters<MakaBridge['sessions']['subscribeEvents']>[1]) => bridge.sessions.subscribeEvents(sessionId, handler),
+    subscribeUsageChanges: (sessionId: string, handler: () => void) => bridge.inspector.subscribeUsageChanges(sessionId, handler),
+  };
+}
+
 export function createDesktopWorkbarServices(
   bridge: DesktopWorkbarBridge = window.maka,
   dependencies: DesktopWorkbarServiceDependencies = DEFAULT_DEPENDENCIES,
@@ -143,15 +153,7 @@ export function createDesktopWorkbarServices(
       saveAs: (sessionId, artifactId) =>
         bridge.app.saveArtifactAs(sessionId, artifactId),
     },
-    inspector: {
-      trace: (sessionId, cursor) => bridge.inspector.trace(sessionId, cursor),
-      summary: (sessionId) => bridge.inspector.summary(sessionId),
-      context: (sessionId) => bridge.inspector.context(sessionId),
-      subscribeSessionEvents: (sessionId, handler) =>
-        bridge.sessions.subscribeEvents(sessionId, handler),
-      subscribeUsageChanges: (sessionId, handler) =>
-        bridge.inspector.subscribeUsageChanges(sessionId, handler),
-    },
+    inspector: createDesktopInspectorService(bridge),
     attachments: bridge.attachments,
     ...(bridge.workBoard
       ? {
diff --git a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts
index e629a4c0ac..c90014325e 100644
--- a/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts
+++ b/apps/desktop/src/renderer/platform/desktop/create-workhub-services.ts
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+import { createDesktopInspectorService } from './create-workbar-services.js';
 import { resolveSystemUiLocale, resolveUiLocale } from '@maka/core/ui-locale';
 import { DEFAULT_UI_FONT_SIZE } from '@maka/core/settings';
 import { AttachmentIngestBlockedError } from '@maka/core/attachments';
@@ -100,6 +101,7 @@ async function readDelegatedTurnResult(
 export function createDesktopWorkHubServices(
   bridge: Pick<
     MakaBridge,
+    | 'inspector'
     | 'workHub'
     | 'workHubControl'
     | 'workHubPresentation'
@@ -113,6 +115,7 @@ export function createDesktopWorkHubServices(
 ): WorkHubServices {
   const delegatedResultCache = new Map<string, string>();
   return {
+    inspector: createDesktopInspectorService(bridge),
     surface: new URLSearchParams(window.location.search).get('surface') === 'workhub' ? 'workhub' : 'main',
     initialLocale: resolveSystemUiLocale(navigator.languages),
     subscribeAppearance(handler) {
diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css
index a05497c757..fa3c86bd1f 100644
--- a/apps/desktop/src/renderer/styles/workhub.css
+++ b/apps/desktop/src/renderer/styles/workhub.css
@@ -161,6 +161,21 @@
   .workHubLive[data-placement='floating'][data-conversation-expanded='false'] .maka-model-wheel-anchor[data-open='true'] {
     width: min(264px, 55vw); height: var(--maka-model-wheel-height);
   }
+  .workHubLive .maka-thinking-level-menu {
+    background: color-mix(in oklch, var(--background-elevated) 94%, transparent);
+    backdrop-filter: blur(12px);
+  }
+  /* A compact native window cannot paint the thinking menu outside its bounds.
+     Keep the menu in the window and scroll its options without resizing the composer. */
+  .workHubLive[data-placement='floating'][data-conversation-expanded='false'] [popover]:has(> .maka-thinking-level-menu) {
+    position-area: none !important;
+    inset: 4px max(4px, calc(100vw - anchor(right))) auto auto;
+    margin: 0;
+  }
+  .workHubLive[data-placement='floating'][data-conversation-expanded='false'] .maka-thinking-level-menu {
+    max-height: min(300px, calc(100dvh - 8px));
+  }
+  .workHubContextInspector { height: 100%; overflow: auto; padding: var(--space-3); }
   .workHubComposerActions { display: flex; align-items: center; gap: 2px; margin-left: auto; }
   .workHubRevealMark { position: absolute; inset: 32px 0 calc(var(--workhub-composer-height, 96px) + 12px); display: grid; place-items: center; z-index: 3; color: var(--foreground); opacity: 0; pointer-events: none; }
   .workHubRevealMark svg { filter: drop-shadow(0 2px 12px var(--background)); }
diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx
index 690d277d62..1cceeeda92 100644
--- a/apps/desktop/stories/session-workbar.stories.tsx
+++ b/apps/desktop/stories/session-workbar.stories.tsx
@@ -39,7 +39,7 @@ import {
   type QuoteCompanionPanelState,
   type SessionWorkbarTab,
   type SessionWorkbarTabKind,
-  type WorkbarSessionUsageSummary,
+  type SessionUsageSummary,
 } from '../src/renderer/features/workbar/testing';
 
 // Fidelity convention (#1433): every story below names the real app path
@@ -683,7 +683,7 @@ const olderTrace: SessionTrace = {
   ],
 };
 
-const emptyUsageSummary: WorkbarSessionUsageSummary = {
+const emptyUsageSummary: SessionUsageSummary = {
   range: { from: NOW, to: NOW },
   totalRequests: 0,
   totalCostUsd: 0,
@@ -715,7 +715,7 @@ const emptyUsageSummary: WorkbarSessionUsageSummary = {
   },
 };
 
-const populatedUsageSummary: WorkbarSessionUsageSummary = {
+const populatedUsageSummary: SessionUsageSummary = {
   range: { from: NOW, to: NOW + 43_600 },
   totalRequests: 3,
   totalCostUsd: 0.0243,
diff --git a/apps/desktop/stories/workhub.stories.tsx b/apps/desktop/stories/workhub.stories.tsx
index 26c645cf37..be0bc2e6d6 100644
--- a/apps/desktop/stories/workhub.stories.tsx
+++ b/apps/desktop/stories/workhub.stories.tsx
@@ -19,6 +19,7 @@
 
 import { useState } from 'react';
 import { ToastProvider, LocaleProvider, AstryxLocaleProvider, ChatSurfaceLayout } from '@maka/ui';
+import type { ThinkingLevel } from '@maka/core/model-thinking';
 import type { StoredMessage, SessionSummary } from '@maka/core/session';
 import type { Meta, StoryObj } from '@storybook/react-vite';
 import { expect, fn, userEvent, within, waitFor } from 'storybook/test';
@@ -32,7 +33,7 @@ const targetId = desktopSessionKey({ hostId: 'story-host', sessionId: 'payments'
 const writes = { answer: fn(), model: fn(), upload: fn(), open: fn(), question: fn(), form: fn() };
 const choices = ['model-a', 'model-b'].map((model, index) => ({
   connectionId: 'connection-test', connectionSlug: 'test', connectionName: 'Test', providerType: 'openai' as const,
-  providerLabel: 'OpenAI', model, label: model, isDefault: index === 0, thinkingLevels: [],
+  providerLabel: 'OpenAI', model, label: model, contextWindow: 100_000, isDefault: index === 0, thinkingLevels: ['low', 'high'] as ThinkingLevel[],
 }));
 function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory: boolean, selectTarget = false, question = false, progress = false): WorkHubServices {
   let failures = failFirst ? 1 : 0;
@@ -75,6 +76,13 @@ function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory:
   const publishExecution = () => updateExecution?.({ type: 'host_execution', available: true, rootTurn: pendingForm ? { sessionId, turnId: pendingForm.turnId, runId: 'selection-run', status: 'waiting_for_user' } : questionPending ? { sessionId, turnId: 'question-turn', runId: 'question-run', status: 'waiting_for_user' } : null });
   const publish = () => { publishExecution(); updateTranscript?.({ messages, hasOlder: false, hasNewer: false, ready: true }); };
   return {
+    inspector: {
+      context: async () => ({ ok: true, data: { status: 'available', completedAt: 1, modelId: session.model, providerId: 'openai', inputTokens: 1000, contextWindow: 100_000 } }),
+      trace: async () => ({ ok: false, error: { code: 'unavailable', message: 'No trace in this fixture' } }),
+      summary: async () => ({ ok: false, error: { code: 'unavailable', message: 'No usage summary in this fixture' } }),
+      subscribeSessionEvents: () => () => {},
+      subscribeUsageChanges: () => () => {},
+    },
     retractQueueEntry: async () => {}, promoteQueueEntry: async () => {},
     updateQueueEntry: async () => {}, reorderQueueEntries: async () => {},
     enqueueMessage: async () => 'admitted',
@@ -129,7 +137,7 @@ function makeServices(failFirst: boolean, withHistory: boolean, coloredHistory:
       publish(); return { kind: 'admitted', turnId: input.turnId };
     },
     configureModel: async (id, input) => {
-      writes.model(id, input); session = { ...session, revision: session.revision + 1, model: input.modelTarget.model }; updateSessions?.();
+      writes.model(id, input); session = { ...session, revision: session.revision + 1, model: input.modelTarget.model, thinkingLevel: input.thinkingLevel ?? undefined }; updateSessions?.();
       return { kind: 'committed', session: { ...session, workspace: { target: { kind: 'host_path', path: '/projects/maka' }, hostCwd: '/projects/maka' }, createdAt: 0, activityAt: 0, labelsTruncated: false, llmConnectionId: 'connection-test', collaborationMode: 'agent', orchestrationMode: 'default' } };
     },
     observe: (_id, _event, _error, _phase, execution) => { updateExecution = execution; publishExecution(); return () => { updateExecution = undefined; }; },
@@ -183,6 +191,7 @@ export const StandardComposer: Story = {
     Object.values(writes).forEach((spy) => spy.mockClear());
     const canvas = within(canvasElement); const page = within(canvasElement.ownerDocument.body);
     await waitFor(() => expect(canvas.getByRole('button', { name: /切换当前任务模型/ })).toBeEnabled());
+    await waitFor(() => expect(canvas.getByRole('button', { name: '打开用量追踪' }).textContent).toContain('1%'));
     const trigger = canvas.getByRole('button', { name: /切换当前任务模型/ });
     const layout = () => Array.from(canvasElement.querySelectorAll('.maka-composer-editor, .maka-composer button')).map((element) => {
       const { x, y, width, height } = element.getBoundingClientRect();
@@ -228,6 +237,29 @@ export const StandardComposer: Story = {
     await waitFor(() => expect(canvasElement.querySelectorAll('.maka-composer-attachment-token')).toHaveLength(0));
   },
 };
+// Real path: WorkHub composer → thinking level → choose an override or restore the default.
+export const ThinkingLevelPicker: Story = {
+  render: () => <Surface />,
+  play: async ({ canvasElement }) => {
+    Object.values(writes).forEach((spy) => spy.mockClear());
+    const canvas = within(canvasElement); const page = within(canvasElement.ownerDocument.body);
+    await waitFor(() => expect(canvas.getByRole('button', { name: '思考级别: 默认' })).toBeEnabled());
+    const usage = canvas.getByRole('button', { name: '打开用量追踪' });
+    await waitFor(() => expect(usage.textContent).toContain('1%'));
+    await userEvent.click(usage);
+    await userEvent.click(await canvas.findByRole('button', { name: '返回对话' }));
+    await userEvent.click(canvas.getByRole('button', { name: '思考级别: 默认' }));
+    await userEvent.click(page.getByRole('menuitemradio', { name: /^高$/ }));
+    await waitFor(() => expect(canvas.getByRole('button', { name: '思考级别: 高' })).toBeEnabled());
+    await expect(writes.model).toHaveBeenCalledWith(sessionId, expect.objectContaining({ thinkingLevel: 'high' }));
+    await userEvent.click(canvas.getByRole('button', { name: '思考级别: 高' }));
+    await userEvent.click(page.getByRole('menuitemradio', { name: /^默认$/ }));
+    await waitFor(() => expect(canvas.getByRole('button', { name: '思考级别: 默认' })).toBeEnabled());
+    await expect(writes.model).toHaveBeenLastCalledWith(sessionId, expect.objectContaining({ expectedRevision: 2, thinkingLevel: null }));
+    await userEvent.click(canvas.getByRole('button', { name: '思考级别: 默认' }));
+    await expect(page.getByRole('menuitemradio', { name: /^默认$/ })).toHaveAttribute('aria-checked', 'true');
+  },
+};
 // Real path: a floating WorkHub progress card → edit its composer → open the model picker.
 export const ProgressModelPicker: Story = {
   render: () => <Surface progress />,
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 55ece72b13..5c33c68720 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -34,6 +34,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `apps/desktop/src/renderer/app-shell.tsx` | shell-chrome-or-panel | AppShell | aligned — uses Astryx (AppShell) | aligned |
 | `apps/desktop/src/renderer/app.tsx` | other | Theme | aligned — uses Astryx (Theme) | aligned |
 | `apps/desktop/src/renderer/application/contracts/feature-services.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx` | shell-chrome-or-panel | Banner, Button, EmptyState, Heading, Section, Text, VStack | aligned — uses Astryx (Banner, Button, EmptyState, Heading, Section, Text, VStack) | aligned |
 | `apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/application/contracts/settings-presentation/settings-route-header.tsx` | other | HStack, Heading, IconButton, Text, Toolbar, VStack | aligned — uses Astryx (HStack, Heading, IconButton, Text, Toolbar, VStack) | aligned |
 | `apps/desktop/src/renderer/application/contracts/settings-presentation/settings-section.tsx` | other | Divider, HStack, Heading, Item, Text, VStack | aligned — uses Astryx (Divider, HStack, Heading, Item, Text, VStack) | aligned |
@@ -102,7 +103,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
 | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx` | shell-chrome-or-panel | Banner, Button, CodeBlock, Spinner | aligned — uses Astryx (Banner, Button, CodeBlock, Spinner) | aligned |
 | `apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx` | shell-chrome-or-panel | EmptyState, IconButton, TextInput, Toolbar, Tooltip | aligned — uses Astryx (EmptyState, IconButton, TextInput, Toolbar, Tooltip) | aligned |
 | `apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
-| `apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx` | shell-chrome-or-panel | Banner, Button, EmptyState, Heading, Section, Text, VStack | aligned — uses Astryx (Banner, Button, EmptyState, Heading, Section, Text, VStack) | aligned |
 | `apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx` | shell-chrome-or-panel | Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton, Text, VStack | aligned — uses Astryx (Banner, Button, Collapsible, CollapsibleGroup, EmptyState, HStack, Section, Skeleton) | aligned |
 | `apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx` | shell-chrome-or-panel | Banner | aligned — uses Astryx (Banner) | aligned |
 | `apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx` | shell-chrome-or-panel | Banner, EmptyState | aligned — uses Astryx (Banner, EmptyState) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 66e55bfa33..b2464d002a 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -5,6 +5,7 @@ apps/desktop/src/renderer/app-shell-overlays.tsx
 apps/desktop/src/renderer/app-shell.tsx
 apps/desktop/src/renderer/app.tsx
 apps/desktop/src/renderer/application/contracts/feature-services.tsx
+apps/desktop/src/renderer/application/contracts/session-inspector/session-inspector-panel.tsx
 apps/desktop/src/renderer/application/contracts/settings-presentation/runtime-host-settings-target.tsx
 apps/desktop/src/renderer/application/contracts/settings-presentation/settings-route-header.tsx
 apps/desktop/src/renderer/application/contracts/settings-presentation/settings-section.tsx
@@ -73,7 +74,6 @@ apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-regi
 apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx
 apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx
 apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx
-apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx
 apps/desktop/src/renderer/features/workbar/tools/review/session-review-panel.tsx
 apps/desktop/src/renderer/features/workbar/tools/side-chat/quote-companion-panel.tsx
 apps/desktop/src/renderer/features/workbar/tools/terminal/session-terminal-panel.tsx
diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
index 99f3ccd020..d3cb2d92ce 100644
--- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
+++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts
@@ -2441,6 +2441,49 @@ test('cold WorkHub recovery waits for Desktop tools across pending-message and a
         kind: 'routing',
         disposition: 'answer_here',
       });
+      if (crashCut === 'pending-message') {
+        // The recovered WorkHub keeps its permanent Session but new turns
+        // must follow the current switch rather than its creation-time default.
+        const currentPolicy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease);
+        const originalMode = (await recoveredStores.sessionStore.readHeader(sessionId)).toolMode;
+        for (const enabled of [true, false]) {
+          const snapshot = await currentPolicy.runtimePolicy.getSnapshot();
+          const changed = await currentPolicy.runtimePolicy.mutate({
+            expectedRevision: snapshot.revision,
+            operation: {
+              kind: 'set_chat_defaults',
+              value: { ...snapshot.policy.chatDefaults, codeModeEnabled: enabled },
+            },
+          });
+          assert.equal(changed.kind, 'committed');
+          const turnId = randomUUID();
+          const started = await composition.handlers['workhub.coordination.answer'](
+            { turnId, text: `Code Mode ${enabled ? 'on' : 'off'}` },
+            context,
+          );
+          assert.ok(started.ok, JSON.stringify(started));
+          const query = await composition.handlers['turn.query']({ sessionId, turnId }, context);
+          assert.ok(query.ok, JSON.stringify(query));
+          const terminal = await waitForTerminal(
+            composition,
+            sessionId,
+            turnId,
+            query.result,
+            context,
+          );
+          assert.equal(terminal.status, 'completed');
+          const run = await readInvocation(recoveredStores, sessionId, terminal.runId!);
+          assert.equal(run.opening.configuration.toolMode, enabled ? 'code_mode' : 'direct');
+          assert.equal(
+            responsesToolNames(provider.requests.at(-1)?.body).includes('exec'),
+            enabled,
+          );
+          assert.equal(
+            (await recoveredStores.sessionStore.readHeader(sessionId)).toolMode,
+            originalMode,
+          );
+        }
+      }
       assert.equal(drained, false);
     } finally {
       await composition?.close();
diff --git a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts
index e2ede9da6d..1ae7df98f1 100644
--- a/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts
+++ b/packages/runtime-host/src/__tests__/hosted-execution-tool-profile.test.ts
@@ -21,6 +21,10 @@ import assert from 'node:assert/strict';
 import test from 'node:test';
 import type { MakaTool } from '@maka/runtime/tool-runtime';
 import { buildBuiltinTools } from '@maka/runtime/builtin-tools';
+import { AiSdkBackend } from '@maka/runtime/ai-sdk-backend';
+import { createBypassExecutionBoundary } from '@maka/core/sandbox-boundary';
+import { WORKHUB_COORDINATION_SESSION_ID } from '@maka/core/session';
+import type { SessionEvent } from '@maka/core/events';
 import { z } from 'zod';
 import { decodeHostedExecutionStartInput } from '../protocol/index.js';
 import {
@@ -136,7 +140,7 @@ test('the WorkHub coordination profile has conversational authority but zero too
   assert.deepEqual(projectHostedExecutionTools([productTool], 'workhub-coordination-v1'), []);
 });
 
-test('WorkHub v2 can read its attachments without inheriting terminal, browser, or filesystem access', async () => {
+test('WorkHub v2 keeps its attachment-only tool ceiling visible in direct and Code Mode', async () => {
   const makeTool = (name: string): MakaTool => ({
     name,
     description: name,
@@ -201,6 +205,119 @@ test('WorkHub v2 can read its attachments without inheriting terminal, browser,
   assert.match(prompt, /only when the user explicitly asks to create new work/u);
   assert.match(prompt, /never implies create_new/u);
   assert.match(prompt, /ordinary request to continue work is routing, not a linked resume/u);
+
+  let providerCatalog = '';
+  const backend = new AiSdkBackend({
+    sessionId: WORKHUB_COORDINATION_SESSION_ID,
+    header: {
+      id: WORKHUB_COORDINATION_SESSION_ID,
+      workspaceRoot: '/workspace',
+      cwd: '/workspace',
+      createdAt: 1,
+      name: 'WorkHub',
+      titleIsManual: true,
+      isFlagged: false,
+      labels: [],
+      isArchived: false,
+      status: 'active',
+      statusUpdatedAt: 1,
+      hasUnread: false,
+      backend: 'ai-sdk',
+      llmConnectionSlug: 'test',
+      connectionLocked: true,
+      model: 'test',
+      permissionMode: 'bypass',
+      toolProfile: 'workhub-coordination-v2',
+      toolMode: 'code_mode',
+      schemaVersion: 1,
+    },
+    connection: {
+      slug: 'test',
+      providerType: 'openai',
+      defaultModel: 'test',
+    },
+    apiKey: 'test',
+    modelId: 'test',
+    tools: [...projected],
+    maxSteps: 1,
+    readExecutionBoundary: async () => createBypassExecutionBoundary(0),
+    readPermissionMode: async () => 'bypass',
+    modelFactory: () => ({
+      specificationVersion: 'v4',
+      provider: 'test',
+      modelId: 'test',
+      supportedUrls: {},
+      doStream: async ({ tools: modelTools }: { tools?: unknown }) => {
+        providerCatalog = JSON.stringify(modelTools);
+        return {
+          stream: new ReadableStream({
+            start(controller) {
+              controller.enqueue({ type: 'stream-start', warnings: [] });
+              controller.enqueue({
+                type: 'tool-call',
+                toolCallId: 'exec-1',
+                toolName: 'exec',
+                input: JSON.stringify({
+                  code: `return {
+              names: Object.keys(tools).sort(),
+              allowed: ['Read', 'mcp__desktop_workhub__control'].map(name =>
+                [name in tools, typeof tools[name]]),
+              forbidden: ['Bash', 'Write', 'mcp__desktop_browser__browser_navigate'].map(name =>
+                [name in tools, typeof tools[name]]),
+              result: await tools.mcp__desktop_workhub__control({})
+            };`,
+                }),
+              });
+              controller.enqueue({
+                type: 'finish',
+                finishReason: { unified: 'tool-calls', raw: 'tool_calls' },
+                usage: { inputTokens: { total: 0 }, outputTokens: { total: 0 } },
+              });
+              controller.close();
+            },
+          }),
+        };
+      },
+    }),
+  });
+  const events: SessionEvent[] = [];
+  try {
+    for await (const event of backend.send({
+      turnId: 'code-mode-ceiling',
+      text: 'Inspect tools',
+      context: [],
+    })) {
+      events.push(event);
+    }
+  } finally {
+    await backend.dispose();
+  }
+  for (const name of projected.map((tool) => tool.name)) assert.ok(providerCatalog.includes(name));
+  assert.doesNotMatch(providerCatalog, /Bash|Write|mcp__desktop_browser__browser_navigate/u);
+  const result = events.find(
+    (event) => event.type === 'tool_result' && event.toolUseId === 'exec-1',
+  );
+  assert.ok(result?.type === 'tool_result', JSON.stringify(events));
+  assert.deepEqual(result.content, {
+    kind: 'json',
+    value: {
+      ok: true,
+      value: {
+        names: projected.map((tool) => tool.name).sort(),
+        allowed: [
+          [true, 'function'],
+          [true, 'function'],
+        ],
+        forbidden: [
+          [false, 'undefined'],
+          [false, 'undefined'],
+          [false, 'undefined'],
+        ],
+        result: control.name,
+      },
+      toolCalls: [{ index: 1, name: control.name }],
+    },
+  });
 });
 
 test('WorkHub routing prompt binds the exact recalled candidate without granting authority', () => {
diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts
index 409d09698e..8512a27443 100644
--- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts
+++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts
@@ -629,6 +629,7 @@ test('WorkHub model authority preserves its execution policy and uses versioned
   });
   const input = {
     expectedRevision: fixture.revision(),
+    thinkingLevel: null,
     modelTarget: {
       kind: 'explicit' as const,
       connectionId: 'connection-1',
@@ -650,6 +651,43 @@ test('WorkHub model authority preserves its execution policy and uses versioned
   assert.equal(corrupt.revision(), 3);
 });
 
+test('WorkHub thinking level persists, clears to default and rejects unsupported levels', async () => {
+  const fixture = createFixture({
+    header: {
+      id: WORKHUB_COORDINATION_SESSION_ID,
+      role: WORKHUB_COORDINATION_SESSION_ROLE,
+      toolProfile: 'workhub-coordination-v2',
+      permissionMode: 'bypass',
+    },
+    connection: {
+      providerType: 'openai-compatible',
+      modelOverrides: { 'model-1': { thinkingLevels: ['low', 'high'] } },
+    },
+  });
+  const modelTarget = {
+    kind: 'explicit' as const,
+    connectionId: 'connection-1',
+    connectionSlug: 'test',
+    model: 'model-1',
+  };
+  const set = (thinkingLevel: 'low' | 'high' | 'xhigh' | null) =>
+    fixture.coordinator.configureWorkHubModel({
+      expectedRevision: fixture.revision(),
+      modelTarget,
+      thinkingLevel,
+    });
+  assert.equal((await set('high')).ok, true);
+  assert.equal(fixture.header().thinkingLevel, 'high');
+  const revision = fixture.revision();
+  assert.equal((await set('xhigh')).ok, false);
+  assert.equal(fixture.revision(), revision);
+  assert.equal(fixture.header().thinkingLevel, 'high');
+  assert.equal((await set(null)).ok, true);
+  assert.equal(fixture.header().thinkingLevel, undefined);
+  assert.equal(fixture.header().permissionMode, 'bypass');
+  assert.equal(fixture.header().toolProfile, 'workhub-coordination-v2');
+});
+
 test('ordinary metadata and configuration reject a corrupt Coordination role on another identity', async () => {
   const corrupt = {
     ...sessionHeader('session-1', ['user-label']),
diff --git a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
index eafaffab63..0c981c433c 100644
--- a/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/workhub-coordination-protocol.test.ts
@@ -50,9 +50,10 @@ test('WorkHub Coordination resolve has a closed empty input and bounded identity
   );
 });
 
-test('WorkHub model configuration only accepts a revision and explicit model identity', () => {
+test('WorkHub model configuration accepts thinking levels without widening its authority', () => {
   const input = {
     expectedRevision: 3,
+    thinkingLevel: null,
     modelTarget: {
       kind: 'explicit',
       connectionId: 'connection-1',
@@ -61,9 +62,18 @@ test('WorkHub model configuration only accepts a revision and explicit model ide
     },
   };
   assert.deepEqual(decodeWorkHubCoordinationConfigureModelInput(input), input);
+  for (const thinkingLevel of ['high', null]) {
+    assert.deepEqual(decodeWorkHubCoordinationConfigureModelInput({ ...input, thinkingLevel }), {
+      ...input,
+      thinkingLevel,
+    });
+  }
   for (const invalid of [
+    { expectedRevision: input.expectedRevision, modelTarget: input.modelTarget },
+    { ...input, thinkingLevel: undefined },
     { ...input, sessionId: 'another-session' },
     { ...input, permissionMode: 'bypass' },
+    { ...input, thinkingLevel: 'extreme' },
     { ...input, expectedRevision: -1 },
     { ...input, modelTarget: { kind: 'default' } },
   ])
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index b1f16e7e5b..10235f989e 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -101,7 +101,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
 export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 154 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 155 as const;
 // 154: External Session import results distinguish committed Sessions from typed source limits.
 // 153: Sessions may select plugin executors and Plugin Platform queries expose them.
 // 152: Assistant completions and transcript rows preserve interrupted responses.
diff --git a/packages/runtime-host/src/protocol/workhub-coordination.ts b/packages/runtime-host/src/protocol/workhub-coordination.ts
index d3c24206fc..730520a288 100644
--- a/packages/runtime-host/src/protocol/workhub-coordination.ts
+++ b/packages/runtime-host/src/protocol/workhub-coordination.ts
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+import type { ThinkingLevel } from '@maka/core/model-thinking';
 import type { AttachmentRef } from '@maka/core/events';
 import { isWorkHubActionResult, type WorkHubActionResult } from '@maka/core/workhub-action-result';
 import { decodeMessageContent } from './turn.js';
@@ -54,6 +55,7 @@ import {
 export interface WorkHubCoordinationConfigureModelInput {
   readonly expectedRevision: number;
   readonly modelTarget: Extract<SessionModelTarget, { readonly kind: 'explicit' }>;
+  readonly thinkingLevel: ThinkingLevel | null;
 }
 
 export function decodeWorkHubCoordinationConfigureModelInput(
@@ -62,15 +64,20 @@ export function decodeWorkHubCoordinationConfigureModelInput(
   const input = requireExactRecord(value, 'WorkHub model configuration', [
     'expectedRevision',
     'modelTarget',
+    'thinkingLevel',
   ]);
   const decoded = decodeSessionConfigurationUpdateInput({
     sessionId: WORKHUB_COORDINATION_SESSION_ID,
     expectedRevision: input.expectedRevision,
-    patch: { modelTarget: input.modelTarget },
+    patch: {
+      modelTarget: input.modelTarget,
+      thinkingLevel: input.thinkingLevel,
+    },
   });
   return {
     expectedRevision: decoded.expectedRevision,
     modelTarget: decoded.patch.modelTarget!,
+    thinkingLevel: decoded.patch.thinkingLevel ?? null,
   };
 }
 
@@ -255,7 +262,10 @@ export const WORKHUB_COORDINATION_OPERATION_SPECS = {
         {
           sessionId: WORKHUB_COORDINATION_SESSION_ID,
           expectedRevision: input.expectedRevision,
-          patch: { modelTarget: input.modelTarget },
+          patch: {
+            modelTarget: input.modelTarget,
+            thinkingLevel: input.thinkingLevel,
+          },
         },
         output,
       ),
diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index c07bf840bb..962bba453c 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -1368,6 +1368,15 @@ export async function createExecutionRuntimeHostComposition(
         },
       }),
       runBackendActivation: (operation) => runtimePolicyActivation.runBackendActivation(operation),
+      resolveFreshTurnToolMode: async (header) => {
+        // WorkHub keeps one permanent Session, so creation-time defaults cannot
+        // track this setting. Snapshot it per turn without rewriting that Session.
+        if (header.id !== WORKHUB_COORDINATION_SESSION_ID) return undefined;
+        return (await runtimePolicyStores.runtimePolicy.getSnapshot()).policy.chatDefaults
+          .codeModeEnabled
+          ? 'code_mode'
+          : 'direct';
+      },
       messageAuthority: runtimeAuthority,
       hostedAgentGraphExecution: {
         readAgentGraphIntentClaim: (graphId, intentId) =>
diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts
index bb52aaa743..adf3e743b8 100644
--- a/packages/runtime-host/src/server/session-catalog-coordinator.ts
+++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts
@@ -715,7 +715,10 @@ export class HostSessionCatalogCoordinator {
       {
         sessionId: WORKHUB_COORDINATION_SESSION_ID,
         expectedRevision: input.expectedRevision,
-        patch: { modelTarget: input.modelTarget },
+        patch: {
+          modelTarget: input.modelTarget,
+          thinkingLevel: input.thinkingLevel,
+        },
       },
       'workhub',
     );
diff --git a/packages/runtime/src/__tests__/code-mode-backend.test.ts b/packages/runtime/src/__tests__/code-mode-backend.test.ts
index f6de93fb99..25b16d45de 100644
--- a/packages/runtime/src/__tests__/code-mode-backend.test.ts
+++ b/packages/runtime/src/__tests__/code-mode-backend.test.ts
@@ -601,7 +601,7 @@ test('keeps direct-only tools out of the cell snapshot', async () => {
     (event): event is Extract<SessionEvent, { type: 'tool_result' }> =>
       event.type === 'tool_result' && event.toolUseId === 'exec-1',
   );
-  assert.match(JSON.stringify(execResult?.content), /unknown_tool/);
+  assert.match(JSON.stringify(execResult?.content), /execution_error/);
 });
 
 test('keeps provider-native tools out of the cell snapshot', async () => {
@@ -638,7 +638,7 @@ test('keeps provider-native tools out of the cell snapshot', async () => {
     (event): event is Extract<SessionEvent, { type: 'tool_result' }> =>
       event.type === 'tool_result' && event.toolUseId === 'exec-1',
   );
-  assert.match(JSON.stringify(execResult?.content), /unknown_tool/);
+  assert.match(JSON.stringify(execResult?.content), /execution_error/);
 });
 
 test('validates nested arguments before ToolRuntime implementation dispatch', async () => {
diff --git a/packages/runtime/src/__tests__/code-mode.test.ts b/packages/runtime/src/__tests__/code-mode.test.ts
index 9c02e81320..bd2b7d5d0c 100644
--- a/packages/runtime/src/__tests__/code-mode.test.ts
+++ b/packages/runtime/src/__tests__/code-mode.test.ts
@@ -271,7 +271,7 @@ test('uses normal partial-execution semantics before an unknown tool failure', a
 
   assert.deepEqual(calls, ['allowed']);
   assert.equal(result.ok, false);
-  if (!result.ok) assert.equal(result.error.kind, 'unknown_tool');
+  if (!result.ok) assert.equal(result.error.kind, 'execution_error');
 });
 
 test('does not dispatch tools inherited from Object.prototype', async () => {
@@ -289,7 +289,7 @@ test('does not dispatch tools inherited from Object.prototype', async () => {
 
   try {
     const result = await execute('return await tools.inheritedCodeModeTool({});');
-    assert.equal(result.ok ? undefined : result.error.kind, 'unknown_tool');
+    assert.equal(result.ok ? undefined : result.error.kind, 'execution_error');
     assert.equal(inheritedCalls, 0);
   } finally {
     delete (Object.prototype as Record<string, unknown>).inheritedCodeModeTool;
diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts
index 584126dad2..40fdd19e9a 100644
--- a/packages/runtime/src/__tests__/session-manager.test.ts
+++ b/packages/runtime/src/__tests__/session-manager.test.ts
@@ -5891,6 +5891,59 @@ describe('SessionManager permission mode updates', () => {
     assert.strictEqual(plan.continuation?.sourceRunId, 'source-run-newer');
   });
 
+  test('fresh-turn tool policy changes only new runs and preserves Session defaults', async () => {
+    const store = new MemorySessionStore();
+    const runStore = new MemoryAgentRunStore();
+    const backends = new BackendRegistry();
+    const instances = new Map<string, TestBackend>();
+    const gate = makeGate();
+    let mode: ToolMode = 'code_mode';
+    let dynamicSessionId: string | undefined;
+    backends.register('ai-sdk', (ctx) => {
+      const backend = new TestBackend(ctx, gate);
+      instances.set(ctx.sessionId, backend);
+      return backend;
+    });
+    const manager = new SessionManager({
+      store,
+      runStore,
+      runtimeEventStore: runStore,
+      backends,
+      newId: nextId(),
+      now: nextNow(6_450),
+      resolveFreshTurnToolMode: async (header) =>
+        header.id === dynamicSessionId ? mode : undefined,
+    });
+    const dynamic = await manager.createSession({ ...makeInput(), toolMode: 'direct' });
+    const ordinary = await manager.createSession({ ...makeInput(), toolMode: 'code_mode' });
+    dynamicSessionId = dynamic.id;
+    const active = manager
+      .sendMessage(dynamic.id, { turnId: 'dynamic-1', text: 'first' })
+      [Symbol.asyncIterator]();
+    await active.next();
+    mode = 'direct';
+    assert.equal(instances.get(dynamic.id)?.sendInputs[0]?.toolMode, 'code_mode');
+    gate.release();
+    while (!(await active.next()).done) {
+      /* Drain the running turn. */
+    }
+    await drainAll(manager.sendMessage(dynamic.id, { turnId: 'dynamic-2', text: 'second' }));
+    await drainAll(manager.sendMessage(ordinary.id, { turnId: 'ordinary-1', text: 'ordinary' }));
+    assert.deepEqual(
+      instances.get(dynamic.id)?.sendInputs.map((input) => input.toolMode),
+      ['code_mode', 'direct'],
+    );
+    assert.equal(instances.get(ordinary.id)?.sendInputs[0]?.toolMode, 'code_mode');
+    assert.equal((await store.readHeader(dynamic.id)).toolMode, 'direct');
+    assert.equal((await store.readHeader(ordinary.id)).toolMode, 'code_mode');
+    assert.deepEqual(
+      (await runStore.listSessionInvocations(dynamic.id)).map(
+        (run) => run.opening.configuration.toolMode,
+      ),
+      ['code_mode', 'direct'],
+    );
+  });
+
   test('RuntimeKernel drives the backend while preserving the SessionEvent stream', async () => {
     const store = new MemorySessionStore();
     const runStore = new MemoryAgentRunStore();
@@ -6417,6 +6470,7 @@ describe('SessionManager permission mode updates', () => {
       runStore,
       runtimeEventStore: runStore,
       toolBoundaryProtocol: 't1_after_preflight_v1',
+      resolveFreshTurnToolMode: async () => 'direct',
       backends,
       childTools: [testTool('Read')],
       inspectContinuationSafety: async () => ({
@@ -6658,6 +6712,8 @@ describe('SessionManager permission mode updates', () => {
       provenance: 'runtime',
       providerStateIdentity,
     });
+    assert.equal(followUpRun?.opening.configuration.toolMode, 'direct');
+    assert.equal(backend?.sendInputs.at(-1)?.toolMode, 'direct');
   });
 
   test('authenticates the exact target-aware continuation projection that reaches the provider', async () => {
@@ -13890,6 +13946,7 @@ class MemorySessionStore implements SessionStore {
       model: input.model ?? 'fake-model',
       ...(input.thinkingLevel !== undefined ? { thinkingLevel: input.thinkingLevel } : {}),
       permissionMode: input.permissionMode,
+      ...(input.toolMode !== undefined ? { toolMode: input.toolMode } : {}),
       collaborationMode: input.collaborationMode ?? 'agent',
       orchestrationMode: input.orchestrationMode ?? 'default',
       transcriptLedgerVersion: 1,
diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts
index 2308947335..a98b858948 100644
--- a/packages/runtime/src/runtime-kernel.ts
+++ b/packages/runtime/src/runtime-kernel.ts
@@ -287,6 +287,8 @@ export interface RuntimeKernelDeps {
   safeBoundaryResumeEnabled?: boolean;
   continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise<void>;
   runBackendActivation?: BackendActivationBoundary;
+  /** Host policy for a fresh turn; continuations retain their invocation snapshot. */
+  resolveFreshTurnToolMode?: (header: SessionHeader) => Promise<ToolMode | undefined>;
   /** Hosted composition capability. When present, the Host owns all message queues. */
   messageAuthority?: RuntimeMessageAuthority;
   /** Hosted composition capability. Omit for embedded interaction ownership. */
@@ -685,6 +687,7 @@ export class RuntimeKernel implements RuntimeKernelLike {
       const run = new AgentRun({
         sessionId,
         header,
+        effectiveToolMode: await this.deps.resolveFreshTurnToolMode?.(header),
         userInput: input,
         runId: options.runId,
         userMessageId: options.userMessageId,
diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts
index 214c6efc2c..1e241812b9 100644
--- a/packages/runtime/src/session-manager.ts
+++ b/packages/runtime/src/session-manager.ts
@@ -71,7 +71,7 @@ import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
 import type { UserQuestionResponse } from '@maka/core/user-question';
 import type { PermissionMode } from '@maka/core/permission';
 import { isCanonicalReadOnlyPermissionProfile } from '@maka/core/permission-profile';
-import { DEFAULT_TOOL_MODE } from '@maka/core/tool-mode';
+import { DEFAULT_TOOL_MODE, type ToolMode } from '@maka/core/tool-mode';
 import type {
   CreateSandboxBoundaryRequest,
   ExecutionBoundary,
@@ -846,6 +846,8 @@ interface SessionManagerBaseDeps {
   inspectContinuationSafety?: (sessionId: string) => Promise<RuntimeContinuationSafetyObservation>;
   continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise<void>;
   runBackendActivation?: BackendActivationBoundary;
+  /** Host policy for a fresh turn; continuations retain their invocation snapshot. */
+  resolveFreshTurnToolMode?: (header: SessionHeader) => Promise<ToolMode | undefined>;
   safeBoundaryResumeEnabled?: boolean;
   /** Hosted composition capability. Omit for the production embedded queue. */
   messageAuthority?: RuntimeMessageAuthority;
diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx
index f107ed7325..c654985b5d 100644
--- a/packages/ui/src/chat-model-switcher.tsx
+++ b/packages/ui/src/chat-model-switcher.tsx
@@ -99,7 +99,7 @@ export function ThinkingLevelSelector(props: {
     <DropdownMenu
       placement="above"
       hasChevron={false}
-      className="maka-composer-quiet-menu"
+      className="maka-composer-quiet-menu maka-thinking-level-menu"
       button={{
         label: currentLabel,
         variant: 'ghost',
diff --git a/patches/@maka+runtime++@ai-sdk+code-mode+1.0.56.patch b/patches/@maka+runtime++@ai-sdk+code-mode+1.0.56.patch
index 7e185cfa33..016ec09876 100644
--- a/patches/@maka+runtime++@ai-sdk+code-mode+1.0.56.patch
+++ b/patches/@maka+runtime++@ai-sdk+code-mode+1.0.56.patch
@@ -12,7 +12,7 @@ index b317e9d..5beb1c2 100644
    timeoutMs?: number;
    /** @defaultValue `64 * 1024 * 1024` */
 diff --git a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/src/run-code-mode.ts b/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/src/run-code-mode.ts
-index 95435bd..fb7ba60 100644
+index 95435bd..d69b159 100644
 --- a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/src/run-code-mode.ts
 +++ b/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/src/run-code-mode.ts
 @@ -159,6 +159,7 @@
@@ -31,7 +31,41 @@ index 95435bd..fb7ba60 100644
      memoryLimitBytes: policy.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES,
      maxStackSizeBytes: policy.maxStackSizeBytes ?? DEFAULT_STACK_LIMIT_BYTES,
      maxResultBytes: policy.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES,
-@@ -363,6 +365,7 @@
+@@ -202,13 +204,11 @@
+ }
+ 
+ function createCodeModeSource(js: string, toolNames: string[]): string {
+-  const bindings = Object.fromEntries(
+-    toolNames.map((toolName, index) => [toolName, `__codeMode.tool${index}`]),
+-  );
+-  const bindingSource = Object.entries(bindings)
+-    .map(([name, reference]) => `${JSON.stringify(name)}:${reference}`)
++  // Expose only the active catalog: unknown names must not look callable.
++  const bindingSource = toolNames
++    .map((name, index) => `[${JSON.stringify(name)}]:(input)=>__codeMode.tool${index}(input)`)
+     .join(',');
+-  return `const __codeModeBindings={${bindingSource}};const tools=new Proxy(Object.create(null),{get(_target,name){const binding=__codeModeBindings[name];return typeof binding==="function"?(input)=>binding(input):(input)=>__codeMode.missing(String(name),input);}});const __codeModeResult=await(async()=>{\n${js}\n})();if(__codeModeResult===undefined)return undefined;return JSON.parse(JSON.stringify(__codeModeResult));`;
++  return `const tools=Object.freeze({__proto__:null,${bindingSource}});const __codeModeResult=await(async()=>{\n${js}\n})();if(__codeModeResult===undefined)return undefined;return JSON.parse(JSON.stringify(__codeModeResult));`;
+ }
+ 
+ function createHostFunctions({
+@@ -236,15 +236,6 @@
+         toolName,
+       });
+   }
+-  group.missing = async (toolName: unknown, toolInput: unknown) =>
+-    await invokeCodeModeTool({
+-      codeModeErrors,
+-      input,
+-      outerToolCallId,
+-      policy,
+-      toolInput,
+-      toolName: String(toolName),
+-    });
+   return { __codeMode: group } as HostFunctions;
+ }
+ 
+@@ -363,6 +354,7 @@
  ): RunLimits {
    return {
      timeoutMs: policy.timeoutMs,
@@ -39,6 +73,28 @@ index 95435bd..fb7ba60 100644
      memoryLimitBytes: policy.memoryLimitBytes,
      maxStackSizeBytes: policy.maxStackSizeBytes,
      maxResultBytes: expandedSerializationLimit(policy.maxResultBytes),
+@@ -545,10 +537,7 @@
+       interruptId: `${toolCallId}:interrupt`,
+       toolName,
+       toolCallId,
+-      input:
+-        interruption.hostFunctionName === '__codeMode.missing'
+-          ? interruption.arguments[1]
+-          : interruption.arguments[0],
++      input: interruption.arguments[0],
+       payload: assertInterruptPayload(interruption.payload),
+     };
+   });
+@@ -568,9 +557,6 @@
+   interruption: RunInterruption,
+   toolNames: string[],
+ ): string {
+-  if (interruption.hostFunctionName === '__codeMode.missing') {
+-    return String(interruption.arguments[0]);
+-  }
+   const match = /^__codeMode\.tool(\d+)$/u.exec(interruption.hostFunctionName);
+   const toolName = match === null ? undefined : toolNames[Number(match[1])];
+   if (toolName === undefined) {
 diff --git a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/index.d.ts b/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/index.d.ts
 index 9cafdbb..3c924f1 100644
 --- a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/index.d.ts
@@ -53,7 +109,7 @@ index 9cafdbb..3c924f1 100644
      timeoutMs?: number;
      /** @defaultValue `64 * 1024 * 1024` */
 diff --git a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/run-code-mode.js b/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/run-code-mode.js
-index 257b976..8095276 100644
+index 257b976..67d8409 100644
 --- a/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/run-code-mode.js
 +++ b/node_modules/@maka/runtime/node_modules/@ai-sdk/code-mode/dist/run-code-mode.js
 @@ -117,6 +117,7 @@
@@ -64,7 +120,41 @@ index 257b976..8095276 100644
      memoryLimitBytes: policy.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES,
      maxStackSizeBytes: policy.maxStackSizeBytes ?? DEFAULT_STACK_LIMIT_BYTES,
      maxResultBytes: policy.maxResultBytes ?? DEFAULT_MAX_RESULT_BYTES,
-@@ -260,6 +261,7 @@
+@@ -138,13 +139,11 @@
+   }
+ }
+ function createCodeModeSource(js, toolNames) {
+-  const bindings = Object.fromEntries(
+-    toolNames.map((toolName, index) => [toolName, `__codeMode.tool${index}`])
+-  );
+-  const bindingSource = Object.entries(bindings).map(([name, reference]) => `${JSON.stringify(name)}:${reference}`).join(",");
+-  return `const __codeModeBindings={${bindingSource}};const tools=new Proxy(Object.create(null),{get(_target,name){const binding=__codeModeBindings[name];return typeof binding==="function"?(input)=>binding(input):(input)=>__codeMode.missing(String(name),input);}});const __codeModeResult=await(async()=>{
+-${js}
+-})();if(__codeModeResult===undefined)return undefined;return JSON.parse(JSON.stringify(__codeModeResult));`;
++  // Expose only the active catalog: unknown names must not look callable.
++  const bindingSource = toolNames
++    .map((name, index) => `[${JSON.stringify(name)}]:(input)=>__codeMode.tool${index}(input)`)
++    .join(",");
++  return `const tools=Object.freeze({__proto__:null,${bindingSource}});const __codeModeResult=await(async()=>{\n${js}\n})();if(__codeModeResult===undefined)return undefined;return JSON.parse(JSON.stringify(__codeModeResult));`;
+ }
+ function createHostFunctions({
+   codeModeErrors,
+@@ -164,14 +163,6 @@
+       toolName
+     });
+   }
+-  group.missing = async (toolName, toolInput) => await invokeCodeModeTool({
+-    codeModeErrors,
+-    input,
+-    outerToolCallId,
+-    policy,
+-    toolInput,
+-    toolName: String(toolName)
+-  });
+   return { __codeMode: group };
+ }
+ async function invokeCodeModeTool({
+@@ -260,6 +251,7 @@
  function toRunLimits(policy, source, userSource) {
    return {
      timeoutMs: policy.timeoutMs,
@@ -72,3 +162,22 @@ index 257b976..8095276 100644
      memoryLimitBytes: policy.memoryLimitBytes,
      maxStackSizeBytes: policy.maxStackSizeBytes,
      maxResultBytes: expandedSerializationLimit(policy.maxResultBytes),
+@@ -402,7 +394,7 @@
+       interruptId: `${toolCallId}:interrupt`,
+       toolName,
+       toolCallId,
+-      input: interruption.hostFunctionName === "__codeMode.missing" ? interruption.arguments[1] : interruption.arguments[0],
++      input: interruption.arguments[0],
+       payload: assertInterruptPayload(interruption.payload)
+     };
+   });
+@@ -417,9 +409,6 @@
+   return Number(match[1]);
+ }
+ function toolNameForInterruption(interruption, toolNames) {
+-  if (interruption.hostFunctionName === "__codeMode.missing") {
+-    return String(interruption.arguments[0]);
+-  }
+   const match = /^__codeMode\.tool(\d+)$/u.exec(interruption.hostFunctionName);
+   const toolName = match === null ? void 0 : toolNames[Number(match[1])];
+   if (toolName === void 0) {


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org