From 692e8265fcc574d2b36ea0d34fa146d80d6ac20a Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 30 Sep 2026 10:39:07 +0530 Subject: [PATCH 01/16] feat(ai): strings and styles for the start-screen usage card Month heat maps, the details block, the day tip and the reload icon, on the start screen's own type scale, plus the AI_CHAT_USAGE_* strings they need. --- src/nls/root/strings.js | 23 ++ src/styles/Extn-AIChatPanel.less | 396 +++++++++++++++++++++++++++++++ 2 files changed, 419 insertions(+) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 7ce025d6bb..fc926b9d18 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2810,6 +2810,29 @@ define({ "AI_CHAT_USAGE_NOTE": "With a Claude subscription, this cost is included in your plan. Pricing shown is for reference only.", "AI_CHAT_USAGE_OPTION_TURNS": "Show usage after every turn", "AI_CHAT_USAGE_OPTION_DOLLARS": "Show $ cost", + "AI_CHAT_USAGE_CARD_TITLE": "Usage", + "AI_CHAT_USAGE_RANGE_30": "{0} \u00b7 last 30 days", + "AI_CHAT_USAGE_DETAILS": "Details", + "AI_CHAT_USAGE_RELOAD": "Reload usage", + "AI_CHAT_USAGE_MONTHS_SHOWN": "{0} of {1} months", + "AI_CHAT_USAGE_LESS": "Less", + "AI_CHAT_USAGE_MORE": "More", + "AI_CHAT_USAGE_SPEND": "spend", + "AI_CHAT_USAGE_TOKENS_LABEL": "tokens", + "AI_CHAT_USAGE_TURNS_LABEL": "turns", + "AI_CHAT_USAGE_ACTIVE_DAYS": "active days", + "AI_CHAT_USAGE_TODAY": "Today", + "AI_CHAT_USAGE_TOP_MODELS": "Top models", + "AI_CHAT_USAGE_BY_SPEND": "by spend", + "AI_CHAT_USAGE_BY_TOKENS": "by tokens", + "AI_CHAT_USAGE_ACTIVITY": "Activity", + "AI_CHAT_USAGE_LAST_3_MONTHS": "last 3 months", + "AI_CHAT_USAGE_LONGEST_STREAK": "Longest streak", + "AI_CHAT_USAGE_STREAK_DAYS": "{0} days", + "AI_CHAT_USAGE_ACTIVE_DAYS_TITLE": "Active days", + "AI_CHAT_USAGE_PER_ACTIVE_DAY": "Avg / active day", + "AI_CHAT_USAGE_TOTAL_LABEL": "Total", + "AI_CHAT_USAGE_NO_USAGE_DAY": "No usage this day", "AI_CHAT_TOOL_ASK_COMPOSING": "Asking a question in the live preview...", "AI_CHAT_LABEL_YOU_IN_PREVIEW": "You, in the live preview", "AI_CHAT_TOOL_ASK_WAITING": "Answer in the live preview, or", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 357b41b673..b1bd5272ab 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -6095,3 +6095,399 @@ display: none; } } + +/* ── Usage card on the start screen: months of use as a heat map ────── */ +/* Hidden until the first turn is on the ledger. The whole card is the + Details toggle, so it keeps the start cards' border but not their hover + lift: a wash over the coloured cells reads as a flash, not a hint. Only + the Details label brightens. Type sits on the start screen's own scale. */ +@ai-usage-heat: #4f8cff; +@ai-usage-heat-top: @ai-start-accent; +@ai-usage-caption: fade(@project-panel-text-2, 70%); + +.ai-chat-start .ai-usage-card { + margin: 16px 0 8px; + padding: 14px 14px 12px; + box-sizing: border-box; + border: 1px solid @ai-start-border; + border-radius: 8px; + background: @ai-start-card-bg; + font-size: @ai-text-secondary; + color: @project-panel-text-1; + cursor: pointer; + outline: none; + + &:focus-visible { + border-color: fade(@ai-start-accent, 50%); + } + + &:hover .ai-usage-card-toggle { + color: @project-panel-text-1; + } + + .ai-usage-card-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + } + + .ai-usage-card-title { + font-size: @ai-text-body; + font-weight: 600; + } + + .ai-usage-card-range { + font-size: @ai-text-meta; + color: @project-panel-text-2; + padding: 2px 6px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.05); + white-space: nowrap; + } + + /* Other Phoenix windows write the same ledger; this reads it again. */ + .ai-usage-card-reload { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + font-size: 11px; + color: fade(@project-panel-text-2, 80%); + cursor: pointer; + + &:hover { + color: @project-panel-text-1; + background: rgba(255, 255, 255, 0.06); + } + + &.ai-usage-reloading i { + animation: ai-usage-spin 0.7s linear infinite; + } + } + + .ai-usage-card-toggle { + margin-left: 6px; + display: inline-flex; + align-items: center; + gap: 5px; + font-size: @ai-text-secondary; + color: @project-panel-text-2; + white-space: nowrap; + + i { + font-size: 9px; + } + } + + .ai-usage-months { + display: flex; + justify-content: center; + gap: 14px; + } + + .ai-usage-month { + display: flex; + flex-direction: column; + gap: 3px; + } + + .ai-usage-month-name { + font-size: @ai-text-meta; + color: @ai-usage-caption; + margin-bottom: 1px; + } + + .ai-usage-grid { + display: grid; + grid-template-columns: repeat(7, 11px); + gap: 3px; + } + + .ai-usage-wd { + font-size: 10px; + line-height: 11px; + color: @ai-usage-caption; + text-align: center; + } + + .ai-usage-cell { + display: block; + width: 11px; + height: 11px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.06); + + &.ai-usage-l1 { background: fade(@ai-usage-heat, 35%); } + &.ai-usage-l2 { background: fade(@ai-usage-heat, 60%); } + &.ai-usage-l3 { background: fade(@ai-usage-heat, 85%); } + &.ai-usage-l4 { background: @ai-usage-heat-top; } + &.ai-usage-pad { background: transparent; } + } + + .ai-usage-legend { + display: flex; + align-items: center; + gap: 3px; + margin-top: 8px; + font-size: @ai-text-meta; + color: @ai-usage-caption; + + .ai-usage-fit { + margin-right: auto; + } + + .ai-usage-scale { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: auto; + } + + .ai-usage-cell { + width: 9px; + height: 9px; + } + } + + /* Details: hidden until the card is opened. */ + .ai-usage-more { + display: none; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + } + + &.ai-usage-open .ai-usage-more { + display: block; + } + + .ai-usage-big { + display: flex; + align-items: flex-end; + gap: 24px; + margin: 4px 0 12px; + + .ai-usage-stat b { + font-size: 19px; + font-weight: 600; + line-height: 1.15; + } + } + + .ai-usage-stat { + display: flex; + flex-direction: column; + line-height: 1.15; + + b { + font-size: @ai-text-body; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + span { + font-size: @ai-text-meta; + color: @ai-usage-caption; + margin-top: 2px; + white-space: nowrap; + } + } + + .ai-usage-bars { + display: flex; + align-items: flex-end; + gap: 3px; + height: 44px; + margin-bottom: 4px; + } + + .ai-usage-bar { + flex: 1; + display: flex; + flex-direction: column-reverse; + border-radius: 2px 2px 0 0; + overflow: hidden; + background: rgba(255, 255, 255, 0.03); + min-height: 1px; + + i { + display: block; + } + } + + .ai-usage-axis { + display: flex; + justify-content: space-between; + font-size: @ai-text-meta; + color: @ai-usage-caption; + margin-bottom: 10px; + } + + .ai-usage-sub { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 0 6px; + font-size: @ai-text-meta; + letter-spacing: 0.06em; + text-transform: uppercase; + color: @ai-usage-caption; + + small { + margin-left: auto; + font-size: @ai-text-meta; + letter-spacing: normal; + text-transform: none; + } + } + + .ai-usage-model { + display: grid; + grid-template-columns: 10px 1fr auto; + align-items: center; + gap: 8px; + padding: 3px 0; + } + + .ai-usage-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + } + + .ai-usage-model-name { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .ai-usage-model-bar { + height: 3px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); + + i { + display: block; + height: 100%; + border-radius: 2px; + } + } + + .ai-usage-amt { + font-variant-numeric: tabular-nums; + } + + .ai-usage-activity { + display: flex; + gap: 16px; + margin: 6px 0 4px; + } +} + +@keyframes ai-usage-spin { + to { transform: rotate(360deg); } +} + +/* The day under the pointer, in the grid or the bars. Lives on the body so + the card's overflow cannot clip it. */ +.ai-usage-day-tip { + position: fixed; + z-index: 1000; + min-width: 196px; + padding: 10px 12px; + box-sizing: border-box; + background: @bc-ai-sidebar-bg; + border: 1px solid @ai-start-border; + border-radius: 8px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); + font-size: @ai-text-secondary; + color: @project-panel-text-1; + pointer-events: none; + + .ai-usage-tip-date { + font-size: @ai-text-body; + font-weight: 600; + margin-bottom: 8px; + white-space: nowrap; + } + + .ai-usage-tip-quiet { + color: @project-panel-text-2; + white-space: nowrap; + } + + .ai-usage-tip-stats { + display: flex; + gap: 14px; + margin-bottom: 9px; + } + + .ai-usage-stat { + display: flex; + flex-direction: column; + line-height: 1.15; + + b { + font-size: @ai-text-body; + font-weight: 600; + font-variant-numeric: tabular-nums; + } + + span { + font-size: @ai-text-meta; + color: @ai-usage-caption; + margin-top: 2px; + } + } + + .ai-usage-tip-split { + display: flex; + height: 5px; + border-radius: 3px; + overflow: hidden; + margin-bottom: 8px; + background: rgba(255, 255, 255, 0.06); + + i { + display: block; + height: 100%; + } + } + + .ai-usage-tip-line { + display: grid; + grid-template-columns: auto 1fr auto auto; + align-items: center; + gap: 8px; + padding: 2px 0; + white-space: nowrap; + } + + .ai-usage-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + } + + .ai-usage-tip-pct { + color: @project-panel-text-2; + font-variant-numeric: tabular-nums; + } + + .ai-usage-tip-amt { + font-variant-numeric: tabular-nums; + } +} From 24db3a8e61a6362d49e6a55e8ea786b2d7162782 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 30 Sep 2026 10:41:13 +0530 Subject: [PATCH 02/16] fix(ai): keep the usage card's stat rows inside a narrow panel The headline and Activity stats wrap instead of running past the card, the range chip trims with an ellipsis, and the reload and Details controls keep their width. --- src/styles/Extn-AIChatPanel.less | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index b1bd5272ab..5eb0952478 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -6130,6 +6130,7 @@ align-items: center; gap: 8px; margin-bottom: 10px; + min-width: 0; } .ai-usage-card-title { @@ -6137,6 +6138,7 @@ font-weight: 600; } + /* The one header item that may give way in a narrow panel. */ .ai-usage-card-range { font-size: @ai-text-meta; color: @project-panel-text-2; @@ -6144,11 +6146,15 @@ border-radius: 4px; background: rgba(255, 255, 255, 0.05); white-space: nowrap; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } /* Other Phoenix windows write the same ledger; this reads it again. */ .ai-usage-card-reload { margin-left: auto; + flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; @@ -6171,6 +6177,7 @@ .ai-usage-card-toggle { margin-left: 6px; + flex-shrink: 0; display: inline-flex; align-items: center; gap: 5px; @@ -6265,10 +6272,12 @@ display: block; } + /* Stat rows wrap in a narrow panel rather than run past the card. */ .ai-usage-big { display: flex; + flex-wrap: wrap; align-items: flex-end; - gap: 24px; + gap: 8px 24px; margin: 4px 0 12px; .ai-usage-stat b { @@ -6391,7 +6400,8 @@ .ai-usage-activity { display: flex; - gap: 16px; + flex-wrap: wrap; + gap: 8px 16px; margin: 6px 0 4px; } } From 3644725fee0568a4f76cfbc5a9e828cc5aebcbf8 Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 30 Sep 2026 11:43:31 +0530 Subject: [PATCH 03/16] feat(ai): month pager strings and styles for the usage details The details' page row (last 30 days or an earlier calendar month) and its arrow titles; the "last 3 months" label goes with the 90-day row. --- src/nls/root/strings.js | 4 +++- src/styles/Extn-AIChatPanel.less | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index fc926b9d18..3fc82e6ad1 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2826,7 +2826,9 @@ define({ "AI_CHAT_USAGE_BY_SPEND": "by spend", "AI_CHAT_USAGE_BY_TOKENS": "by tokens", "AI_CHAT_USAGE_ACTIVITY": "Activity", - "AI_CHAT_USAGE_LAST_3_MONTHS": "last 3 months", + "AI_CHAT_USAGE_LAST_30_DAYS": "Last 30 days", + "AI_CHAT_USAGE_EARLIER_MONTH": "Earlier month", + "AI_CHAT_USAGE_LATER_MONTH": "Later month", "AI_CHAT_USAGE_LONGEST_STREAK": "Longest streak", "AI_CHAT_USAGE_STREAK_DAYS": "{0} days", "AI_CHAT_USAGE_ACTIVE_DAYS_TITLE": "Active days", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 5eb0952478..68ffd05169 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -6272,6 +6272,45 @@ display: block; } + /* The details' page: the last 30 days, or an earlier calendar month. */ + .ai-usage-month-nav { + display: flex; + align-items: center; + gap: 2px; + margin: 0 0 6px -4px; + } + + .ai-usage-month-prev, + .ai-usage-month-next { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + font-size: 10px; + color: fade(@project-panel-text-2, 90%); + cursor: pointer; + + &:hover { + color: @project-panel-text-1; + background: rgba(255, 255, 255, 0.06); + } + + &.ai-usage-nav-off { + color: fade(@project-panel-text-2, 35%); + background: none; + cursor: default; + } + } + + .ai-usage-month-label { + min-width: 110px; + text-align: center; + font-size: @ai-text-secondary; + color: @project-panel-text-1; + } + /* Stat rows wrap in a narrow panel rather than run past the card. */ .ai-usage-big { display: flex; From 8b1ec63efa24d3d6b1641b6aea39a198e37debac Mon Sep 17 00:00:00 2001 From: abose Date: Wed, 30 Sep 2026 17:18:38 +0530 Subject: [PATCH 04/16] feat(utils): add a retry helper with configurable backoff and limits Async.retry runs an operation again after a failure: attempts, per-retry delays (a number or a list whose last value repeats), jitter, a per-attempt timeout, a shouldRetry gate, an onRetry hook and an AbortSignal. The defaults are one quick retry then longer waits, about two minutes in all, for network blips and service restarts. Always returns a native Promise. --- src/utils/Async.js | 127 ++++++++++++++++++++++++++++++++++++++++ test/spec/Async-test.js | 123 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) diff --git a/src/utils/Async.js b/src/utils/Async.js index 21cdb8c93e..1bf3b9d169 100644 --- a/src/utils/Async.js +++ b/src/utils/Async.js @@ -349,6 +349,132 @@ define(function (require, exports, module) { return wrapper.promise(); } + /** The waits retry() puts between attempts when none are given: 5 s, 15 s, 30 s, 60 s. */ + const RETRY_DEFAULT_DELAYS_MS = [5000, 15000, 30000, 60000]; + const RETRY_DEFAULT_ATTEMPTS = 5; + const RETRY_DEFAULT_JITTER = 0.25; + + /** + * Runs an operation again when it fails, waiting longer before each retry. Meant for calls + * that may hit a network blip or a service that is restarting: the first retry is quick and + * the later ones are spaced out, so a short outage is ridden out without hammering the other + * side. Each wait is jittered so many clients recovering at once do not retry in step. + * + * The operation is called with the attempt number, starting at 1, and returns a Promise (a + * plain value or another thenable, such as a jQuery promise, is adopted with Promise.resolve). + * A thrown error or a rejection counts as a failure. Always returns a native Promise: it + * resolves with the first successful value and rejects with the last failure once the attempts + * are used up, or as soon as `shouldRetry` says a failure is not worth retrying, or when the + * signal aborts. + * + * @param {function(number): *} operation - called with the attempt number (1-based) + * @param {Object} [options] + * @param {number} [options.attempts=5] - how many times to try in all, the first included + * @param {number|Array} [options.delaysMs=[5000, 15000, 30000, 60000]] - the wait before + * each retry. A number is a fixed wait; an array gives the waits in order, and its last + * value repeats when there are more retries than entries. + * @param {number} [options.jitter=0.25] - each wait is varied at random by up to this fraction + * either way; 0 disables it + * @param {function(*, number): boolean} [options.shouldRetry] - given the failure and the + * attempt that failed; return false to stop at once. Every failure is retried by default. + * @param {function(*, number, number)} [options.onRetry] - told the failure, the attempt that + * failed and the wait in ms before each retry, for logging + * @param {number} [options.timeoutMs] - a cap on each attempt; one that runs longer fails + * with Async.ERROR_TIMEOUT and is retried like any other failure + * @param {AbortSignal} [options.signal] - aborting it ends the waiting and rejects with the + * signal's reason + * @return {Promise} + */ + function retry(operation, options) { + const settings = options || {}; + const attempts = Math.max(1, settings.attempts || RETRY_DEFAULT_ATTEMPTS); + const jitter = typeof settings.jitter === "number" ? Math.max(0, settings.jitter) : RETRY_DEFAULT_JITTER; + let delays = settings.delaysMs === undefined ? RETRY_DEFAULT_DELAYS_MS : settings.delaysMs; + if (!Array.isArray(delays)) { + delays = [delays]; + } + if (!delays.length) { + delays = [0]; + } + const signal = settings.signal; + + function waitFor(retryIndex) { + const base = delays[Math.min(retryIndex, delays.length - 1)]; + const spread = base * jitter; + return Math.max(0, Math.round(base + (Math.random() * 2 - 1) * spread)); + } + + function attemptOnce(attempt) { + let result; + try { + result = operation(attempt); + } catch (err) { + return Promise.reject(err); + } + const promise = Promise.resolve(result); + if (!settings.timeoutMs) { + return promise; + } + return new Promise(function (resolve, reject) { + const timer = window.setTimeout(function () { + reject(ERROR_TIMEOUT); + }, settings.timeoutMs); + promise.then(function (value) { + window.clearTimeout(timer); + resolve(value); + }, function (err) { + window.clearTimeout(timer); + reject(err); + }); + }); + } + + return new Promise(function (resolve, reject) { + if (signal && signal.aborted) { + reject(signal.reason); + return; + } + let timer = null; + function onAbort() { + window.clearTimeout(timer); + reject(signal.reason); + } + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + function finish(settle, value) { + if (signal) { + signal.removeEventListener("abort", onAbort); + } + settle(value); + } + + function run(attempt) { + attemptOnce(attempt).then(function (value) { + finish(resolve, value); + }, function (err) { + if (signal && signal.aborted) { + return; + } + const more = attempt < attempts && + (!settings.shouldRetry || settings.shouldRetry(err, attempt) !== false); + if (!more) { + finish(reject, err); + return; + } + const wait = waitFor(attempt - 1); + if (settings.onRetry) { + settings.onRetry(err, attempt, wait); + } + timer = window.setTimeout(function () { + run(attempt + 1); + }, wait); + }); + } + run(1); + }); + } + /** * Allows waiting for all the promises to be either resolved or rejected. * Unlike $.when(), it does not call .fail() or .always() handlers on first @@ -596,6 +722,7 @@ define(function (require, exports, module) { exports.doInParallel_aggregateErrors = doInParallel_aggregateErrors; exports.firstSequentially = firstSequentially; exports.withTimeout = withTimeout; + exports.retry = retry; exports.waitForAll = waitForAll; exports.ERROR_TIMEOUT = ERROR_TIMEOUT; exports.chain = chain; diff --git a/test/spec/Async-test.js b/test/spec/Async-test.js index 36276a8d65..ad72cf6861 100644 --- a/test/spec/Async-test.js +++ b/test/spec/Async-test.js @@ -622,5 +622,128 @@ define(function (require, exports, module) { expect(queue2._curPromise).toBe(null); }); }); + + describe("retry", function () { + const NO_JITTER = 0; + + /** An operation that fails `failures` times, then resolves with "ok"; records how often it ran. */ + function flaky(failures) { + const op = function (attempt) { + op.calls.push(attempt); + if (attempt <= failures) { + return Promise.reject(new Error("fail " + attempt)); + } + return Promise.resolve("ok"); + }; + op.calls = []; + return op; + } + + it("resolves with the first success and runs the operation once when it succeeds", async function () { + const op = flaky(0); + const value = await Async.retry(op, { delaysMs: 1, jitter: NO_JITTER }); + expect(value).toBe("ok"); + expect(op.calls).toEqual([1]); + }); + + it("retries after failures, waiting the given delays, and tells onRetry each time", async function () { + const op = flaky(2); + const retries = []; + const value = await Async.retry(op, { + delaysMs: [1, 2], jitter: NO_JITTER, + onRetry: function (err, attempt, waitMs) { retries.push([err.message, attempt, waitMs]); } + }); + expect(value).toBe("ok"); + expect(op.calls).toEqual([1, 2, 3]); + expect(retries).toEqual([["fail 1", 1, 1], ["fail 2", 2, 2]]); + }); + + it("rejects with the last failure once the attempts are used up", async function () { + const op = flaky(10); + let failure = null; + await Async.retry(op, { attempts: 3, delaysMs: 1, jitter: NO_JITTER }).catch(function (err) { failure = err; }); + expect(failure && failure.message).toBe("fail 3"); + expect(op.calls).toEqual([1, 2, 3]); + }); + + it("stops at once when shouldRetry says no", async function () { + const op = flaky(10); + let failure = null; + await Async.retry(op, { + attempts: 5, delaysMs: 1, jitter: NO_JITTER, + shouldRetry: function (err, attempt) { return attempt < 2; } + }).catch(function (err) { failure = err; }); + expect(failure && failure.message).toBe("fail 2"); + expect(op.calls).toEqual([1, 2]); + }); + + it("repeats the last delay when there are more retries than delays, and a number is a fixed delay", async function () { + const waits = []; + await Async.retry(flaky(3), { + attempts: 4, delaysMs: [1, 3], jitter: NO_JITTER, + onRetry: function (err, attempt, waitMs) { waits.push(waitMs); } + }); + expect(waits).toEqual([1, 3, 3]); + waits.length = 0; + await Async.retry(flaky(2), { + delaysMs: 2, jitter: NO_JITTER, + onRetry: function (err, attempt, waitMs) { waits.push(waitMs); } + }); + expect(waits).toEqual([2, 2]); + }); + + it("varies each wait by the jitter and never below zero", async function () { + const waits = []; + await Async.retry(flaky(3), { + attempts: 4, delaysMs: 20, jitter: 0.5, + onRetry: function (err, attempt, waitMs) { waits.push(waitMs); } + }); + expect(waits.length).toBe(3); + waits.forEach(function (wait) { + expect(wait).toBeGreaterThanOrEqual(10); + expect(wait).toBeLessThanOrEqual(30); + }); + }); + + it("fails a slow attempt with ERROR_TIMEOUT and retries it", async function () { + let calls = 0; + const value = await Async.retry(function () { + calls++; + if (calls === 1) { + return new Promise(function () {}); // never settles + } + return "ok"; + }, { timeoutMs: 5, delaysMs: 1, jitter: NO_JITTER, + onRetry: function (err) { expect(err).toBe(Async.ERROR_TIMEOUT); } }); + expect(value).toBe("ok"); + expect(calls).toBe(2); + }); + + it("ends the waiting when the signal aborts", async function () { + const controller = new AbortController(); + const op = flaky(10); + let failure = null; + const pending = Async.retry(op, { delaysMs: 10000, jitter: NO_JITTER, signal: controller.signal }) + .catch(function (err) { failure = err; }); + await awaitsFor(function () { return op.calls.length === 1; }, "the first attempt"); + controller.abort(new Error("gone")); + await pending; + expect(failure && failure.message).toBe("gone"); + expect(op.calls).toEqual([1]); + }); + + it("treats a thrown error as a failure and accepts jQuery promises", async function () { + let calls = 0; + const value = await Async.retry(function () { + calls++; + if (calls === 1) { + throw new Error("sync fail"); + } + return new $.Deferred().resolve("ok").promise(); + }, { delaysMs: 1, jitter: NO_JITTER }); + expect(value).toBe("ok"); + expect(calls).toBe(2); + }); + }); }); }); From 493367af3df61d80ebaadfea09bdcaa47d8b3e83 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 1 Oct 2026 15:55:44 +0530 Subject: [PATCH 05/16] feat(ai): styles for the token split tips and a flush turn line The usage tips now serve two kinds: the day under the pointer on the start-screen card, and a token split by kind, on the card's tokens figure or under a turn line. Both live on the body so neither the card's overflow nor the scrolling messages can clip them. The tokens that open a split carry a dotted underline. The usage line under an answer loses its 14px indent and sits flush with the answer and its label. --- src/styles/Extn-AIChatPanel.less | 52 +++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 68ffd05169..a5b95173e7 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -6077,7 +6077,8 @@ display: flex; align-items: center; gap: 8px; - margin: -2px 0 10px 14px; + // Flush with the answer above it and its "Claude" label. + margin: -2px 0 10px; font-size: @ai-text-meta; color: @project-panel-text-2; white-space: nowrap; @@ -6091,6 +6092,16 @@ opacity: 0.5; } + // The tokens open their split by kind on hover; the dotted rule hints at it. + .ai-usage-turn-tokens { + border-bottom: 1px dotted fade(@project-panel-text-1, 50%); + cursor: default; + + &:hover { + color: @project-panel-text-1; + } + } + &.ai-usage-hidden { display: none; } @@ -6324,6 +6335,12 @@ font-weight: 600; line-height: 1.15; } + + // The tokens figure opens its split by kind on hover; the dotted rule hints at it. + .ai-usage-stat-kinds b { + align-self: flex-start; + border-bottom: 1px dotted fade(@project-panel-text-1, 50%); + } } .ai-usage-stat { @@ -6449,9 +6466,11 @@ to { transform: rotate(360deg); } } -/* The day under the pointer, in the grid or the bars. Lives on the body so - the card's overflow cannot clip it. */ -.ai-usage-day-tip { +/* The usage tips: the day under the pointer on the start-screen card, and a + token split there or under a turn line. They live on the body so neither the + card's overflow nor the scrolling messages can clip them. */ +.ai-usage-day-tip, +.ai-usage-turn-tip { position: fixed; z-index: 1000; min-width: 196px; @@ -6539,4 +6558,29 @@ .ai-usage-tip-amt { font-variant-numeric: tabular-nums; } + + // The tokens figure's split: kind, count and share, the total under a rule. A kind + // with nothing in it stays in its place, dimmed. + .ai-usage-tip-kind { + display: grid; + grid-template-columns: 1fr auto 34px; + gap: 12px; + padding: 2px 0; + white-space: nowrap; + + .ai-usage-tip-amt, + .ai-usage-tip-pct { + text-align: right; + } + + &.ai-usage-tip-none { + color: @project-panel-text-2; + } + + &.ai-usage-tip-total { + border-top: 1px solid @ai-start-border; + margin-top: 4px; + padding-top: 6px; + } + } } From aa2d44396406f2d08c6f17d745e437d4bbb9cb32 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 1 Oct 2026 16:40:40 +0530 Subject: [PATCH 06/16] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 02c85fb6a4..a13db58b0b 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "7486704f94dbfb399158791417998b8370f5ca12" + "commitID": "2d0f79db372f9fa1aba9027ec7b09b0bbe6240f9" } } From a3da9c0f8115b98d775c45e74ab69f5cb5696d59 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 1 Oct 2026 18:54:18 +0530 Subject: [PATCH 07/16] feat(ai): blank cells for the days still to come in the usage heat map The current month's grid now draws every day; the days after today get their own faint, never-coloured cell. --- src/styles/Extn-AIChatPanel.less | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index a5b95173e7..192417ecbd 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -6243,6 +6243,8 @@ &.ai-usage-l2 { background: fade(@ai-usage-heat, 60%); } &.ai-usage-l3 { background: fade(@ai-usage-heat, 85%); } &.ai-usage-l4 { background: @ai-usage-heat-top; } + // Days of the current month still to come: drawn, never coloured. + &.ai-usage-future { background: rgba(255, 255, 255, 0.03); } &.ai-usage-pad { background: transparent; } } From e5250246e2f870ca8deb56606513b81c4cf020a2 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 1 Oct 2026 20:32:29 +0530 Subject: [PATCH 08/16] build: bump dist/brackets.js prod size limit to 13 MB The minified bundle is now 12.09 MB, past the 12 MB limit. Raise it by 1 MB to restore the margin the size policy asks for. --- gulpfile.js/validate-build.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gulpfile.js/validate-build.js b/gulpfile.js/validate-build.js index b24c9897e6..edfd5cdc0d 100644 --- a/gulpfile.js/validate-build.js +++ b/gulpfile.js/validate-build.js @@ -41,7 +41,7 @@ const PROD_MAX_TOTAL_SIZE_MB = 80; // for the aggregate/total). Bump a limit only enough to restore that margin when a file legitimately // grows - don't pad it large, so unexpected size jumps still get caught. const LARGE_FILE_LIST_PROD = { - 'dist/brackets.js': 12, // this is the full minified file itself renamed in prod (~11 MB + 1 MB margin) + 'dist/brackets.js': 13, // this is the full minified file itself renamed in prod (~12 MB + 1 MB margin) 'dist/phoenix/virtualfs.js.map': 3 }; From d9847a90689a0065a513181970d2d28a2d1ea642 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 1 Oct 2026 22:46:15 +0530 Subject: [PATCH 09/16] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index a13db58b0b..b7e905fed1 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "2d0f79db372f9fa1aba9027ec7b09b0bbe6240f9" + "commitID": "4e3d3259a29d5327f12e976218a0acd2de23999f" } } From c4299ea771d329de51cb0a96d92d8f2d4302dd88 Mon Sep 17 00:00:00 2001 From: abose Date: Thu, 1 Oct 2026 22:51:09 +0530 Subject: [PATCH 10/16] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index b7e905fed1..07436ec01c 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "4e3d3259a29d5327f12e976218a0acd2de23999f" + "commitID": "e397b43622520eaa9835a6052e6f1e7657d9225d" } } From 22fabd42b6a9a42bb48e806f962c8217b7a482c9 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 2 Oct 2026 02:00:32 +0530 Subject: [PATCH 11/16] feat(ai): spend tip on the usage card The spend figure and the header chip say a Claude subscription includes the spend at no extra cost, with no charge to Phoenix Pro. Dotted underlines mark the spend and token figures as hoverable. --- src/nls/root/strings.js | 2 ++ src/styles/Extn-AIChatPanel.less | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 3fc82e6ad1..fdb033748a 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2808,6 +2808,8 @@ define({ "AI_CHAT_USAGE_API_TIME": "Time in API", "AI_CHAT_USAGE_COST": "Cost at list price", "AI_CHAT_USAGE_NOTE": "With a Claude subscription, this cost is included in your plan. Pricing shown is for reference only.", + "AI_CHAT_USAGE_SPEND_INCLUDED": "With a Claude subscription, this spend is included in your plan at no extra cost.", + "AI_CHAT_USAGE_SPEND_NO_PRO_CHARGE": "No charge to your Phoenix Pro subscription.", "AI_CHAT_USAGE_OPTION_TURNS": "Show usage after every turn", "AI_CHAT_USAGE_OPTION_DOLLARS": "Show $ cost", "AI_CHAT_USAGE_CARD_TITLE": "Usage", diff --git a/src/styles/Extn-AIChatPanel.less b/src/styles/Extn-AIChatPanel.less index 192417ecbd..a8d2eee539 100644 --- a/src/styles/Extn-AIChatPanel.less +++ b/src/styles/Extn-AIChatPanel.less @@ -6160,6 +6160,13 @@ min-width: 0; overflow: hidden; text-overflow: ellipsis; + + // With dollars shown, the chip opens the spend tip on hover. + &.ai-usage-card-range-spend { + cursor: default; + text-decoration: underline dotted fade(@project-panel-text-2, 60%); + text-underline-offset: 3px; + } } /* Other Phoenix windows write the same ledger; this reads it again. */ @@ -6338,7 +6345,8 @@ line-height: 1.15; } - // The tokens figure opens its split by kind on hover; the dotted rule hints at it. + // The spend and tokens figures open a tip on hover; the dotted rule hints at it. + .ai-usage-stat-spend b, .ai-usage-stat-kinds b { align-self: flex-start; border-bottom: 1px dotted fade(@project-panel-text-1, 50%); @@ -6585,4 +6593,19 @@ padding-top: 6px; } } + + // The spend figures' tip: a Claude plan covers the spend; Phoenix Pro adds no charge. + .ai-usage-tip-note, + .ai-usage-tip-foot { + max-width: 240px; + white-space: normal; + line-height: 1.45; + } + + .ai-usage-tip-foot { + margin-top: 8px; + padding-top: 7px; + border-top: 1px solid @ai-start-border; + color: @project-panel-text-2; + } } From 2e0c43a078bf7e2660f16b7aa0b9b86c7c83be13 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 2 Oct 2026 02:00:58 +0530 Subject: [PATCH 12/16] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 07436ec01c..e4d3946b54 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "e397b43622520eaa9835a6052e6f1e7657d9225d" + "commitID": "4d951ff473b6e31d9cc87423109c634ed11ee79d" } } From 0cb073df97706389654a620eee9ddea57010392b Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 2 Oct 2026 18:55:57 +0530 Subject: [PATCH 13/16] fix(ai): clarify Visual AI labels and update Pro pin --- src/nls/root/strings.js | 4 ++-- tracking-repos.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index fdb033748a..e2bc53c088 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -2660,7 +2660,7 @@ define({ // AI CHAT PANEL "AI_CHAT_TITLE": "Claude Code", - "AI_CHAT_MODE_CHAT": "Claude Code", + "AI_CHAT_MODE_CHAT": "Visual AI", "AI_CHAT_MODE_CLI": "Claude Code CLI", "AI_CHAT_MODE_CODEX_CLI": "Codex CLI", "AI_CHAT_START_TYPE_HINT": "Type here to get started", @@ -2687,7 +2687,7 @@ define({ "AI_CHAT_DEMO_PROMPT_COPIED": "Copied", "AI_CHAT_DEMO_SPEED_TITLE": "Playback speed (click to change)", "AI_CHAT_START_WORK_WITH": "Work with", - "AI_CHAT_START_CHAT_NAME": "{0} · Visual AI", + "AI_CHAT_START_CHAT_NAME": "Visual AI with {0}", "AI_CHAT_START_CHAT_DESC": "Built-in chat for visual workflows", "AI_CHAT_START_BY_PROVIDER": "By {0}", "AI_CHAT_START_CLI_SEPARATOR": "Command-line tools · connects with {APP_NAME}", diff --git a/tracking-repos.json b/tracking-repos.json index e4d3946b54..7dc0e72bee 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "4d951ff473b6e31d9cc87423109c634ed11ee79d" + "commitID": "160f90b0a298321bf80c95979e0f4123bcf70919" } } From a3677d2435e0d50c866133cc9feefb1c1c8b55b4 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 2 Oct 2026 21:11:04 +0530 Subject: [PATCH 14/16] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 7dc0e72bee..9c58bd520e 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "160f90b0a298321bf80c95979e0f4123bcf70919" + "commitID": "628ef3e2d36db4952b88fbb8d8bff5fffbe06f66" } } From 8afcd414546a7d1b28a937d916575af0fe9e17a5 Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 2 Oct 2026 21:18:34 +0530 Subject: [PATCH 15/16] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 9c58bd520e..678ddc4010 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "628ef3e2d36db4952b88fbb8d8bff5fffbe06f66" + "commitID": "8402694d78a6cc88205e4f25f0ffa1f6d60f1893" } } From eaa99eeb10f9b3cce402f5274a4fb8fc298a027a Mon Sep 17 00:00:00 2001 From: abose Date: Fri, 2 Oct 2026 22:24:09 +0530 Subject: [PATCH 16/16] build: update pro deps --- tracking-repos.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracking-repos.json b/tracking-repos.json index 678ddc4010..3169a0c51d 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "8402694d78a6cc88205e4f25f0ffa1f6d60f1893" + "commitID": "e31639b0d77eb4ae07ebb9e627d372387f7d6126" } }