Merge pull request #9958 from SivanCola/feature/composer-toolbar-refresh

feat(desktop): refine composer controls and feedback / 优化输入栏交互与运行反馈
This commit is contained in:
SivanCola authored and GitHub committed 2026-09-08 22:27:08 +08:00
commit e2298bd782
35 files changed
+983 -688

No files matched your search

+2 -2
View File
@@ -1399,7 +1399,7 @@ status_bar_items = ["model", "cache", "balance"]
if got.ConfigPath != config.UserConfigPath() {
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
}
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "text" {
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "icon" {
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
}
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
@@ -1412,7 +1412,7 @@ status_bar_items = ["model", "cache", "balance"]
t.Fatalf("SetDesktopLanguage: %v", err)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "text" {
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "icon" {
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
}
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import { initialState, reducer, type State } from "../lib/useController";
const originalNow = Date.now;
let now = 1_000;
Date.now = () => now;
const done = (s: State) => reducer(s, { type: "event", e: { kind: "turn_done" } });
const idle = (s: State, backgroundJobs = 0) => reducer(s, {
type: "backend_status", running: false, pendingPrompt: false,
cancellable: false, cancelRequested: false, backgroundJobs,
});
const metrics = (s: State) => [s.turnDoneAt, s.lastTurnDoneAt, s.lastTurnOutputTokens,
s.lastTurnModelMs, s.lastTurnWaitAccumMs, s.lastTurnOutputEstimated];
try {
const active: State = { ...initialState, running: true, turnActive: true,
turnStartAt: 1_000, backgroundJobs: 1, turnOutputTokens: 20, turnTokens: 100,
turnModelActiveMs: 2_000, live: { id: "a", text: "x".repeat(16), reasoning: "", reasoningComplete: true } };
now = 21_000;
const completed = done(active);
assert.equal(completed.turnDoneAt - completed.turnStartAt, 20_000);
assert.equal(completed.lastTurnOutputTokens, 24);
now = 90_000;
const jobsEnded = idle(completed);
assert.equal(jobsEnded.backgroundJobs, 0);
assert.deepEqual(metrics(jobsEnded), metrics(completed), "background completion preserves the final snapshot");
assert.deepEqual(metrics(done(jobsEnded)), metrics(completed), "duplicate turn_done is idempotent");
now = 21_000;
const fallback = idle(active, 1);
now = 90_000;
assert.deepEqual(metrics(done(fallback)), metrics(fallback), "late turn_done preserves a backend-settled snapshot");
now = 21_000;
const waiting = done({ ...active, promptWaitStartedAt: 11_000, turnModelActiveAt: 10_000 });
assert.equal(waiting.lastTurnWaitAccumMs, 10_000, "open wait closes at completion");
assert.equal(waiting.promptWaitStartedAt, undefined);
assert.equal(waiting.lastTurnModelMs, 13_000, "model activity closes at the same timestamp");
assert.deepEqual(metrics(idle(waiting)), metrics(waiting));
const planGate = done({ ...active, promptWaitStartedAt: 11_000,
approval: { id: "plan", tool: "exit_plan_mode", subject: "Plan ready" } });
assert.equal(planGate.running, true, "plan approval remains actionable after turn_done");
assert.equal(planGate.promptWaitStartedAt, 21_000);
now = 90_000;
const gateClosed = idle(planGate);
assert.deepEqual(metrics(gateClosed), metrics(planGate), "later plan approval wait cannot rewrite the settled snapshot");
const lateUsage = reducer(completed, { type: "event", e: { kind: "usage", usage: {
promptTokens: 100, completionTokens: 30, totalTokens: 130, cacheHitTokens: 0,
cacheMissTokens: 100, sessionCacheHitTokens: 0, sessionCacheMissTokens: 100,
} } });
assert.equal(lateUsage.turnDoneAt, completed.turnDoneAt, "late usage cannot change the completion timestamp");
now = 100_000;
for (const next of [
reducer(completed, { type: "user", text: "next", seq: 1, submissionId: "next-turn" }),
reducer(completed, { type: "event", e: { kind: "turn_started", turnStartedAt: now } }),
reducer(completed, { type: "backend_status", running: true, cancellable: true, turnStartedAt: now }),
]) {
assert.equal(next.turnDoneAt, 0, "new turn clears completion through every entrypoint");
assert.equal(next.turnOutputTokens, 0);
now += 1_000;
assert.notEqual(done(next).turnDoneAt, completed.turnDoneAt);
}
const retry = reducer(active, { type: "event", e: { kind: "retrying", retryAttempt: 1, retryMax: 3 } });
assert.equal(retry.turnStartAt, active.turnStartAt);
assert.equal(retry.turnOutputTokens, 20, "same-turn retry retains counters");
assert.deepEqual(metrics(completed), [21_000, 21_000, 24, 2_000, 0, true], "other tab transitions cannot mutate a completed state");
console.log("completed-turn telemetry: all assertions passed");
} finally {
Date.now = originalNow;
}
@@ -284,39 +284,21 @@ console.log("\ncomposer goal toggle");
eq(textarea.value, "/reviewer ship the release notes", "prefix insert preserves the draft as a subagent task");
eq(calls.send.length, 0, "prefix insert does not send the subagent task");
const intentButton = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
if (!intentButton) throw new Error("composer intent button did not render");
eq(intentButton.textContent?.trim(), "Standard", "execution method trigger shows only the current method");
eq(intentButton.getAttribute("aria-label"), "Execution method · Standard", "execution method trigger keeps its full accessible name");
const intentTooltipTrigger = intentButton.closest(".tooltip-trigger");
if (!intentTooltipTrigger) throw new Error("composer intent tooltip trigger did not render");
await act(async () => {
intentTooltipTrigger.dispatchEvent(new Event("focusin", { bubbles: true }));
await flushTimers();
});
await waitFor("execution method tooltip", () => document.querySelector('[role="tooltip"]') !== null);
eq(document.querySelector('[role="tooltip"]')?.textContent, "Execution method · Standard: Analyze and act as you go", "execution method tooltip combines category, value, and summary");
await act(async () => {
intentTooltipTrigger.dispatchEvent(new Event("focusout", { bubbles: true }));
await flushTimers();
});
eq(document.querySelector(".composer-task-mode-trigger"), null, "default execution has no mode chip");
const intentButton = document.querySelector(".composer-content-trigger") as HTMLButtonElement;
await act(async () => {
intentButton.click();
await flushTimers();
});
const taskModeItems = document.querySelectorAll(".composer-intent-menu__item");
eq(taskModeItems.length, 3, "task method menu exposes three mutually exclusive choices");
eq(taskModeItems.length, 2, "task method menu exposes only Plan and Goal");
eq(document.querySelectorAll(".composer-intent-switch").length, 0, "task method menu does not present independent switches");
const planButton = taskModeItems[1] as HTMLButtonElement | undefined;
const planButton = taskModeItems[0] as HTMLButtonElement | undefined;
if (!planButton) throw new Error("composer Plan menu item did not render");
ok(planButton.textContent?.includes("tool use follows current permissions and sandbox settings") === true, "Plan menu explains that permissions and sandbox still govern tools");
eq(planButton.querySelector(".composer-access-menu__desc"), null, "Plan menu keeps a single-line label");
ok(planButton.textContent?.toLowerCase().includes("read-only") === false, "Plan menu does not present Plan as a read-only permission mode");
const askApprovalButton = document.querySelector(".composer-modebar__item--ask") as HTMLButtonElement | null;
if (!askApprovalButton) throw new Error("composer Ask approval button did not render");
ok(askApprovalButton.title.includes("Ask is not read-only"), "Ask tooltip distinguishes approval policy from read-only sandboxing");
const goalButton = taskModeItems[2] as HTMLButtonElement | undefined;
const goalButton = taskModeItems[1] as HTMLButtonElement | undefined;
if (!goalButton) throw new Error("composer goal menu item did not render");
await act(async () => {
@@ -328,6 +310,25 @@ console.log("\ncomposer goal toggle");
eq(calls.setCollaborationMode.join(","), "goal", "enabling goal mode switches only the collaboration axis");
eq(textarea.value, "/reviewer ship the release notes", "enabling goal mode preserves the prefixed draft text");
await rerender({ collaborationMode: "plan" });
ok(document.querySelector(".composer-task-mode-trigger") !== null, "Plan exposes its active mode chip");
await act(async () => {
document.querySelector<HTMLButtonElement>(".composer-content-trigger")?.click();
await flushTimers();
});
await act(async () => {
document.querySelector<HTMLButtonElement>(".composer-intent-menu__item")?.click();
await flushTimers();
});
eq(calls.setCollaborationMode.at(-1), "normal", "selecting active Plan exits to the implicit default");
eq(textarea.value, "/reviewer ship the release notes", "exiting Plan preserves the draft");
await act(async () => {
document.querySelector<HTMLButtonElement>(".composer-task-mode-trigger")?.click();
await flushTimers();
});
eq(calls.setCollaborationMode.at(-1), "normal", "clicking the mode chip exits Plan directly");
eq(textarea.value, "/reviewer ship the release notes", "dismissing the chip preserves the draft");
await act(async () => {
root.unmount();
});
@@ -706,7 +707,7 @@ console.log("\ncomposer goal toggle");
ok(intentButton.textContent?.includes("Goal") === true, "task method trigger exposes an active goal");
await act(async () => {
intentButton.click();
(document.querySelector(".composer-content-trigger") as HTMLButtonElement).click();
await flushTimers();
});
@@ -720,6 +721,11 @@ console.log("\ncomposer goal toggle");
});
eq(calls.clearGoal, 1, "explicit stop action clears the active goal");
eq(calls.setCollaborationMode.length, 0, "stopping a goal does not race a second mode update");
await act(async () => {
intentButton.click();
await flushTimers();
});
eq(calls.clearGoal, 2, "dismissing an active goal chip uses the existing clear-goal action");
await act(async () => {
root.unmount();
@@ -1,8 +1,8 @@
// Run: tsx src/__tests__/composer-run-strip.test.tsx
//
// The run state lives inside the composer card (no floating pill, no layout
// jump), stop has a fixed home next to send, and a pending approval/ask shifts
// the strip into a waiting state instead of a ticking "working" spinner.
// Ordinary work uses the perimeter trace and an accessible status announcement.
// Timing/throughput live in the context popover; approval/ask retain an in-card
// attention strip, and stop keeps a fixed home next to send.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
@@ -38,6 +38,15 @@ function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function readRunMetrics() {
await act(async () => {
const ring = document.querySelector(".context-ring") as HTMLButtonElement;
if (ring.getAttribute("aria-expanded") !== "true") ring.click();
await flushTimers();
});
return ` ${document.querySelector(".context-ring-popover")?.textContent ?? ""}`;
}
class TestResizeObserver {
observe() {}
unobserve() {}
@@ -182,8 +191,12 @@ console.log("\ncomposer run strip");
eq(document.querySelector(".composer__btn--stop"), null, "idle composer renders no stop button");
ok(document.querySelector(".composer__btn--send") !== null, "idle composer keeps the send button");
eq(document.querySelector(".composer-toolbar--status-only"), null, "floating status pill is gone");
const yolo = document.querySelector<HTMLButtonElement>(".composer-modebar__item--yolo");
ok(yolo !== null, "approval bar always exposes Yolo alongside Ask and Auto");
await act(async () => {
document.querySelector<HTMLButtonElement>(".composer-meta__control--approval button")?.click();
await flushTimers();
});
const yolo = document.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')[2];
ok(yolo !== null, "approval menu exposes Yolo alongside Ask and Auto");
await act(async () => {
yolo?.click();
await flushTimers();
@@ -207,14 +220,14 @@ console.log("\ncomposer run strip");
const chrome = document.body.textContent ?? "";
eq(chrome.includes("Execution setting"), false, "composer chrome does not mention execution setting");
const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
const intentTrigger = document.querySelector(".composer-content-trigger") as HTMLButtonElement | null;
if (!intentTrigger) throw new Error("task intent trigger did not render");
await act(async () => {
intentTrigger.click();
await flushTimers();
});
eq(document.querySelector(".composer-intent-menu")?.textContent?.includes("Work mode"), false, "task-intent menu does not own a work-mode section");
eq(document.querySelectorAll('.composer-intent-menu [role="menuitemradio"]').length, 3, "task method menu exposes direct, plan, and goal");
eq(document.querySelectorAll('.composer-intent-menu [role="menuitemradio"]').length, 2, "task method menu exposes plan and goal");
await act(async () => {
root.unmount();
@@ -228,7 +241,7 @@ console.log("\ncomposer run strip");
{
const dom = installDom();
const timers = installWindowTimerQueue();
const { root } = await renderComposer({ showContextWindowRing: true });
const { root } = await renderComposer({ showContextWindowRing: true, collaborationMode: "plan" });
for (const selector of [".composer-task-mode-trigger"]) {
const trigger = document.querySelector(selector) as HTMLButtonElement | null;
@@ -254,8 +267,8 @@ console.log("\ncomposer run strip");
intentTrigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
timers.advance(120);
});
ok(intentTrigger.classList.contains("composer-task-mode-trigger--open"), "a sustained Creation hover still opens the trigger");
ok(document.querySelector(".composer-intent-menu") !== null, "a sustained Creation hover still renders the menu");
ok(intentTrigger.classList.contains("composer-task-mode-trigger--removable"), "mode chip exposes its dismiss interaction");
ok(document.querySelector(".composer-intent-menu") === null, "hovering a mode chip does not open the add menu");
timers.restore();
await act(async () => {
@@ -270,11 +283,11 @@ console.log("\ncomposer run strip");
const dom = installDom();
const { root } = await renderComposer({ disabled: true, goal: "ship it", collaborationMode: "goal" });
const task = document.querySelector<HTMLButtonElement>(".composer-task-mode-trigger");
const approvals = Array.from(document.querySelectorAll<HTMLButtonElement>(".composer-modebar--approval button"));
const approvals = Array.from(document.querySelectorAll<HTMLButtonElement>(".composer-meta__control--approval button"));
const send = document.querySelector<HTMLButtonElement>(".composer__btn--send");
eq(document.querySelector(".composer-profile-trigger"), null, "runtime transition has no execution-setting control");
ok(Boolean(task?.disabled), "runtime transition disables Goal mode changes");
ok(approvals.length === 3 && approvals.every((button) => button.disabled), "runtime transition disables Ask/Auto/Yolo changes");
ok(approvals.length === 1 && approvals.every((button) => button.disabled), "runtime transition disables Ask/Auto/Yolo changes");
ok(Boolean(send?.disabled), "runtime transition disables submit");
await act(async () => {
@@ -283,18 +296,17 @@ console.log("\ncomposer run strip");
dom.window.close();
}
// Running: strip lives inside the card, ticker is aria-hidden, stop cancels.
// Running: no visible strip, stable accessible announcement, stop cancels.
{
const dom = installDom();
const { root, calls } = await renderComposer({ running: true, turnStartAt: Date.now() });
const strip = document.querySelector(".composer-card .composer-run-strip");
ok(strip !== null, "running strip renders inside the composer card");
const ticker = strip?.querySelector(".composer-run-strip__text");
eq(ticker?.getAttribute("aria-hidden"), "true", "ticking spinner text stays out of the accessibility tree");
const live = strip?.querySelector(".sr-only[role=\"status\"]");
eq(strip, null, "ordinary running state has no visible strip");
const live = document.querySelector(".composer-card .sr-only[role=\"status\"]");
eq(live?.textContent, "Reasonix is working", "live region announces the stable state text only");
ok(document.querySelector(".composer-card--running") !== null, "running card keeps its running modifier");
eq(document.querySelector(".composer-glowring")?.getAttribute("aria-hidden"), "true", "active work mounts a decorative perimeter trace");
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
if (!stop) throw new Error("running composer stop button did not render");
@@ -324,9 +336,10 @@ console.log("\ncomposer run strip");
eq(text?.getAttribute("aria-hidden"), null, "waiting text is static and stays accessible");
eq(document.querySelector(".composer-card--running"), null, "waiting card hands the running accent off to the prompt card");
ok(document.querySelector(".composer-card--waiting") !== null, "waiting card takes the waiting modifier");
eq(document.querySelector(".composer-glowring"), null, "waiting removes the trace rather than running an invisible animation");
const modeButtons = [...document.querySelectorAll(".composer-modebar--approval .composer-modebar__item")] as HTMLButtonElement[];
ok(modeButtons.length === 3 && modeButtons.every((b) => !b.disabled), "approval bar stays usable while its own prompt disables the composer");
const modeButtons = [...document.querySelectorAll(".composer-meta__control--approval button")] as HTMLButtonElement[];
ok(modeButtons.length === 1 && modeButtons.every((b) => !b.disabled), "approval bar stays usable while its own prompt disables the composer");
await rerender({ pendingApprovalLabel: null, pendingAsk: true });
eq(
@@ -341,8 +354,8 @@ console.log("\ncomposer run strip");
await rerender({ pendingAsk: false, disabled: false });
ok(
document.querySelector(".composer-run-strip__text")?.getAttribute("aria-hidden") === "true",
"resolving the prompt returns the strip to the ticking spinner",
document.querySelector(".composer-run-strip") === null,
"resolving the prompt removes the attention strip",
);
await act(async () => {
@@ -393,8 +406,8 @@ console.log("\ncomposer run strip");
});
await rerender({ pendingApprovalLabel: null, disabled: false });
const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
ok(/ 30s| 31s/.test(ticker), `ticker excludes the time spent waiting for approval (got "${ticker}")`);
const ticker = await readRunMetrics();
ok(/30s|31s/.test(ticker), `ticker excludes the time spent waiting for approval (got "${ticker}")`);
ok(!/ 32s| 33s/.test(ticker), "ticker does not count the ~2.4s approval wait as model time");
await act(async () => {
@@ -416,8 +429,8 @@ console.log("\ncomposer run strip");
});
await rerender({ suspendedByDecision: false, disabled: false });
const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
ok(/ 15s| 16s/.test(ticker), `suspendedByDecision excludes wait time from model clock (got "${ticker}")`);
const ticker = await readRunMetrics();
ok(/15s|16s/.test(ticker), `suspendedByDecision excludes wait time from model clock (got "${ticker}")`);
ok(!/ 17s| 18s/.test(ticker), "suspended wait is not counted as model work");
await act(async () => {
@@ -469,9 +482,9 @@ console.log("\ncomposer run strip");
turnWaitAccumMs: closedWaitMs,
});
const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
const ticker = await readRunMetrics();
// 8s turn age − ~3.3s user wait ≈ 5s model work (not ~8s wall, not ~0–2s from A leak).
ok(/ 4s| 5s| 6s/.test(ticker), `tab B excludes background user-wait from model clock (got "${ticker}")`);
ok(/4s|5s|6s/.test(ticker), `tab B excludes background user-wait from model clock (got "${ticker}")`);
ok(!/ 7s| 8s| 9s| 10s| 11s/.test(ticker), "background suspension is not counted as model work");
ok(!/ 5[5-9]s| 6[0-9]s/.test(ticker), "tab B does not show tab A's ~60s turn age as model time");
@@ -500,7 +513,7 @@ console.log("\ncomposer run strip");
},
});
const ticker = document.querySelector(".composer-run-strip")?.textContent ?? "";
const ticker = await readRunMetrics();
ok(ticker.includes("10 tokens/s"), "streaming TPS uses provider-output time instead of full turn age");
ok(ticker.includes("18 tokens"), "streaming token total adds the current request estimate to completed usage");
@@ -510,6 +523,85 @@ console.log("\ncomposer run strip");
dom.window.close();
}
// Metrics survive wait/retry/completion and derive completed time from the
// controller timestamp, including when mounting an already completed tab.
{
const dom = installDom();
const start = Date.now() - 30_000;
const { root, rerender } = await renderComposer({
running: true, turnStartAt: start, turnTokens: 100,
turnOutputTokens: 20, turnModelActiveMs: 2_000,
});
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
ok((await readRunMetrics()).includes("100 tokens"), "approval wait retains turn tokens");
await rerender({ pendingApprovalLabel: null, disabled: false,
retry: { attempt: 1, max: 3 } });
ok((await readRunMetrics()).includes("10 tokens/s"), "retry retains throughput");
await rerender({ running: false, retry: undefined, turnDoneAt: start + 20_000,
lastTurnOutputTokens: 24, turnWaitAccumMs: 0 });
const completed = await readRunMetrics();
ok(/19s|20s/.test(completed), `completed duration uses the controller timestamp minus local wait (got "${completed}")`);
ok(completed.includes("104 tokens"), "completion keeps final in-flight token estimates");
await rerender({ lastTurnWaitAccumMs: 0, turnWaitAccumMs: 60_000 });
ok((await readRunMetrics()).includes("20s"), "later wait accounting cannot change the frozen completion duration");
await rerender({ sessionKey: "completed-tab", tabId: "completed-tab" });
ok((await readRunMetrics()).includes("20s"), "switching to a completed tab preserves its duration");
await rerender({ running: true, turnStartAt: Date.now(), turnDoneAt: 0,
turnTokens: 0, turnOutputTokens: 0, turnModelActiveMs: 0 });
ok(!(await readRunMetrics()).includes("104 tokens"), "new turn does not inherit previous turn metrics");
await act(async () => { root.unmount(); });
dom.window.close();
}
{
const dom = installDom();
const picked: string[] = [];
let modelChanges = 0;
const { root, rerender } = await renderComposer({
effort: { supported: true, current: "auto", default: "high", levels: ["auto", "high", "max"] },
onSetEffort: level => picked.push(level),
onSwitchModel: () => { modelChanges += 1; },
});
const trigger = document.querySelector<HTMLButtonElement>(".composer-effort-control button");
if (!trigger) throw new Error("missing independent effort selector");
await act(async () => { trigger.click(); await flushTimers(); });
const high = [...document.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')].find(e => e.textContent === "high");
if (!high) throw new Error("missing high effort option");
await act(async () => { high.click(); await flushTimers(); });
eq(picked.join(","), "high", "separate effort selector changes reasoning effort");
eq(modelChanges, 0, "changing effort does not switch models");
eq(trigger.getAttribute("aria-expanded"), "false", "effort menu closes after selection");
await rerender({ effort: { supported: false, current: "auto", default: "auto", levels: [] } });
eq(document.querySelector(".composer-effort-control"), null, "unsupported models hide effort control");
await act(async () => root.unmount());
dom.window.close();
}
{
const dom = installDom();
const floors: string[] = [];
const modeChanges: string[] = [];
const { root, rerender } = await renderComposer({ collaborationMode: "plan", onSetQualityFloor: floor => floors.push(floor), onSetCollaborationMode: mode => modeChanges.push(mode) });
eq(document.querySelector(".composer-delivery-trigger"), null, "default standard has no delivery chip");
await act(async () => {
document.querySelector<HTMLButtonElement>(".composer-content-trigger")?.click();
await flushTimers();
});
const toggle = document.querySelector<HTMLButtonElement>('[role="menuitemcheckbox"]');
if (!toggle) throw new Error("delivery toggle missing");
eq(toggle.getAttribute("aria-checked"), "false", "delivery is off by default");
await act(async () => { toggle.click(); await flushTimers(); });
eq(floors.at(-1), "delivery", "delivery toggle enables delivery verification");
await rerender({ qualityFloor: "delivery" });
const chip = document.querySelector<HTMLButtonElement>(".composer-delivery-trigger");
if (!chip) throw new Error("delivery chip missing");
await act(async () => { chip.click(); await flushTimers(); });
eq(floors.at(-1), "standard", "closing delivery chip restores implicit standard");
eq(modeChanges.length, 0, "delivery does not change Plan or Goal mode");
await act(async () => root.unmount());
dom.window.close();
}
// Resize consistency: --composer-height always carries the logical height in
// every writer (React render, live drag, keyboard), with the run strip's
// reservation isolated in a CSS calc. A manual height is the draft's minimum,
@@ -525,6 +617,7 @@ console.log("\ncomposer run strip");
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
const handle = document.querySelector(".composer-resize-handle") as HTMLButtonElement;
eq((document.querySelector(".composer-card") as HTMLElement).style.getPropertyValue("--composer-height"), "140px", "fresh composer defaults to the selected 140px height");
await act(async () => {
handle.focus();
handle.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
@@ -533,7 +626,7 @@ console.log("\ncomposer run strip");
const card = document.querySelector(".composer-card") as HTMLElement;
eq(card.style.getPropertyValue("--composer-height"), "104px", "render path writes the logical height, not a compensated one");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "running card reserves the strip height via its own variable");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "ordinary running state reserves no strip height");
// Drag while running: the live writer stays in logical-height space.
await act(async () => {
@@ -548,7 +641,7 @@ console.log("\ncomposer run strip");
await flushTimers();
});
eq(card.style.getPropertyValue("--composer-height"), "124px", "drag release keeps the same logical-height space as the render path");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "strip reservation survives the drag");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "dragging does not restore the removed strip reservation");
eq(handle.getAttribute("aria-valuenow"), "124", "separator reports the logical height");
await rerender({ running: false, turnStartAt: undefined });
@@ -598,6 +691,14 @@ console.log("\ncomposer run strip");
eq(textarea.style.height, `${viewportCap - 58}px`, "oversized input uses the capped content viewport");
eq(textarea.style.overflowY, "auto", "oversized draft scrolls only after reaching the cap");
measuredDraftHeight = 22;
await updateDraft("");
await act(async () => {
handle.dispatchEvent(new window.MouseEvent("dblclick", { bubbles: true }));
await flushTimers();
});
eq(card.style.getPropertyValue("--composer-height"), "140px", "reset restores the 140px default after manual resizing");
await act(async () => {
root.unmount();
});
@@ -100,6 +100,23 @@ function installContextPanelMock(fn: (tabId: string) => Promise<ContextPanelInfo
};
}
async function checkSeparateDurations() {
const dom = installDom();
installContextPanelMock(async () => ({ ...contextPanelInfo(12), elapsedMs: 120_000 }));
const { root } = await renderRing({ turnMetrics: { elapsed: "20s", tokens: "104 tokens", tps: "12 tokens/s" } });
await act(async () => {
(document.querySelector(".context-ring") as HTMLButtonElement).click();
await wait();
});
const rows = [...document.querySelectorAll(".context-ring-popover__row")];
const value = (label: string) => rows.find(row => row.querySelector(".context-ring-popover__label")?.textContent === label)
?.querySelector(".context-ring-popover__value")?.textContent;
eq(value("Turn time"), "20s", "turn duration has its own stable label");
ok(Boolean(value("Session time")) && value("Session time") !== "20s", "session duration stays separate from turn duration");
await act(async () => { root.unmount(); });
dom.window.close();
}
async function renderRing(props: Partial<Parameters<typeof ContextWindowRing>[0]> = {}) {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
@@ -127,6 +144,23 @@ async function renderRing(props: Partial<Parameters<typeof ContextWindowRing>[0]
console.log("\ncontext window ring");
{
const dom = installDom();
installContextPanelMock(async () => contextPanelInfo(2));
const { root } = await renderRing();
const trigger = document.querySelector<HTMLButtonElement>(".context-ring")!;
eq(trigger.textContent, "10%", "ring exposes its percentage outside Creation layout");
await act(async () => { trigger.focus(); trigger.click(); await wait(); });
eq(trigger.getAttribute("aria-expanded"), "true", "keyboard activation opens usage details");
await act(async () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await wait(230);
});
eq(trigger.getAttribute("aria-expanded"), "false", "Escape cancels pending hover timers without reopening");
await act(async () => { root.unmount(); });
dom.window.close();
}
{
const dom = installDom();
const calls: string[] = [];
@@ -251,5 +285,6 @@ console.log("\ncontext window ring");
dom.window.close();
}
await checkSeparateDurations();
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -91,35 +91,35 @@ await act(async () => {
await flush();
});
ok(document.body.textContent?.includes("Shown · 16") === true, "expanded editor labels the visible zone with its count");
ok(document.body.textContent?.includes(`Shown · ${DEFAULT_STATUS_BAR_ITEMS.length}`) === true, "expanded editor labels the visible zone with its count");
ok(document.body.textContent?.includes("Hidden · 0") === true, "expanded editor labels the hidden zone with its count");
ok(document.querySelectorAll('[data-statusbar-drop-zone="hidden"]').length === 1, "hidden zone is an explicit drag target");
const modelRow = document.querySelector<HTMLElement>('[data-statusbar-setting-item="model"]');
const modelToggle = modelRow?.querySelector<HTMLInputElement>('input[type="checkbox"]');
const balanceRow = document.querySelector<HTMLElement>('[data-statusbar-setting-item="balance"]');
const balanceToggle = balanceRow?.querySelector<HTMLInputElement>('input[type="checkbox"]');
await act(async () => {
modelToggle?.click();
balanceToggle?.click();
await flush();
});
ok(latestItems.length === 15 && !latestItems.includes("model"), "clearing a visible item removes it from the persisted order");
ok(document.body.textContent?.includes("Shown · 15") === true, "visible count updates after hiding an item");
ok(latestItems.length === DEFAULT_STATUS_BAR_ITEMS.length - 1 && !latestItems.includes("balance"), "clearing a visible item removes it from the persisted order");
ok(document.body.textContent?.includes(`Shown · ${DEFAULT_STATUS_BAR_ITEMS.length - 1}`) === true, "visible count updates after hiding an item");
ok(document.body.textContent?.includes("Hidden · 1") === true, "hidden count updates after hiding an item");
ok(document.querySelector('[data-statusbar-drop-zone="hidden"] [data-statusbar-setting-item="model"]') != null, "hidden item moves into the hidden zone");
ok(document.querySelector('[data-statusbar-drop-zone="hidden"] [data-statusbar-setting-item="balance"]') != null, "hidden item moves into the hidden zone");
const showAll = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent === "Show all");
await act(async () => {
showAll?.click();
await flush();
});
ok(latestItems.length === 16 && latestItems.at(-1) === "model", "show all restores hidden items without discarding the current visible order");
ok(latestItems.length === DEFAULT_STATUS_BAR_ITEMS.length && latestItems.at(-1) === "balance", "show all restores hidden items without discarding the current visible order");
const moveWorkspaceDown = document.querySelector<HTMLButtonElement>('button[aria-label="Move workspace down"]');
await act(async () => {
moveWorkspaceDown?.click();
await flush();
});
ok(latestItems[0] === "git_branch" && latestItems[1] === "workspace", "keyboard order controls update the visible order");
ok(latestItems[0] === "cache" && latestItems[1] === "workspace", "keyboard order controls update the visible order");
const restoreDefault = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent === "Restore default");
await act(async () => {
@@ -36,14 +36,25 @@ function renderStatusBar(props: Partial<Parameters<typeof StatusBar>[0]> = {}):
console.log("\nstatus bar workspace");
{
ok(normalizeStatusBarItems(["model"]).join(",") === "workspace", "model-only migration keeps one workspace item");
ok(normalizeStatusBarItems(["model", "unknown"]).join(",") === "workspace", "removed model with unknown entries remains compact");
ok(normalizeStatusBarItems(["cache", "model", "git_branch", "workspace"]).join(",") === "cache,workspace", "migration preserves valid order and merges branch duplicates");
for (const input of [undefined, [], ["unknown"]]) {
ok(normalizeStatusBarItems(input).join(",") === DEFAULT_STATUS_BAR_ITEMS.join(","), "missing or invalid configuration retains defaults");
}
const saved = JSON.parse(JSON.stringify(normalizeStatusBarItems(["model"])));
ok(normalizeStatusBarItems(saved).join(",") === "workspace", "migration stays compact after save and reload");
}
{
const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
ok(defaultItems.includes("workspace"), "workspace is a default configurable status item");
ok(defaultItems.includes("git_branch"), "git branch is a default configurable status item");
ok(!defaultItems.includes("git_branch"), "branch is merged into the workspace setting");
ok(
normalizeStatusBarItems(["git_branch", "workspace", "cache"]).join(",") === "git_branch,workspace,cache",
"workspace items preserve configured order",
normalizeStatusBarItems(["git_branch", "workspace", "cache"]).join(",") === "workspace,cache",
"legacy workspace items merge at their first configured position",
);
}
@@ -108,7 +119,7 @@ console.log("\nstatus bar workspace");
gitBranch: "feature/meta",
};
const html = renderStatusBar(propsWithLegacySandbox);
ok(html.includes("workspace/repo"), "workspace chip uses workspace path");
ok(!html.includes("workspace/repo"), "workspace path stays out of the visible branch label");
ok(!html.includes("sandbox/repo"), "workspace chip does not display sandbox path");
ok(html.includes("feature/meta"), "git branch remains visible");
}
@@ -131,12 +142,14 @@ console.log("\nstatus bar workspace");
workspaceName: "repo",
gitBranch: "feature/meta",
});
ok(html.indexOf("feature/meta") >= 0 && html.indexOf("workspace/repo") >= 0, "workspace and git branch render as configured items");
ok(html.indexOf("feature/meta") < html.indexOf("workspace/repo"), "workspace items follow configured order");
ok(html.includes("feature/meta") && !html.includes("workspace/repo"), "combined chip shows only the current branch");
ok((html.match(/class="stat statusbar__workspace"/g) || []).length === 1, "legacy items render one combined chip");
ok(!html.includes('<b>…/workspace/repo</b>'), "workspace path is not a separate visible label when a branch is available");
}
{
const html = renderStatusBar({ items: ["model"] });
ok(!html.includes("stat--model"), "legacy model setting cannot restore the removed model entry");
ok(!html.includes("YOLO"), "status bar renders only configured status items, not mode indicators");
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar hides the operational jobs entry while idle");
}
@@ -36,6 +36,8 @@ export function projectConversation({ local, remote, tab, activeTabId, backgroun
const modelLabel = remote ? remote.modelLabel || tab?.label : local.meta?.label;
const timing = {
turnPhase: runtime.turnPhase, turnStartAt: runtime.turnStartAt,
turnDoneAt: runtime.turnDoneAt, lastTurnOutputTokens: runtime.lastTurnOutputTokens,
lastTurnWaitAccumMs: runtime.lastTurnWaitAccumMs,
turnWaitAccumMs: runtime.turnWaitAccumMs, promptWaitStartedAt: runtime.promptWaitStartedAt,
turnTokens: runtime.turnTokens, turnOutputTokens: runtime.turnOutputTokens,
turnOutputCharsAtUsage: runtime.turnOutputCharsAtUsage,
@@ -49,7 +49,7 @@ export function useDesktopPreferences() {
return {
desktopLayoutStyle: layoutStyleFromSnapshot(snapshot?.desktopLayoutStyle),
startupUpdateChecksEnabled: snapshot ? snapshot.checkUpdates !== false : startupFailed ? true : null,
statusBarStyle: snapshot ? snapshot.statusBarStyle === "text" ? "text" as const : "icon" as const : "text" as const,
statusBarStyle: snapshot?.statusBarStyle === "text" ? "text" as const : "icon" as const,
statusBarItems: snapshot ? normalizeStatusBarItems(snapshot.statusBarItems) : DEFAULT_STATUS_BAR_ITEMS,
sidebarImConnections, imTopicSources,
configLoadWarnings: warnings.configLoadWarnings, reloadConfigWarnings: warnings.reload, dismissConfigWarnings: warnings.dismiss,
+124 -329
View File
@@ -2,7 +2,7 @@ import { recoveryStatusText, type RecoveryRetry } from "../lib/recoveryStatus";
import { useAppNavigationStore } from "../store/appNavigation";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import { ArrowRight, ArrowUp, Check, ChevronsUpDown, CornerDownRight, Equal, Eye, FileText, Folder, Gauge, List, MessageSquare, PackageCheck, Plus, Search, Shield, ShieldAlert, ShieldCheck, Square, Target, Trash2, X } from "lucide-react";
import { ArrowRight, ArrowUp, Brain, Check, CornerDownRight, Eye, FileText, Folder, Lightbulb, List, MessageSquare, Plus, Search, Shield, ShieldAlert, ShieldCheck, Square, Target, Trash2, X } from "lucide-react";
import { asArray } from "../lib/array";
import { filterAtMatches } from "../lib/atMatches";
import { DedupIndex, sha256 } from "../lib/attachDedup";
@@ -17,7 +17,7 @@ import { guidanceIsInFlight, guidanceNeedsRetry, guidanceTextMatches, kickIdleGu
import { canUsePromptHistory, composerEnterAction, composerEscapeAction, composerMenuKeyAction, insertComposerNewline, isFnKeyEvent, isImeKeyEvent, promptHistoryDirectionFromEvent } from "../lib/composerKeyboard";
import { cacheGeneration, loadOlder } from "../lib/composerHistory";
import { sessionTurnsLabel } from "../lib/sessionTurnsPresentation";
import { SPINNER_WORDS, useI18n, type Translator } from "../lib/i18n";
import { useI18n, type Translator } from "../lib/i18n";
import { detectShortcutPlatform, formatShortcutCombo, isReservedComposerHistoryShortcut, matchesShortcut, useShortcutComboLabel } from "../lib/keyboardShortcuts";
import { fallbackCopyText } from "../lib/clipboard";
import {
@@ -50,7 +50,7 @@ import {
import { SlashMenu, sortSlashCommandsForMenu } from "./SlashMenu";
import { ArgMenu } from "./ArgMenu";
import { ANCHORED_POPOVER_CLOSE_MS, AnchoredPopover } from "./AnchoredPopover";
import { EffortSwitcher } from "./EffortSwitcher";
import { ComposerChoice } from "./ComposerChoice";
const ModelSwitcher = lazy(() => import("./ModelSwitcher").then((module) => ({ default: module.ModelSwitcher })));
import { Tooltip } from "./Tooltip";
const RecoveryWaitBanner = lazy(() => import("./RecoveryWaitBanner").then((module) => ({ default: module.RecoveryWaitBanner })));
@@ -104,6 +104,7 @@ export interface WorkspaceReference {
const LONG_PASTE_MIN_CHARS = 2000;
const LONG_PASTE_MIN_LINES = 20;
const COMPOSER_MIN_HEIGHT = 104;
const COMPOSER_DEFAULT_HEIGHT = 140;
const COMPOSER_MAX_HEIGHT = 360;
// Height reserved for the in-card run strip while a turn runs; applied via a
// CSS calc so --composer-height always stays in "logical height" space.
@@ -407,7 +408,7 @@ function clampComposerHeight(height: number): number {
}
function loadComposerHeight(): number | null {
return loadOptionalLayoutSize("composerHeight", clampComposerHeight);
return loadOptionalLayoutSize("composerHeight", clampComposerHeight) ?? clampComposerHeight(COMPOSER_DEFAULT_HEIGHT);
}
function fmtElapsed(ms: number): string {
@@ -579,6 +580,9 @@ export function Composer({
decisionPending = false,
ready,
turnStartAt,
turnDoneAt,
lastTurnOutputTokens,
lastTurnWaitAccumMs,
turnWaitAccumMs = 0,
promptWaitStartedAt,
turnTokens,
@@ -664,6 +668,9 @@ export function Composer({
// and a completed turn may have installed skills or MCP prompts.
ready?: boolean;
turnStartAt?: number;
turnDoneAt?: number;
lastTurnOutputTokens?: number;
lastTurnWaitAccumMs?: number;
// Tab-scoped user-wait from the controller (approval/ask). Counts while the
// tab is in the background so Composer does not invent a wait start on focus.
turnWaitAccumMs?: number;
@@ -760,8 +767,6 @@ export function Composer({
const [textareaAutoOverflow, setTextareaAutoOverflow] = useState(false);
const [intentMenuOpen, setIntentMenuOpen] = useState(false);
const [intentMenuClosing, setIntentMenuClosing] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [moreMenuClosing, setMoreMenuClosing] = useState(false);
const [contentMenuOpen, setContentMenuOpen] = useState(false);
const [showPastChats, setShowPastChats] = useState(false);
const [directPastChats, setDirectPastChats] = useState(false);
@@ -804,9 +809,7 @@ export function Composer({
const composerWrapRef = useRef<HTMLDivElement>(null);
const contentMenuAnchorRef = useRef<HTMLButtonElement>(null);
const intentMenuAnchorRef = useRef<HTMLButtonElement>(null);
const moreMenuAnchorRef = useRef<HTMLButtonElement>(null);
const intentCloseTimerRef = useRef<number | null>(null);
const moreCloseTimerRef = useRef<number | null>(null);
// Creation chrome: hover-open task menus (same pattern as ContextWindowRing).
const intentHoverTimerRef = useRef<number | null>(null);
const creationChrome = showContextWindowRing;
@@ -943,8 +946,6 @@ export function Composer({
setImageViewer((current) => current.open ? { ...current, open: false } : current);
setIntentMenuOpen(false);
setIntentMenuClosing(false);
setMoreMenuOpen(false);
setMoreMenuClosing(false);
};
const composerEditSnapshot = (
@@ -1938,16 +1939,6 @@ export function Composer({
timerRef.current = null;
};
const openIntentMenu = useCallback(() => {
clearIntentCloseTimer();
clearHoverTimer(intentHoverTimerRef);
setContentMenuOpen(false);
setDirectPastChats(false);
setDismissed(true);
setIntentMenuClosing(false);
setIntentMenuOpen(true);
}, [clearIntentCloseTimer]);
const closeIntentMenu = useCallback((afterClose?: () => void) => {
clearIntentCloseTimer();
clearHoverTimer(intentHoverTimerRef);
@@ -1966,15 +1957,6 @@ export function Composer({
clearHoverTimer(intentHoverTimerRef);
}, [clearIntentCloseTimer]);
const onIntentHoverEnter = useCallback(() => {
if (!creationChrome || disabled || running) return;
clearHoverTimer(intentHoverTimerRef);
intentHoverTimerRef.current = window.setTimeout(() => {
intentHoverTimerRef.current = null;
openIntentMenu();
}, 120);
}, [creationChrome, disabled, openIntentMenu, running]);
const onIntentHoverLeave = useCallback(() => {
if (!creationChrome) return;
clearHoverTimer(intentHoverTimerRef);
@@ -1990,35 +1972,6 @@ export function Composer({
clearHoverTimer(intentHoverTimerRef);
}, [creationChrome]);
const clearMoreCloseTimer = useCallback(() => {
if (moreCloseTimerRef.current === null) return;
window.clearTimeout(moreCloseTimerRef.current);
moreCloseTimerRef.current = null;
}, []);
const openMoreMenu = useCallback(() => {
clearMoreCloseTimer();
setContentMenuOpen(false);
setDirectPastChats(false);
setDismissed(true);
setMoreMenuClosing(false);
setMoreMenuOpen(true);
}, [clearMoreCloseTimer]);
const closeMoreMenu = useCallback((afterClose?: () => void) => {
clearMoreCloseTimer();
setMoreMenuClosing(true);
window.requestAnimationFrame(() => setMoreMenuOpen(false));
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
moreCloseTimerRef.current = window.setTimeout(() => {
moreCloseTimerRef.current = null;
setMoreMenuClosing(false);
afterClose?.();
}, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
}, [clearMoreCloseTimer]);
useEffect(() => () => clearMoreCloseTimer(), [clearMoreCloseTimer]);
const fileDedupKey = async (file: File): Promise<AttachmentDedupKey> => ({
hash: await sha256(file),
source: `file:${file.name}:${file.size}:${file.lastModified}`,
@@ -3032,7 +2985,7 @@ export function Composer({
};
const resetComposerHeight = () => {
setComposerHeight(null);
setComposerHeight(clampComposerHeight(COMPOSER_DEFAULT_HEIGHT));
clearLayoutSize("composerHeight");
};
@@ -3221,7 +3174,6 @@ export function Composer({
const openContentMenu = () => {
if (intentMenuOpen || intentMenuClosing) closeIntentMenu();
if (moreMenuOpen || moreMenuClosing) closeMoreMenu();
setDirectPastChats(false);
setShowPastChats(false);
setDismissed(true);
@@ -3637,7 +3589,14 @@ export function Composer({
// --composer-height stays in logical card-height space. It may be the saved
// manual floor or a larger content-derived height; the run-strip reservation
// remains separate so the live resize writer uses the same coordinate space.
const showRunStrip = Boolean(retry || running);
const waitingPrompt = suspendedByDecision
? null
: pendingApprovalLabel
? "approval"
: pendingAsk
? "ask"
: null;
const showRunStrip = Boolean(retry || waitingPrompt);
const effectiveComposerHeight = composerHeight === null
? null
: resolveComposerContentSizing({
@@ -3667,17 +3626,18 @@ export function Composer({
requestActiveDraftFrame(focusComposerInput);
};
const chooseTaskMode = (nextMode: CollaborationMode) => {
setContentMenuOpen(false);
closeIntentMenu(() => {
if (nextMode !== collaborationMode) onSetCollaborationMode(nextMode);
requestActiveDraftFrame(focusComposerInput);
});
};
const floorOn = qualityFloor === "delivery";
const chooseQualityFloor = (floor: QualityFloor) => {
if (floor === qualityFloor) return;
onSetQualityFloor?.(floor);
};
const stopGoalMode = () => {
setContentMenuOpen(false);
closeIntentMenu(() => {
onClearGoal();
requestActiveDraftFrame(focusComposerInput);
@@ -3688,42 +3648,20 @@ export function Composer({
: collaborationMode === "goal"
? "composer.taskModeGoalShort"
: "composer.taskModeDirectShort";
const taskModeTooltipSummaryKey = collaborationMode === "plan"
? "composer.taskModePlanTooltipSummary"
: collaborationMode === "goal"
? "composer.taskModeGoalTooltipSummary"
: "composer.taskModeDirectTooltipSummary";
const TaskModeIcon = collaborationMode === "plan" ? List : collaborationMode === "goal" ? Target : ArrowRight;
const taskModeTriggerLabel = t("composer.taskModeTrigger", { mode: t(taskModeShortKey) });
const taskModeTooltipLabel = t("composer.controlTooltip", {
category: t("composer.intentMenuTitle"),
mode: t(taskModeShortKey),
summary: t(taskModeTooltipSummaryKey),
});
const TaskModeIcon = collaborationMode === "plan" ? Lightbulb : collaborationMode === "goal" ? Target : ArrowRight;
const taskModeTriggerLabel = `${t("common.close")} ${t(taskModeShortKey)}`;
const taskModeTooltipLabel = taskModeTriggerLabel;
const effortOptions = asArray(effort?.options);
const effortLabel = (id: string) => effortOptions.find((option) => option.id === id)?.name || id;
const effortLabel = (id: string) => id === "auto" ? t("common.auto") : effortOptions.find((option) => option.id === id)?.name || id;
const effortLevels = effort?.options ? ["auto", ...effortOptions.map((option) => option.id)] : asArray(effort?.levels);
const currentEffort = effort?.current || "auto";
const compactEffortTitle = currentEffort === "auto"
? t("status.effortAutoTitle", { def: effort?.default || "auto" })
: `${t("status.effortTitle")}: ${currentEffort}`;
const hasEffort = Boolean(effort?.supported && effortLevels.length > 0);
const chooseEffortLevel = (level: string) => {
closeMoreMenu(() => {
if (level !== currentEffort) onSetEffort(level);
requestActiveDraftFrame(focusComposerInput);
});
if (level !== currentEffort) onSetEffort(level);
};
// Run-strip state machine: retry > waiting-approval > waiting-ask > streaming.
// Decision surfaces own the "waiting on user" UI; while suspendedByDecision
// is true we still pause the work clock but do not render a waiting strip.
const waitingPrompt = suspendedByDecision
? null
: pendingApprovalLabel
? "approval"
: pendingAsk
? "ask"
: null;
const pauseWorkClock = suspendedByDecision || Boolean(waitingPrompt);
// Decision surfaces hide the whole composer, so mode controls stay disabled.
// Legacy tests that pass pendingApprovalLabel without suspendedByDecision
@@ -3768,8 +3706,7 @@ export function Composer({
setDirectPastChats(false);
setShowPastChats(false);
closeIntentMenu();
closeMoreMenu();
}, [suspendedByDecision, closeIntentMenu, closeMoreMenu]);
}, [suspendedByDecision, closeIntentMenu]);
// Live text+reasoning character count for the run-strip TPS fallback. Reads
// through the live store's own subscription so stream deltas re-render only
// this component — the controller's bump path stays text-delta-free.
@@ -3811,26 +3748,22 @@ export function Composer({
: running && !suspendedByDecision
? turnPhaseLabel
: null;
const runTicker = !retry && !pauseWorkClock && running && turnStartAt
const runMetrics = turnStartAt && (running || turnDoneAt)
? (() => {
const elapsedMs = Math.max(0, now - turnStartAt - waitAccumMs);
const words = SPINNER_WORDS[locale];
const word = words[Math.floor(elapsedMs / 3000) % words.length];
const metricsNow = turnDoneAt || now;
const elapsedMs = Math.max(0, metricsNow - turnStartAt - (turnDoneAt ? lastTurnWaitAccumMs ?? waitAccumMs : waitAccumMs));
const usageTokens = turnTokens ?? 0;
// Include streaming tool-call args in the estimate so TPS stays
// meaningful while the model streams a write_file / long tool body.
const inFlightChars = Math.max(0, liveTextChars - (turnOutputCharsAtUsage ?? 0)) + (turnArgChars ?? 0);
const estimatedChars = Math.round(inFlightChars / 4);
const estimatedChars = !turnDoneAt ? Math.round(inFlightChars / 4)
: Math.max(0, (lastTurnOutputTokens ?? turnOutputTokens ?? 0) - (turnOutputTokens ?? 0));
const liveTokens = usageTokens + estimatedChars;
const tok = liveTokens > 0 ? ` · ↓ ${formatTokens(liveTokens)} ${t("status.tokens")}` : "";
const outTok: number = (turnOutputTokens ?? 0) + estimatedChars;
const modelActiveAt = liveModelActiveAt ?? turnModelActiveAt;
const modelElapsedMs = Math.max(0, turnModelActiveMs + (modelActiveAt && modelActiveAt > 0 ? Math.max(0, now - modelActiveAt) : 0));
const modelElapsedMs = Math.max(0, turnModelActiveMs + (modelActiveAt && modelActiveAt > 0 ? Math.max(0, metricsNow - modelActiveAt) : 0));
const tps = outTok > 0 && modelElapsedMs >= 500 ? Math.round(outTok / (modelElapsedMs / 1000)) : null;
const tpsStr = tps !== null ? ` · ${tps} tokens/s` : "";
const suffix = `${tpsStr}${tok}`;
const prefix = `${word}… ${fmtElapsed(elapsedMs)}`;
return { prefix, suffix: suffix || null };
return { elapsed: fmtElapsed(elapsedMs), tokens: liveTokens > 0 ? `${formatTokens(liveTokens)} ${t("status.tokens")}` : null, tps: tps !== null ? `${tps} tokens/s` : null };
})()
: null;
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0 &&
@@ -3847,9 +3780,11 @@ export function Composer({
? t("composer.steerPlaceholder", { combo: sendComboLabel })
: goalModeOn && !activeGoal
? t("composer.goalInputPlaceholder")
: t("composer.placeholder");
: planModeOn
? t("composer.planInputPlaceholder")
: t("composer.placeholder");
const composerMetaClass = [
"composer-meta",
"composer-meta composer-meta--unified",
hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
].join(" ");
@@ -3943,11 +3878,11 @@ export function Composer({
requestActiveDraftFrame(() => taRef.current?.focus());
}}
/>
<AnchoredPopover
open={contentMenuOpen && !disabled && !readOnly && !running}
anchorRef={contentMenuAnchorRef}
onClose={() => setContentMenuOpen(false)}
className="composer-access-menu composer-content-menu"
{!heroMode && <AnchoredPopover
open={(contentMenuOpen || intentMenuOpen) && !disabled && !readOnly && !running}
anchorRef={contentMenuOpen ? contentMenuAnchorRef : intentMenuAnchorRef}
onClose={() => { setContentMenuOpen(false); closeIntentMenu(); }}
className="composer-access-menu composer-content-menu composer-intent-menu composer-menu-surface"
align="start"
>
<ComposerContentMenuActions
@@ -3956,15 +3891,6 @@ export function Composer({
onChooseAttachment={chooseAttachmentFiles}
onInsertTrigger={insertContentTrigger}
/>
</AnchoredPopover>
{!heroMode && <AnchoredPopover
open={intentMenuOpen}
closing={intentMenuClosing}
anchorRef={intentMenuAnchorRef}
onClose={() => closeIntentMenu()}
className="composer-access-menu composer-intent-menu"
align="start"
>
<div
className="composer-access-menu__section"
role="menu"
@@ -3973,33 +3899,17 @@ export function Composer({
onMouseLeave={creationChrome ? onIntentHoverLeave : undefined}
>
<div className="composer-access-menu__label">{t("composer.intentMenuTitle")}</div>
<button
type="button"
role="menuitemradio"
aria-checked={collaborationMode === "normal"}
className={`composer-access-menu__item composer-intent-menu__item${collaborationMode === "normal" ? " composer-access-menu__item--active" : ""}`}
onClick={() => chooseTaskMode("normal")}
disabled={disabled || running}
>
<ArrowRight size={16} />
<span className="composer-access-menu__copy">
<span className="composer-access-menu__title">{t("composer.taskModeDirect")}</span>
<span className="composer-access-menu__desc">{t("composer.taskModeDirectDesc")}</span>
</span>
{collaborationMode === "normal" && <Check className="composer-intent-menu__check" size={16} aria-hidden="true" />}
</button>
<button
type="button"
role="menuitemradio"
aria-checked={planModeOn}
className={`composer-access-menu__item composer-intent-menu__item${planModeOn ? " composer-access-menu__item--active" : ""}`}
onClick={() => chooseTaskMode("plan")}
onClick={() => chooseTaskMode(planModeOn ? "normal" : "plan")}
disabled={disabled || running}
>
<List size={16} />
<span className="composer-access-menu__copy">
<span className="composer-access-menu__title">{t("composer.taskModePlan")}</span>
<span className="composer-access-menu__desc">{t("composer.taskModePlanDesc")}</span>
</span>
{planModeOn && <Check className="composer-intent-menu__check" size={16} aria-hidden="true" />}
</button>
@@ -4008,14 +3918,13 @@ export function Composer({
role="menuitemradio"
aria-checked={goalModeOn}
className={`composer-access-menu__item composer-intent-menu__item${goalModeOn ? " composer-access-menu__item--active" : ""}`}
onClick={() => chooseTaskMode("goal")}
onClick={() => chooseTaskMode(goalModeOn && !activeGoal ? "normal" : "goal")}
disabled={disabled || running}
title={activeGoal || undefined}
>
<Target size={16} />
<span className="composer-access-menu__copy">
<span className="composer-access-menu__title">{t("composer.taskModeGoal")}</span>
<span className="composer-access-menu__desc">{activeGoal || t("composer.taskModeGoalDesc")}</span>
</span>
{goalModeOn && <Check className="composer-intent-menu__check" size={16} aria-hidden="true" />}
</button>
@@ -4074,38 +3983,19 @@ export function Composer({
</div>
)}
</div>
<div className="composer-access-menu__section" role="menu" aria-label={t("composer.qualityFloor")} data-inferred={floorInferred || undefined}>
<div className="composer-access-menu__label">{t("composer.qualityFloor")}</div>
<button type="button" role="menuitemcheckbox"
aria-checked={qualityFloor === "delivery"}
className={`composer-access-menu__item${qualityFloor === "delivery" ? " composer-access-menu__item--active" : ""}`}
disabled={approvalBarDisabled || !onSetQualityFloor}
onClick={() => { chooseQualityFloor(qualityFloor === "delivery" ? "standard" : "delivery"); setContentMenuOpen(false); closeIntentMenu(); }}>
<ShieldCheck size={18} aria-hidden="true" />
<span className="composer-access-menu__copy"><span className="composer-access-menu__title">{t("composer.qualityFloorDelivery")}</span></span>
{qualityFloor === "delivery" && <Check size={14} aria-hidden="true" />}
</button>
</div>
</AnchoredPopover>}
<AnchoredPopover
open={moreMenuOpen && !disabled && !running}
closing={moreMenuClosing}
anchorRef={moreMenuAnchorRef}
onClose={() => closeMoreMenu()}
className="composer-access-menu composer-more-menu"
align="end"
>
{hasEffort && (
<div className="composer-access-menu__section">
<div className="composer-access-menu__label">{t("status.effortTitle")}</div>
<div className="composer-more-menu__items" role="listbox" aria-label={t("status.effortTitle")}>
{effortLevels.map((level) => (
<button
key={level}
type="button"
role="option"
aria-selected={level === currentEffort}
className={`composer-more-menu__item${level === currentEffort ? " composer-more-menu__item--active" : ""}`}
onClick={() => chooseEffortLevel(level)}
disabled={running}
>
<Gauge size={14} />
<span>{effortLabel(level)}</span>
{level === currentEffort && <Check size={13} />}
</button>
))}
</div>
</div>
)}
</AnchoredPopover>
{menuMode === "slash" && (
<SlashMenu
items={slashMatches}
@@ -4426,6 +4316,9 @@ export function Composer({
ref={composerCardRef}
style={composerCardStyle}
>
{running && !waitingPrompt && !retry?.recovery?.waiting && (
<span className="composer-glowring" aria-hidden="true"><i /></span>
)}
<button
className="composer-resize-handle"
type="button"
@@ -4440,24 +4333,13 @@ export function Composer({
onKeyDown={onComposerResizeKeyDown}
onDoubleClick={resetComposerHeight}
/>
{runStateText && (
{showRunStrip && runStateText && (
<div className={`composer-run-strip${waitingPrompt ? " composer-run-strip--waiting" : ""}`}>
<span className="composer-run-strip__dot" aria-hidden="true" />
{runTicker ? (
<>
<span className="composer-run-strip__text" aria-hidden="true">{runTicker.prefix}</span>
{runTicker.suffix && (
<Tooltip label={t("composer.runStripEstimateHint")}>
<span className="composer-run-strip__text" aria-hidden="true">{runTicker.suffix}</span>
</Tooltip>
)}
</>
) : (
<span className="composer-run-strip__text">{runStateText}</span>
)}
<span className="sr-only" role="status">{runStateText}</span>
<span className="composer-run-strip__text">{runStateText}</span>
</div>
)}
<span className="sr-only" role="status">{runStateText}</span>
<div
className={`composer${invocations.length > 0 ? " composer--has-invocation" : ""}${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
onDrop={onDrop}
@@ -4579,29 +4461,6 @@ export function Composer({
{composerPrompt}
</span>
)}
{running && (
<Tooltip label={t("composer.stop")}>
<button
className="composer__btn composer__btn--stop"
type="button"
onClick={() => void handleCancel()}
disabled={cancelSettlingDraftsRef.current.has(draftKey)}
aria-label={t("composer.stop")}
>
<Square size={12} fill="currentColor" />
</button>
</Tooltip>
)}
<Tooltip label={submitTooltip}>
<button
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
onClick={submit}
disabled={submitBlocked}
aria-label={submitTooltip}
>
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
</button>
</Tooltip>
</div>
</div>
<ContextMenu
@@ -4633,126 +4492,55 @@ export function Composer({
</Tooltip>
</div>
)}
{!heroMode && (
{!heroMode && <div className="composer-meta__control composer-meta__control--approval">
<ComposerChoice key={`approval-${tabId}`} label={toolApprovalMode === "yolo" ? "Yolo" : t(toolApprovalMode === "ask" ? "composer.accessAskShort" : "common.auto")}
showChevron
icon={toolApprovalMode === "yolo" ? <ShieldAlert size={16} /> : toolApprovalMode === "auto" ? <ShieldCheck size={16} /> : <Shield size={16} />}
tone={`composer-choice--permission-${toolApprovalMode}`}
value={toolApprovalMode} disabled={approvalBarDisabled} onPick={value => chooseApprovalMode(value as ToolApprovalMode)}
options={[
{ value: "ask", label: t("composer.accessAskShort"), icon: <Shield size={18} />, description: t("composer.accessAskDesc") },
{ value: "auto", label: t("common.auto"), icon: <ShieldCheck size={18} />, description: t("composer.accessAutoDesc") },
{ value: "yolo", label: "Yolo", icon: <ShieldAlert size={18} />, description: t("composer.accessYoloDesc"), title: t("composer.accessYoloTitle", { shortcut: yoloComboLabel }) },
]} />
</div>}
{!heroMode && collaborationMode !== "normal" && (
<div className="composer-meta__control composer-meta__control--intent">
<Tooltip label={taskModeTooltipLabel} disabled={intentMenuOpen || intentMenuClosing || creationChrome}>
<Tooltip label={taskModeTooltipLabel} disabled={intentMenuOpen || intentMenuClosing}>
<button
ref={intentMenuAnchorRef}
type="button"
className={`composer-task-mode-trigger${intentMenuOpen || intentMenuClosing ? " composer-task-mode-trigger--open" : ""}`}
onClick={() => (intentMenuOpen || intentMenuClosing ? closeIntentMenu() : openIntentMenu())}
onMouseEnter={creationChrome ? onIntentHoverEnter : undefined}
onMouseLeave={creationChrome ? onIntentHoverLeave : undefined}
className="composer-task-mode-trigger composer-task-mode-trigger--removable"
onClick={() => { if (goalModeOn && activeGoal) stopGoalMode(); else chooseTaskMode("normal"); }}
disabled={disabled || running}
aria-haspopup="menu"
aria-expanded={intentMenuOpen && !intentMenuClosing}
aria-label={taskModeTriggerLabel}
title={intentMenuOpen || intentMenuClosing || creationChrome ? undefined : taskModeTriggerLabel}
>
<TaskModeIcon size={14} aria-hidden="true" />
<span className="composer-task-mode-trigger__icon"><TaskModeIcon size={16} aria-hidden="true" /><X className="composer-task-mode-trigger__remove" size={14} aria-hidden="true" /></span>
<span className="composer-task-mode-trigger__value">{t(taskModeShortKey)}</span>
<ChevronsUpDown size={11} aria-hidden="true" />
</button>
</Tooltip>
</div>
)}
{!heroMode && (
<div className="composer-meta__control composer-meta__control--approval">
{/* A pending tool approval disables the composer, but the approval
bar stays usable so mode changes remain possible mid-prompt;
the approval card explains that the pending request still needs
an explicit decision. */}
<div
className="composer-modebar composer-modebar--approval"
data-mode={toolApprovalMode}
title={t("composer.accessMenuTitle", { shortcut: yoloComboLabel })}
>
<span className="composer-modebar__thumb" aria-hidden="true" />
<button
type="button"
className={`composer-modebar__item composer-modebar__item--ask${toolApprovalMode === "ask" ? " composer-modebar__item--active" : ""}`}
onClick={() => chooseApprovalMode("ask")}
disabled={approvalBarDisabled}
aria-pressed={toolApprovalMode === "ask"}
title={t("composer.accessAskTitle")}
>
<Shield size={14} />
<span>{t("composer.modeAsk")}</span>
{!heroMode && qualityFloor === "delivery" && (
<div className="composer-meta__control composer-meta__control--delivery">
<Tooltip label={`${t("common.close")} ${t("composer.qualityFloorDelivery")}`}>
<button type="button"
className="composer-task-mode-trigger composer-task-mode-trigger--removable composer-delivery-trigger"
aria-label={`${t("common.close")} ${t("composer.qualityFloorDelivery")}`}
disabled={approvalBarDisabled || !onSetQualityFloor}
onClick={() => { chooseQualityFloor("standard"); requestActiveDraftFrame(focusComposerInput); }}>
<span className="composer-task-mode-trigger__icon"><ShieldCheck size={16} aria-hidden="true" /><X className="composer-task-mode-trigger__remove" size={14} aria-hidden="true" /></span>
<span className="composer-task-mode-trigger__value">{t("composer.qualityFloorDelivery")}</span>
</button>
<button
type="button"
className={`composer-modebar__item composer-modebar__item--auto${toolApprovalMode === "auto" ? " composer-modebar__item--active" : ""}`}
onClick={() => chooseApprovalMode("auto")}
disabled={approvalBarDisabled}
aria-pressed={toolApprovalMode === "auto"}
title={t("composer.accessAutoTitle")}
>
<ShieldCheck size={14} />
<span>{t("composer.modeNormal")}</span>
</button>
<button
type="button"
className={`composer-modebar__item composer-modebar__item--yolo${toolApprovalMode === "yolo" ? " composer-modebar__item--active" : ""}`}
onClick={() => chooseApprovalMode("yolo")}
disabled={approvalBarDisabled}
aria-pressed={toolApprovalMode === "yolo"}
title={t("composer.accessYoloTitle", { shortcut: yoloComboLabel })}
>
<ShieldAlert size={14} />
<span>{t("composer.modeYolo")}</span>
</button>
</div>
</Tooltip>
</div>
)}
{!heroMode && (
<div className="composer-meta__control composer-meta__control--floor">
{/* Orthogonal to the intent menu: the floor only raises the
completion gates, so goal mode and delivery combine. */}
<div
className="composer-modebar composer-modebar--floor"
data-floor={floorOn ? "delivery" : "standard"}
title={t("composer.qualityFloor")}
>
<span className="composer-modebar__thumb" aria-hidden="true" />
<button
type="button"
className={`composer-modebar__item${floorOn ? "" : " composer-modebar__item--active"}`}
onClick={() => chooseQualityFloor("standard")}
disabled={approvalBarDisabled || !onSetQualityFloor}
aria-pressed={!floorOn}
title={t("composer.qualityFloor")}
>
<Equal size={14} />
<span>{t("composer.qualityFloorStandard")}</span>
</button>
<button
type="button"
className={`composer-modebar__item${floorOn ? " composer-modebar__item--active" : ""}`}
onClick={() => chooseQualityFloor("delivery")}
disabled={approvalBarDisabled || !onSetQualityFloor}
aria-pressed={floorOn}
title={t("composer.qualityFloorDeliveryTitle")}
>
<PackageCheck size={14} />
<span>{t("composer.qualityFloorDelivery")}</span>
{floorOn && floorInferred ? <span className="composer-modebar__inferred" /> : null}
</button>
</div>
</div>
)}
{!heroMode && <span className="composer-meta__divider" aria-hidden="true" />}
<div className="composer-meta__control composer-meta__control--model">
{/*
Creation-only: showContextWindowRing is wired to sidebarCreation
(desktopLayoutStyle === "creation") in App.tsx. The ring popover
is portaled to <body> without an .app--creation prefix, so its
styles look global but only ever apply in creation layout. If you
ever surface this ring in another layout, its font sizes already
scale via --font-scale (see .context-ring-popover in styles.css).
*/}
{!heroMode && showContextWindowRing && (
{!heroMode && (
<ContextWindowRing
enabled={showContextWindowRing}
enabled
turnMetrics={runMetrics ?? undefined}
context={context}
tabId={tabId}
turnCost={turnCost}
@@ -4763,37 +4551,44 @@ export function Composer({
balance={balance}
/>
)}
<Suspense fallback={<span className="modelsw__label">{modelLabel}</span>}><ModelSwitcher label={modelLabel} tabId={tabId} onPick={onSwitchModel} onManage={() => {
<Suspense fallback={<span className="modelsw__label">{modelLabel}</span>}><ModelSwitcher composerMenu label={modelLabel} tabId={tabId} onPick={onSwitchModel} onManage={() => {
useAppNavigationStore.getState().setSettingsFocus({ target: "model-access" });
useAppNavigationStore.getState().setSettingsTarget("models");
}} /></Suspense>
{hasEffort && !heroMode && <div className="composer-effort-control">
<ComposerChoice key={`effort-${tabId}`} label={effortLabel(currentEffort)}
ariaLabel={`${t("status.effortTitle")}: ${effortLabel(currentEffort)}`}
icon={<Brain size={16} />} showChevron
value={currentEffort} disabled={disabled || readOnly || running}
onPick={chooseEffortLevel}
options={effortLevels.map(level => ({ value: level, label: effortLabel(level) }))} />
</div>}
</div>
{!heroMode && hasEffort && (
<div className="composer-meta__control composer-meta__control--effort">
<EffortSwitcher effort={effort} disabled={running} onPick={onSetEffort} />
</div>
)}
{!heroMode && hasEffort && (
<div className="composer-meta__control composer-meta__control--more">
<Tooltip label={compactEffortTitle} disabled={moreMenuOpen || moreMenuClosing}>
<div className="composer-toolbar-send">
{running && (
<Tooltip label={t("composer.stop")}>
<button
ref={moreMenuAnchorRef}
className="composer__btn composer__btn--stop"
type="button"
className={`composer-more-trigger composer-more-trigger--effort${currentEffort !== "auto" ? " composer-more-trigger--explicit" : ""}${moreMenuOpen || moreMenuClosing ? " composer-more-trigger--open" : ""}`}
onClick={() => (moreMenuOpen || moreMenuClosing ? closeMoreMenu() : openMoreMenu())}
disabled={disabled || running}
aria-haspopup="menu"
aria-expanded={moreMenuOpen && !moreMenuClosing}
aria-label={compactEffortTitle}
title={moreMenuOpen || moreMenuClosing ? undefined : compactEffortTitle}
onClick={() => void handleCancel()}
disabled={cancelSettlingDraftsRef.current.has(draftKey)}
aria-label={t("composer.stop")}
>
<Gauge size={14} />
<span>{effortLabel(currentEffort)}</span>
<ChevronsUpDown size={11} />
<Square size={12} fill="currentColor" />
</button>
</Tooltip>
</div>
)}
)}
<Tooltip label={submitTooltip}>
<button
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
onClick={submit}
disabled={submitBlocked}
aria-label={submitTooltip}
>
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
</button>
</Tooltip>
</div>
</div>
</div>
</div>
@@ -0,0 +1,36 @@
import { useRef, useState, type ReactNode } from "react";
import { Check, ChevronDown } from "lucide-react";
import { AnchoredPopover } from "./AnchoredPopover";
export function ComposerChoice({ label, ariaLabel, icon, showChevron, value, options, disabled, onPick, tone }: {
label: string;
ariaLabel?: string;
showChevron?: boolean;
icon?: ReactNode;
value: string;
options: { value: string; label: string; description?: string; icon?: ReactNode; title?: string }[];
disabled?: boolean;
onPick: (value: string) => void;
tone?: string;
}) {
const [open, setOpen] = useState(false);
const anchor = useRef<HTMLButtonElement>(null);
return <>
<button ref={anchor} type="button" className={`composer-choice ${tone || ""}`} disabled={disabled}
aria-label={ariaLabel || label} aria-haspopup="menu" aria-expanded={open && !disabled} onClick={() => setOpen(!open)}>
{icon}<span>{label}</span>{(showChevron ?? !icon) && <ChevronDown size={12} />}
</button>
<AnchoredPopover open={open && !disabled} anchorRef={anchor} onClose={() => setOpen(false)} className="composer-access-menu composer-menu-surface" align="start">
<div role="menu" aria-label={ariaLabel || label}>
{options.map(option => <button key={option.value} type="button" role="menuitemradio"
data-value={option.value} title={option.title}
aria-checked={value === option.value} className={`composer-access-menu__item${value === option.value ? " composer-access-menu__item--active" : ""}`}
onClick={() => { setOpen(false); onPick(option.value); }}>
{option.icon}<span className="composer-access-menu__copy"><span className="composer-access-menu__title">{option.label}</span>
{option.description && <span className="composer-access-menu__desc">{option.description}</span>}</span>
{value === option.value && <Check size={14} />}
</button>)}
</div>
</AnchoredPopover>
</>;
}
@@ -15,19 +15,18 @@ export function ComposerContentMenuActions({
const t = useT();
return (
<div className="composer-access-menu__section" role="menu" aria-label={t("composer.contentMenuTitle")}>
<div className="composer-access-menu__label">{t("composer.contentMenuTitle")}</div>
{attachmentInputEnabled ? <>
<button type="button" role="menuitem" className="composer-access-menu__item composer-content-menu__item" onClick={onChooseAttachment}>
<FilePlus2 size={16} aria-hidden="true" />
<span className="composer-access-menu__copy">
<span className="composer-access-menu__title">{t("composer.contentAddAttachment")}</span>
<span className="composer-access-menu__desc">{t("composer.contentAddAttachmentDesc")}</span>
</span>
</button>
<button type="button" role="menuitem" className="composer-access-menu__item composer-content-menu__item" onClick={() => onInsertTrigger("@")}>
<AtSign size={16} aria-hidden="true" />
<span className="composer-access-menu__copy">
<span className="composer-access-menu__title">{t("composer.contentReferenceFiles")}</span>
<span className="composer-access-menu__desc">{t("composer.contentReferenceFilesDesc")}</span>
</span>
</button>
</> : null}
@@ -35,7 +34,6 @@ export function ComposerContentMenuActions({
<Hash size={16} aria-hidden="true" />
<span className="composer-access-menu__copy">
<span className="composer-access-menu__title">{t("composer.contentReferenceSessions")}</span>
<span className="composer-access-menu__desc">{t("composer.contentReferenceSessionsDesc")}</span>
</span>
</button>
<button type="button" role="menuitem" className="composer-access-menu__item composer-content-menu__item"
@@ -44,7 +42,6 @@ export function ComposerContentMenuActions({
<span className="composer-content-menu__trigger-icon" aria-hidden="true">/</span>
<span className="composer-access-menu__copy">
<span className="composer-access-menu__title">{t("composer.contentUseCommands")}</span>
<span className="composer-access-menu__desc">{textPresent ? t("composer.contentUseCommandsEmptyOnly") : t("composer.contentUseCommandsDesc")}</span>
</span>
</button>
</div>
@@ -9,6 +9,7 @@ import { AnchoredPopover } from "./AnchoredPopover";
import { contextWindowStatus, formatCacheHitRate } from "../lib/contextPanelUtils";
interface ContextWindowRingProps {
turnMetrics?: { elapsed: string; tokens: string | null; tps: string | null };
enabled?: boolean;
context?: ContextInfo;
tabId?: string;
@@ -20,8 +21,8 @@ interface ContextWindowRingProps {
balance?: BalanceInfo;
}
const RING = 20;
const RING_R = (RING - 3) / 2;
const RING = 18;
const RING_R = (RING - 2) / 2;
const RING_C = 2 * Math.PI * RING_R;
function fmtCompact(n: number): string {
@@ -40,7 +41,7 @@ function fmtDuration(ms: number, t: ReturnType<typeof useI18n>['t']): string {
return t("context.durationMinutesSeconds", { minutes, seconds });
}
export function ContextWindowRing({ enabled = true, context, tabId, turnCost, turnRateBand, currency, cacheHitTokens, cacheMissTokens, balance }: ContextWindowRingProps) {
export function ContextWindowRing({ enabled = true, context, tabId, turnCost, turnRateBand, currency, cacheHitTokens, cacheMissTokens, balance, turnMetrics }: ContextWindowRingProps) {
const { locale, t } = useI18n();
const [open, setOpen] = useState(false);
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
@@ -88,6 +89,7 @@ export function ContextWindowRing({ enabled = true, context, tabId, turnCost, tu
}, []);
const onEnter = useCallback(() => {
if (enterTimer.current != null) clearTimeout(enterTimer.current);
if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
loadInfo();
enterTimer.current = setTimeout(() => setOpen(true), 200);
@@ -148,30 +150,44 @@ export function ContextWindowRing({ enabled = true, context, tabId, turnCost, tu
className={`context-ring${open ? " context-ring--open" : ""} context-ring--${status.tone}`}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
onFocus={onEnter}
onBlur={onLeave}
onClick={() => {
if (enterTimer.current != null) clearTimeout(enterTimer.current);
if (leaveTimer.current != null) clearTimeout(leaveTimer.current);
loadInfo();
setOpen(true);
}}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={t("context.windowUsageSummary", { used: String(used), window: String(windowTokens), pct: rawUsagePct })}
>
<svg width={RING} height={RING} viewBox={`0 0 ${RING} ${RING}`} className="context-ring__svg">
<circle className="context-ring__track" cx={RING / 2} cy={RING / 2} r={RING_R} fill="none" strokeWidth={3} />
<circle className="context-ring__track" cx={RING / 2} cy={RING / 2} r={RING_R} fill="none" strokeWidth={2} />
<circle
className="context-ring__arc"
cx={RING / 2} cy={RING / 2} r={RING_R}
fill="none" strokeWidth={3}
fill="none" strokeWidth={2}
strokeLinecap="round"
strokeDasharray={RING_C}
strokeDashoffset={ringOffset}
transform={`rotate(-90 ${RING / 2} ${RING / 2})`}
/>
</svg>
<span className="context-ring__percent">{rawUsagePct}%</span>
</button>
<AnchoredPopover
open={open}
anchorRef={triggerRef}
onClose={() => setOpen(false)}
className={`context-ring-popover context-ring-popover--${status.tone}`}
onClose={() => {
if (enterTimer.current != null) clearTimeout(enterTimer.current);
setOpen(false);
}}
className={`context-ring-popover context-ring-popover--${status.tone} composer-menu-surface`}
align="end"
placement="auto"
>
<div className="context-ring-popover__inner" onMouseEnter={onPopoverEnter} onMouseLeave={onPopoverLeave}>
<div className="context-ring-popover__inner" role="dialog" aria-label={t("context.windowUsageSummary", { used: String(used), window: String(windowTokens), pct: rawUsagePct })} onMouseEnter={onPopoverEnter} onMouseLeave={onPopoverLeave}>
<div className="context-ring-popover__header">
<span className="context-ring-popover__title">
{fmtCompact(used)} / {fmtCompact(windowTokens)}
@@ -198,10 +214,18 @@ export function ContextWindowRing({ enabled = true, context, tabId, turnCost, tu
)}
{elapsed && (
<div className="context-ring-popover__row">
<span className="context-ring-popover__label">{t("context.time")}</span>
<span className="context-ring-popover__label">{t("context.sessionTime")}</span>
<span className="context-ring-popover__value">{elapsed}</span>
</div>
)}
{turnMetrics && <div className="context-ring-popover__turn-metrics" title={t("composer.runStripEstimateHint")}>
{[[t("context.turnTime"), turnMetrics.elapsed], [t("status.tpsLabel"), turnMetrics.tps], [t("status.turnTokensLabel"), turnMetrics.tokens]].map(([label, value]) => value && (
<div className="context-ring-popover__row" key={label}>
<span className="context-ring-popover__label">{label}</span>
<span className="context-ring-popover__value">{value}</span>
</div>
))}
</div>}
<div className="context-ring-popover__row">
<span className="context-ring-popover__label">{t("status.cacheLabel")}</span>
<span className="context-ring-popover__value">{turnCacheRate}</span>
@@ -1,6 +1,6 @@
import { formatTokens } from "../lib/format";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Brain, Check, ChevronsUpDown, Search, Settings } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Brain, Check, ChevronDown, Cpu, Search, Settings } from "lucide-react";
import { asArray } from "../lib/array";
import { app } from "../lib/bridge";
import { useT } from "../lib/i18n";
@@ -15,8 +15,14 @@ export function ModelSwitcher({
tabId,
onPick,
onManage,
detailLabel,
details,
composerMenu = false,
}: {
label: string;
detailLabel?: string;
details?: ReactNode;
composerMenu?: boolean;
tabId?: string;
onPick: (name: string) => boolean | Promise<boolean>;
onManage?: () => void;
@@ -108,7 +114,7 @@ export function ModelSwitcher({
const cur = models.find((m) => m.current) ?? models.find((m) => m.model === label || m.ref === label);
return cur ? (cur.displayName?.trim() || providerLabel(cur.provider, t)) : null;
}, [label, models, t]);
const triggerLabel = currentProvider ? `${label} · ${currentProvider}` : label;
const triggerLabel = [label, currentProvider, detailLabel].filter(Boolean).join(" · ");
const pick = (model: ModelInfo) => {
setOpen(false);
@@ -167,17 +173,17 @@ export function ModelSwitcher({
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Brain size={14} className="modelsw__kind" />
<span className="modelsw__label">{label}</span>
<ChevronsUpDown size={11} />
<Cpu size={14} className="modelsw__kind" />
<span className="modelsw__label">{label}{detailLabel && <span className="modelsw__detail"> · {detailLabel}</span>}</span>
<ChevronDown size={12} />
</button>
</Tooltip>
<AnchoredPopover
open={open}
anchorRef={triggerRef}
onClose={() => setOpen(false)}
className="modelsw__menu modelsw__menu--portal"
style={{ minWidth: Math.max(triggerWidth || 200, 200), maxWidth: "min(90vw, 480px)" }}
className={`modelsw__menu modelsw__menu--portal${composerMenu ? " composer-menu-surface" : ""}`}
style={composerMenu ? undefined : { minWidth: Math.max(triggerWidth || 200, 200), maxWidth: "min(90vw, 480px)" }}
>
<div role="listbox">
<div className="modelsw__search" role="presentation">
@@ -220,6 +226,7 @@ export function ModelSwitcher({
</div>
))}
</div>
{details}
{onManage && <button className="modelsw__item modelsw__manage" type="button" onClick={() => { setOpen(false); onManage(); }}><Settings size={14} />{t("providerUI.manageModels")}</button>}
</AnchoredPopover>
</div>
@@ -1474,17 +1474,13 @@ function desktopLayoutStyleLabel(style: DesktopLayoutStyle, t: ReturnType<typeof
type StatusBarStyle = "icon" | "text";
function normalizeStatusBarStyle(style: string | undefined): StatusBarStyle {
return style === "icon" ? "icon" : "text";
return style === "text" ? "text" : "icon";
}
function statusBarItemLabel(id: StatusBarItemId, t: ReturnType<typeof useT>): string {
switch (id) {
case "model":
return t("settings.statusBarItem.model");
case "workspace":
return t("settings.statusBarItem.workspace");
case "git_branch":
return t("settings.statusBarItem.gitBranch");
case "cache":
return t("status.cacheLabel");
case "cache_avg":
+6 -24
View File
@@ -164,7 +164,6 @@ export function StatusBar({
context,
usage,
balance,
running,
sessionTurns,
sessionTokens,
turnTokens,
@@ -176,8 +175,7 @@ export function StatusBar({
turnRateBand,
cost,
currency,
modelLabel,
labelStyle = "text",
labelStyle = "icon",
items,
workspacePath,
workspaceName,
@@ -244,12 +242,12 @@ export function StatusBar({
const turnEstimated = usage?.estimated === true;
const sessionEstimated = context.estimated === true;
const markEstimated = (value: string, estimated: boolean) => estimated && value !== "-" ? `≈${value}` : value;
const turnCostLabel = appendRateBand(markEstimated(formatMoneyLocalized(turnCost, currency, { locale }), turnEstimated), turnRateBand, t);
const turnCostLabel = appendRateBand(markEstimated(formatMoneyLocalized(turnCost, currency, { locale, fractionDigits: 2 }), turnEstimated), turnRateBand, t);
const costLabel = markEstimated(formatMoneyLocalized(cost, currency, { locale }), sessionEstimated);
const displayWorkspacePath = (workspacePath || workspaceName || "").trim();
const workspaceLabel = compactPath(displayWorkspacePath, workspaceName);
const branchLabel = (gitBranch || "").trim();
const workspaceTitle = displayWorkspacePath ? workspaceTooltip(t, displayWorkspacePath, workspacePath, branchLabel) : "";
const workspaceTitle = workspaceTooltip(t, displayWorkspacePath, workspacePath, branchLabel);
const turnLabel = formatTurnCount(sessionTurns, t);
const tokenLabel = markEstimated(formatTokenCount(sessionTokens), sessionEstimated);
const turnTokenLabel = markEstimated(formatTokenCount(turnTokens), turnEstimated);
@@ -289,27 +287,11 @@ export function StatusBar({
const cacheTooltip = sourceCacheTooltip(t, t("status.cacheTitle"), context);
const avgCacheTooltip = sourceCacheTooltip(t, t("status.cacheAvgTitle"), context);
const itemRenderers: Record<StatusBarItemId, ReactNode> = {
model: (
<Tooltip label={t("status.modelTitle")}>
<span className="stat stat--model">
<span className={`statusbar__dot ${running ? "statusbar__dot--busy" : ""}`} />
{modelLabel && <span className="statusbar__model">{modelLabel}</span>}
</span>
</Tooltip>
),
workspace: workspaceLabel ? (
workspace: branchLabel || workspaceLabel ? (
<Tooltip label={workspaceTitle} className="statusbar__metric statusbar__metric--workspace">
<span className="stat statusbar__workspace">
<span className="stat__label stat__label--icon" aria-hidden="true"><Folder size={12} /></span>
<b>{workspaceLabel}</b>
</span>
</Tooltip>
) : null,
git_branch: branchLabel ? (
<Tooltip label={`${t("status.gitBranchTitle")}: ${branchLabel}`} className="statusbar__metric statusbar__metric--branch">
<span className="stat statusbar__branch">
<span className="stat__label stat__label--icon" aria-hidden="true"><GitBranch size={12} /></span>
<b>{branchLabel}</b>
<span className="stat__label stat__label--icon" aria-hidden="true">{branchLabel ? <GitBranch size={12} /> : <Folder size={12} />}</span>
<b>{branchLabel || workspaceLabel}</b>
</span>
</Tooltip>
) : null,
+1 -1
View File
@@ -1887,7 +1887,7 @@ function makeMockApp(): AppBindings {
conversationWidth: "standard",
closeBehavior: "background",
displayMode: "standard", sessionExperience: "standard", reasoningDisplayMode: "auto", reasoningDisplayModeExplicit: false,
statusBarStyle: "text",
statusBarStyle: "icon",
statusBarItems: [...DEFAULT_STATUS_BAR_ITEMS],
defaultToolApprovalMode: "auto",
checkUpdates: true,
+6 -3
View File
@@ -48,6 +48,7 @@ export function formatMoneyAmount(amount?: string, currency?: string, empty: "ze
interface MoneyFormatOptions {
locale?: string;
empty?: "zero" | "dash";
fractionDigits?: 2 | 4;
}
function isoCurrencyCode(currency?: string): string | null {
@@ -65,13 +66,15 @@ function isoCurrencyCode(currency?: string): string | null {
export function formatMoneyLocalized(amount?: number, currency?: string, options: MoneyFormatOptions = {}): string {
const empty = options.empty ?? "zero";
if (typeof amount !== "number" || amount <= 0) {
return empty === "dash" ? "-" : formatMoney(0, currency, empty);
return empty === "dash" ? "-" : options.fractionDigits === undefined
? formatMoney(0, currency, empty) : `${currencySymbol(currency)}${(0).toFixed(options.fractionDigits)}`;
}
const code = isoCurrencyCode(currency);
if (!code) return formatMoney(amount, currency, empty);
if (!code) return options.fractionDigits === undefined ? formatMoney(amount, currency, empty)
: `${currencySymbol(currency)}${amount.toFixed(options.fractionDigits)}`;
const digits = amount < 1 ? 4 : 2;
const digits = options.fractionDigits ?? (amount < 1 ? 4 : 2);
return new Intl.NumberFormat(options.locale, {
style: "currency",
currency: code,
+7 -4
View File
@@ -1,7 +1,5 @@
export const STATUS_BAR_ITEM_IDS = [
"model",
"workspace",
"git_branch",
"cache",
"cache_avg",
"session_tokens",
@@ -26,11 +24,16 @@ const statusBarItemSet = new Set<string>(STATUS_BAR_ITEM_IDS);
export function normalizeStatusBarItems(items: readonly string[] | null | undefined): StatusBarItemId[] {
const out: StatusBarItemId[] = [];
const seen = new Set<string>();
let removedModel = false;
for (const raw of items ?? []) {
const id = String(raw ?? "").trim();
const value = String(raw ?? "").trim();
if (value === "model") removedModel = true;
// Older preferences stored workspace and branch independently. Keep their
// first configured position while rendering one combined workspace item.
const id = value === "git_branch" ? "workspace" : value;
if (!statusBarItemSet.has(id) || seen.has(id)) continue;
out.push(id as StatusBarItemId);
seen.add(id);
}
return out.length > 0 ? out : [...DEFAULT_STATUS_BAR_ITEMS];
return out.length > 0 ? out : removedModel ? ["workspace"] : [...DEFAULT_STATUS_BAR_ITEMS];
}
+5 -3
View File
@@ -1202,7 +1202,8 @@ function endTurnModelActivity(s: State, now = Date.now(), stashForUsage = false)
}
function snapshotCompletedTurnTelemetry(s: State, now = Date.now()): State {
const settled = endTurnModelActivity(s, now);
if (!s.turnStartAt || s.turnDoneAt > 0) return s;
const settled = endPromptWait(endTurnModelActivity(s, now), now);
const liveChars = (settled.live?.text.length ?? 0) + (settled.live?.reasoning.length ?? 0);
const inFlightChars = settled.turnOutputTokens > 0
? Math.max(0, liveChars - settled.turnOutputCharsAtUsage) + settled.turnArgChars
@@ -1909,7 +1910,7 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
if (e.turnId && s.activeTurnId && e.turnId !== s.activeTurnId) return s;
const now = Date.now();
s = snapshotCompletedTurnTelemetry(s, now);
const workDurationMs = currentTurnDurationMs(s, now);
const workDurationMs = s.turnDoneAt ? Math.max(1, s.turnDoneAt - s.turnStartAt - (s.lastTurnWaitAccumMs ?? 0)) : undefined;
const completedItems = removeEmptyAssistantItems(s.items.map((it) => {
if (it.kind === "assistant") {
const completedLive = s.live?.id === it.id ? completeLiveReasoning(s.live, now) : undefined;
@@ -2022,7 +2023,7 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State
streamInterruptNoticeShown: undefined,
};
// Close user-wait unless the plan approval gate remains open.
if (!keepPlanApproval) next = endPromptWait(next, now);
next = keepPlanApproval ? beginPromptWait(next, now) : endPromptWait(next, now);
return next;
}
default: return s;
@@ -2137,6 +2138,7 @@ export function reducer(s: State, a: Action): State {
if (foregroundRunning) {
return {
...s,
...(s.turnDoneAt > 0 && turnStartedAt !== s.turnStartAt ? resetTurnTiming(turnStartedAt) : {}),
...runtimeStatus,
running: true,
turnActive: true,
+9 -5
View File
@@ -771,6 +771,7 @@ export const en = {
"composer.goalModeActiveDesc": "Keeps working until complete, blocked, or stopped.",
"composer.controlTooltip": "{category} · {mode}: {summary}",
"composer.goalInputPlaceholder": "Enter a goal…",
"composer.planInputPlaceholder": "Describe your task to create a plan…",
"composer.goalInputRequired": "Enter a goal",
"composer.subagentTaskRequired": "Enter a task for the subagent",
"composer.planHint": "shift+tab",
@@ -796,7 +797,7 @@ export const en = {
"composer.taskModeGoal": "Goal · Keep progressing",
"composer.taskModeGoalShort": "Goal",
"composer.taskModeGoalDesc": "Enter a goal, then keep working until done or blocked.",
"composer.qualityFloor": "Delivery floor",
"composer.qualityFloor": "Verification",
"composer.qualityFloorStandard": "Standard",
"composer.qualityFloorDelivery": "Delivery",
"composer.qualityFloorDeliveryTitle": "Delivery: writes require full verification and green project checks; combines with goal mode.",
@@ -809,14 +810,15 @@ export const en = {
"composer.goalRuntimeLine": "turns {turnsUsed} · requests {requestsUsed} · tokens {tokensUsed} · work time {workTime}",
"composer.accessMenuTitle": "Tool access ({shortcut} toggles YOLO)",
"composer.accessAsk": "Ask first",
"composer.accessAskShort": "Ask",
"composer.accessAskTitle": "Ask before approval-gated tool calls; Ask is not read-only",
"composer.accessAskDesc": "Approval-gated writes and shell commands ask first; this is not a read-only mode.",
"composer.accessAskDesc": "Ask before approval-gated actions",
"composer.accessAuto": "Auto approve",
"composer.accessAutoTitle": "Execute automatically and ask only for user-owned plan decisions",
"composer.accessAutoDesc": "Executes operations allowed by permissions and asks only for user-owned plan decisions.",
"composer.accessAutoDesc": "Run allowed actions; ask when needed",
"composer.accessYolo": "YOLO approval",
"composer.accessYoloTitle": "YOLO approval ({shortcut}) skips ordinary tool permission prompts",
"composer.accessYoloDesc": "Skips ordinary tool permission prompts; deny rules and fresh reviews still apply, while ask questions and plan confirmations still wait.",
"composer.accessYoloDesc": "Skip routine approvals; safety rules still apply",
"composer.accessFull": "Full access",
"composer.accessFullTitle": "Full access skips ordinary tool permission prompts",
"composer.accessFullDesc": "Skips ordinary tool permission prompts; deny rules and fresh reviews still apply, while ask questions and plan confirmations still wait.",
@@ -1950,7 +1952,7 @@ export const en = {
"settings.statusBarItemsHiddenEmpty": "Hidden items will appear here",
"settings.statusBarItemsHiddenEmptyHint": "Clear an item on the left, or drag it here to hide it.",
"settings.statusBarItem.model": "Model",
"settings.statusBarItem.workspace": "Workspace",
"settings.statusBarItem.workspace": "Workspace and branch",
"settings.statusBarItem.gitBranch": "Git branch",
"settings.statusBarItem.drag": "Drag to move {label}",
"settings.statusBarItem.moveUp": "Move {label} up",
@@ -3587,6 +3589,8 @@ export const en = {
"context.moreSources": "{count} more sources",
"context.requests": "Requests",
"context.time": "Runtime",
"context.sessionTime": "Session time",
"context.turnTime": "Turn time",
"context.durationSeconds": "{seconds}s",
"context.durationMinutesSeconds": "{minutes}m {seconds}s",
+9 -5
View File
@@ -2570,6 +2570,8 @@ export const zhTW: Record<DictKey, string> = {
"context.sessionCost": "本次費用",
"context.requests": "請求數",
"context.time": "執行時間",
"context.sessionTime": "會話耗時",
"context.turnTime": "本輪耗時",
"context.durationSeconds": "{seconds}秒",
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
@@ -2759,6 +2761,7 @@ export const zhTW: Record<DictKey, string> = {
"composer.goalModeActiveDesc": "會自動推進,直到完成、阻塞或停止。",
"composer.controlTooltip": "{category} · {mode}:{summary}",
"composer.goalInputPlaceholder": "請輸入目標…",
"composer.planInputPlaceholder": "描述你的任務以產生計劃…",
"composer.goalInputRequired": "請輸入目標",
"composer.subagentTaskRequired": "請輸入子智能體任務",
"composer.modeAsk": "詢問",
@@ -2779,7 +2782,7 @@ export const zhTW: Record<DictKey, string> = {
"composer.taskModeGoal": "目標 · 持續推進",
"composer.taskModeGoalShort": "目標",
"composer.taskModeGoalDesc": "輸入目標後持續工作,直到完成或阻塞。",
"composer.qualityFloor": "交付底線",
"composer.qualityFloor": "驗收",
"composer.qualityFloorStandard": "標準",
"composer.qualityFloorDelivery": "交付",
"composer.qualityFloorDeliveryTitle": "交付:寫入後必須全量驗證並通過專案檢查;可與目標模式並用。",
@@ -2792,14 +2795,15 @@ export const zhTW: Record<DictKey, string> = {
"composer.goalRuntimeLine": "輪次 {turnsUsed} · 請求 {requestsUsed} · token {tokensUsed} · 工作時間 {workTime}",
"composer.accessMenuTitle": "工具權限({shortcut} 切換 Yolo)",
"composer.accessAsk": "需要批准",
"composer.accessAskShort": "詢問",
"composer.accessAskTitle": "需審批的工具呼叫會先詢問;詢問不是唯讀模式",
"composer.accessAskDesc": "需審批的寫入與命令會先詢問;這不是唯讀模式。",
"composer.accessAskDesc": "需審批的操作,執行前先詢問",
"composer.accessAuto": "自動批准",
"composer.accessAutoTitle": "自動執行,只在需要使用者決定計畫時詢問",
"composer.accessAutoDesc": "自動執行權限策略允許的操作,只在計畫需要使用者決定時詢問。",
"composer.accessAutoDesc": "自動執行允許的操作,必要時詢問",
"composer.accessYolo": "Yolo批准",
"composer.accessYoloTitle": "Yolo批准({shortcut})會跳過普通工具權限提示",
"composer.accessYoloDesc": "跳過普通工具權限提示;deny 與強制新鮮審查仍生效,ask 問題和計劃確認仍會等待。",
"composer.accessYoloDesc": "跳過一般審批,仍受安全規則約束",
"composer.accessFull": "完全存取",
"composer.accessFullTitle": "完全存取會跳過普通工具權限提示",
"composer.accessFullDesc": "跳過普通工具權限提示;deny 與強制新鮮審查仍生效,ask 問題和計劃確認仍會等待。",
@@ -3065,7 +3069,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.statusBarItemsHiddenEmpty": "未顯示的項目將出現在此處",
"settings.statusBarItemsHiddenEmptyHint": "取消勾選左側項目,或拖動到此處隱藏。",
"settings.statusBarItem.model": "模型",
"settings.statusBarItem.workspace": "工作區",
"settings.statusBarItem.workspace": "工作區與分支",
"settings.statusBarItem.gitBranch": "Git 分支",
"settings.statusBarItem.drag": "拖拽移動 {label}",
"settings.statusBarItem.moveUp": "上移 {label}",
+12 -8
View File
@@ -772,6 +772,7 @@ export const zh: Record<DictKey, string> = {
"composer.goalModeActiveDesc": "会自动推进,直到完成、阻塞或停止。",
"composer.controlTooltip": "{category} · {mode}:{summary}",
"composer.goalInputPlaceholder": "请输入目标…",
"composer.planInputPlaceholder": "描述你的任务以生成计划…",
"composer.goalInputRequired": "请输入目标",
"composer.subagentTaskRequired": "请输入子智能体任务",
"composer.planHint": "shift+tab",
@@ -790,14 +791,14 @@ export const zh: Record<DictKey, string> = {
"composer.taskModeDirectShort": "常规",
"composer.taskModeDirectDesc": "边分析边执行,适合明确的日常任务。",
"composer.taskModeDirectTooltipSummary": "边分析边执行",
"composer.taskModePlan": "计划 · 确认后执行",
"composer.taskModePlan": "计划模式",
"composer.taskModePlanShort": "计划",
"composer.taskModePlanDesc": "先产出计划;工具是否执行仍由当前权限与沙箱决定。",
"composer.taskModePlanTooltipSummary": "先规划,确认后执行",
"composer.taskModeGoal": "目标 · 持续推进",
"composer.taskModeGoal": "目标模式",
"composer.taskModeGoalShort": "目标",
"composer.taskModeGoalDesc": "输入目标后持续工作,直到完成或阻塞。",
"composer.qualityFloor": "交付底线",
"composer.qualityFloor": "验收",
"composer.qualityFloorStandard": "标准",
"composer.qualityFloorDelivery": "交付",
"composer.qualityFloorDeliveryTitle": "交付:写入后必须全量验证并通过项目检查;可与目标模式并用。",
@@ -810,14 +811,15 @@ export const zh: Record<DictKey, string> = {
"composer.goalRuntimeLine": "轮次 {turnsUsed} · 请求 {requestsUsed} · token {tokensUsed} · 工作时间 {workTime}",
"composer.accessMenuTitle": "工具权限({shortcut} 切换 Yolo)",
"composer.accessAsk": "需要批准",
"composer.accessAskShort": "询问",
"composer.accessAskTitle": "需审批的工具调用会先询问;询问不是只读模式",
"composer.accessAskDesc": "需审批的写入与命令会先询问;这不是只读模式。",
"composer.accessAskDesc": "需审批的操作,执行前先询问",
"composer.accessAuto": "自动批准",
"composer.accessAutoTitle": "自动执行,只在需要用户决定计划时询问",
"composer.accessAutoDesc": "自动执行权限策略允许的操作,只在计划需要用户决定时询问。",
"composer.accessYolo": "Yolo批准",
"composer.accessAutoDesc": "自动执行允许的操作,必要时询问",
"composer.accessYolo": "跳过普通审批",
"composer.accessYoloTitle": "Yolo批准({shortcut})会跳过普通工具权限提示",
"composer.accessYoloDesc": "跳过普通工具权限提示;deny 与强制新鲜审查仍生效,ask 问题和计划确认仍会等待。",
"composer.accessYoloDesc": "跳过常规审批,仍受安全规则约束",
"composer.accessFull": "完全访问",
"composer.accessFullTitle": "完全访问会跳过普通工具权限提示",
"composer.accessFullDesc": "跳过普通工具权限提示;deny 与强制新鲜审查仍生效,ask 问题和计划确认仍会等待。",
@@ -1952,7 +1954,7 @@ export const zh: Record<DictKey, string> = {
"settings.statusBarItemsHiddenEmpty": "未显示的项目将出现在此处",
"settings.statusBarItemsHiddenEmptyHint": "取消勾选左侧项目,或拖动到此处隐藏。",
"settings.statusBarItem.model": "模型",
"settings.statusBarItem.workspace": "工作区",
"settings.statusBarItem.workspace": "工作区与分支",
"settings.statusBarItem.gitBranch": "Git 分支",
"settings.statusBarItem.drag": "拖拽移动 {label}",
"settings.statusBarItem.moveUp": "上移 {label}",
@@ -3590,6 +3592,8 @@ export const zh: Record<DictKey, string> = {
"context.moreSources": "还有 {count} 个来源",
"context.requests": "请求数",
"context.time": "运行时间",
"context.sessionTime": "会话耗时",
"context.turnTime": "本轮耗时",
"context.durationSeconds": "{seconds}秒",
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
+284 -184
View File
@@ -6804,6 +6804,40 @@ body > .mermaid-diagram--fullscreen {
display: flex;
flex-direction: column;
}
/* Ported from studio's glowring: a constant-speed trace along the perimeter.
Mounted only during active work, so idle/waiting cards consume no frames. */
.composer-glowring {
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1.5px;
pointer-events: none;
overflow: hidden;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.composer-glowring i {
position: absolute;
width: 150px;
height: 150px;
background: radial-gradient(circle closest-side, var(--accent) 0%,
color-mix(in srgb, var(--accent) 42%, transparent) 38%, transparent 72%);
offset-path: border-box;
animation: composer-border-trace 3.4s linear infinite;
}
@keyframes composer-border-trace {
to { offset-distance: 100%; }
}
@supports not (offset-path: border-box) {
.composer-glowring { background: var(--accent); }
.composer-glowring i { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.composer-glowring { background: color-mix(in srgb, var(--accent) 60%, transparent); }
.composer-glowring i { display: none; animation: none; }
}
.composer-card:focus-within {
border-color: var(--fg-faint);
}
@@ -7737,82 +7771,6 @@ body > .mermaid-diagram--fullscreen {
transform: none;
}
.composer-profile-trigger {
--wails-draggable: no-drag;
--composer-profile-accent: var(--fg-dim);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-width: 0;
height: var(--composer-control-height);
padding: 0 8px;
border: 1px solid var(--border);
border-radius: var(--composer-control-radius);
background: var(--surface-2);
color: var(--fg-dim);
box-shadow: var(--app-graphite-shadow);
font: inherit;
font-size: var(--composer-control-font-size);
font-weight: var(--composer-control-font-weight);
transition: border-color 0.14s, background 0.14s, color 0.14s, transform 0.14s var(--ease-out);
}
.composer-profile-trigger[data-profile="economy"] {
--composer-profile-accent: #3b82f6;
}
.composer-profile-trigger[data-profile="delivery"] {
--composer-profile-accent: #f97316;
}
.composer-profile-trigger > svg:first-child {
flex: 0 0 auto;
color: var(--composer-profile-accent);
}
.composer-profile-trigger > svg:last-child {
flex: 0 0 auto;
color: var(--fg-faint);
}
.composer-profile-trigger__label {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.composer-profile-trigger__value {
color: var(--fg);
font-weight: var(--composer-control-font-weight);
}
.composer-profile-trigger:hover:not(:disabled),
.composer-profile-trigger:focus-visible,
.composer-profile-trigger--open {
outline: none;
color: var(--fg);
border-color: var(--border-soft);
background: var(--bg-soft);
transform: none;
}
.composer-profile-trigger:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
}
.composer-profile-trigger:focus-visible {
box-shadow: var(--focus-ring);
transform: none;
}
.composer-more-trigger {
--wails-draggable: no-drag;
display: inline-flex;
@@ -8205,22 +8163,11 @@ body > .mermaid-diagram--fullscreen {
overflow-wrap: anywhere;
}
.composer-intent-menu,
.composer-profile-menu {
.composer-intent-menu {
width: min(284px, calc(100vw - 16px));
}
.composer-profile-menu .composer-access-menu__desc {
color: var(--fg-dim);
font-size: 12px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.composer-intent-menu__item,
.composer-profile-menu__item {
.composer-intent-menu__item {
height: auto;
min-height: 58px;
padding-block: 8px;
@@ -8232,6 +8179,49 @@ body > .mermaid-diagram--fullscreen {
color: var(--accent);
}
:root[data-theme-style] .app .composer-meta--unified .composer-task-mode-trigger--removable {
border: 0;
background: transparent;
box-shadow: none;
border-radius: 16px;
gap: 6px;
padding: 5px 8px;
color: var(--fg-dim);
}
:root[data-theme-style] .app .composer-meta--unified .composer-task-mode-trigger--removable:hover {
background: var(--bg-elev-2);
}
.composer-task-mode-trigger--removable .composer-task-mode-trigger__value {
font-weight: 400;
}
.composer-task-mode-trigger__icon {
display: grid;
place-items: center;
width: 16px;
height: 16px;
}
.composer-task-mode-trigger__icon > svg {
grid-area: 1 / 1;
}
.composer-task-mode-trigger__remove {
visibility: hidden;
}
.composer-task-mode-trigger--removable:is(:hover, :focus-visible) .composer-task-mode-trigger__icon > svg:first-child {
visibility: hidden;
}
.composer-task-mode-trigger--removable:is(:hover, :focus-visible) .composer-task-mode-trigger__remove {
visibility: visible;
}
.composer-content-menu .composer-access-menu__item > svg {
width: 18px;
height: 18px;
}
.composer-content-menu .composer-content-menu__trigger-icon {
flex-basis: 18px;
width: 18px;
}
.composer-modebar__inferred {
display: inline-block;
width: 5px;
@@ -32233,7 +32223,6 @@ body > .mermaid-diagram--fullscreen {
.app--creation .composer-card::before,
:root[data-theme-style] .app--creation .composer-card::before {
--creation-run-angle: 0deg;
content: "";
position: absolute;
inset: -1px;
@@ -32322,30 +32311,7 @@ body > .mermaid-diagram--fullscreen {
.app--creation .composer-card--running::before,
:root[data-theme-style] .app--creation .composer-card--running::before {
opacity: 1;
background: conic-gradient(
from var(--creation-run-angle),
transparent 0deg,
transparent 42deg,
color-mix(in srgb, var(--accent) 28%, transparent) 62deg,
color-mix(in srgb, var(--accent) 96%, #fff 16%) 82deg,
color-mix(in srgb, var(--accent) 28%, transparent) 104deg,
transparent 126deg,
transparent 220deg,
color-mix(in srgb, var(--accent) 24%, transparent) 238deg,
color-mix(in srgb, var(--accent) 82%, #fff 14%) 258deg,
color-mix(in srgb, var(--accent) 24%, transparent) 280deg,
transparent 304deg,
transparent 360deg
);
filter: drop-shadow(0 0 12px color-mix(in srgb, var(--accent) 52%, transparent));
animation: creation-composer-border-run 3.2s linear infinite;
}
@property --creation-run-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
opacity: 0;
}
@property --creation-hover-angle {
@@ -32354,23 +32320,6 @@ body > .mermaid-diagram--fullscreen {
initial-value: 0deg;
}
@keyframes creation-composer-border-run {
0% {
--creation-run-angle: 0deg;
opacity: 0.76;
}
42% {
opacity: 1;
}
58% {
opacity: 0.88;
}
100% {
--creation-run-angle: 360deg;
opacity: 0.76;
}
}
@keyframes creation-composer-border-settle {
0%, 62% {
opacity: 0;
@@ -32831,88 +32780,61 @@ body > .mermaid-diagram--fullscreen {
/* —— Creation 上下文窗口环形进度 —— */
.context-ring {
display: none;
}
.app--creation .context-ring,
:root[data-theme-style] .app--creation .context-ring {
.context-ring,
:root[data-theme-style] .context-ring {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: auto;
min-width: 30px;
height: 30px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
border-radius: 50%;
border-radius: 6px;
flex-shrink: 0;
margin-right: 10px;
margin-right: 8px;
gap: 6px;
transition: background 0.16s var(--ease-out);
}
.app--creation .context-ring:hover,
:root[data-theme-style] .app--creation .context-ring:hover {
.context-ring:hover,
:root[data-theme-style] .context-ring:hover {
background: color-mix(in srgb, var(--fg-faint) 8%, transparent);
}
.app--creation .context-ring__svg {
.context-ring__svg {
display: block;
width: 20px;
height: 20px;
width: 18px;
height: 18px;
}
.app--creation .context-ring__track {
stroke: color-mix(in srgb, var(--fg-faint) 18%, transparent);
.context-ring__track {
stroke: color-mix(in srgb, var(--context-ring-color, var(--fg-faint)) 25%, transparent);
}
.app--creation .context-ring__arc {
.context-ring--good { --context-ring-color: var(--ok); }
.context-ring--notice { --context-ring-color: var(--warn); }
.context-ring--warn { --context-ring-color: var(--err); }
.context-ring__arc {
transition: stroke-dashoffset 0.4s var(--ease-out), stroke 0.4s var(--ease-out);
}
.app--creation .context-ring--good .context-ring__arc {
stroke: var(--accent);
.context-ring--good .context-ring__arc {
stroke: var(--ok);
}
.app--creation .context-ring--notice .context-ring__arc {
.context-ring--notice .context-ring__arc {
stroke: var(--warn);
}
.app--creation .context-ring--warn .context-ring__arc {
.context-ring--warn .context-ring__arc {
stroke: var(--err);
}
/* popover 卡片 —— 对齐 context-panel__capacity-card 风格(portal 到 body,无 .app--creation 前缀) */
/*
/*
* NOTE (font-scale): this popover is portaled to <body>, so it has no
* .app--creation prefix, yet it is ONLY rendered in creation layout
* (Composer gates it behind showContextWindowRing = sidebarCreation =
* desktopLayoutStyle === "creation"). Font sizes here intentionally use
* calc(<px> * var(--font-scale)) to match the rest of creation and follow
* NOTE (font-scale): this shared popover is portaled to <body>, so it has no
* layout-specific prefix. Font sizes here intentionally use
* calc(<px> * var(--font-scale)) to match the composer and follow
* the interface text-size setting. Keep the calc(); do NOT revert to bare px.
* The typography-overflow-contract test asserts no bare-px font-size remains
* in .context-ring-popover rules, so a plain "12px" here will fail CI.
*/
.context-ring-popover,
:root[data-theme-style] .context-ring-popover {
min-width: 220px;
max-width: 270px;
padding: 0;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--accent) 12%, var(--border-soft));
background:
linear-gradient(180deg, color-mix(in srgb, var(--accent) 5%, var(--bg)) 0%, var(--bg) 100%);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.28);
font-size: calc(12px * var(--font-scale));
line-height: 1.5;
}
/* 警告/注意态 tinted border + gradient */
.context-ring-popover--notice,
:root[data-theme-style] .context-ring-popover--notice {
border-color: color-mix(in srgb, var(--warn) 26%, var(--border-soft));
background:
linear-gradient(180deg, color-mix(in srgb, var(--warn) 6%, var(--bg)) 0%, var(--bg) 100%);
}
.context-ring-popover--warn,
:root[data-theme-style] .context-ring-popover--warn {
border-color: color-mix(in srgb, var(--err) 28%, var(--border-soft));
background:
linear-gradient(180deg, color-mix(in srgb, var(--err) 6%, var(--bg)) 0%, var(--bg) 100%);
}
.context-ring-popover__inner {
padding: 10px;
}
@@ -39350,3 +39272,181 @@ body > .mermaid-diagram--fullscreen {
.tool__mcp-app-open:hover {
filter: brightness(0.95);
}
/* Shared composer toolbar: context and permissions left, model controls right. */
:root[data-theme-style] .composer-meta.composer-meta--unified .composer-meta__params,
.composer-meta.composer-meta--unified .composer-meta__params {
display: flex;
flex-wrap: wrap;
width: 100%;
min-width: 0;
gap: 6px;
align-items: center;
}
.composer-meta--unified { container-type: inline-size; }
:root[data-theme-style] .composer-meta--unified .composer-meta__control,
.composer-meta--unified .composer-meta__control {
display: inline-flex;
flex: 0 0 auto;
width: auto;
min-width: 0;
max-width: none;
}
.composer-meta--unified .composer-meta__control--content { order: 0; }
.composer-meta--unified .composer-meta__control--approval { order: 1; }
.composer-meta--unified .composer-meta__control--intent { order: 2; }
.composer-meta--unified .composer-meta__control--delivery { order: 3; }
:root[data-theme-style] .composer-meta--unified .composer-meta__control--model,
.composer-meta--unified .composer-meta__control--model {
order: 4;
flex: 0 1 auto;
max-width: 48%;
}
.composer-toolbar-send { order: 5; display: flex; gap: 6px; flex-shrink: 0; margin-left: auto; }
:root[data-theme-style] .composer-meta--unified .modelsw__trigger,
:root[data-theme-style] .composer-meta--unified .composer-content-trigger,
.composer-meta--unified .modelsw__trigger,
.composer-meta--unified .composer-content-trigger,
.composer-choice {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px;
border: 0;
background: transparent;
box-shadow: none;
color: var(--fg);
font: inherit;
cursor: pointer;
}
.composer-choice { white-space: nowrap; border-radius: 6px; }
.composer-choice:hover { background: var(--bg-soft); }
.composer-choice:disabled { opacity: 0.5; cursor: default; }
.composer-choice--warning { color: var(--accent); }
.composer-choice:focus-visible, .context-ring:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.composer-meta--unified .modelsw { min-width: 0; }
.composer-meta--unified .composer-effort-control { flex: 0 0 auto; }
.composer-meta--unified .composer-meta__control--model { gap: 4px; }
.composer-meta--unified .context-ring__svg { transform: translateY(1px); }
:root[data-theme-style] .composer-meta--unified .context-ring,
.composer-meta--unified .context-ring { margin-right: 0; }
:root[data-theme-style] .composer-meta--unified .composer-meta__control--approval,
.composer-meta--unified .composer-meta__control--approval { margin-left: 0; }
:root[data-theme-style] .composer-meta--unified .composer-meta__control--model { margin-left: auto; }
.composer-meta--unified .modelsw__kind,
.composer-meta--unified .context-ring__percent { display: none; }
.composer-meta--unified .composer-toolbar-send { margin-left: 2px; }
.composer-meta--unified .modelsw__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.modelsw__detail, .context-ring__percent { color: var(--fg-dim); }
.context-ring__percent { font-size: calc(12px * var(--font-scale)); }
@container (max-width: 650px) {
:root[data-theme-style] .composer-meta--unified .composer-meta__control--model,
.composer-meta--unified .composer-meta__control--model { max-width: 55%; flex: 0 1 auto; }
.composer-meta--unified .modelsw__kind { display: none; }
}
@container (max-width: 520px) {
:root[data-theme-style] .composer-meta.composer-meta--unified .composer-meta__params,
.composer-meta.composer-meta--unified .composer-meta__params {
flex-wrap: nowrap;
gap: 4px;
}
.composer-meta--unified .composer-meta__control--model { gap: 4px; }
.composer-meta--unified .composer-choice > span,
.composer-meta--unified .composer-task-mode-trigger__value,
.composer-meta--unified .modelsw__label,
.composer-meta--unified .modelsw__trigger > svg:last-child,
.composer-meta--unified .context-ring__percent { display: none; }
:root[data-theme-style] .composer-meta--unified .composer-meta__control--model {
flex: 0 0 auto;
max-width: none;
}
.composer-meta--unified .modelsw__kind { display: inline-flex; }
.composer-meta--unified .composer-effort-control .composer-choice > svg:last-child,
.composer-meta--unified .composer-meta__control--approval .composer-choice > svg:last-child { display: none; }
.composer-meta--unified .composer-toolbar-send { margin-left: 0; }
}
/* Composer popovers share one surface and row rhythm across all menus. */
:root .composer-menu-surface,
:root[data-theme-style] .composer-menu-surface {
--composer-menu-radius: 20px;
--composer-menu-row-radius: 18px;
--composer-menu-row-height: 34px;
width: max-content;
min-width: min(128px, calc(100vw - 16px));
max-width: min(320px, calc(100vw - 16px));
padding: 10px 6px;
border: 1px solid var(--border);
border-radius: var(--composer-menu-radius);
background: var(--bg-elev);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.28);
color: var(--fg);
font-size: calc(14px * var(--font-scale));
line-height: 22px;
}
:root .composer-menu-surface :is(.composer-access-menu__item, .modelsw__item) {
height: auto;
min-height: var(--composer-menu-row-height);
padding: 6px 10px;
gap: 10px;
border-radius: var(--composer-menu-row-radius);
color: var(--fg);
font-size: inherit;
font-weight: 400;
line-height: 22px;
}
:root .composer-menu-surface .composer-access-menu__title {
font-size: inherit;
font-weight: 400;
line-height: 22px;
}
:root .composer-menu-surface :is(.composer-access-menu__label, .modelsw__group-label) {
padding: 2px 10px 6px;
color: var(--fg-faint);
font-size: calc(13px * var(--font-scale));
line-height: 20px;
font-weight: 500;
text-transform: none;
letter-spacing: normal;
}
:root .composer-menu-surface .composer-access-menu__section { gap: 0; }
:root .composer-menu-surface :is(.composer-access-menu__section + .composer-access-menu__section, .modelsw__group + .modelsw__group, .modelsw__manage) {
margin-top: 12px;
border-top: 0;
}
:root .composer-menu-surface .composer-access-menu__section + .composer-access-menu__section { padding-top: 0; }
:root .composer-menu-surface :is(.composer-access-menu__desc, .modelsw__empty) {
font-size: calc(12px * var(--font-scale));
line-height: 18px;
color: var(--fg-dim);
}
:root .composer-menu-surface :is(.composer-access-menu__item, .modelsw__item):is(:hover, :focus-visible),
:root .composer-menu-surface :is(.composer-access-menu__item--active, .modelsw__item--current) {
background: var(--bg-elev-2);
color: var(--fg);
}
:root .composer-menu-surface :is(.composer-access-menu__item, .modelsw__item):focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
:root .composer-menu-surface button:disabled { opacity: 0.45; cursor: default; }
:root .composer-menu-surface :is(.modelsw__check, .composer-intent-menu__check, .composer-access-menu__item > svg:last-child) { color: var(--accent); }
:root .composer-menu-surface .modelsw__search {
min-height: 34px;
margin: 0 4px 12px;
padding: 6px 10px;
border-radius: 14px;
}
:root .composer-menu-surface .modelsw__search-input { font-size: inherit; }
:root .composer-menu-surface .context-ring-popover__inner { padding: 6px 10px; }
:root .composer-menu-surface.modelsw__menu { min-width: min(240px, calc(100vw - 16px)); }
:root .composer-menu-surface.context-ring-popover { min-width: min(260px, calc(100vw - 16px)); }
:root .composer-menu-surface .composer-access-menu__title { overflow-wrap: anywhere; }
:root .composer-choice--permission-auto { color: var(--ok); }
:root { --composer-permission-ask: #60a5fa; }
:root[data-theme="light"] { --composer-permission-ask: #2563eb; }
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) { --composer-permission-ask: #2563eb; }
}
:root .composer-choice--permission-ask { color: var(--composer-permission-ask); }
:root .composer-choice--permission-yolo { color: var(--err); }
+2 -2
View File
@@ -14,7 +14,7 @@ func desktopStartupSettingsFromConfig(cfg *config.Config) DesktopStartupSettings
return DesktopStartupSettingsView{
Bot: botSettingsView(config.BotConfig{}), DesktopLayoutStyle: "workbench",
DesktopTheme: "auto", DesktopThemeStyle: "graphite", DesktopTerminalTheme: "auto",
DisplayMode: "standard", SessionExperience: "standard", ReasoningDisplayMode: "auto", StatusBarStyle: "text",
DisplayMode: "standard", SessionExperience: "standard", ReasoningDisplayMode: "auto", StatusBarStyle: "icon",
StatusBarItems: config.DefaultDesktopStatusBarItems(), CheckUpdates: true,
UpdateChannel: "stable", ConversationWidth: "standard",
}
@@ -56,7 +56,7 @@ func (a *App) defaultSettingsView() SettingsView {
Bot: botSettingsView(config.BotConfig{}), AutoPlan: "off", DesktopLayoutStyle: "workbench",
DesktopTheme: "auto", DesktopThemeStyle: "graphite", DesktopTerminalTheme: "auto",
CloseBehavior: "background", DisplayMode: "standard", ReasoningDisplayMode: "auto",
StatusBarStyle: "text", StatusBarItems: config.DefaultDesktopStatusBarItems(), SessionExperience: "standard",
StatusBarStyle: "icon", StatusBarItems: config.DefaultDesktopStatusBarItems(), SessionExperience: "standard",
DefaultToolApprovalMode: "auto", CheckUpdates: true, UpdateChannel: "stable",
Telemetry: true, Metrics: true, ExpandThinking: false, ConversationWidth: "standard",
}
+32
View File
@@ -0,0 +1,32 @@
# Desktop composer controls
[简体中文](COMPOSER_CONTROLS.zh-CN.md)
Use the **+** menu to attach content or enable Plan, Goal, or Delivery mode.
Normal execution and Standard delivery are the defaults. Active modes appear
as removable chips; removing Delivery restores Standard without changing Plan
or Goal. Approval policy remains a separate Ask/Auto/Yolo menu. Model and
reasoning effort have independent selectors; unsupported models hide effort.
The status bar no longer repeats the model name. Its turn cost uses two decimal
places; detailed cost values retain their existing precision.
The context ring opens usage details. **Turn time** excludes user-approval and
answer waits and stops at the controller's completion timestamp. Retry time
remains part of the turn. Turn tokens and throughput remain available during
waits, retries, and after completion; in-flight tokens are estimates. **Session
time** is the separately reported session aggregate. Starting a new turn resets
turn metrics. These live metrics are not a persisted historical report.
Completed metrics are settled once per turn; later background-job updates do
not replace them.
The composer defaults to 140px and preserves manual resizing. Running work
uses a theme-aware perimeter trace; reduced motion uses a static outline.
Approval, answer, and retry notices remain visible.
The bottom status bar combines workspace and branch into one item: it shows
the branch name, with both workspace path and branch in the tooltip. Non-Git
workspaces show the workspace name. Older item lists are deduplicated. For a
model-only legacy configuration, migration retains only workspace and branch. On the
first upgrade, status labels default to icons; subsequent manual text/icon
choices are preserved. An older binary that rewrites preferences can remove
the upgrade marker, causing the icon default to apply again on re-upgrade.
+25
View File
@@ -0,0 +1,25 @@
# 桌面输入区控件
[English](COMPOSER_CONTROLS.md)
通过 **+** 菜单添加内容,或启用计划、目标、交付模式。默认采用常规执行
和标准交付。已启用的模式显示为可移除标签;移除交付标签会恢复标准,
不会改变计划或目标模式。权限仍由独立的询问/自动/Yolo 菜单控制。
模型和思维力度分别选择;模型不支持时隐藏思维力度入口。
底部信息栏不再重复显示模型名称,本轮费用保留两位小数,费用详情仍保留
原有精度。
圆环打开用量详情。**本轮耗时**扣除等待审批和用户回答的时间,并在控制器
记录的完成时间停止;重试时间仍计入本轮。等待、重试和结束后保留本轮
token 与吞吐指标,流式传输中的 token 为估算值。**会话耗时**单独展示
会话累计值。新一轮开始时重置本轮指标;这些实时指标不是持久化历史报告。
每个回合的完成指标只结算一次,后续后台作业状态更新不会覆盖它们。
输入区默认高度为 140px,并保留手动调整的高度。运行时使用适配主题的
边框动画,减少动态效果设置下显示静态边框。审批、回答和重试提示仍然可见。
底部信息栏将工作区和分支合并为一项:显示当前分支名,悬停提示同时展示
工作区路径和分支。非 Git 工作区显示工作区名称,旧配置列表自动去重。
旧配置如果只显示模型,迁移后仅保留工作区与分支。
首次升级默认使用图标版,后续手动选择的文字版或图标版会保留。旧版本
重写配置可能移除升级标记,再次升级时会重新应用图标版默认值。
+5 -2
View File
@@ -486,15 +486,18 @@ func (c *Config) DesktopDefaultToolApprovalMode() string {
}
// DesktopStatusBarStyle normalizes the desktop status bar metric label style.
// Default is "text"; explicit "icon" preserves the user's compact choice.
// Unmigrated configurations adopt icon labels once; later choices are preserved.
func (c *Config) DesktopStatusBarStyle() string {
if !c.Desktop.StatusBarStyleInitialized {
return "icon"
}
switch strings.ToLower(strings.TrimSpace(c.Desktop.StatusBarStyle)) {
case "icon":
return "icon"
case "text":
return "text"
default:
return "text"
return "icon"
}
}
+14 -13
View File
@@ -6,19 +6,20 @@ import "strings"
// separate from top-level language and [ui] so desktop choices do not affect CLI
// language, terminal colours, or provider-visible prompt/request data.
type DesktopConfig struct {
Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
Currency string `toml:"currency"` // legacy display currency; migrated to [billing].display_currency
LayoutStyle string `toml:"layout_style"` // workbench|creation; legacy classic is migrated on startup
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
TerminalTheme string `toml:"terminal_theme"` // auto|dark|light; auto follows the desktop app theme
ExternalOpener string `toml:"external_opener"` // preferred installed app used by the desktop Open control
CloseBehavior string `toml:"close_behavior"` // quit|background; desktop window close behavior
DisplayMode string `toml:"display_mode"` // standard|compact (legacy "minimal" maps to compact); transcript display mode
StatusBarStyle string `toml:"status_bar_style"` // icon|text; desktop status bar metric labels
StatusBarItems []string `toml:"status_bar_items"` // ordered visible desktop status bar items
DefaultToolApprovalMode string `toml:"default_tool_approval_mode"` // ask|auto|yolo; defaults to auto for newly-created desktop sessions
CheckUpdates *bool `toml:"check_updates"` // startup update checks; nil keeps the default enabled
Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
Currency string `toml:"currency"` // legacy display currency; migrated to [billing].display_currency
LayoutStyle string `toml:"layout_style"` // workbench|creation; legacy classic is migrated on startup
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
TerminalTheme string `toml:"terminal_theme"` // auto|dark|light; auto follows the desktop app theme
ExternalOpener string `toml:"external_opener"` // preferred installed app used by the desktop Open control
CloseBehavior string `toml:"close_behavior"` // quit|background; desktop window close behavior
DisplayMode string `toml:"display_mode"` // standard|compact (legacy "minimal" maps to compact); transcript display mode
StatusBarStyle string `toml:"status_bar_style"` // icon|text; desktop status bar metric labels
StatusBarStyleInitialized bool `toml:"status_bar_style_initialized"` // one-time icon default upgrade; later choices are user-owned
StatusBarItems []string `toml:"status_bar_items"` // ordered visible desktop status bar items
DefaultToolApprovalMode string `toml:"default_tool_approval_mode"` // ask|auto|yolo; defaults to auto for newly-created desktop sessions
CheckUpdates *bool `toml:"check_updates"` // startup update checks; nil keeps the default enabled
// UpdateChannel is a legacy compatibility field. It is accepted on read but
// ignored and omitted from future canonical writes.
UpdateChannel string `toml:"update_channel"`
+1
View File
@@ -374,6 +374,7 @@ func (c *Config) SetDesktopStatusBarStyle(style string) error {
default:
return fmt.Errorf("status bar style %q: must be icon|text", style)
}
c.Desktop.StatusBarStyleInitialized = true
return nil
}
+3 -3
View File
@@ -289,8 +289,8 @@ func TestDesktopExternalOpenerValidation(t *testing.T) {
}
func TestDesktopStatusBarStyleNormalizes(t *testing.T) {
if got := Default().DesktopStatusBarStyle(); got != "text" {
t.Fatalf("default desktop status bar style = %q, want text", got)
if got := Default().DesktopStatusBarStyle(); got != "icon" {
t.Fatalf("default desktop status bar style = %q, want icon", got)
}
for _, tt := range []struct {
in string
@@ -302,7 +302,7 @@ func TestDesktopStatusBarStyleNormalizes(t *testing.T) {
{"icons", "icon", false},
{"text", "text", false},
{"labels", "text", false},
{"later", "text", true},
{"later", "icon", true},
} {
c := Default()
if err := c.SetDesktopStatusBarStyle(tt.in); (err != nil) != tt.wantErr {
+1
View File
@@ -119,6 +119,7 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
}
fmt.Fprintf(&b, "close_behavior = %q # desktop: quit|background when the window close button is clicked\n", c.DesktopCloseBehavior())
fmt.Fprintf(&b, "status_bar_style = %q # desktop: icon|text metric labels in the bottom status bar\n", c.DesktopStatusBarStyle())
b.WriteString("status_bar_style_initialized = true # icon default upgrade applied; preserve later user choices\n")
fmt.Fprintf(&b, "status_bar_items = %s # desktop: ordered visible bottom status bar items\n", renderStringArray(c.DesktopStatusBarItems()))
fmt.Fprintf(&b, "default_tool_approval_mode = %q # desktop: Ask/Auto/YOLO default for newly-created sessions\n", c.DesktopDefaultToolApprovalMode())
fmt.Fprintf(&b, "check_updates = %v # desktop: check for new versions on startup\n", c.DesktopCheckUpdates())
+2
View File
@@ -205,6 +205,7 @@ func TestRenderTOMLRoundTrips(t *testing.T) {
orig.Desktop.CloseBehavior = "background"
orig.Desktop.DisplayMode = "compact"
orig.Desktop.StatusBarStyle = "text"
orig.Desktop.StatusBarStyleInitialized = true
orig.Desktop.StatusBarItems = []string{"model", "balance", "cache"}
orig.Desktop.DefaultToolApprovalMode = "auto"
orig.Desktop.CheckUpdates = boolPtr(false)
@@ -784,6 +785,7 @@ func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) {
c.Desktop.ThemeStyle = "graphite"
c.Desktop.CloseBehavior = "background"
c.Desktop.StatusBarStyle = "text"
c.Desktop.StatusBarStyleInitialized = true
c.Desktop.DefaultToolApprovalMode = "auto"
c.Desktop.CheckUpdates = boolPtr(false)
c.Desktop.UpdateChannel = "preview"
@@ -0,0 +1,46 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestStatusBarIconDefaultUpgradePreservesLaterChoice(t *testing.T) {
home := isolateUserConfigHome(t)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
path := UserConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("[desktop]\nstatus_bar_style = \"text\"\ntheme = \"dark\"\n"), 0600); err != nil {
t.Fatal(err)
}
cfg := LoadForEdit(path)
if cfg.DesktopStatusBarStyle() != "icon" {
t.Fatal("legacy text setting must adopt the new icon default")
}
if err := cfg.SaveTo(path); err != nil {
t.Fatal(err)
}
cfg = LoadForEdit(path)
if !cfg.Desktop.StatusBarStyleInitialized || cfg.DesktopStatusBarStyle() != "icon" || cfg.DesktopTheme() != "dark" {
t.Fatal("save must persist the migration and preserve unrelated preferences")
}
if err := cfg.SetDesktopStatusBarStyle("text"); err != nil {
t.Fatal(err)
}
for range 2 {
if err := cfg.SaveTo(path); err != nil {
t.Fatal(err)
}
cfg = LoadForEdit(path)
if cfg.DesktopStatusBarStyle() != "text" {
t.Fatal("later manual text choice must survive repeated reloads")
}
}
if strings.Contains(RenderTOMLForScope(cfg, RenderScopeProject), "status_bar_style_initialized") {
t.Fatal("desktop migration marker must not enter project configuration")
}
}
+3 -3
View File
@@ -72,7 +72,7 @@
"test-file-size": 307
},
"desktop/frontend/src/__tests__/composer-goal-toggle.test.tsx": {
"test-file-size": 1758
"test-file-size": 1764
},
"desktop/frontend/src/__tests__/composer-session-draft.test.tsx": {
"test-file-size": 505
@@ -840,7 +840,7 @@
},
"internal/config/edit.go": {
"essay": 5,
"file-size": 1679
"file-size": 1680
},
"internal/config/edit_test.go": {
"test-file-size": 2425
@@ -885,7 +885,7 @@
"function-size": 946
},
"internal/config/render_test.go": {
"test-file-size": 759
"test-file-size": 761
},
"internal/control/approval.go": {
"essay": 5,