From 1d17cb87b3c406639462fe29da69c5329ef55bd6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 3 Aug 2026 01:27:27 +0300 Subject: [PATCH] feat(walkthrough): write walkthroughs in the reader's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A guided explanation is only useful in a language the reader reads, so the panel header gets a language picker alongside the model one, defaulting to the interface language. Like the model, it is request state rather than a setting: the language travels with the read and the generation, and the one a walkthrough was written in is stored with it, so reopening a review describes what is there instead of what a fresh one would be. Only prose is translated. Hunk aliases resolve back to hunk ids and icon/importance are validated against fixed English values, so a translated one would be dropped by the normalizer — silently losing an anchor or a style. Identifiers and paths stay as they appear in the code. The language is part of the cache key, and a read now asks the cache for the exact request it was given before falling back to the pointer. Without that the panel answered a request to switch languages with the text it already had, leaving the other language unused in the cache. Alongside it: - The answer budget is derived from the resolved model instead of a flat 24k. That number was the same for a 64k-context model and for one that admits to 384k output tokens, and on the latter it was the only reason generation failed: the model spent the whole allowance reasoning and returned nothing. It is now min(96k, max(24k, a quarter of the context)) capped by the catalog's output limit, decided once so the input reserve and the request cannot drift apart. - A read no longer offers Cancel. It is a few hundred milliseconds of git with nothing to cancel, and the button flickered on every model or language change. When the panel is showing a fallback, a banner names what is on screen versus what was asked for — only once the read has settled. - The header keeps one 32px control height and drops its labels below 680px instead of squeezing them to two letters and an ellipsis. Docs and module documentation updated in every locale. --- CHANGELOG.md | 2 +- packages/docs/content/docs/es/walkthrough.mdx | 10 +- packages/docs/content/docs/fr/walkthrough.mdx | 10 +- packages/docs/content/docs/ja/walkthrough.mdx | 10 +- packages/docs/content/docs/ko/walkthrough.mdx | 10 +- packages/docs/content/docs/pl/walkthrough.mdx | 10 +- .../docs/content/docs/pt-br/walkthrough.mdx | 10 +- packages/docs/content/docs/uk/walkthrough.mdx | 10 +- packages/docs/content/docs/walkthrough.mdx | 10 +- .../docs/content/docs/zh-cn/walkthrough.mdx | 10 +- .../sections/agents/ModelSelector.tsx | 39 ++- .../views/walkthrough/WalkthroughView.tsx | 176 ++++++++++++-- packages/ui/src/lib/i18n/messages/en.ts | 5 + packages/ui/src/lib/i18n/messages/es.ts | 5 + packages/ui/src/lib/i18n/messages/fr.ts | 5 + packages/ui/src/lib/i18n/messages/ja.ts | 5 + packages/ui/src/lib/i18n/messages/ko.ts | 5 + packages/ui/src/lib/i18n/messages/pl.ts | 5 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 + packages/ui/src/lib/i18n/messages/uk.ts | 5 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 + packages/ui/src/lib/walkthrough/api.ts | 6 +- packages/ui/src/lib/walkthrough/types.ts | 5 + .../ui/src/stores/useWalkthroughStore.test.ts | 65 ++++- packages/ui/src/stores/useWalkthroughStore.ts | Bin 10879 -> 12437 bytes .../server/lib/small-model/DOCUMENTATION.md | 5 + packages/web/server/lib/small-model/index.js | 30 ++- .../web/server/lib/small-model/index.test.js | 23 ++ .../server/lib/walkthrough/DOCUMENTATION.md | 93 ++++++- packages/web/server/lib/walkthrough/index.js | 111 +++++++-- .../web/server/lib/walkthrough/jobs.test.js | 47 ++++ .../server/lib/walkthrough/language.test.js | 226 ++++++++++++++++++ .../web/server/lib/walkthrough/languages.js | 59 +++++ packages/web/server/lib/walkthrough/prompt.js | 24 +- packages/web/server/lib/walkthrough/routes.js | 11 +- .../web/server/lib/walkthrough/routes.test.js | 38 ++- packages/web/server/lib/walkthrough/schema.js | 2 +- packages/web/server/lib/walkthrough/store.js | 6 +- 39 files changed, 1027 insertions(+), 81 deletions(-) create mode 100644 packages/web/server/lib/walkthrough/language.test.js create mode 100644 packages/web/server/lib/walkthrough/languages.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b083faa9..9e57a8f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- **Walkthrough:** a new guided walkthrough reorders a diff into a sequence of stops — the model groups related changes, explains what each one does, and orders them so each builds on the last. Start one from the Changes and pull-request views for uncommitted work, a branch against its base, or a pull request; nothing runs on its own. +- **Walkthrough:** a new guided walkthrough reorders a diff into a sequence of stops — the model groups related changes, explains what each one does, and orders them so each builds on the last. Start one from the Changes and pull-request views for uncommitted work, a branch against its base, or a pull request; nothing runs on its own. Walkthroughs are written in your interface language by default, and the panel can generate one in any other supported language. - **Mobile/Tablet:** reworked the tablet and foldable layout around the phone's navigation — a persistent resizable sessions sidebar on the left, the workspace (Changes, Files, Terminal, Notes, MCP) as a resizable right sidebar, and app pages like settings and instances shown as centered dialogs. An open diff, edited file, or attached terminal now survives rotation. - Performance: fixed Bun dependency chunking so the web app no longer downloads a single 18.5 MB vendor bundle at startup; heavy syntax highlighting, screenshot, diagram, editor, and image-conversion libraries now load only when needed (thanks to @makeittech). - Mobile/Android: pairing QR codes can now be scanned on devices without Google Play Services; the camera closes as soon as a code is recognized, followed by a connection-in-progress screen. diff --git a/packages/docs/content/docs/es/walkthrough.mdx b/packages/docs/content/docs/es/walkthrough.mdx index 8eb2c207..a8df7498 100644 --- a/packages/docs/content/docs/es/walkthrough.mdx +++ b/packages/docs/content/docs/es/walkthrough.mdx @@ -33,11 +33,19 @@ El selector solo ofrece modelos capaces de devolver salida estructurada, porque Al reabrir el panel verás el modelo que produjo lo que tienes delante, así que **Regenerar** repite con el mismo salvo que lo cambies. +## Elegir el idioma + +Los recorridos se escriben en el idioma de tu interfaz por defecto. El selector de idioma de la cabecera del panel arranca ahí, y puedes elegir cualquier otro idioma al que esté traducido OpenChamber para una sola revisión: una explicación guiada solo sirve en un idioma que leas con soltura. + +Solo se traduce la prosa. Los identificadores, las rutas de archivo y los nombres de API se quedan tal cual aparecen en tu código, así que lo que nombra una parada sigue siendo lo que puedes buscar. + +Si todavía no hay nada generado en el idioma que elegiste, el panel no se vacía: sigue mostrando el recorrido que tiene y lo dice. Pulsa **Generar recorrido** para obtenerlo en el idioma nuevo. + ## Coste y caché Nada se genera por su cuenta. La generación solo empieza cuando la pides, y regenerar también es manual. -Los resultados se guardan en caché según el contenido exacto del diff. Devuelve el árbol de trabajo a un estado anterior y el recorrido anterior vuelve gratis, sin llamar al modelo. Cambia de modelo y vuelve: el recorrido de cada uno sigue ahí. +Los resultados se guardan en caché según el contenido exacto del diff. Devuelve el árbol de trabajo a un estado anterior y el recorrido anterior vuelve gratis, sin llamar al modelo. El idioma y el modelo forman parte de esa clave, así que cada combinación se guarda por separado: cuando un diff ya tiene recorrido en dos idiomas, cambiar entre ellos es instantáneo y no cuesta nada. La generación se ejecuta en el servidor de OpenChamber, no en la pestaña del navegador. Recarga la página o cierra el panel y continúa; al volver, el resultado te espera. Solo **Cancelar** la detiene. diff --git a/packages/docs/content/docs/fr/walkthrough.mdx b/packages/docs/content/docs/fr/walkthrough.mdx index 5f7dc41e..39262b71 100644 --- a/packages/docs/content/docs/fr/walkthrough.mdx +++ b/packages/docs/content/docs/fr/walkthrough.mdx @@ -33,11 +33,19 @@ Le sélecteur ne propose que des modèles capables de sortie structurée, sans l En rouvrant le panneau, vous voyez le modèle qui a produit ce que vous avez sous les yeux ; **Régénérer** reprend donc le même tant que vous n’en changez pas. +## Choisir la langue + +Les parcours sont rédigés dans la langue de votre interface par défaut. Le sélecteur de langue dans l’en-tête du panneau démarre là, et vous pouvez choisir n’importe quelle autre langue dans laquelle OpenChamber est traduit pour une seule relecture : une explication guidée ne sert que dans une langue que vous lisez à l’aise. + +Seule la prose est traduite. Les identifiants, les chemins de fichiers et les noms d’API restent exactement tels qu’ils apparaissent dans votre code, si bien que ce qu’une étape nomme reste ce que vous pouvez rechercher. + +Si rien n’a encore été généré dans la langue choisie, le panneau ne se vide pas : il continue d’afficher le parcours qu’il a et le signale. Appuyez sur **Générer le parcours** pour l’obtenir dans la nouvelle langue. + ## Coût et cache Rien ne se génère tout seul. La génération ne démarre que sur votre demande, et la régénération est manuelle elle aussi. -Les résultats sont mis en cache d’après le contenu exact du diff. Ramenez la copie de travail à un état antérieur et le parcours d’alors revient gratuitement, sans appel au modèle. Changez de modèle puis revenez : le parcours de chacun est toujours là. +Les résultats sont mis en cache d’après le contenu exact du diff. Ramenez la copie de travail à un état antérieur et le parcours d’alors revient gratuitement, sans appel au modèle. La langue et le modèle font partie de cette clé, donc chaque combinaison est conservée séparément : dès qu’un diff a un parcours en deux langues, passer de l’une à l’autre est instantané et gratuit. La génération tourne sur le serveur OpenChamber, pas dans votre onglet. Rechargez la page ou fermez le panneau : le travail continue et le résultat vous attend. Seul **Annuler** l’interrompt. diff --git a/packages/docs/content/docs/ja/walkthrough.mdx b/packages/docs/content/docs/ja/walkthrough.mdx index ea43496f..ad12a3d3 100644 --- a/packages/docs/content/docs/ja/walkthrough.mdx +++ b/packages/docs/content/docs/ja/walkthrough.mdx @@ -33,11 +33,19 @@ description: 差分をアルファベット順ではなく、意味の通る順 パネルを開き直すと、目の前の内容を生成したモデルが表示されます。したがって**再生成**は、変更しない限り同じモデルで繰り返します。 +## 言語の選択 + +ウォークスルーは既定でインターフェースの言語で書かれます。パネル上部の言語セレクターはその言語から始まり、1 回のレビューだけ OpenChamber が翻訳されている他の言語に切り替えることもできます。案内は、無理なく読める言語でなければ意味がありません。 + +翻訳されるのは文章だけです。識別子、ファイルパス、API 名はコードにあるままなので、ストップが指し示すものはそのまま検索できます。 + +選んだ言語でまだ何も生成されていない場合、パネルは空にならず、手元にあるウォークスルーを表示したままその旨を伝えます。**ウォークスルーを生成** を押すと、新しい言語で生成されます。 + ## コストとキャッシュ 勝手に生成されることはありません。生成はあなたが求めたときだけ始まり、再生成も手動です。 -結果は差分の正確な内容に対してキャッシュされます。作業ツリーを以前の状態に戻せば、そのときのウォークスルーがモデル呼び出し無しで戻ります。モデルを切り替えて戻しても、それぞれのウォークスルーは残っています。 +結果は差分の正確な内容に対してキャッシュされます。作業ツリーを以前の状態に戻せば、そのときのウォークスルーがモデル呼び出し無しで戻ります。言語とモデルもこのキーの一部なので、組み合わせごとに別々に保存されます。ある差分に 2 つの言語のウォークスルーができれば、その切り替えは即座で無料です。 生成はブラウザのタブではなく OpenChamber サーバー上で動きます。ページを再読み込みしてもパネルを閉じても処理は続き、戻れば結果が待っています。止められるのは**キャンセル**だけです。 diff --git a/packages/docs/content/docs/ko/walkthrough.mdx b/packages/docs/content/docs/ko/walkthrough.mdx index 9d305838..33f23ba7 100644 --- a/packages/docs/content/docs/ko/walkthrough.mdx +++ b/packages/docs/content/docs/ko/walkthrough.mdx @@ -33,11 +33,19 @@ diff는 파일 경로순으로 정렬되지만, 그 순서가 변경을 이해 패널을 다시 열면 지금 보고 있는 결과를 만든 모델이 표시되므로, 바꾸지 않는 한 **다시 생성**은 같은 모델로 반복합니다. +## 언어 선택 + +워크스루는 기본적으로 인터페이스 언어로 작성됩니다. 패널 헤더의 언어 선택기가 그 언어에서 시작하며, 한 번의 리뷰에 한해 OpenChamber가 번역된 다른 언어를 고를 수 있습니다. 안내는 편하게 읽을 수 있는 언어여야 쓸모가 있습니다. + +번역되는 것은 서술뿐입니다. 식별자, 파일 경로, API 이름은 코드에 있는 그대로 남으므로, 각 지점이 가리키는 이름을 그대로 검색할 수 있습니다. + +선택한 언어로 아직 생성된 것이 없으면 패널은 비워지지 않고, 가지고 있는 워크스루를 계속 보여주면서 그 사실을 알립니다. **워크스루 생성**을 누르면 새 언어로 만들어집니다. + ## 비용과 캐시 저절로 생성되는 것은 없습니다. 생성은 요청할 때만 시작되고, 재생성도 수동입니다. -결과는 diff의 정확한 내용을 기준으로 캐시됩니다. 작업 트리를 이전 상태로 되돌리면 그때의 워크스루가 모델 호출 없이 그대로 돌아옵니다. 모델을 바꿨다가 되돌려도 각각의 워크스루가 남아 있습니다. +결과는 diff의 정확한 내용을 기준으로 캐시됩니다. 작업 트리를 이전 상태로 되돌리면 그때의 워크스루가 모델 호출 없이 그대로 돌아옵니다. 언어와 모델도 이 키의 일부라서 조합마다 따로 보관됩니다. 한 diff에 두 언어의 워크스루가 생기면 그 사이를 오가는 것은 즉시 이루어지고 비용도 들지 않습니다. 생성은 브라우저 탭이 아니라 OpenChamber 서버에서 실행됩니다. 페이지를 새로 고치거나 패널을 닫아도 작업은 계속되고, 돌아오면 결과가 기다립니다. 멈추는 것은 **취소**뿐입니다. diff --git a/packages/docs/content/docs/pl/walkthrough.mdx b/packages/docs/content/docs/pl/walkthrough.mdx index 5377f828..2215808e 100644 --- a/packages/docs/content/docs/pl/walkthrough.mdx +++ b/packages/docs/content/docs/pl/walkthrough.mdx @@ -33,11 +33,19 @@ Lista pokazuje tylko modele potrafiące zwracać ustrukturyzowaną odpowiedź, b Po ponownym otwarciu panelu zobaczysz model, który stworzył to, co masz przed sobą, więc **Wygeneruj ponownie** powtórzy tym samym, dopóki go nie zmienisz. +## Wybór języka + +Przewodniki są domyślnie pisane w języku Twojego interfejsu. Selektor języka w nagłówku panelu zaczyna właśnie od niego, a dla pojedynczego przeglądu możesz wybrać dowolny inny język, na który przetłumaczono OpenChamber — prowadzone wyjaśnienie ma sens tylko w języku, który czytasz swobodnie. + +Tłumaczona jest wyłącznie proza. Identyfikatory, ścieżki plików i nazwy API pozostają dokładnie takie, jakie są w Twoim kodzie, więc to, co nazywa dany przystanek, nadal da się wyszukać. + +Jeśli w wybranym języku nic jeszcze nie powstało, panel się nie opróżnia: nadal pokazuje przewodnik, który ma, i informuje o tym. Naciśnij **Wygeneruj przewodnik**, aby otrzymać go w nowym języku. + ## Koszt i pamięć podręczna Nic nie generuje się samo. Generowanie zaczyna się wyłącznie na Twoje żądanie, ponowne również jest ręczne. -Wyniki są zapisywane w pamięci podręcznej według dokładnej treści różnic. Przywróć drzewo robocze do wcześniejszego stanu, a tamten przewodnik wróci za darmo, bez wywołania modelu. Przełącz model i wróć — przewodnik każdego z nich nadal tam jest. +Wyniki są zapisywane w pamięci podręcznej według dokładnej treści różnic. Przywróć drzewo robocze do wcześniejszego stanu, a tamten przewodnik wróci za darmo, bez wywołania modelu. Język i model są częścią tego klucza, więc każda kombinacja jest przechowywana osobno: gdy różnice mają już przewodnik w dwóch językach, przełączanie między nimi jest natychmiastowe i nic nie kosztuje. Generowanie działa na serwerze OpenChamber, nie w karcie przeglądarki. Odśwież stronę albo zamknij panel, a praca trwa dalej; po powrocie wynik czeka. Zatrzymuje ją tylko **Anuluj**. diff --git a/packages/docs/content/docs/pt-br/walkthrough.mdx b/packages/docs/content/docs/pt-br/walkthrough.mdx index 59dbf88d..d541ace8 100644 --- a/packages/docs/content/docs/pt-br/walkthrough.mdx +++ b/packages/docs/content/docs/pt-br/walkthrough.mdx @@ -33,11 +33,19 @@ O seletor só oferece modelos capazes de devolver saída estruturada, porque sem Ao reabrir o painel você vê o modelo que produziu o que está na tela, então **Gerar novamente** repete com o mesmo, a menos que você troque. +## Escolhendo o idioma + +Os percursos são escritos no idioma da sua interface por padrão. O seletor de idioma no cabeçalho do painel começa por ele, e você pode escolher qualquer outro idioma para o qual o OpenChamber esteja traduzido para uma única revisão — uma explicação guiada só serve num idioma que você lê com folga. + +Só a prosa é traduzida. Identificadores, caminhos de arquivo e nomes de API continuam exatamente como aparecem no seu código, então o que uma parada nomeia continua sendo o que você consegue buscar. + +Se ainda não houver nada gerado no idioma escolhido, o painel não se esvazia: ele continua mostrando o percurso que tem e avisa. Toque em **Gerar percurso** para obtê-lo no novo idioma. + ## Custo e cache Nada é gerado sozinho. A geração só começa quando você pede, e gerar de novo também é manual. -Os resultados ficam em cache pelo conteúdo exato do diff. Volte a árvore de trabalho para um estado anterior e aquele percurso retorna de graça, sem chamar o modelo. Troque de modelo e volte: o percurso de cada um continua lá. +Os resultados ficam em cache pelo conteúdo exato do diff. Volte a árvore de trabalho para um estado anterior e aquele percurso retorna de graça, sem chamar o modelo. O idioma e o modelo fazem parte dessa chave, então cada combinação é guardada separadamente: quando um diff já tem percurso em dois idiomas, alternar entre eles é instantâneo e não custa nada. A geração roda no servidor do OpenChamber, não na aba do navegador. Recarregue a página ou feche o painel e o trabalho continua; ao voltar, o resultado está esperando. Só **Cancelar** interrompe. diff --git a/packages/docs/content/docs/uk/walkthrough.mdx b/packages/docs/content/docs/uk/walkthrough.mdx index ef87ab6d..187d5ff6 100644 --- a/packages/docs/content/docs/uk/walkthrough.mdx +++ b/packages/docs/content/docs/uk/walkthrough.mdx @@ -33,11 +33,19 @@ Diff упорядкований за шляхами файлів, а це май Відкривши панель знову, ви побачите модель, яка створила те, що перед вами, тож **Створити заново** повторить тією самою, доки ви її не зміните. +## Вибір мови + +За замовчуванням розбір пишеться мовою вашого інтерфейсу. Саме з неї починає селектор мови в шапці панелі, і для окремого розбору можна обрати будь-яку іншу мову, якою перекладено OpenChamber — пояснення має сенс лише тією мовою, яку ви читаєте вільно. + +Перекладається лише проза. Ідентифікатори, шляхи до файлів і назви API лишаються точно такими, як у вашому коді, тож те, що називає зупинка, і далі можна знайти пошуком. + +Якщо обраною мовою ще нічого не згенеровано, панель не порожніє: вона й далі показує наявний розбір і повідомляє про це. Натисніть **Створити розбір**, щоб отримати його новою мовою. + ## Витрати й кеш Ніщо не генерується саме. Генерація починається лише на ваш запит, і повторна теж робиться вручну. -Результати кешуються за точним вмістом diff. Поверніть робоче дерево до попереднього стану — і попередній розбір повернеться безкоштовно, без звернення до моделі. Перемкніть модель і назад — розбір кожної з них лишиться на місці. +Результати кешуються за точним вмістом diff. Поверніть робоче дерево до попереднього стану — і попередній розбір повернеться безкоштовно, без звернення до моделі. Мова й модель — частина цього ключа, тож кожна комбінація зберігається окремо: коли для diff уже є розбір двома мовами, перемикання між ними миттєве й безкоштовне. Генерація виконується на сервері OpenChamber, а не у вкладці браузера. Перезавантажте сторінку чи закрийте панель — робота триває, а результат чекатиме на вас. Зупиняє її лише кнопка **Скасувати**. diff --git a/packages/docs/content/docs/walkthrough.mdx b/packages/docs/content/docs/walkthrough.mdx index f4b54a31..6d372bf5 100644 --- a/packages/docs/content/docs/walkthrough.mdx +++ b/packages/docs/content/docs/walkthrough.mdx @@ -33,11 +33,19 @@ The picker only offers models that can return structured output, because the wal Reopening a panel shows the model that produced what you are looking at, so **Regenerate** repeats with the same one unless you change it. +## Choosing the language + +Walkthroughs are written in your interface language by default. The language picker in the panel header starts there, and you can pick any other language OpenChamber is translated into for a single review — a guided explanation is only useful in a language you read comfortably. + +Only the prose is translated. Identifiers, file paths, and API names stay exactly as they appear in your code, so what a stop names is still what you can search for. + +When nothing has been generated yet in the language you picked, the panel keeps showing the walkthrough it has and says so, rather than emptying itself. Press **Generate walkthrough** to get one in the new language. + ## Cost and caching Nothing generates on its own. Generation only ever starts when you ask, and regeneration is manual too. -Results are cached against the exact content of the diff. Return the working tree to an earlier state and the earlier walkthrough comes back for free, no model call. Switch models and back, and each one's walkthrough is still there. +Results are cached against the exact content of the diff. Return the working tree to an earlier state and the earlier walkthrough comes back for free, no model call. Language and model are both part of that key, so each combination is kept separately: once a diff has a walkthrough in two languages, switching between them is instant and costs nothing. Generation runs on the OpenChamber server, not in your browser tab. Reload the page or close the panel and it keeps going; come back and the result is waiting. Pressing **Cancel** is the only thing that stops it. diff --git a/packages/docs/content/docs/zh-cn/walkthrough.mdx b/packages/docs/content/docs/zh-cn/walkthrough.mdx index 99171d37..24d982d7 100644 --- a/packages/docs/content/docs/zh-cn/walkthrough.mdx +++ b/packages/docs/content/docs/zh-cn/walkthrough.mdx @@ -33,11 +33,19 @@ description: 按讲得通的顺序读差异,而不是按字母顺序。 重新打开面板时会显示生成当前内容的那个模型,所以只要你不更换,**重新生成**就会沿用它。 +## 选择语言 + +导读默认使用你的界面语言书写。面板顶部的语言选择器从该语言开始,你也可以只为这一次导读改用 OpenChamber 已翻译的任意其他语言——讲解只有用你读起来轻松的语言才有价值。 + +只有叙述文字会被翻译。标识符、文件路径和 API 名称保持代码中的原样,因此每个停靠点提到的名字仍然可以直接搜索。 + +如果所选语言还没有生成过内容,面板不会清空:它会继续显示已有的导读并说明这一点。按 **生成导读** 即可得到新语言的版本。 + ## 开销与缓存 不会自行生成。生成只在你请求时开始,重新生成同样需要手动触发。 -结果按差异的确切内容缓存。把工作区恢复到先前状态,当时的导读就会免费回来,不会调用模型。切换模型再切回来,各自的导读都还在。 +结果按差异的确切内容缓存。把工作区恢复到先前状态,当时的导读就会免费回来,不会调用模型。语言和模型都属于这个键的一部分,因此每种组合各自保存:一个差异一旦有了两种语言的导读,在它们之间切换就是即时且免费的。 生成运行在 OpenChamber 服务器上,而不是浏览器标签页里。刷新页面或关闭面板,工作仍在继续;回来时结果已在等你。只有**取消**能停止它。 diff --git a/packages/ui/src/components/sections/agents/ModelSelector.tsx b/packages/ui/src/components/sections/agents/ModelSelector.tsx index 0d3841fa..ef62dfe1 100644 --- a/packages/ui/src/components/sections/agents/ModelSelector.tsx +++ b/packages/ui/src/components/sections/agents/ModelSelector.tsx @@ -27,6 +27,13 @@ interface ModelSelectorProps { placeholder?: string; tooltipsEnabled?: boolean; dropdownPortalToBody?: boolean; + /** + * Drop the model name and the chevron, leaving the provider logo. For + * headers that run out of room before they run out of controls — the logo + * still says which provider is answering, which is the part a glance is + * usually after. + */ + compact?: boolean; } export const ModelSelector: React.FC = ({ @@ -39,6 +46,7 @@ export const ModelSelector: React.FC = ({ placeholder, tooltipsEnabled = true, dropdownPortalToBody = false, + compact = false, }) => { const { t } = useI18n(); const { isReady, isUnavailable } = useOpenCodeReadiness(); @@ -169,26 +177,35 @@ export const ModelSelector: React.FC = ({ return ( -
+
{!isReady ? ( <> - - {isUnavailable ? t('common.unavailable') : t('common.loading')} - + {!compact && ( + + {isUnavailable ? t('common.unavailable') : t('common.loading')} + + )} ) : ( <> {providerId ? : } - {triggerLabel} + {!compact && ( + {triggerLabel} + )} )} - + {!compact && }
diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 3120bd90..c0403b75 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -10,7 +10,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { useI18n } from '@/lib/i18n'; +import { useI18n, type Locale } from '@/lib/i18n'; import { buildWalkthroughView } from '@/lib/walkthrough/model'; import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types'; import { ModelSelector } from '@/components/sections/agents/ModelSelector'; @@ -54,8 +54,19 @@ const TOC_MIN_WIDTH = 180; // than half the panel no matter how far the user drags. const TOC_MAX_FRACTION = 0.5; +// Below this the header controls wrap onto a second row and the labels squeeze +// to two letters and an ellipsis, which reads as broken rather than dense. The +// controls drop their text instead: every one of them carries an icon that +// already identifies it. +// +// Every control in this row is 32px tall — `Button` size `sm` and the dropdown +// trigger's `default` size are both h-8, so this is the design system's form +// scale rather than a number picked here. Three heights in one row (28px +// pickers, 32px action, 36px arrows) read as misalignment, not hierarchy. +const HEADER_COMPACT_WIDTH = 680; + export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { - const { t } = useI18n(); + const { t, locale, locales, label } = useI18n(); const rootRef = useRef(null); const [panelWidth, setPanelWidth] = useState(0); @@ -76,6 +87,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const [draggingToc, setDraggingToc] = useState(false); const showToc = panelWidth === 0 || panelWidth >= TOC_MIN_PANEL_WIDTH; + // Zero means the observer has not reported yet; assume there is room rather + // than rendering a compact header for one frame on every open. + const compactHeader = panelWidth > 0 && panelWidth < HEADER_COMPACT_WIDTH; // Clamped on read rather than on write: the panel can be resized after the // width was stored, and a remembered 400px column must not swallow a narrow // panel. @@ -229,12 +243,28 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const requestSource = useWalkthroughStore((state) => state.requestSource); const selectModel = useWalkthroughStore((state) => state.selectModel); const selectedModel = useWalkthroughStore((state) => state.getSelectedModel(directory, source)); + const selectLanguage = useWalkthroughStore((state) => state.selectLanguage); + const selectedLanguage = useWalkthroughStore((state) => state.getSelectedLanguage(directory, source)); - // Reloads on a model change too: whether this diff fits, and whether the - // model can produce structured output, are answers about a specific model. + // Explicit pick first, then the language the walkthrough on screen is + // actually written in, then the interface locale. The middle step matters for + // the same reason it does for the model: reopening a review should describe + // what is there, not what a fresh one would be. + const generatedLanguage = entry.result?.language; + const activeLanguage: Locale = ( + selectedLanguage && locales.includes(selectedLanguage as Locale) + ? (selectedLanguage as Locale) + : generatedLanguage && locales.includes(generatedLanguage as Locale) + ? (generatedLanguage as Locale) + : locale + ); + + // Reloads on a model or language change: whether this diff fits, and whether + // the model can produce structured output, are answers about a specific + // request — and the language instruction is part of that request. useEffect(() => { - void load(directory, source); - }, [directory, load, source, selectedModel]); + void load(directory, source, { language: activeLanguage }); + }, [activeLanguage, directory, load, source, selectedModel]); const view = useMemo(() => buildWalkthroughView(entry.result), [entry.result]); @@ -278,6 +308,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { ); const [sourceMenuOpen, setSourceMenuOpen] = useState(false); + const [languageMenuOpen, setLanguageMenuOpen] = useState(false); const sourceValue = source.kind === 'working-tree' ? source.scope : source.kind; const sourceLabel = source.kind === 'branch' ? t('walkthrough.scope.branch') @@ -331,7 +362,27 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { [modelsMetadata] ); - const isBusy = entry.status === 'loading' || entry.status === 'generating'; + // Only generation is worth interrupting. A read is a few hundred milliseconds + // of git with nothing to cancel, and offering a Cancel button for it made the + // action flicker every time the model or language changed. + const isGeneratingEntry = entry.status === 'generating'; + + // What is on screen versus what is being asked for. A read that has settled + // is the only thing that can answer this: while one is in flight the panel is + // still showing the previous answer, and a banner claiming something is + // missing before we know would be the same flicker in another place. + const shownModel = entry.result?.model + ? `${entry.result.model.providerID}/${entry.result.model.modelID}` + : undefined; + const shownLanguage = entry.result?.language; + const shownLocale = shownLanguage && locales.includes(shownLanguage as Locale) + ? (shownLanguage as Locale) + : undefined; + const settled = entry.status === 'ready' && Boolean(view); + // An entry written before walkthroughs had a language carries none. Unknown + // is not the same as different, so it is not reported as missing. + const languageMissing = settled && Boolean(shownLocale) && shownLocale !== activeLanguage; + const modelMissing = settled && Boolean(shownModel) && Boolean(activeModel) && shownModel !== activeModel; // The stage list outlives the work by a beat. Assembling takes milliseconds, // so without this the result replaces the list before the last step is ever @@ -371,9 +422,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const handleGenerate = useCallback( (force: boolean) => { - void generate(directory, source, { force }); + void generate(directory, source, { force, language: activeLanguage }); }, - [directory, generate, source] + [activeLanguage, directory, generate, source] ); return ( @@ -383,7 +434,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { + + + + {t('walkthrough.language.menuLabel')} + + { + setLanguageMenuOpen(false); + selectLanguage(directory, source, value); + }} + > + {locales.map((value) => ( + + {label(value)} + + ))} + + + + {/* Choosing a roomier model for a risky change is a per-review call, so this is panel state rather than a settings edit. */} { isModelAllowed={isStructuredOutputCapable} tooltipsEnabled={false} dropdownPortalToBody - className="h-7 min-w-0 max-w-48" + compact={compactHeader} + className={cn('h-8 min-w-0', !compactHeader && 'max-w-48')} /> {view && ( <> ) : ( )}
@@ -516,6 +624,38 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { )} + {/* Switching the model or the language is a request for a walkthrough + that may not exist yet. Falling back to the last one is better than an + empty panel, but only if the panel says so — otherwise the picker + claims Ukrainian over English prose. */} + {(languageMissing || modelMissing) && ( +
+ + + {languageMissing && modelMissing + ? t('walkthrough.missing.languageAndModel') + : languageMissing + ? t('walkthrough.missing.language', { + requested: label(activeLanguage), + shown: label(shownLocale as Locale), + }) + : t('walkthrough.missing.model', { model: activeModelId })} + + +
+ )} + {view?.isStale && entry.status !== 'generating' && (
@@ -529,7 +669,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { className="ml-auto" // Clicking this again mid-flight would abort the running generation // and start another — paying for the same answer twice. - disabled={isBusy} + disabled={isGeneratingEntry} onClick={() => handleGenerate(true)} > {t('walkthrough.action.regenerate')} diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 7372135d..703c09de 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1106,6 +1106,11 @@ export const dict = { 'walkthrough.scope.working': 'Unstaged', 'walkthrough.scope.branch': 'This branch', 'walkthrough.scope.selectorAria': 'Select what to review', + 'walkthrough.language.menuLabel': 'Walkthrough language', + 'walkthrough.missing.language': 'No walkthrough in {requested} yet — showing the one in {shown}.', + 'walkthrough.missing.model': 'No walkthrough from {model} yet — showing the last one generated here.', + 'walkthrough.missing.languageAndModel': 'No walkthrough in this language from this model yet — showing the last one generated here.', + 'walkthrough.language.selectorAria': 'Select the walkthrough language', 'walkthrough.scope.pullRequest': 'PR #{number}', 'walkthrough.action.generate': 'Generate walkthrough', 'walkthrough.action.regenerate': 'Regenerate', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 6d9ac6ff..8bf0a3d9 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1107,6 +1107,11 @@ export const dict: Record = { "walkthrough.scope.working": "Sin preparar", "walkthrough.scope.branch": "Esta rama", "walkthrough.scope.selectorAria": "Elegir qué revisar", + "walkthrough.language.menuLabel": "Idioma del recorrido", + "walkthrough.missing.language": "Aún no hay un recorrido en {requested}: se muestra el de {shown}.", + "walkthrough.missing.model": "Aún no hay un recorrido de {model}: se muestra el último generado aquí.", + "walkthrough.missing.languageAndModel": "Aún no hay un recorrido en este idioma con este modelo: se muestra el último generado aquí.", + "walkthrough.language.selectorAria": "Elegir el idioma del recorrido", "walkthrough.scope.pullRequest": "PR n.º {number}", "walkthrough.action.generate": "Generar recorrido", "walkthrough.action.regenerate": "Regenerar", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 3e39161c..8fe1e83a 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -931,6 +931,11 @@ export const dict = { 'walkthrough.scope.working': 'Non indexées', 'walkthrough.scope.branch': 'Cette branche', 'walkthrough.scope.selectorAria': 'Choisir ce qui est examiné', + 'walkthrough.language.menuLabel': 'Langue du parcours', + 'walkthrough.missing.language': 'Pas encore de parcours en {requested} — voici celui en {shown}.', + 'walkthrough.missing.model': 'Pas encore de parcours généré par {model} — voici le dernier généré ici.', + 'walkthrough.missing.languageAndModel': 'Pas encore de parcours dans cette langue avec ce modèle — voici le dernier généré ici.', + 'walkthrough.language.selectorAria': 'Choisir la langue du parcours', 'walkthrough.scope.pullRequest': 'PR n° {number}', 'walkthrough.action.generate': 'Générer le parcours', 'walkthrough.action.regenerate': 'Régénérer', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 38ef7e90..1fa51ddf 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1103,6 +1103,11 @@ export const dict: Record = { 'walkthrough.scope.working': '未ステージ', 'walkthrough.scope.branch': 'このブランチ', 'walkthrough.scope.selectorAria': 'レビュー対象を選択', + 'walkthrough.language.menuLabel': 'ウォークスルーの言語', + 'walkthrough.missing.language': '{requested}のウォークスルーはまだありません。{shown}のものを表示しています。', + 'walkthrough.missing.model': '{model} が生成したウォークスルーはまだありません。ここで最後に生成されたものを表示しています。', + 'walkthrough.missing.languageAndModel': 'この言語・このモデルのウォークスルーはまだありません。ここで最後に生成されたものを表示しています。', + 'walkthrough.language.selectorAria': 'ウォークスルーの言語を選択', 'walkthrough.scope.pullRequest': 'PR #{number}', 'walkthrough.action.generate': 'ウォークスルーを生成', 'walkthrough.action.regenerate': '再生成', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 3a5baa9a..4b1c9a8f 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1107,6 +1107,11 @@ export const dict: Record = { 'walkthrough.scope.working': '스테이지 안 됨', 'walkthrough.scope.branch': '이 브랜치', 'walkthrough.scope.selectorAria': '리뷰 대상 선택', + 'walkthrough.language.menuLabel': '워크스루 언어', + 'walkthrough.missing.language': '{requested} 워크스루가 아직 없어 {shown} 워크스루를 표시합니다.', + 'walkthrough.missing.model': '{model}(으)로 생성한 워크스루가 아직 없어 마지막으로 생성된 것을 표시합니다.', + 'walkthrough.missing.languageAndModel': '이 언어와 이 모델로 생성한 워크스루가 아직 없어 마지막으로 생성된 것을 표시합니다.', + 'walkthrough.language.selectorAria': '워크스루 언어 선택', 'walkthrough.scope.pullRequest': 'PR #{number}', 'walkthrough.action.generate': '워크스루 생성', 'walkthrough.action.regenerate': '다시 생성', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 066eef4e..657a331e 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1419,6 +1419,11 @@ export const dict: Record = { 'walkthrough.scope.working': 'Poza poczekalnią', 'walkthrough.scope.branch': 'Ta gałąź', 'walkthrough.scope.selectorAria': 'Wybierz, co przejrzeć', + 'walkthrough.language.menuLabel': 'Język przewodnika', + 'walkthrough.missing.language': 'Nie ma jeszcze przewodnika w języku {requested} — pokazujemy ten w języku {shown}.', + 'walkthrough.missing.model': 'Nie ma jeszcze przewodnika od modelu {model} — pokazujemy ostatni wygenerowany tutaj.', + 'walkthrough.missing.languageAndModel': 'Nie ma jeszcze przewodnika w tym języku od tego modelu — pokazujemy ostatni wygenerowany tutaj.', + 'walkthrough.language.selectorAria': 'Wybierz język przewodnika', 'walkthrough.scope.pullRequest': 'PR #{number}', 'walkthrough.action.generate': 'Wygeneruj przewodnik', 'walkthrough.action.regenerate': 'Wygeneruj ponownie', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 15b2ae73..0f6b1730 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1107,6 +1107,11 @@ export const dict: Record = { "walkthrough.scope.working": "Fora do stage", "walkthrough.scope.branch": "Este branch", "walkthrough.scope.selectorAria": "Escolher o que revisar", + "walkthrough.language.menuLabel": "Idioma do percurso", + "walkthrough.missing.language": "Ainda não há um percurso em {requested} — exibindo o de {shown}.", + "walkthrough.missing.model": "Ainda não há um percurso gerado por {model} — exibindo o último gerado aqui.", + "walkthrough.missing.languageAndModel": "Ainda não há um percurso neste idioma com este modelo — exibindo o último gerado aqui.", + "walkthrough.language.selectorAria": "Escolher o idioma do percurso", "walkthrough.scope.pullRequest": "PR nº {number}", "walkthrough.action.generate": "Gerar percurso", "walkthrough.action.regenerate": "Gerar novamente", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 71d6b0f1..71f45507 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1107,6 +1107,11 @@ export const dict: Record = { "walkthrough.scope.working": "Поза індексом", "walkthrough.scope.branch": "Ця гілка", "walkthrough.scope.selectorAria": "Обрати, що розбирати", + "walkthrough.language.menuLabel": "Мова розбору", + "walkthrough.missing.language": "Розбору мовою {requested} ще немає — показано той, що мовою {shown}.", + "walkthrough.missing.model": "Розбору від {model} ще немає — показано останній згенерований тут.", + "walkthrough.missing.languageAndModel": "Розбору цією мовою від цієї моделі ще немає — показано останній згенерований тут.", + "walkthrough.language.selectorAria": "Обрати мову розбору", "walkthrough.scope.pullRequest": "PR #{number}", "walkthrough.action.generate": "Створити розбір", "walkthrough.action.regenerate": "Створити заново", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 8304e139..ae9126f6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1107,6 +1107,11 @@ export const dict: Record = { 'walkthrough.scope.working': '未暂存', 'walkthrough.scope.branch': '当前分支', 'walkthrough.scope.selectorAria': '选择评审范围', + 'walkthrough.language.menuLabel': '导读语言', + 'walkthrough.missing.language': '尚无{requested}导读,当前显示的是{shown}版本。', + 'walkthrough.missing.model': '尚无由 {model} 生成的导读,当前显示最近一次生成的版本。', + 'walkthrough.missing.languageAndModel': '尚无使用该语言和该模型生成的导读,当前显示最近一次生成的版本。', + 'walkthrough.language.selectorAria': '选择导读语言', 'walkthrough.scope.pullRequest': 'PR #{number}', 'walkthrough.action.generate': '生成导读', 'walkthrough.action.regenerate': '重新生成', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 715ca089..cc1c707c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1119,6 +1119,11 @@ export const dict: Record = { 'walkthrough.scope.working': '未暫存', 'walkthrough.scope.branch': '目前分支', 'walkthrough.scope.selectorAria': '選擇審閱範圍', + 'walkthrough.language.menuLabel': '導讀語言', + 'walkthrough.missing.language': '尚無{requested}導讀,目前顯示的是{shown}版本。', + 'walkthrough.missing.model': '尚無由 {model} 產生的導讀,目前顯示最近一次產生的版本。', + 'walkthrough.missing.languageAndModel': '尚無使用該語言與該模型產生的導讀,目前顯示最近一次產生的版本。', + 'walkthrough.language.selectorAria': '選擇導讀語言', 'walkthrough.scope.pullRequest': 'PR #{number}', 'walkthrough.action.generate': '產生導讀', 'walkthrough.action.regenerate': '重新產生', diff --git a/packages/ui/src/lib/walkthrough/api.ts b/packages/ui/src/lib/walkthrough/api.ts index 482c033a..344bf853 100644 --- a/packages/ui/src/lib/walkthrough/api.ts +++ b/packages/ui/src/lib/walkthrough/api.ts @@ -31,13 +31,14 @@ const throwFromResponse = async (response: Response, fallback: string): Promise< export async function fetchWalkthrough( directory: string, source: WalkthroughSource, - options: { model?: string; signal?: AbortSignal } = {} + options: { model?: string; language?: string; signal?: AbortSignal } = {} ): Promise { const response = await runtimeFetch(BASE, { query: { directory, source: JSON.stringify(source), ...(options.model ? { model: options.model } : {}), + ...(options.language ? { language: options.language } : {}), }, signal: options.signal, }); @@ -50,7 +51,7 @@ export async function fetchWalkthrough( export async function generateWalkthrough( directory: string, source: WalkthroughSource, - options: { force?: boolean; model?: string; signal?: AbortSignal } = {} + options: { force?: boolean; model?: string; language?: string; signal?: AbortSignal } = {} ): Promise { const response = await runtimeFetch(`${BASE}/generate`, { method: 'POST', @@ -60,6 +61,7 @@ export async function generateWalkthrough( source, force: options.force === true, ...(options.model ? { model: options.model } : {}), + ...(options.language ? { language: options.language } : {}), }), signal: options.signal, }); diff --git a/packages/ui/src/lib/walkthrough/types.ts b/packages/ui/src/lib/walkthrough/types.ts index b6240fd2..8c6b5a23 100644 --- a/packages/ui/src/lib/walkthrough/types.ts +++ b/packages/ui/src/lib/walkthrough/types.ts @@ -62,6 +62,11 @@ export interface WalkthroughResult { source: WalkthroughSource; walkthrough: Walkthrough | null; model?: WalkthroughModel; + /** + * Language the prose on screen is written in — not necessarily the one being + * asked for now. Null for an entry written before the setting existed. + */ + language?: string | null; generatedAt?: string; fromCache?: boolean; hunks: WalkthroughHunk[]; diff --git a/packages/ui/src/stores/useWalkthroughStore.test.ts b/packages/ui/src/stores/useWalkthroughStore.test.ts index 643bc9a2..892d07de 100644 --- a/packages/ui/src/stores/useWalkthroughStore.test.ts +++ b/packages/ui/src/stores/useWalkthroughStore.test.ts @@ -33,28 +33,35 @@ let generateCalls = 0; let releaseGeneration: (() => void) | undefined; let lastReadModel: string | undefined; let lastGenerateModel: string | undefined; +let lastReadLanguage: string | undefined; +let lastGenerateLanguage: string | undefined; mock.module('@/lib/walkthrough/api', () => ({ fetchWalkthrough: async ( _directory: string, _source: WalkthroughSource, - options: { model?: string } = {}, + options: { model?: string; language?: string } = {}, ) => { lastReadModel = options.model; + lastReadLanguage = options.language; return readResult; }, generateWalkthrough: async ( _directory: string, _source: WalkthroughSource, - options: { model?: string } = {}, + options: { model?: string; language?: string } = {}, ) => { generateCalls += 1; lastGenerateModel = options.model; + lastGenerateLanguage = options.language; return new Promise((resolve) => { releaseGeneration = () => resolve(finished); }); }, cancelWalkthroughGeneration: async () => {}, + // The store imports this for its progress poller. Leaving it out of the mock + // makes the whole module fail to load, which reads as an unrelated crash. + fetchWalkthroughStage: async () => null, })); mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'local' })); @@ -162,3 +169,57 @@ describe('useWalkthroughStore — model selection', () => { .toBe('anthropic/claude-haiku-4-5'); }); }); + +describe('useWalkthroughStore — walkthrough language', () => { + beforeEach(() => { + useWalkthroughStore.getState().reset(); + readResult = result(); + generateCalls = 0; + lastReadLanguage = undefined; + lastGenerateLanguage = undefined; + }); + + afterEach(() => { + useWalkthroughStore.getState().reset(); + }); + + // The read carries it too: readiness is an answer about a specific request, + // and the language instruction is part of that request. + test('sends the resolved language with both the read and the generation', async () => { + await useWalkthroughStore.getState().load('/repo', SOURCE, { language: 'uk' }); + await flush(); + expect(lastReadLanguage).toBe('uk'); + + void useWalkthroughStore.getState().generate('/repo', SOURCE, { language: 'uk' }); + await flush(); + expect(lastGenerateLanguage).toBe('uk'); + releaseGeneration?.(); + await flush(); + }); + + test('keeps an explicit choice apart per source', () => { + const branch: WalkthroughSource = { kind: 'branch', baseRef: 'main', headRef: 'feature' }; + useWalkthroughStore.getState().selectLanguage('/repo', SOURCE, 'ja'); + + expect(useWalkthroughStore.getState().getSelectedLanguage('/repo', branch)).toBe(undefined); + expect(useWalkthroughStore.getState().getSelectedLanguage('/repo', SOURCE)).toBe('ja'); + }); + + test('clearing the choice returns to no explicit language', () => { + useWalkthroughStore.getState().selectLanguage('/repo', SOURCE, 'ja'); + useWalkthroughStore.getState().selectLanguage('/repo', SOURCE, null); + + expect(useWalkthroughStore.getState().getSelectedLanguage('/repo', SOURCE)).toBe(undefined); + }); + + test('a re-attach after a reload still names the language it would ask for', async () => { + readResult = result({ generating: true }); + + await useWalkthroughStore.getState().load('/repo', SOURCE, { language: 'pl' }); + await flush(); + + expect(lastGenerateLanguage).toBe('pl'); + releaseGeneration?.(); + await flush(); + }); +}); diff --git a/packages/ui/src/stores/useWalkthroughStore.ts b/packages/ui/src/stores/useWalkthroughStore.ts index 452db8fe9c84955a7545107b3b2d39e5b1b3e0ca..dbb6206d7af930d1fc38ac7f6637376f40c639e6 100644 GIT binary patch delta 1143 zcmaJ=&ubGw6sA>3+9cYvp+7A?P;494q!xsl`m2bb7{o&lf=DyjeVg58G81Gtn&G+`UUa+@MQd0oMe104N z@=#`?)?zg@kH@d>-jo`e7${$9bS!XX0NO)Ptbq_9Q&L3dhIEjFuRus$if;G-1*99E z`_OI1>)H!stL6b!Dmy?FVVTl0KnL2s_Q2N`FyT-Y(*{!0*C@4L9-wSeJ_Q72bcjF$ z8&wP|n*qAC5jGa0+#;fUyw{aI~+#MUaGtsr9}%=VzQNxOQ_FnaUS9;$?p@o z?MdH8`0?m@6ph$|%>=29lZbX7%-8g;Ye5xl)>7DqW1Lr+U60{?p|4}$GQ#B2W*j5I zgRRm`5BgO_E=7$fNvK>da=%*i&#a4z6}A~X(i@63H`3>hTOga-mqJ}C!9$4k&_U_I z7d7)cJ!@X3Qwt$SaQuK8BZE#Hw!&-kF0-&6#ScQ!kK0H%RVg_#u`!GdT`KDRN#7pH z^gBN2w|SjjOU6vhx6D%FUtqO&`bc?nEhIDiPo$u0jYmx4;GhyqW-~W6X2m4T>Zwh$ zvv}0}TADP8?EHG12i=W9efWUy!g5Sx1q43Ock0-=3m0M>G`F%B_vyWv%A3#GS4j8j$4?+R6Xb~|2 z1qvV_ARv?P4;hm?849zI6|)1AD;E#5U>OPnvyvQA0+V+iC6gT#3$w=`atM=>9K(~n SDiM=AAqlfSDFO$RRxm$f*(4?a diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index 2498fa82..a9d60a99 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -56,6 +56,11 @@ other runtime API. stays at the default overruns the context, and the failure looks like a truncation bug rather than a budgeting one. `describeSmallModel` takes `outputReserveTokens` so readiness checks agree with what generation will do. + It may be a **function** of `{ contextTokens, outputTokenLimit }` for callers + that want as much answer room as the resolved model allows — they cannot name + a number before knowing which model they got. The resolved value comes back as + `outputTokens`, which is what the caller should then request, so the reserve + and the request are the same number by construction. - Reasoning models can spend the entire output budget thinking and return nothing. That case (empty content with `finish_reason: 'length'`, or content empty while `reasoning_content` is populated) throws with diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 20e469c0..40e5e7f5 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -205,6 +205,18 @@ export function listAuthenticatedProviders() { * proxies especially), and treating "unknown" as "unsupported" would hide * models that work fine. */ +/** + * The reserve, resolved against the model that was actually picked. + * + * A caller that wants "as much answer room as this model allows" cannot state a + * number up front — it does not know which model it will get. Passing a + * function lets it decide once the limits are known, and keeps the reserve and + * the eventual request the same number by construction. + */ +const resolveReserveTokens = (outputReserveTokens, limits) => ( + typeof outputReserveTokens === 'function' ? outputReserveTokens(limits) : outputReserveTokens +); + export async function describeSmallModel({ directory, preferredProviderID, preferredModelID, outputReserveTokens, overrideModel } = {}) { const auth = readAuthFile(); const catalog = await getModelCatalog().catch(() => ({})); @@ -224,11 +236,20 @@ export async function describeSmallModel({ directory, preferredProviderID, prefe if (!resolved) return resolved; const entry = catalog?.[resolved.providerID]?.models?.[resolved.modelID]; - const { maxChars, contextTokens, contextKnown } = getModelInputCharBudget({ + const outputTokenLimit = Number(entry?.limit?.output) > 0 ? Number(entry.limit.output) : null; + // Two passes: the first only to learn the context, which a caller-supplied + // reserve function needs before it can answer. + const { contextTokens, contextKnown } = getModelInputCharBudget({ catalog, providerID: resolved.providerID, modelID: resolved.modelID, - outputReserveTokens, + }); + const reserveTokens = resolveReserveTokens(outputReserveTokens, { contextTokens, outputTokenLimit }); + const { maxChars } = getModelInputCharBudget({ + catalog, + providerID: resolved.providerID, + modelID: resolved.modelID, + outputReserveTokens: reserveTokens, }); return { @@ -236,7 +257,10 @@ export async function describeSmallModel({ directory, preferredProviderID, prefe inputCharBudget: maxChars, contextTokens, contextKnown, + // What the caller should ask for, so the request and the reserve above + // cannot drift apart. + outputTokens: Number(reserveTokens) > 0 ? Number(reserveTokens) : null, structuredOutput: typeof entry?.structured_output === 'boolean' ? entry.structured_output : null, - outputTokenLimit: Number(entry?.limit?.output) > 0 ? Number(entry.limit.output) : null, + outputTokenLimit, }; } diff --git a/packages/web/server/lib/small-model/index.test.js b/packages/web/server/lib/small-model/index.test.js index f20ab692..14e5f015 100644 --- a/packages/web/server/lib/small-model/index.test.js +++ b/packages/web/server/lib/small-model/index.test.js @@ -214,6 +214,29 @@ describe('output budget and input reserve', () => { expect(described.inputCharBudget).toBe(304_000); }); + + // A caller that wants "as much room as this model allows" cannot name a + // number before knowing which model it got, so it hands over the decision. + it('lets the reserve be decided from the resolved model\'s limits', async () => { + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/roomy' } }); + + const described = await describeSmallModel({ + directory: '/proj', + outputReserveTokens: ({ contextTokens, outputTokenLimit }) => Math.min(contextTokens / 10, outputTokenLimit), + }); + + // 100k context, 8k output limit -> 8k reserved, leaving 92k tokens. + expect(described.outputTokens).toBe(8_000); + expect(described.inputCharBudget).toBe(92_000 * 4); + }); + + it('reports the reserve it used so the caller can request the same number', async () => { + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/unlisted' } }); + + const described = await describeSmallModel({ directory: '/proj', outputReserveTokens: 24_000 }); + + expect(described.outputTokens).toBe(24_000); + }); }); afterAll(() => { diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index ec1c8ccc..892d403d 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -22,6 +22,7 @@ has to ask for it. - `store.js` — content-addressed cache entries plus mutable pointers. - `pull-request.js` — PR diffs via the shared GitHub octokit helper. - `model-settings.js` — the feature's own model override. +- `languages.js` — the languages the prose may be written in. - `index.js` — orchestration. - `routes.js` — `/api/walkthrough*`. @@ -117,6 +118,53 @@ model picker, only shows providers with a usable login. The in-panel picker on a blocked walkthrough writes this setting too, so recovering from a refusal never silently changes the model behind commit messages. +## Output language + +A walkthrough its reader cannot read is worth nothing, so the prose language is +a per-review choice in the panel header, defaulting to the interface language. +Like the model it is request state rather than a setting: it travels as +`language` on `GET` and `POST`, and it is not persisted, because the language a +walkthrough was written in is already recorded in its cache entry and returned +as `language` — which makes it the better default on reopen than any remembered +preference. Resolution is *explicit choice → language of what is on screen → +interface locale*. + +Only prose is translated. Hunk aliases are keys that resolve back to hunk ids, +and `icon`/`importance` are validated against fixed English values, so a +translated one is dropped by the normalizer — losing an anchor or a style +silently. The prompt says so explicitly. + +`languages.js` owns the accepted tags; they match the UI's `Locale` union, and +anything else — unknown, malformed, absent — resolves to English rather than +failing the request. The default language adds no instruction at all, since the +system prompt is already English. + +The language is part of the cache key. Without that, asking for a translation +would be answered with the untranslated entry that was already there; with it, +switching language and back returns the earlier walkthrough for free, exactly as +switching models does. + +The read follows the same rule: `GET` builds the cache key for the language and +model being asked for and answers from that entry when it exists, before +consulting the pointer. The pointer alone was not enough — it records what was +generated here *last*, which after a switch is the answer to a different +question, and the panel kept showing the English review while the picker said +Ukrainian and the Ukrainian one sat unused in the cache. The key is computed +from the diff the read already parsed, so this costs one file read and no extra +git work. + +Falling back to the pointer still happens when nothing exists in the requested +language: an English review beats an empty panel, and the response says which +language it is in so the panel can say so too — it shows a banner naming what is +on screen versus what was asked for, and only once a read has settled, because +claiming something is missing while still looking for it is the same flicker in +another place. Serving an entry makes it the last +one shown here, so the pointer follows it — otherwise a regeneration would +re-author from a walkthrough the reader is not looking at. + +Attaching to a running job still ignores the language of the second request, +because the job already has one. That matches how the model behaves. + ## Structured output, and what happens when it is refused `structured_output: false` in the catalog blocks generation up front. A @@ -145,18 +193,36 @@ for a wasted first call. ## Output budget -Generation asks for 24k output tokens (capped per model by the catalog), and the -input budget reserves exactly that much. A walkthrough itself is only a few -thousand tokens of JSON — the headroom exists because reasoning models spend the -same budget thinking first and return nothing when it runs out. When that still -happens, `code: 'output-exhausted'` reports it as what it is: this model cannot -finish this job, so pick another or review a narrower scope. +A walkthrough itself is only a few thousand tokens of JSON. The budget exists +for what comes before it: reasoning models spend the same allowance thinking and +return nothing when it runs out, which is a bill for no answer. + +The ask is therefore derived from the resolved model rather than fixed: +`min(96k, max(24k, a quarter of the context))`, then capped by the catalog's +`limit.output`. A flat 24k was the same number for a 64k-context model and for +one that admits to 384k output tokens and a million of context — and on the +latter it was the only reason generation failed. + +The bounds are not arbitrary. The **same number is reserved from the input +allowance**, so the ceiling and the context share are what stop a generous +answer budget from eating the diff it is supposed to describe; the 24k floor is +what this feature always asked for, so no model gets less room than before. A +model whose own `limit.output` is below the floor gets its limit, because asking +for more than a provider allows is rejected by some and ignored by others. + +`describeSmallModel` decides this once — the walkthrough hands it the rule as a +function and reads back `outputTokens` — so the reserve and the request cannot +drift apart. + +When a model exhausts even that, `code: 'output-exhausted'` reports it as what +it is: this model cannot finish this job, so pick another or review a narrower +scope. ## Caching and staleness **Cache entries** (`entries/.json`) are immutable and content-addressed. The key covers walkthrough version, prompt version, repo root, source, provider, -model, and every file's path/status/hunk-ids. The key is computed from the +model, output language, and every file's path/status/hunk-ids. The key is computed from the *current* diff, so a hit means the walkthrough was written about exactly this code; there is no freshness question to ask of an entry, because staleness is a miss. Returning the working tree to an earlier state therefore costs nothing. @@ -167,6 +233,11 @@ cannot: which walkthrough was last shown here, and has the code moved since. A pointer whose entry has been evicted reads as "no walkthrough" — truthful, and the next generation overwrites it. +A pointer is a *fallback*, not the primary lookup. A read that can name the +entry it wants — same diff, same model, same language — goes straight to it and +moves the pointer there; the pointer answers only when nothing matches the +request exactly. + Regeneration is manual and re-authors rather than merges: the previous walkthrough goes into the prompt as prose so the model can keep what is still true, with its anchors deliberately stripped so everything is re-anchored @@ -275,9 +346,11 @@ be used for this: it re-runs the whole git pipeline. ## Routes -- `GET /api/walkthrough?directory&source` — last walkthrough, the current hunk - index, staleness, and `readiness`. Never generates. -- `POST /api/walkthrough/generate` — `{ directory, source, force }`. Survives +- `GET /api/walkthrough?directory&source&model&language` — last walkthrough, the + current hunk index, staleness, and `readiness`. Never generates. `language` + matters here because readiness is measured against the prompt that would be + sent, and the language instruction is part of it. +- `POST /api/walkthrough/generate` — `{ directory, source, force, model, language }`. Survives client disconnects; a concurrent call for the same source joins the running job. - `GET /api/walkthrough/progress?directory&source` — the current stage, or diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js index 3ef99d35..600b8af4 100644 --- a/packages/web/server/lib/walkthrough/index.js +++ b/packages/web/server/lib/walkthrough/index.js @@ -2,6 +2,7 @@ import { getRepositoryRoot } from '../git/service.js'; import { describeSmallModel, generateSmallModelText } from '../small-model/index.js'; import { buildDigest } from './digest.js'; import { indexHunks } from './hunks.js'; +import { normalizeLanguage } from './languages.js'; import { buildPrompt, JSON_SHAPE_INSTRUCTION } from './prompt.js'; import { normalizeWalkthrough, parseModelJson, responseSchema } from './schema.js'; import { @@ -41,11 +42,41 @@ const generationTimeoutMs = (hunkCount) => Math.min( GENERATION_TIMEOUT_MAX_MS, GENERATION_TIMEOUT_BASE_MS + Math.max(0, hunkCount) * GENERATION_TIMEOUT_PER_HUNK_MS, ); -// A full walkthrough is a few thousand tokens of JSON, but reasoning models -// spend the same budget thinking first and return nothing if it runs out. The -// reserve subtracted from the input budget matches this exactly, so a bigger -// answer allowance costs input room rather than overrunning the context. -const MAX_OUTPUT_TOKENS = 24_000; +// A full walkthrough is a few thousand tokens of JSON. The budget exists for +// what comes before it: reasoning models spend the same allowance thinking and +// return nothing when it runs out, which is a bill for no answer. +// +// So the ask is derived from the model rather than fixed. A flat 24k was the +// same number for a 64k-context model and for one that admits to 384k output +// tokens, and on the latter it was the only reason generation failed. +// +// The reserve subtracted from the input budget is the same number, always: ask +// for more than was reserved and a large diff overruns the context mid-answer, +// which surfaces as a truncation bug rather than a budgeting one. +const MIN_OUTPUT_TOKENS = 24_000; +// A ceiling, because the reserve is taken out of the input allowance: a model +// that would let us ask for 384k tokens of answer would also let us spend a +// third of a million tokens of context reserving them, and no walkthrough needs +// that much thinking. +const MAX_OUTPUT_TOKENS = 96_000; +// Above this share of the context, the reserve starts costing more diff than +// the extra room is worth. +const OUTPUT_CONTEXT_SHARE = 0.25; + +/** + * Answer allowance for a specific model: as much as it admits it can emit, + * bounded by a share of its context and never below what this feature always + * asked for. + */ +const walkthroughOutputTokens = ({ contextTokens, outputTokenLimit }) => { + const wanted = Math.min( + MAX_OUTPUT_TOKENS, + Math.max(MIN_OUTPUT_TOKENS, Math.floor((Number(contextTokens) || 0) * OUTPUT_CONTEXT_SHARE)), + ); + // A model whose own limit is below the floor gets its limit: asking for more + // than a provider allows is rejected outright by some and ignored by others. + return Number(outputTokenLimit) > 0 ? Math.min(wanted, Number(outputTokenLimit)) : wanted; +}; const fail = (message, statusCode, extra = {}) => Object.assign(new Error(message), { statusCode, ...extra }); @@ -138,11 +169,11 @@ const modelLabel = (model) => `${model.providerID}/${model.modelID}`; */ const resolveModel = (directory, explicitModel) => describeSmallModel({ directory, - outputReserveTokens: MAX_OUTPUT_TOKENS, + outputReserveTokens: walkthroughOutputTokens, overrideModel: explicitModel || readWalkthroughModelOverride(), }); -export const __testing = { generationTimeoutMs }; +export const __testing = { generationTimeoutMs, walkthroughOutputTokens }; /** * Current diff for a source, parsed into files and hunks. @@ -204,10 +235,11 @@ const serializeHunks = (files) => files.flatMap((file) => file.hunks.map((hunk) * Read the last walkthrough for a source, resolved against the current diff. * Never generates and never spends tokens. */ -export async function getWalkthrough({ directory, source: rawSource, model: explicitModel }, deps = {}) { +export async function getWalkthrough({ directory, source: rawSource, model: explicitModel, language: rawLanguage }, deps = {}) { const source = parseSource(rawSource); const repoRoot = await getRepositoryRoot(directory); const key = sourceKey(source); + const language = normalizeLanguage(rawLanguage); const pointer = readPointer(repoRoot, key); // One diff, one model lookup, both answers. These used to be separate @@ -219,7 +251,7 @@ export async function getWalkthrough({ directory, source: rawSource, model: expl ]); const { files } = built; const hunkIndex = indexHunks(files); - const readiness = computeReadiness({ ...built, model, source }); + const readiness = computeReadiness({ ...built, model, source, language }); const base = { source, @@ -229,7 +261,27 @@ export async function getWalkthrough({ directory, source: rawSource, model: expl generating: isGenerating(repoRoot, key), }; - const entry = pointer ? readCachedWalkthrough(pointer.cacheKey) : null; + // Ask the cache for *this* request before falling back to the pointer. + // + // The pointer only knows which walkthrough was generated here last, which + // after a model or language switch is the answer to a different question: + // the panel would keep showing the English review while the picker said + // Ukrainian, even though the Ukrainian one was sitting in the cache. The key + // is computed from the diff this read already parsed, so this costs a file + // read and no git work at all. + const requestedKey = model + ? buildCacheKey({ + repoRoot, + sourceKey: key, + providerID: model.providerID, + modelID: model.modelID, + language, + files, + }) + : null; + + const requested = requestedKey ? readCachedWalkthrough(requestedKey) : null; + const entry = requested ?? (pointer ? readCachedWalkthrough(pointer.cacheKey) : null); if (!entry) { // No pointer, or the pointer outlived its entry (eviction, manual cleanup). // "No walkthrough" is the truthful answer either way; the pointer is left @@ -237,10 +289,26 @@ export async function getWalkthrough({ directory, source: rawSource, model: expl return { ...base, walkthrough: null }; } + // Showing it makes it the last walkthrough shown here, and a regeneration + // re-authors from whatever the reader is actually looking at. Only written + // when it moved, so an unchanged read stays a pure read. + if (requested && pointer?.cacheKey !== requestedKey) { + writePointer(repoRoot, key, { + repoRoot, + sourceKey: key, + cacheKey: requestedKey, + generatedAt: requested.generatedAt, + }); + } + return { ...base, walkthrough: entry.walkthrough, model: entry.model, + // The language the text on screen is actually written in, which is not + // necessarily the one being asked for now. The picker needs the difference: + // it is what lets it default to what produced this rather than to a setting. + language: entry.language ?? null, generatedAt: entry.generatedAt, ...resolveAgainstCurrent(entry.walkthrough, hunkIndex), }; @@ -254,7 +322,7 @@ export async function getWalkthrough({ directory, source: rawSource, model: expl * answers need the same diff, and computing it twice doubled the git work on * every panel open. */ -function computeReadiness({ model, digest, files, fileCount, hunkCount, generatedFileCount, source }) { +function computeReadiness({ model, digest, files, fileCount, hunkCount, generatedFileCount, source, language }) { if (!model) return { ready: false, reason: 'no-model' }; if (hunkCount === 0) { @@ -265,7 +333,10 @@ function computeReadiness({ model, digest, files, fileCount, hunkCount, generate return { ready: false, reason, model, generatedFileCount }; } - const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source }); + // Built with the same language the generation would use: the instruction is + // part of the prompt, so a readiness answer computed without it would be + // measuring a request nobody is going to send. + const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source, language }); const requiredChars = prompt.length + system.length; if (model.structuredOutput === false) { @@ -292,10 +363,11 @@ function computeReadiness({ model, digest, files, fileCount, hunkCount, generate * which also means returning to a previous state of the working tree costs * nothing. */ -export async function generateWalkthrough({ directory, source: rawSource, force = false, model: explicitModel }, deps = {}) { +export async function generateWalkthrough({ directory, source: rawSource, force = false, model: explicitModel, language: rawLanguage }, deps = {}) { const source = parseSource(rawSource); const repoRoot = await getRepositoryRoot(directory); const key = sourceKey(source); + const language = normalizeLanguage(rawLanguage); // Attach to a running job rather than starting a second one. A user who // refreshed and pressed the button again wants the answer, not two bills. @@ -303,7 +375,7 @@ export async function generateWalkthrough({ directory, source: rawSource, force if (existing) return existing.promise; const controller = new AbortController(); - const promise = runGeneration({ directory, source, repoRoot, key, force, explicitModel, signal: controller.signal }, deps) + const promise = runGeneration({ directory, source, repoRoot, key, force, explicitModel, language, signal: controller.signal }, deps) .finally(() => { if (jobs.get(jobKey(repoRoot, key))?.controller === controller) { jobs.delete(jobKey(repoRoot, key)); @@ -314,7 +386,7 @@ export async function generateWalkthrough({ directory, source: rawSource, force return promise; } -async function runGeneration({ directory, source, repoRoot, key, force, explicitModel, signal }, deps) { +async function runGeneration({ directory, source, repoRoot, key, force, explicitModel, language, signal }, deps) { const model = await resolveModel(directory, explicitModel); if (!model) { @@ -335,6 +407,7 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit sourceKey: key, providerID: model.providerID, modelID: model.modelID, + language, files, }); @@ -353,6 +426,7 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit source, walkthrough: cached.walkthrough, model: cached.model, + language: cached.language ?? null, generatedAt: cached.generatedAt, fromCache: true, hunks: serializeHunks(files), @@ -374,7 +448,7 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit } } - const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source, previousWalkthrough }); + const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source, previousWalkthrough, language }); if (model.structuredOutput === false) { throw fail( @@ -392,7 +466,8 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit responseSchema: options.responseSchema, onOverflow: 'error', timeoutMs: generationTimeoutMs(hunkCount), - maxOutputTokens: MAX_OUTPUT_TOKENS, + // The number the input budget was already reduced by, not a fresh guess. + maxOutputTokens: model.outputTokens ?? MIN_OUTPUT_TOKENS, signal, }); @@ -486,6 +561,7 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit repoRoot, sourceKey: key, model: { providerID: model.providerID, modelID: model.modelID, source: model.source }, + language, walkthrough, }; @@ -498,6 +574,7 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit source, walkthrough, model: entry.model, + language, generatedAt, fromCache: false, hunks: serializeHunks(files), diff --git a/packages/web/server/lib/walkthrough/jobs.test.js b/packages/web/server/lib/walkthrough/jobs.test.js index bbef1f5e..b5485c7e 100644 --- a/packages/web/server/lib/walkthrough/jobs.test.js +++ b/packages/web/server/lib/walkthrough/jobs.test.js @@ -137,6 +137,25 @@ describe('generation jobs', () => { .toEqual({ cancelled: false }); }); + // The reserve and the request must be the same number: asking for more than + // was subtracted from the input allowance overruns the context mid-answer. + it('requests exactly the budget the model resolution reserved', async () => { + describeSmallModel.mockResolvedValue({ + providerID: 'opencode-go', + modelID: 'deepseek-v4-flash', + source: 'config', + inputCharBudget: 1_000_000, + structuredOutput: true, + outputTokens: 96_000, + outputTokenLimit: 384_000, + }); + generateSmallModelText.mockResolvedValue({ text: RESPONSE }); + + await generateWalkthrough({ directory: '/repo', source: SOURCE }); + + expect(generateSmallModelText.mock.calls.at(-1)[0].maxOutputTokens).toBe(96_000); + }); + it('serves the cache once the job has finished, without calling the model again', async () => { generateSmallModelText.mockResolvedValue({ text: RESPONSE }); @@ -171,6 +190,34 @@ describe('generation timeout', () => { }); }); +// The failure this replaced: a flat 24k ask, spent entirely on reasoning by a +// model that advertises 384k output tokens and a million of context. The ceiling +// exists because the same number is reserved out of the input allowance. +describe('output budget', () => { + const { walkthroughOutputTokens } = walkthroughTesting; + + it('asks a roomy model for far more than the old fixed budget', () => { + expect(walkthroughOutputTokens({ contextTokens: 1_000_000, outputTokenLimit: 384_000 })).toBe(96_000); + }); + + it('never asks for more than the model says it can emit', () => { + expect(walkthroughOutputTokens({ contextTokens: 202_752, outputTokenLimit: 32_768 })).toBe(32_768); + }); + + it('keeps the reserve to a share of the context', () => { + expect(walkthroughOutputTokens({ contextTokens: 200_000, outputTokenLimit: 64_000 })).toBe(50_000); + }); + + it('holds the old floor for a small or uncatalogued model', () => { + expect(walkthroughOutputTokens({ contextTokens: 64_000, outputTokenLimit: null })).toBe(24_000); + expect(walkthroughOutputTokens({ contextTokens: 0, outputTokenLimit: null })).toBe(24_000); + }); + + it('yields to a model whose own limit is below the floor', () => { + expect(walkthroughOutputTokens({ contextTokens: 128_000, outputTokenLimit: 8_192 })).toBe(8_192); + }); +}); + describe('generation stages', () => { beforeEach(() => { // Without this the previous suite's cache entry is a hit for the same diff --git a/packages/web/server/lib/walkthrough/language.test.js b/packages/web/server/lib/walkthrough/language.test.js new file mode 100644 index 00000000..3b8897b3 --- /dev/null +++ b/packages/web/server/lib/walkthrough/language.test.js @@ -0,0 +1,226 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'walkthrough-language-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +vi.mock('../git/service.js', () => ({ + getRepositoryRoot: vi.fn(async () => '/repo'), + getDiff: vi.fn(), + getRangeDiff: vi.fn(), + getUntrackedDiffs: vi.fn(async () => []), + listUntrackedPaths: vi.fn(async () => []), +})); +vi.mock('../small-model/index.js', () => ({ + describeSmallModel: vi.fn(), + generateSmallModelText: vi.fn(), +})); + +const { normalizeLanguage, languageName } = await import('./languages.js'); +const { buildPrompt } = await import('./prompt.js'); +const { buildCacheKey } = await import('./store.js'); +const { generateWalkthrough, getWalkthrough } = await import('./index.js'); +const { describeSmallModel, generateSmallModelText } = await import('../small-model/index.js'); +const { getDiff } = await import('../git/service.js'); + +const SOURCE = { kind: 'working-tree', scope: 'all' }; + +const PATCH = `diff --git a/src/a.ts b/src/a.ts +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,1 +1,2 @@ ++const added = true; +`; + +const RESPONSE = JSON.stringify({ + title: 'Change', + focus: 'why', + chapters: [{ + title: 'Data', + icon: 'doc', + blurb: '', + stops: [{ title: 'Adds a flag', hunks: ['h1'], importance: 'normal', prose: 'It adds a flag.' }], + }], +}); + +const PROMPT_INPUT = { + digest: { files: [] }, + fileCount: 1, + hunkCount: 1, + source: SOURCE, +}; + +const FILES = [{ path: 'src/a.ts', status: 'modified', hunks: [{ id: 'unstaged:src/a.ts:abcd1234' }] }]; +const keyFor = (language) => buildCacheKey({ + repoRoot: '/repo', + sourceKey: 'working-tree:all', + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + language, + files: FILES, +}); + +describe('normalizeLanguage', () => { + it('accepts the tags the interface uses', () => { + expect(normalizeLanguage('uk')).toBe('uk'); + expect(normalizeLanguage('zh-TW')).toBe('zh-TW'); + expect(normalizeLanguage('pt-BR')).toBe('pt-BR'); + }); + + it('tolerates case and separator drift from a platform locale', () => { + expect(normalizeLanguage('uk-UA')).toBe('uk'); + expect(normalizeLanguage('pt_br')).toBe('pt-BR'); + expect(normalizeLanguage('ja-JP')).toBe('ja'); + }); + + // A language preference is about prose. Refusing to write a walkthrough over + // an unrecognised tag would be a worse answer than writing it in English. + it('falls back to English rather than failing', () => { + expect(normalizeLanguage('kl')).toBe('en'); + expect(normalizeLanguage('')).toBe('en'); + expect(normalizeLanguage(undefined)).toBe('en'); + expect(normalizeLanguage({ toString: () => 'uk' })).toBe('en'); + }); + + it('names languages in English, matching the language of the prompt', () => { + expect(languageName('uk')).toBe('Ukrainian'); + expect(languageName('nope')).toBe('English'); + }); +}); + +describe('prompt language instruction', () => { + it('says nothing when the prompt language is already the output language', () => { + const { system } = buildPrompt({ ...PROMPT_INPUT, language: 'en' }); + expect(system).not.toMatch(/Write all prose/); + }); + + it('asks for prose in the chosen language', () => { + const { system } = buildPrompt({ ...PROMPT_INPUT, language: 'uk' }); + expect(system).toMatch(/Write all prose in Ukrainian/); + }); + + // Aliases are keys the server resolves back to hunk ids and icon/importance + // are validated against fixed English values, so a translated one is dropped + // by the normalizer — silently losing an anchor or a style. + it('holds back the parts that are not prose', () => { + const { system } = buildPrompt({ ...PROMPT_INPUT, language: 'ja' }); + expect(system).toMatch(/Keep these in English exactly as given/); + expect(system).toMatch(/hunk aliases/); + expect(system).toMatch(/"importance"/); + }); + + it('defaults to English when no language is passed', () => { + expect(buildPrompt(PROMPT_INPUT).system).toBe(buildPrompt({ ...PROMPT_INPUT, language: 'en' }).system); + }); +}); + +describe('cache key', () => { + // Without the language in the key, asking for a translation is answered with + // the untranslated entry that was already there. + it('separates walkthroughs written in different languages', () => { + expect(keyFor('uk')).not.toBe(keyFor('en')); + }); + + it('is stable for the same language', () => { + expect(keyFor('uk')).toBe(keyFor('uk')); + }); +}); + +describe('generating in a language', () => { + beforeEach(() => { + fs.rmSync(path.join(TEMP_DATA_DIR, 'walkthroughs'), { recursive: true, force: true }); + describeSmallModel.mockResolvedValue({ + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + source: 'config', + inputCharBudget: 1_000_000, + structuredOutput: true, + }); + getDiff.mockImplementation(async (_dir, options) => (options?.staged ? '' : PATCH)); + generateSmallModelText.mockReset(); + generateSmallModelText.mockResolvedValue({ text: RESPONSE }); + }); + + afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); + }); + + it('sends the instruction and records the language with the result', async () => { + const result = await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + + expect(generateSmallModelText.mock.calls[0][0].system).toMatch(/Write all prose in Ukrainian/); + expect(result.language).toBe('uk'); + }); + + it('does not serve one language from the other language cache entry', async () => { + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + generateSmallModelText.mockClear(); + + const english = await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'en' }); + + expect(english.fromCache).toBeFalsy(); + expect(generateSmallModelText).toHaveBeenCalledTimes(1); + expect(english.language).toBe('en'); + }); + + // Switching away and back must not cost a second generation: the earlier + // walkthrough is still addressed by its own key. + it('returns the earlier language from cache when it is asked for again', async () => { + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'en' }); + generateSmallModelText.mockClear(); + + const back = await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + + expect(back.fromCache).toBe(true); + expect(back.language).toBe('uk'); + expect(generateSmallModelText).not.toHaveBeenCalled(); + }); + + // The pointer only knows what was generated here last. After a language + // switch that is the answer to a different question, and reading it instead + // left the panel showing English while the picker said Ukrainian — with the + // Ukrainian walkthrough sitting unused in the cache. + it('reads back the walkthrough written in the language being asked for', async () => { + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'en' }); + generateSmallModelText.mockClear(); + + const ukrainian = await getWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + + expect(ukrainian.language).toBe('uk'); + expect(ukrainian.walkthrough).toBeTruthy(); + expect(generateSmallModelText).not.toHaveBeenCalled(); + }); + + it('switches back and forth without generating anything', async () => { + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'ja' }); + generateSmallModelText.mockClear(); + + expect((await getWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' })).language).toBe('uk'); + expect((await getWalkthrough({ directory: '/repo', source: SOURCE, language: 'ja' })).language).toBe('ja'); + expect((await getWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' })).language).toBe('uk'); + expect(generateSmallModelText).not.toHaveBeenCalled(); + }); + + // Falling back is still right: an English review beats an empty panel, and + // the response says which language it is in so the panel can be honest. + it('falls back to the last walkthrough when none exists in that language', async () => { + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'en' }); + + const korean = await getWalkthrough({ directory: '/repo', source: SOURCE, language: 'ko' }); + + expect(korean.walkthrough).toBeTruthy(); + expect(korean.language).toBe('en'); + }); + + it('treats an unknown language as English rather than failing the request', async () => { + const result = await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'kl' }); + + expect(result.language).toBe('en'); + expect(generateSmallModelText.mock.calls[0][0].system).not.toMatch(/Write all prose/); + }); +}); diff --git a/packages/web/server/lib/walkthrough/languages.js b/packages/web/server/lib/walkthrough/languages.js new file mode 100644 index 00000000..787dfd36 --- /dev/null +++ b/packages/web/server/lib/walkthrough/languages.js @@ -0,0 +1,59 @@ +// Language the walkthrough prose is written in. +// +// This is the server's own list rather than an import from the UI package: the +// server cannot reach `packages/ui`, and the two lists answer different +// questions anyway. The UI list is "which locales do we have a dictionary +// for"; this one is "which languages may we ask a model to write in", and it +// needs the English endonym-free name that goes into the prompt. +// +// The tags match the UI's `Locale` union so the picker can pass its own value +// straight through. A tag we do not know resolves to English, which is exactly +// what the feature did before the setting existed. + +export const DEFAULT_LANGUAGE = 'en'; + +// Value is what the prompt says to write in. Naming the language in English +// keeps the instruction in the same language as the rest of the system prompt, +// which every model handles more reliably than a switch mid-sentence. +const LANGUAGE_NAMES = { + en: 'English', + fr: 'French', + 'zh-CN': 'Simplified Chinese', + 'zh-TW': 'Traditional Chinese', + uk: 'Ukrainian', + es: 'Spanish', + 'pt-BR': 'Brazilian Portuguese', + ko: 'Korean', + pl: 'Polish', + ja: 'Japanese', +}; + +/** + * Coerce a caller-supplied tag to one we support. + * + * Unknown, absent, and malformed all collapse to English rather than failing + * the request: the language is a preference about prose, and refusing to + * generate a walkthrough over one is a worse answer than writing it in English. + */ +export function normalizeLanguage(value) { + if (typeof value !== 'string' || !value) return DEFAULT_LANGUAGE; + if (Object.hasOwn(LANGUAGE_NAMES, value)) return value; + + // Tolerate case and separator drift (`uk-UA`, `pt_br`) so a runtime that + // passes a platform locale does not silently fall back to English. + const normalized = value.toLowerCase().replace(/_/g, '-'); + const match = Object.keys(LANGUAGE_NAMES).find((tag) => { + const lower = tag.toLowerCase(); + return lower === normalized || normalized.startsWith(`${lower}-`); + }); + if (match) return match; + + const base = normalized.split('-')[0]; + const baseMatch = Object.keys(LANGUAGE_NAMES).find((tag) => tag.toLowerCase() === base); + return baseMatch ?? DEFAULT_LANGUAGE; +} + +/** English name of a normalized tag, for the prompt. */ +export function languageName(language) { + return LANGUAGE_NAMES[language] ?? LANGUAGE_NAMES[DEFAULT_LANGUAGE]; +} diff --git a/packages/web/server/lib/walkthrough/prompt.js b/packages/web/server/lib/walkthrough/prompt.js index 5387fe6f..3f789810 100644 --- a/packages/web/server/lib/walkthrough/prompt.js +++ b/packages/web/server/lib/walkthrough/prompt.js @@ -1,3 +1,4 @@ +import { DEFAULT_LANGUAGE, languageName } from './languages.js'; import { MAX_CHAPTERS, MAX_CHAPTER_TITLE_CHARS, MAX_HUNKS_PER_STOP, MAX_STOPS } from './schema.js'; const SYSTEM = `You are writing a guided review of a code change for the engineer who is about to read it. @@ -24,6 +25,21 @@ Rules: Respond with a single JSON object and nothing else. (Some providers refuse a structured-output request unless the word "json" appears in the request, which is why this is stated explicitly.)`; +// A reader who cannot follow English prose gets nothing out of a walkthrough, +// so the output language is the reader's, not the codebase's. +// +// Only prose is translated. Hunk aliases are keys the server resolves back to +// hunk ids, and `icon`/`importance` are enums the normalizer validates against +// fixed English values — translating either produces a walkthrough that drops +// its anchors or loses its styling, silently and completely. Identifiers taken +// from the diff stay verbatim for the same reason a translated function name +// would be unsearchable. +const languageInstruction = (language) => ` + +Write all prose in ${languageName(language)}: the walkthrough title, the focus line, chapter titles and blurbs, and stop titles and prose. The reader of this review reads ${languageName(language)}. + +Keep these in English exactly as given, regardless of the prose language: hunk aliases (h1, h2, …), the "icon" values, and the "importance" values. Keep identifiers, file paths, and API names as they appear in the code — never translate them.`; + const sizing = ({ fileCount, hunkCount }) => { const targetStops = Math.max(1, Math.min(MAX_STOPS, Math.round(hunkCount / 2.5) || 1)); const targetChapters = hunkCount <= 4 @@ -65,7 +81,7 @@ export const JSON_SHAPE_INSTRUCTION = ` Return ONLY a JSON object, with no prose around it and no markdown fences, in exactly this shape: {"title": string, "focus": string, "chapters": [{"title": string, "icon": "bug"|"wrench"|"path"|"flask"|"doc"|"gear", "blurb": string, "stops": [{"title": string, "hunks": [string], "importance": "critical"|"normal"|"context", "prose": string}]}]}`; -export function buildPrompt({ digest, fileCount, hunkCount, source, previousWalkthrough }) { +export function buildPrompt({ digest, fileCount, hunkCount, source, previousWalkthrough, language = DEFAULT_LANGUAGE }) { const sourceLine = source.kind === 'working-tree' ? `Uncommitted local changes (${source.scope === 'all' ? 'staged and unstaged' : source.scope}).` : source.kind === 'branch' @@ -79,5 +95,9 @@ ${previousWalkthroughSection(previousWalkthrough)} Change digest: ${JSON.stringify(digest)}`; - return { system: SYSTEM, prompt }; + // The default language adds nothing: the system prompt is already English, so + // saying so would only spend context restating it. + const system = language === DEFAULT_LANGUAGE ? SYSTEM : `${SYSTEM}${languageInstruction(language)}`; + + return { system, prompt }; } diff --git a/packages/web/server/lib/walkthrough/routes.js b/packages/web/server/lib/walkthrough/routes.js index c6932f1e..e7ea12b1 100644 --- a/packages/web/server/lib/walkthrough/routes.js +++ b/packages/web/server/lib/walkthrough/routes.js @@ -41,6 +41,7 @@ export function registerWalkthroughRoutes(app, { getWalkthroughService }) { directory, source: readSource(req.query.source), model: typeof req.query.model === 'string' ? req.query.model : undefined, + language: typeof req.query.language === 'string' ? req.query.language : undefined, }, { getPullRequestDiff }, ); @@ -57,13 +58,19 @@ export function registerWalkthroughRoutes(app, { getWalkthroughService }) { app.post('/api/walkthrough/generate', async (req, res) => { try { const { generateWalkthrough, getPullRequestDiff } = await getWalkthroughService(); - const { directory, source, force, model } = req.body || {}; + const { directory, source, force, model, language } = req.body || {}; if (!directory || typeof directory !== 'string') { return res.status(400).json({ error: 'directory is required' }); } const result = await generateWalkthrough( - { directory, source, force: force === true, model: typeof model === 'string' ? model : undefined }, + { + directory, + source, + force: force === true, + model: typeof model === 'string' ? model : undefined, + language: typeof language === 'string' ? language : undefined, + }, { getPullRequestDiff }, ); if (clientIsGone(res)) return; diff --git a/packages/web/server/lib/walkthrough/routes.test.js b/packages/web/server/lib/walkthrough/routes.test.js index 42187f2a..88ffaa55 100644 --- a/packages/web/server/lib/walkthrough/routes.test.js +++ b/packages/web/server/lib/walkthrough/routes.test.js @@ -14,11 +14,15 @@ describe('walkthrough routes', () => { let releaseJob; let job; + let lastArgs; + const service = { - async getWalkthrough() { + async getWalkthrough(args) { + lastArgs = args; return { walkthrough: null, hunks: [], hunkCount: 0, generating: Boolean(job) }; }, - async generateWalkthrough() { + async generateWalkthrough(args) { + lastArgs = args; if (job) return job; job = new Promise((resolve) => { releaseJob = () => resolve({ walkthrough: { title: 'DONE' }, hunks: [], hunkCount: 1 }); @@ -40,6 +44,7 @@ describe('walkthrough routes', () => { beforeEach(async () => { job = null; releaseJob = undefined; + lastArgs = undefined; const app = express(); app.use(express.json()); registerWalkthroughRoutes(app, { getWalkthroughService: async () => service }); @@ -94,6 +99,35 @@ describe('walkthrough routes', () => { expect(job).toBeNull(); }); + // The language belongs to the request, not to a setting, so both the read + // and the generation have to carry it: readiness is computed from a prompt + // that contains the language instruction. + it('carries the requested language into the service', async () => { + await fetch( + `${base}/api/walkthrough?directory=/repo&language=uk&source=${encodeURIComponent(JSON.stringify(SOURCE))}`, + ); + expect(lastArgs.language).toBe('uk'); + + const pending = fetch(`${base}/api/walkthrough/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/repo', source: SOURCE, language: 'ja' }), + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + releaseJob(); + await pending; + + expect(lastArgs.language).toBe('ja'); + }); + + it('ignores a language that is not a string', async () => { + await fetch( + `${base}/api/walkthrough?directory=/repo&language[]=uk&source=${encodeURIComponent(JSON.stringify(SOURCE))}`, + ); + + expect(lastArgs.language).toBeUndefined(); + }); + it('cancels through its own endpoint rather than a dropped connection', async () => { generate().catch(() => {}); await new Promise((resolve) => setTimeout(resolve, 20)); diff --git a/packages/web/server/lib/walkthrough/schema.js b/packages/web/server/lib/walkthrough/schema.js index b7e9805c..d61f503d 100644 --- a/packages/web/server/lib/walkthrough/schema.js +++ b/packages/web/server/lib/walkthrough/schema.js @@ -8,7 +8,7 @@ export const WALKTHROUGH_VERSION = 1; // Bumping this invalidates every cached walkthrough, which is the point: a // changed prompt produces different output and old entries would misrepresent // what the current code would say. -export const PROMPT_VERSION = 2; +export const PROMPT_VERSION = 3; export const MAX_CHAPTERS = 6; export const MAX_STOPS = 16; diff --git a/packages/web/server/lib/walkthrough/store.js b/packages/web/server/lib/walkthrough/store.js index 90f3304d..61571d9c 100644 --- a/packages/web/server/lib/walkthrough/store.js +++ b/packages/web/server/lib/walkthrough/store.js @@ -78,7 +78,7 @@ const readJson = (filePath) => { * Content-addressed key. Every input that can change the output is in here: * change any of them and you get a miss rather than a stale hit. */ -export function buildCacheKey({ repoRoot, sourceKey, providerID, modelID, files }) { +export function buildCacheKey({ repoRoot, sourceKey, providerID, modelID, language, files }) { const canonical = JSON.stringify({ walkthroughVersion: WALKTHROUGH_VERSION, promptVersion: PROMPT_VERSION, @@ -86,6 +86,10 @@ export function buildCacheKey({ repoRoot, sourceKey, providerID, modelID, files sourceKey, providerID, modelID, + // Without this, switching language hits the entry written in the previous + // one and the panel answers a request to translate with the untranslated + // text it already had. + language, files: [...files] .map((file) => ({ path: file.path, status: file.status, hunkIds: file.hunks.map((hunk) => hunk.id) })) .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),