feat(ai-chat): context badge shows current/max (#189) #213

Closed
Ghost wants to merge 2 commits from feat/189-context-badge into develop

Closes #189

What

The floating AI-chat header badge flipped meaning between states — a live per-turn token counter while streaming vs. the context size at rest — so it "reset to 1" on each prompt and showed two different quantities in the same spot. This makes it consistently show current context / max context, e.g. 572 / 200k.

  • The max comes from a new admin-set AI setting chatContextWindow (tokens) — provider-independent and always exact (per the issue's accepted design; /v1/models etc. are unreliable).
  • The server stamps it onto the assistant message metadata (maxContextTokens) next to contextTokens, so the client reads both from the last persisted row — no client-side model resolution, survives public shares / future per-role models.
  • When no limit is configured, only the current size is shown (prior at-rest behaviour). current > max is shown unclamped.
  • The live header counter is removed; live "Thinking · N tokens" feedback in the chat body is unchanged.

Changes

Server: chatContextWindow added to AiProviderSettings / PROVIDER_SETTINGS_KEYS / MaskedAiSettings / ResolvedAiConfig; DTO (@IsOptional @IsInt @Min(0)); resolve() + getMasked() passthrough; repo parity allowlist; flushAssistant writes metadata.maxContextTokens when > 0; streamChat passes resolved.chatContextWindow.

Client: new ContextBadge component (extracted, "current [/ max]", no live mode); removed the liveTurnTokens header path + dead liveTurnTokens() util (kept estimateTokens); Context window (tokens) NumberInput in AI settings; metadata + service types; i18n (en/ru).

Tests

  • context-badge.test.tsx — current/max display, current-only, no-limit, above-max unclamped, hidden until a size exists, tooltip on hover.
  • flushAssistant writes/omits maxContextTokens; DTO validation for chatContextWindow; parity test covers the new key.

Verify

  • pnpm --filter client exec tsc -b — clean
  • pnpm --filter server exec tsc --noEmit -p tsconfig.json — clean
  • pnpm --filter client exec vitest run src/features/ai-chat — 166 passed
  • pnpm --filter server exec jest src/integrations/ai src/core/ai-chat — 399 passed

🤖 Generated with Claude Code

Closes #189 ## What The floating AI-chat header badge flipped meaning between states — a live per-turn token counter while streaming vs. the context size at rest — so it "reset to 1" on each prompt and showed two different quantities in the same spot. This makes it consistently show **current context / max context**, e.g. `572 / 200k`. - The **max** comes from a new admin-set AI setting `chatContextWindow` (tokens) — provider-independent and always exact (per the issue's accepted design; `/v1/models` etc. are unreliable). - The server stamps it onto the assistant message metadata (`maxContextTokens`) next to `contextTokens`, so the client reads both from the last persisted row — no client-side model resolution, survives public shares / future per-role models. - When no limit is configured, only the current size is shown (prior at-rest behaviour). `current > max` is shown unclamped. - The live header counter is removed; live "Thinking · N tokens" feedback in the chat body is unchanged. ## Changes **Server**: `chatContextWindow` added to `AiProviderSettings` / `PROVIDER_SETTINGS_KEYS` / `MaskedAiSettings` / `ResolvedAiConfig`; DTO (`@IsOptional @IsInt @Min(0)`); `resolve()` + `getMasked()` passthrough; repo parity allowlist; `flushAssistant` writes `metadata.maxContextTokens` when `> 0`; `streamChat` passes `resolved.chatContextWindow`. **Client**: new `ContextBadge` component (extracted, "current [/ max]", no live mode); removed the `liveTurnTokens` header path + dead `liveTurnTokens()` util (kept `estimateTokens`); `Context window (tokens)` `NumberInput` in AI settings; metadata + service types; i18n (en/ru). ## Tests - `context-badge.test.tsx` — current/max display, current-only, no-limit, above-max unclamped, hidden until a size exists, tooltip on hover. - `flushAssistant` writes/omits `maxContextTokens`; DTO validation for `chatContextWindow`; parity test covers the new key. ## Verify - `pnpm --filter client exec tsc -b` — clean - `pnpm --filter server exec tsc --noEmit -p tsconfig.json` — clean - `pnpm --filter client exec vitest run src/features/ai-chat` — 166 passed - `pnpm --filter server exec jest src/integrations/ai src/core/ai-chat` — 399 passed 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Ghost added 1 commit 2026-06-26 06:28:24 +03:00
The header badge in the floating AI-chat window flipped meaning between
states (a live per-turn token counter while streaming vs. the context
size at rest), which made it "reset to 1" on each prompt and confused
users. Make it consistently show the current context size, with the
model's context window as an optional "/ max" denominator.

The max comes from a new admin-set AI setting (chatContextWindow, in
tokens) — provider-independent and always exact. The server stamps it
onto the assistant message metadata (maxContextTokens) next to
contextTokens, so the client reads both from the last row with no
client-side model resolution (survives shares / future per-role models).

- server: chatContextWindow in AiProviderSettings/keys/masked/resolved,
  DTO (@IsInt @Min(0)), settings-service resolve/getMasked, repo parity
  allowlist; flushAssistant writes metadata.maxContextTokens when > 0.
- client: ContextBadge component (extracted, shows "current [/ max]",
  no live mode); removed the liveTurnTokens header path + dead util fn;
  Context-window NumberInput in AI settings; i18n strings.
- live "Thinking · N tokens" feedback in the chat body is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vvzvlad added the feature label 2026-06-26 15:49:29 +03:00
Owner

Code review — PR #213: context-badge показывает current/max, новая admin-настройка chatContextWindow (#189)

Вердикт: Request changes. Фича по сути не работает: новое числовое поле chatContextWindow записывается в jsonb через ::text-каст и превращается в JSON-строку "200000", которую клиентские guard'ы typeof === "number" отбрасывают — знаменатель / max не отрисовывается никогда. Must-fix — починить запись/чтение числового поля и закрыть это DB-round-trip тестом.

Объём: дифф developfeat/189-context-badge (merge-base 3ddc329b), 18 файлов, +305/−347. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход.

Must fix before merge

  • [regressions] Хранить chatContextWindow как JSON-число, а не строку через ::textapps/server/src/database/repos/workspace/workspace.repo.ts:24,267-271
    Этот PR добавляет chatContextWindow в AI_PROVIDER_SETTINGS_ALLOWED — это ПЕРВОЕ числовое поле, проходящее через updateAiProviderSettings, который строит патч как sql\${v}::text`. DTO валидирует поле как @IsInt() (dto/update-ai-settings.dto.ts:32-36), то есть в SQL уходит реальное JS-число 200000, а jsonb_build_object('chatContextWindow', '200000'::text)даёт JSON-строку{"chatContextWindow":"200000"}, а не число. Для всех прежних ключей каст был безвреден, т.к. они уже строки. readProvider/resolve (ai-settings.service.ts:143,159+) делают plain-passthrough без коэрса → возвращается JS-строка "200000". Серверный guard if (extra?.maxContextTokens && extra.maxContextTokens > 0) (ai-chat.service.ts:1263) пропускает строку через JS-коэрс и пишет её в metadata.maxContextTokensкак строку. Клиент же требуетtypeof max === "number" (ai-chat-window.tsx:307) и typeof maxContextTokens === "number" (context-badge.tsx:42) — строка не проходит. Итог: админ, настроивший context window, НИКОГДА не видит знаменатель / max— вся фича PR молча не делает ничего. Fix: вupdateAiProviderSettingsсохранять числовые provider-поля настоящими JSON-числами (ветвить каст поtypeof v::int/числовой каст или to_jsonb), либо коэрсить chatContextWindowчерезNumber(...)на чтении вreadProvider/resolve. Обязательно добавить DB-round-trip тест с проверкой jsonb_typeof(settings->'ai'->'provider'->'chatContextWindow') = 'number'`.

  • [documentation] Добавить запись в CHANGELOG [Unreleased] про chatContextWindow и смену смысла бейджаCHANGELOG.md (секция [Unreleased])
    Проект активно ведёт CHANGELOG под [Unreleased] и документирует прямо сопоставимые изменения: предыдущая admin-настройка chatApiStyle (#177) имеет и ### Added, и ### Changed записи. Этот PR вводит новую admin-настройку chatContextWindow («Context window (tokens)») И user-facing смену поведения — бейдж больше не переключается на live per-turn счётчик во время стрима, а показывает «current / max», — но CHANGELOG не тронут. Не блокирует: ни один документ не противоречит коду, это пробел в полноте документации против собственной конвенции репозитория (AGENTS.md требует CHANGELOG на релизе). Fix: добавить под ## [Unreleased] ### Added-пункт про настройку chatContextWindow (драйвер знаменателя / max) и ### Changed-пункт про новый смысл бейджа, со ссылкой (#189).

Test coverage

Новый числовой data-flow (chatContextWindowmaxContextTokens → бейдж) — это именно то место, где скрылся блокирующий дефект, и он НЕ покрыт сквозным тестом. Добавленный ai-chat.service.spec.ts тестирует compactToolOutput, а не round-trip контекст-окна; context-badge.test.tsx проверяет только клиентский рендер при уже-числовом maxContextTokens и не ловит строковый знаменатель, приходящий с сервера; ai-provider-settings-keys.spec.ts — только parity ключей. Без покрытия:

  • [test-coverage] Закрыть сквозной путь сохранения/чтения числового context windowapps/server/src/database/repos/workspace/workspace.repo.ts, apps/server/src/integrations/ai/ai-settings.service.ts
    Нет теста, утверждающего, что после updateAiProviderSettings(... chatContextWindow: 200000) чтение через resolve() возвращает число 200000 (а не строку), и что в jsonb лежит jsonb_typeof = 'number'. Именно этот тест поймал бы must-fix-регрессию. Fix: добавить DB round-trip тест на тип и значение; опционально — серверный тест, что metadata.maxContextTokens оказывается числом.

Architecture & design (forward-looking, non-blocking)

  • Единый workspace-scalar chatContextWindow против нескольких моделей на workspaceapps/server/src/integrations/ai + клиентский context-badge
    PR добавляет один workspace-уровневый скаляр и штампует его как maxContextTokens на каждый assistant-turn (ai-chat.service.ts:621). Но чат уже гоняет более одной модели на workspace: дефолтный chatModel, дешёвый publicShareChatModel и per-role оверрайды через roleModelOverride. Один знаменатель корректен максимум для одной из них; комментарии в коде это честно признают, и context > max показывается без клампа. Дефект здесь не возникает (degrade graceful), фиксируется как forward-looking constraint.
    оставить единый workspace-scalar (как сделано).** Effort: S. Pros: ноль доп. работы, одно поле, совпадает с rationale в ai.types.ts (нет провайдеро-независимого способа узнать окно), unclamped-показ деградирует без крэша. Cons: знаменатель неверен, когда role/public-share идут на модели другого размера (бейдж может читаться 210k / 200k для turn'а на 1M-окне).
## Code review — PR #213: context-badge показывает current/max, новая admin-настройка `chatContextWindow` (#189) **Вердикт: Request changes.** Фича по сути не работает: новое числовое поле `chatContextWindow` записывается в jsonb через `::text`-каст и превращается в JSON-строку `"200000"`, которую клиентские guard'ы `typeof === "number"` отбрасывают — знаменатель `/ max` не отрисовывается никогда. Must-fix — починить запись/чтение числового поля и закрыть это DB-round-trip тестом. _Объём: дифф `develop`…`feat/189-context-badge` (merge-base `3ddc329b`), 18 файлов, +305/−347. Прогнаны параллельные аспектные ревьюеры (security, stability, conventions, documentation, regressions, test-coverage, simplification, architecture) + judge-проход._ ### Must fix before merge - **[regressions] Хранить `chatContextWindow` как JSON-число, а не строку через `::text`** — `apps/server/src/database/repos/workspace/workspace.repo.ts:24,267-271` Этот PR добавляет `chatContextWindow` в `AI_PROVIDER_SETTINGS_ALLOWED` — это ПЕРВОЕ числовое поле, проходящее через `updateAiProviderSettings`, который строит патч как `sql\`${v}::text\``. DTO валидирует поле как `@IsInt()` (`dto/update-ai-settings.dto.ts:32-36`), то есть в SQL уходит реальное JS-число `200000`, а `jsonb_build_object('chatContextWindow', '200000'::text)` даёт JSON-строку `{"chatContextWindow":"200000"}`, а не число. Для всех прежних ключей каст был безвреден, т.к. они уже строки. `readProvider`/`resolve` (`ai-settings.service.ts:143,159+`) делают plain-passthrough без коэрса → возвращается JS-строка `"200000"`. Серверный guard `if (extra?.maxContextTokens && extra.maxContextTokens > 0)` (`ai-chat.service.ts:1263`) пропускает строку через JS-коэрс и пишет её в `metadata.maxContextTokens` как строку. Клиент же требует `typeof max === "number"` (`ai-chat-window.tsx:307`) и `typeof maxContextTokens === "number"` (`context-badge.tsx:42`) — строка не проходит. Итог: админ, настроивший context window, НИКОГДА не видит знаменатель `/ max` — вся фича PR молча не делает ничего. Fix: в `updateAiProviderSettings` сохранять числовые provider-поля настоящими JSON-числами (ветвить каст по `typeof v` — `::int`/числовой каст или `to_jsonb`), либо коэрсить `chatContextWindow` через `Number(...)` на чтении в `readProvider`/`resolve`. Обязательно добавить DB-round-trip тест с проверкой `jsonb_typeof(settings->'ai'->'provider'->'chatContextWindow') = 'number'`. - **[documentation] Добавить запись в CHANGELOG `[Unreleased]` про `chatContextWindow` и смену смысла бейджа** — `CHANGELOG.md` (секция `[Unreleased]`) Проект активно ведёт CHANGELOG под `[Unreleased]` и документирует прямо сопоставимые изменения: предыдущая admin-настройка `chatApiStyle` (#177) имеет и `### Added`, и `### Changed` записи. Этот PR вводит новую admin-настройку `chatContextWindow` («Context window (tokens)») И user-facing смену поведения — бейдж больше не переключается на live per-turn счётчик во время стрима, а показывает «current / max», — но CHANGELOG не тронут. Не блокирует: ни один документ не противоречит коду, это пробел в полноте документации против собственной конвенции репозитория (AGENTS.md требует CHANGELOG на релизе). Fix: добавить под `## [Unreleased]` `### Added`-пункт про настройку `chatContextWindow` (драйвер знаменателя `/ max`) и `### Changed`-пункт про новый смысл бейджа, со ссылкой `(#189)`. ### Test coverage Новый числовой data-flow (`chatContextWindow` → `maxContextTokens` → бейдж) — это именно то место, где скрылся блокирующий дефект, и он НЕ покрыт сквозным тестом. Добавленный `ai-chat.service.spec.ts` тестирует `compactToolOutput`, а не round-trip контекст-окна; `context-badge.test.tsx` проверяет только клиентский рендер при уже-числовом `maxContextTokens` и не ловит строковый знаменатель, приходящий с сервера; `ai-provider-settings-keys.spec.ts` — только parity ключей. Без покрытия: - **[test-coverage] Закрыть сквозной путь сохранения/чтения числового context window** — `apps/server/src/database/repos/workspace/workspace.repo.ts`, `apps/server/src/integrations/ai/ai-settings.service.ts` Нет теста, утверждающего, что после `updateAiProviderSettings(... chatContextWindow: 200000)` чтение через `resolve()` возвращает число `200000` (а не строку), и что в jsonb лежит `jsonb_typeof = 'number'`. Именно этот тест поймал бы must-fix-регрессию. Fix: добавить DB round-trip тест на тип и значение; опционально — серверный тест, что `metadata.maxContextTokens` оказывается числом. ### Architecture & design (forward-looking, non-blocking) - **Единый workspace-scalar `chatContextWindow` против нескольких моделей на workspace** — `apps/server/src/integrations/ai` + клиентский context-badge PR добавляет один workspace-уровневый скаляр и штампует его как `maxContextTokens` на каждый assistant-turn (`ai-chat.service.ts:621`). Но чат уже гоняет более одной модели на workspace: дефолтный `chatModel`, дешёвый `publicShareChatModel` и per-role оверрайды через `roleModelOverride`. Один знаменатель корректен максимум для одной из них; комментарии в коде это честно признают, и `context > max` показывается без клампа. Дефект здесь не возникает (degrade graceful), фиксируется как forward-looking constraint. оставить единый workspace-scalar (как сделано).** Effort: S. Pros: ноль доп. работы, одно поле, совпадает с rationale в `ai.types.ts` (нет провайдеро-независимого способа узнать окно), unclamped-показ деградирует без крэша. Cons: знаменатель неверен, когда role/public-share идут на модели другого размера (бейдж может читаться `210k / 200k` для turn'а на 1M-окне).
Ghost added 1 commit 2026-06-26 17:19:52 +03:00
chatContextWindow (#189) is the first numeric provider field routed
through WorkspaceRepo.updateAiProviderSettings, whose patch builder cast
every value as `${v}::text`. The DTO validates it as @IsInt(), so a JS
number 200000 was stored as the JSON STRING "200000". The client guards
require `typeof === "number"` (ai-chat-window.tsx, context-badge.tsx),
so the `/ max` badge denominator never rendered and the whole feature
silently no-opped.

Branch the jsonb_build_object value cast by JS runtime type: numbers ->
::numeric (real JSON number), booleans -> ::boolean, everything else ->
::text (unchanged for the existing string fields). This is the root fix
(store as a real number) rather than coercing on read, so every reader
sees the correct type.

Add a DB round-trip int-spec asserting
jsonb_typeof(settings->'ai'->'provider'->'chatContextWindow') = 'number'
and that the value re-reads as the number 200000, including the
partial-merge path. CHANGELOG: Added entry for the chatContextWindow
setting and a Changed entry for the badge's new "used / max" meaning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghost closed this pull request 2026-06-26 17:39:55 +03:00
Owner

Code review (re-review) — PR #213: context badge показывает current/max (#189)

Вердикт: Approve. Прошлый блокер закрыт по существу: chatContextWindow теперь кастуется как ::numeric и сохраняется/читается как JSON-число, а не строка "200000". Дельта новых блокеров не вносит.

Ре-ревью дельты d88fe4cd..88199703 (3 файлов, +123/−6). Аспекты: stability, conventions, documentation, regressions, test-coverage (параллельные ревьюеры + judge).

Статус прошлых блокеров

  • Закрыт. Прошлый блокер (значение писалось/читалось как СТРОКА через ::text) устранён в workspace.repo.ts:277-284: cast теперь ветвится по JS-типу — number → ::numeric, boolean → ::boolean, остальное ::text. Число попадает в jsonb как JSON-число (jsonb_typeof = 'number', {"chatContextWindow":200000}), а не как "200000".
  • Типизация DTO→service→client согласована. DTO update-ai-settings.dto.ts:34-37@IsOptional @IsInt @Min(0) chatContextWindow?: number; ai-settings.service.ts и ai.types.tschatContextWindow?: number; клиентские guard'ы typeof === "number" (ai-provider-settings.tsx:379) теперь получают реальное число. Никакой строковой коэрции на пути не осталось.

Must fix before merge

Нет.

Non-blocking

Нет.

Test coverage

Покрыто. Новая ветка cast'а покрыта integration-тестом workspace-repo-ai-provider-settings.int-spec.ts на реальном SQL: проверяется и JS-тип возвращённого значения (toBe(200000), typeof === 'number'), и on-disk jsonb_typeof = 'number', и сохранность соседних строковых полей при partial-merge апдейте. DTO-валидация chatContextWindow (@IsInt @Min(0)) покрыта ai-provider-settings-keys.spec.ts.

## Code review (re-review) — PR #213: context badge показывает current/max (#189) **Вердикт: Approve.** Прошлый блокер закрыт по существу: `chatContextWindow` теперь кастуется как `::numeric` и сохраняется/читается как JSON-число, а не строка `"200000"`. Дельта новых блокеров не вносит. _Ре-ревью дельты `d88fe4cd..88199703` (3 файлов, +123/−6). Аспекты: stability, conventions, documentation, regressions, test-coverage (параллельные ревьюеры + judge)._ ### Статус прошлых блокеров - **Закрыт.** Прошлый блокер (значение писалось/читалось как СТРОКА через `::text`) устранён в `workspace.repo.ts:277-284`: cast теперь ветвится по JS-типу — `number → ::numeric`, `boolean → ::boolean`, остальное `::text`. Число попадает в jsonb как JSON-число (`jsonb_typeof = 'number'`, `{"chatContextWindow":200000}`), а не как `"200000"`. - **Типизация DTO→service→client согласована.** DTO `update-ai-settings.dto.ts:34-37` — `@IsOptional @IsInt @Min(0) chatContextWindow?: number`; `ai-settings.service.ts` и `ai.types.ts` — `chatContextWindow?: number`; клиентские guard'ы `typeof === "number"` (`ai-provider-settings.tsx:379`) теперь получают реальное число. Никакой строковой коэрции на пути не осталось. ### Must fix before merge Нет. ### Non-blocking Нет. ### Test coverage Покрыто. Новая ветка cast'а покрыта integration-тестом `workspace-repo-ai-provider-settings.int-spec.ts` на реальном SQL: проверяется и JS-тип возвращённого значения (`toBe(200000)`, `typeof === 'number'`), и on-disk `jsonb_typeof = 'number'`, и сохранность соседних строковых полей при partial-merge апдейте. DTO-валидация `chatContextWindow` (`@IsInt @Min(0)`) покрыта `ai-provider-settings-keys.spec.ts`.

Pull request closed

Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#213