mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #9958 from SivanCola/feature/composer-toolbar-refresh
feat(desktop): refine composer controls and feedback / 优化输入栏交互与运行反馈
This commit is contained in:
35 files changed
+983
-688
No files matched your search
+2
-2
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,25 @@
|
||||
# 桌面输入区控件
|
||||
|
||||
[English](COMPOSER_CONTROLS.md)
|
||||
|
||||
通过 **+** 菜单添加内容,或启用计划、目标、交付模式。默认采用常规执行
|
||||
和标准交付。已启用的模式显示为可移除标签;移除交付标签会恢复标准,
|
||||
不会改变计划或目标模式。权限仍由独立的询问/自动/Yolo 菜单控制。
|
||||
模型和思维力度分别选择;模型不支持时隐藏思维力度入口。
|
||||
底部信息栏不再重复显示模型名称,本轮费用保留两位小数,费用详情仍保留
|
||||
原有精度。
|
||||
|
||||
圆环打开用量详情。**本轮耗时**扣除等待审批和用户回答的时间,并在控制器
|
||||
记录的完成时间停止;重试时间仍计入本轮。等待、重试和结束后保留本轮
|
||||
token 与吞吐指标,流式传输中的 token 为估算值。**会话耗时**单独展示
|
||||
会话累计值。新一轮开始时重置本轮指标;这些实时指标不是持久化历史报告。
|
||||
每个回合的完成指标只结算一次,后续后台作业状态更新不会覆盖它们。
|
||||
|
||||
输入区默认高度为 140px,并保留手动调整的高度。运行时使用适配主题的
|
||||
边框动画,减少动态效果设置下显示静态边框。审批、回答和重试提示仍然可见。
|
||||
|
||||
底部信息栏将工作区和分支合并为一项:显示当前分支名,悬停提示同时展示
|
||||
工作区路径和分支。非 Git 工作区显示工作区名称,旧配置列表自动去重。
|
||||
旧配置如果只显示模型,迁移后仅保留工作区与分支。
|
||||
首次升级默认使用图标版,后续手动选择的文字版或图标版会保留。旧版本
|
||||
重写配置可能移除升级标记,再次升级时会重新应用图标版默认值。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user