diff --git a/packages/docs/content/docs/es/walkthrough.mdx b/packages/docs/content/docs/es/walkthrough.mdx new file mode 100644 index 00000000..8eb2c207 --- /dev/null +++ b/packages/docs/content/docs/es/walkthrough.mdx @@ -0,0 +1,63 @@ +--- +title: Recorrido por los cambios +description: Lee un diff en el orden que tiene sentido, no en orden alfabético. +--- + +# Recorrido por los cambios + +Un diff está ordenado por ruta de archivo, que casi nunca es el orden en el que el cambio cobra sentido. El recorrido lo reordena: las ediciones relacionadas se agrupan en **paradas**, cada parada explica qué hace ahora el código de forma distinta, y las paradas se ordenan para que cada una se apoye en la anterior. + +Explica y ordena. No juzga tu código ni emite veredictos — para eso está [Review](/git/). + +Ábrelo con el icono **Recorrido** en la barra derecha, o con el botón **Recorrido con IA** en los paneles de cambios y de pull request. Ambos solo abren el panel; no se genera nada hasta que pulsas **Generar recorrido**. + +## Qué puede recorrer + +| Ámbito | Qué incluye | +| --- | --- | +| Todo sin confirmar | Todo lo que aún no está en un commit: preparado, sin preparar y archivos nuevos | +| Preparados | Solo lo que iría a un commit ahora mismo | +| Sin preparar | Árbol de trabajo y archivos nuevos | +| Esta rama | Todos los commits de la rama que no están en su base | +| Pull request | El cambio tal como existe en GitHub | + +**Esta rama** no significa "commits sin subir": es todo lo que la rama añade a su base, se haya subido o no. Por eso, tras hacer commit pero antes de subirlo, esta y el pull request difieren a propósito: una muestra lo que hiciste, el otro lo que ven ahora quienes revisan. + +Cada ámbito se guarda por separado, así que cambiar entre ellos nunca pierde nada. + +## Elegir el modelo + +Los recorridos usan tu modelo pequeño por defecto. Elige otro en **Ajustes → Sesiones → Modelo del recorrido de cambios**, o solo para una revisión desde la cabecera del panel — útil cuando un cambio es lo bastante delicado como para merecer un modelo más potente. + +El selector solo ofrece modelos capaces de devolver salida estructurada, porque sin ella el recorrido no se puede montar. Si un modelo se queda corto para el diff, la generación se rechaza con una explicación en vez de recortar la entrada en silencio: un recorrido escrito sobre medio diff suena seguro y se equivoca. + +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. + +## 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í. + +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. + +## Ser honesto sobre lo desactualizado + +Cada parada está anclada al contenido exacto del código que describe, así que el panel puede avisarte cuando ese código ha cambiado: + +- **Pasos desactualizados** — el código que describía una parada cambió o desapareció. El recorrido se sigue mostrando, marcado, para que decidas si regenerar. +- **Sin cubrir** — cambios del diff actual que ninguna parada describe. Ahí entran las ediciones hechas después de generar, los cambios que el recorrido consideró rutinarios y los archivos de bloqueo u otra salida generada, que se dejan fuera del modelo a propósito. Todo aparece al final para que nada desaparezca en silencio. + +Regenerar no parchea, reescribe: el recorrido anterior va al modelo como contexto, así que lo que sigue siendo cierto se conserva, y todo se reancla al código actual. + +## Notas + +- Puedes comentar cualquier línea igual que en la vista de diff; los comentarios se adjuntan al campo del chat. +- Disponible en escritorio y anchos de tableta. No se ofrece en la extensión de VS Code ni en la app móvil. +- Recorrer un pull request requiere una cuenta de GitHub conectada — consulta [Issues y PR de GitHub](/github/). + +## Relacionado + +- [Git y GitHub](/git/) — el panel de cambios del que lee, y la acción Review que sí juzga el código +- [Issues y PR de GitHub](/github/) — conecta GitHub para recorrer pull requests +- [Proveedores, modelos y agentes](/providers/) — de dónde sale el modelo pequeño diff --git a/packages/docs/content/docs/fr/walkthrough.mdx b/packages/docs/content/docs/fr/walkthrough.mdx new file mode 100644 index 00000000..5f7dc41e --- /dev/null +++ b/packages/docs/content/docs/fr/walkthrough.mdx @@ -0,0 +1,63 @@ +--- +title: Parcours des modifications +description: Lisez un diff dans l’ordre qui a du sens, pas dans l’ordre alphabétique. +--- + +# Parcours des modifications + +Un diff est trié par chemin de fichier, ce qui n’est presque jamais l’ordre dans lequel la modification prend son sens. Le parcours le réorganise : les changements liés sont regroupés en **étapes**, chaque étape explique ce que le code fait désormais différemment, et les étapes s’enchaînent pour que chacune s’appuie sur la précédente. + +Il explique et ordonne. Il ne juge pas votre code et ne rend aucun verdict — c’est le rôle de [Review](/git/). + +Ouvrez-le par l’icône **Parcours** dans la barre de droite, ou par le bouton **Parcours IA** dans les panneaux des modifications et de la pull request. Les deux se contentent d’ouvrir le panneau ; rien n’est généré tant que vous n’appuyez pas sur **Générer le parcours**. + +## Ce qu’il peut parcourir + +| Portée | Ce qu’elle couvre | +| --- | --- | +| Tout non validé | Tout ce qui n’est pas encore dans un commit : indexé, non indexé et nouveaux fichiers | +| Indexées | Uniquement ce qui partirait dans un commit maintenant | +| Non indexées | Copie de travail et nouveaux fichiers | +| Cette branche | Tous les commits de la branche absents de sa base | +| Pull request | La modification telle qu’elle existe sur GitHub | + +**Cette branche** ne veut pas dire « commits non poussés » : c’est tout ce que la branche ajoute à sa base, poussé ou non. Après un commit mais avant un push, elle et la pull request diffèrent donc volontairement : l’une montre ce que vous avez fait, l’autre ce que voient les relecteurs. + +Chaque portée est stockée séparément : passer de l’une à l’autre ne perd jamais rien. + +## Choisir le modèle + +Les parcours utilisent votre petit modèle par défaut. Choisissez-en un autre dans **Paramètres → Sessions → Modèle du parcours des modifications**, ou pour une seule relecture depuis l’en-tête du panneau — utile quand une modification est assez risquée pour mériter un modèle plus solide. + +Le sélecteur ne propose que des modèles capables de sortie structurée, sans laquelle le parcours ne peut pas être assemblé. Si un modèle est trop petit pour le diff, la génération est refusée avec une explication plutôt que de tronquer l’entrée en silence : un parcours écrit sur la moitié d’un diff sonne assuré et se trompe. + +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. + +## 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à. + +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. + +## Rester honnête sur l’obsolescence + +Chaque étape est ancrée au contenu exact du code qu’elle décrit, ce qui permet au panneau de signaler quand ce code a bougé : + +- **Étapes obsolètes** — le code décrit par une étape a changé ou disparu. Le parcours reste affiché, marqué, pour que vous décidiez s’il faut régénérer. +- **Non traité** — des modifications du diff actuel qu’aucune étape ne décrit. On y trouve les changements faits après la génération, ceux que le parcours a jugés courants, ainsi que les fichiers de verrouillage et autres sorties générées, délibérément tenus hors du modèle. Tout est listé à la fin pour que rien ne disparaisse en silence. + +Régénérer ne rapièce pas, cela réécrit : le parcours précédent est fourni au modèle comme contexte, ce qui reste vrai est conservé, et tout est réancré sur le code actuel. + +## Notes + +- Vous pouvez commenter n’importe quelle ligne comme dans la vue diff ; les commentaires se rattachent au champ du chat. +- Disponible sur ordinateur et sur les largeurs de tablette. Non proposé dans l’extension VS Code ni dans l’application mobile. +- Parcourir une pull request exige un compte GitHub connecté — voir [Issues et PR GitHub](/github/). + +## Voir aussi + +- [Git et GitHub](/git/) — le panneau des modifications qu’il lit, et l’action Review qui, elle, juge le code +- [Issues et PR GitHub](/github/) — connectez GitHub pour parcourir les pull requests +- [Fournisseurs, modèles et agents](/providers/) — d’où vient le petit modèle diff --git a/packages/docs/content/docs/ja/walkthrough.mdx b/packages/docs/content/docs/ja/walkthrough.mdx new file mode 100644 index 00000000..ea43496f --- /dev/null +++ b/packages/docs/content/docs/ja/walkthrough.mdx @@ -0,0 +1,63 @@ +--- +title: 変更のウォークスルー +description: 差分をアルファベット順ではなく、意味の通る順序で読みます。 +--- + +# 変更のウォークスルー + +差分はファイルパス順に並びますが、それは変更の意味が通る順序であることはほとんどありません。ウォークスルーはこれを並べ替えます。関連する編集を**ステップ**にまとめ、各ステップはコードが今までと何が違う動きをするのかを説明し、前のステップの上に次が積み上がる順序で並びます。 + +説明し、順序を与えるものです。コードを評価したり判定を下したりはしません。それは [Review](/git/) の役割です。 + +右側のレールの**ウォークスルー**アイコン、または変更パネルとプルリクエストパネルの **AI ウォークスルー**ボタンから開きます。どちらもパネルを開くだけで、**ウォークスルーを生成**を押すまで何も生成されません。 + +## 対象にできる範囲 + +| 範囲 | 含まれるもの | +| --- | --- | +| 未コミットすべて | まだコミットされていないもの全部: ステージ済み、未ステージ、新規ファイル | +| ステージ済み | 今コミットすれば入るものだけ | +| 未ステージ | 作業ツリーと新規ファイル | +| このブランチ | ベースに無い、このブランチのすべてのコミット | +| プルリクエスト | GitHub 上に存在する形の変更 | + +**このブランチ**は「未プッシュのコミット」ではなく、プッシュの有無に関わらずブランチがベースに追加したすべてです。そのためコミット後・プッシュ前には、これとプルリクエストは意図的に食い違います。前者はあなたが何をしたかを、後者はレビュアーが今何を見ているかを示します。 + +範囲ごとに別々に保存されるため、切り替えても失われるものはありません。 + +## モデルの選択 + +ウォークスルーは既定でスモールモデルを使います。**設定 → セッション → 変更ウォークスルーのモデル**で別のモデルを選べます。一度だけならパネルのヘッダーからも選べます。リスクの高い変更を、より強いモデルに任せたいときに便利です。 + +選択肢に出るのは構造化出力を返せるモデルだけです。それが無ければウォークスルーは組み立てられません。差分に対してモデルが小さすぎる場合は、入力を黙って切り詰めるのではなく、理由を示して生成を拒否します。差分の半分だけを見て書かれたウォークスルーは、自信ありげに間違えるからです。 + +パネルを開き直すと、目の前の内容を生成したモデルが表示されます。したがって**再生成**は、変更しない限り同じモデルで繰り返します。 + +## コストとキャッシュ + +勝手に生成されることはありません。生成はあなたが求めたときだけ始まり、再生成も手動です。 + +結果は差分の正確な内容に対してキャッシュされます。作業ツリーを以前の状態に戻せば、そのときのウォークスルーがモデル呼び出し無しで戻ります。モデルを切り替えて戻しても、それぞれのウォークスルーは残っています。 + +生成はブラウザのタブではなく OpenChamber サーバー上で動きます。ページを再読み込みしてもパネルを閉じても処理は続き、戻れば結果が待っています。止められるのは**キャンセル**だけです。 + +## 古くなったことを正直に示す + +各ステップは説明対象のコードの正確な内容に紐づいているため、そのコードが動いたことをパネルが伝えられます。 + +- **古くなったステップ** — ステップが説明していたコードが変わった、あるいは無くなった。ウォークスルーは印を付けたまま表示され、再生成するかはあなたが決めます。 +- **未対応** — 現在の差分のうち、どのステップも説明していない変更。生成後に加えた編集、ウォークスルーが定型的と判断した変更、そして意図的にモデルへ渡していないロックファイルなどの生成物が含まれます。すべて末尾に一覧されるので、黙って消えるものはありません。 + +再生成は継ぎ当てではなく書き直しです。前回のウォークスルーが文脈としてモデルに渡るため、まだ正しい部分は残り、すべてが現在のコードに紐づけ直されます。 + +## 補足 + +- 差分ビューと同じように任意の行にコメントできます。コメントはチャットの入力欄に添付されます。 +- デスクトップとタブレット幅で利用できます。VS Code 拡張とモバイルアプリでは提供されません。 +- プルリクエストのウォークスルーには GitHub アカウントの接続が必要です。[GitHub の Issue と PR](/github/) を参照してください。 + +## 関連 + +- [Git と GitHub](/git/) — 読み取り元となる変更パネルと、実際にコードを評価する Review アクション +- [GitHub の Issue と PR](/github/) — プルリクエストを扱うために GitHub を接続する +- [プロバイダー・モデル・エージェント](/providers/) — スモールモデルの出どころ diff --git a/packages/docs/content/docs/ko/walkthrough.mdx b/packages/docs/content/docs/ko/walkthrough.mdx new file mode 100644 index 00000000..9d305838 --- /dev/null +++ b/packages/docs/content/docs/ko/walkthrough.mdx @@ -0,0 +1,63 @@ +--- +title: 변경 워크스루 +description: diff를 알파벳순이 아니라 이해되는 순서로 읽습니다. +--- + +# 변경 워크스루 + +diff는 파일 경로순으로 정렬되지만, 그 순서가 변경을 이해하기 좋은 순서인 경우는 거의 없습니다. 워크스루는 이를 다시 배열합니다. 관련된 수정들을 **단계**로 묶고, 각 단계는 코드가 이제 무엇을 다르게 하는지 설명하며, 앞 단계 위에 다음 단계가 쌓이도록 순서를 정합니다. + +설명하고 순서를 부여할 뿐, 코드를 심사하거나 판정을 내리지 않습니다. 그건 [Review](/git/)의 역할입니다. + +오른쪽 레일의 **워크스루** 아이콘이나, 변경 패널과 풀 리퀘스트 패널의 **AI 워크스루** 버튼으로 엽니다. 둘 다 패널을 열기만 하며, **워크스루 생성**을 누르기 전에는 아무것도 생성되지 않습니다. + +## 다룰 수 있는 범위 + +| 범위 | 포함되는 것 | +| --- | --- | +| 커밋되지 않은 전체 | 아직 커밋되지 않은 모든 것: 스테이지됨, 스테이지 안 됨, 새 파일 | +| 스테이지됨 | 지금 커밋하면 들어갈 것만 | +| 스테이지 안 됨 | 작업 트리와 새 파일 | +| 이 브랜치 | 베이스에 없는 이 브랜치의 모든 커밋 | +| 풀 리퀘스트 | GitHub에 존재하는 형태의 변경 | + +**이 브랜치**는 "푸시하지 않은 커밋"이 아니라, 푸시 여부와 무관하게 브랜치가 베이스에 더한 전부입니다. 그래서 커밋한 뒤 푸시하기 전에는 이것과 풀 리퀘스트가 의도적으로 달라집니다. 하나는 당신이 한 일을, 다른 하나는 리뷰어가 지금 보는 것을 보여줍니다. + +범위마다 따로 저장되므로 전환해도 잃는 것이 없습니다. + +## 모델 선택 + +워크스루는 기본적으로 스몰 모델을 사용합니다. **설정 → 세션 → 변경 워크스루 모델**에서 다른 모델을 고르거나, 한 번만 쓸 모델은 패널 헤더에서 고를 수 있습니다. 변경이 충분히 위험해서 더 강한 모델에 맡기고 싶을 때 유용합니다. + +선택 목록에는 구조화된 출력을 반환할 수 있는 모델만 나옵니다. 그것 없이는 워크스루를 구성할 수 없기 때문입니다. 모델이 diff에 비해 작으면 입력을 조용히 잘라내는 대신 이유를 설명하며 생성을 거부합니다. diff의 절반만 보고 쓴 워크스루는 자신 있게 틀리기 때문입니다. + +패널을 다시 열면 지금 보고 있는 결과를 만든 모델이 표시되므로, 바꾸지 않는 한 **다시 생성**은 같은 모델로 반복합니다. + +## 비용과 캐시 + +저절로 생성되는 것은 없습니다. 생성은 요청할 때만 시작되고, 재생성도 수동입니다. + +결과는 diff의 정확한 내용을 기준으로 캐시됩니다. 작업 트리를 이전 상태로 되돌리면 그때의 워크스루가 모델 호출 없이 그대로 돌아옵니다. 모델을 바꿨다가 되돌려도 각각의 워크스루가 남아 있습니다. + +생성은 브라우저 탭이 아니라 OpenChamber 서버에서 실행됩니다. 페이지를 새로 고치거나 패널을 닫아도 작업은 계속되고, 돌아오면 결과가 기다립니다. 멈추는 것은 **취소**뿐입니다. + +## 오래됨을 정직하게 알리기 + +각 단계는 설명 대상 코드의 정확한 내용에 묶여 있어서, 그 코드가 변했을 때 패널이 알려줄 수 있습니다. + +- **오래된 단계** — 단계가 설명하던 코드가 바뀌었거나 사라졌습니다. 워크스루는 표시된 채로 계속 보이며, 다시 생성할지는 당신이 정합니다. +- **미포함** — 현재 diff에서 어떤 단계도 설명하지 않는 변경입니다. 생성 이후에 한 수정, 워크스루가 일상적이라고 판단한 변경, 그리고 의도적으로 모델에 넘기지 않는 잠금 파일 등 생성물이 여기에 들어갑니다. 모두 끝에 나열되므로 조용히 사라지는 것은 없습니다. + +재생성은 기우는 것이 아니라 다시 쓰는 것입니다. 이전 워크스루가 맥락으로 모델에 전달되어 여전히 맞는 부분은 살아남고, 전체가 현재 코드에 다시 묶입니다. + +## 참고 + +- diff 보기와 똑같이 아무 줄에나 코멘트할 수 있고, 코멘트는 채팅 입력창에 첨부됩니다. +- 데스크톱과 태블릿 너비에서 사용할 수 있습니다. VS Code 확장과 모바일 앱에서는 제공되지 않습니다. +- 풀 리퀘스트 워크스루에는 연결된 GitHub 계정이 필요합니다 — [GitHub 이슈와 PR](/github/)을 참고하세요. + +## 관련 문서 + +- [Git과 GitHub](/git/) — 이 기능이 읽어오는 변경 패널, 그리고 실제로 코드를 심사하는 Review 액션 +- [GitHub 이슈와 PR](/github/) — 풀 리퀘스트를 다루려면 GitHub를 연결하세요 +- [프로바이더, 모델, 에이전트](/providers/) — 스몰 모델이 어디서 오는지 diff --git a/packages/docs/content/docs/pl/walkthrough.mdx b/packages/docs/content/docs/pl/walkthrough.mdx new file mode 100644 index 00000000..5377f828 --- /dev/null +++ b/packages/docs/content/docs/pl/walkthrough.mdx @@ -0,0 +1,63 @@ +--- +title: Przewodnik po zmianach +description: Czytaj różnice w kolejności, która ma sens, a nie alfabetycznie. +--- + +# Przewodnik po zmianach + +Różnice są posortowane po ścieżkach plików, a to prawie nigdy nie jest kolejność, w której zmiana staje się zrozumiała. Przewodnik układa je na nowo: powiązane edycje trafiają do wspólnych **kroków**, każdy krok tłumaczy, co kod robi teraz inaczej, a kolejność kroków jest taka, by każdy opierał się na poprzednim. + +Tłumaczy i porządkuje. Nie ocenia kodu i nie wydaje werdyktów — od tego jest [Review](/git/). + +Otwórz go ikoną **Przewodnik** na prawym pasku albo przyciskiem **Przewodnik AI** w panelach zmian i pull requestu. Oba tylko otwierają panel; nic nie powstaje, dopóki nie naciśniesz **Wygeneruj przewodnik**. + +## Co można przejrzeć + +| Zakres | Co obejmuje | +| --- | --- | +| Wszystko niezatwierdzone | Wszystko, czego nie ma jeszcze w commicie: poczekalnia, drzewo robocze i nowe pliki | +| W poczekalni | Tylko to, co trafiłoby teraz do commita | +| Poza poczekalnią | Drzewo robocze i nowe pliki | +| Ta gałąź | Wszystkie commity gałęzi, których nie ma w jej bazie | +| Pull request | Zmiana w postaci, w jakiej istnieje na GitHubie | + +**Ta gałąź** to nie „commity bez pusha", lecz wszystko, co gałąź dokłada do swojej bazy — niezależnie od pusha. Dlatego po commicie, a przed pushem, ona i pull request celowo się różnią: jedno pokazuje, co zrobiłeś, drugie to, co widzą teraz recenzenci. + +Każdy zakres jest zapisywany osobno, więc przełączanie między nimi niczego nie gubi. + +## Wybór modelu + +Przewodniki domyślnie używają małego modelu. Inny wybierzesz w **Ustawienia → Sesje → Model przewodnika po zmianach** albo — na jeden raz — w nagłówku panelu. Przydaje się, gdy zmiana jest na tyle ryzykowna, że zasługuje na mocniejszy model. + +Lista pokazuje tylko modele potrafiące zwracać ustrukturyzowaną odpowiedź, bo bez niej przewodnika nie da się złożyć. Jeśli model jest za mały na te różnice, generowanie zostaje odrzucone z wyjaśnieniem, zamiast po cichu obciąć wejście: przewodnik napisany na podstawie połowy różnic brzmi pewnie i się myli. + +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. + +## 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. + +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**. + +## Uczciwość wobec nieaktualności + +Każdy krok jest przypięty do dokładnej treści kodu, który opisuje, więc panel potrafi powiedzieć, kiedy ten kod się zmienił: + +- **Nieaktualne kroki** — kod opisywany przez krok zmienił się albo zniknął. Przewodnik nadal się wyświetla, z oznaczeniem, żebyś sam zdecydował o ponownym wygenerowaniu. +- **Nieuwzględnione** — zmiany w bieżących różnicach, których nie opisuje żaden krok. Trafiają tu edycje zrobione po wygenerowaniu, zmiany uznane przez przewodnik za rutynowe oraz pliki blokad i inne wyniki narzędzi, celowo trzymane poza modelem. Wszystko jest wypisane na końcu, żeby nic nie zniknęło po cichu. + +Ponowne wygenerowanie nie łata, lecz pisze od nowa: poprzedni przewodnik trafia do modelu jako kontekst, więc to, co nadal jest prawdą, zostaje, a całość zostaje przypięta do bieżącego kodu. + +## Uwagi + +- Możesz komentować dowolną linię tak samo jak w widoku różnic; komentarze dołączają się do pola czatu. +- Dostępne na komputerze i przy szerokościach tabletu. Nie ma tego w rozszerzeniu VS Code ani w aplikacji mobilnej. +- Przewodnik po pull requeście wymaga połączonego konta GitHub — zobacz [Issues i PR na GitHubie](/github/). + +## Powiązane + +- [Git i GitHub](/git/) — panel zmian, z którego to czyta, oraz akcja Review, która faktycznie ocenia kod +- [Issues i PR na GitHubie](/github/) — połącz GitHub, aby przeglądać pull requesty +- [Dostawcy, modele i agenci](/providers/) — skąd bierze się mały model diff --git a/packages/docs/content/docs/pt-br/walkthrough.mdx b/packages/docs/content/docs/pt-br/walkthrough.mdx new file mode 100644 index 00000000..59dbf88d --- /dev/null +++ b/packages/docs/content/docs/pt-br/walkthrough.mdx @@ -0,0 +1,63 @@ +--- +title: Percurso pelas mudanças +description: Leia um diff na ordem que faz sentido, não em ordem alfabética. +--- + +# Percurso pelas mudanças + +Um diff é ordenado por caminho de arquivo, que quase nunca é a ordem em que a mudança faz sentido. O percurso reorganiza isso: edições relacionadas viram **paradas**, cada parada explica o que o código passa a fazer de diferente, e as paradas seguem uma ordem em que cada uma se apoia na anterior. + +Ele explica e ordena. Não julga o seu código nem dá veredictos — isso é papel do [Review](/git/). + +Abra pelo ícone **Percurso** na barra direita ou pelo botão **Percurso com IA** nos painéis de mudanças e de pull request. Ambos apenas abrem o painel; nada é gerado até você apertar **Gerar percurso**. + +## O que dá para percorrer + +| Escopo | O que inclui | +| --- | --- | +| Tudo sem commit | Tudo que ainda não está em commit: no stage, fora do stage e arquivos novos | +| No stage | Só o que iria para um commit agora | +| Fora do stage | Árvore de trabalho e arquivos novos | +| Este branch | Todos os commits do branch que não estão na base | +| Pull request | A mudança como ela existe no GitHub | + +**Este branch** não quer dizer "commits sem push": é tudo o que o branch acrescenta à sua base, com push ou sem. Por isso, depois do commit e antes do push, ele e o pull request divergem de propósito: um mostra o que você fez, o outro o que os revisores veem agora. + +Cada escopo é guardado separadamente, então alternar entre eles nunca perde nada. + +## Escolhendo o modelo + +Os percursos usam o seu modelo pequeno por padrão. Escolha outro em **Configurações → Sessões → Modelo do percurso de mudanças**, ou apenas para uma revisão no cabeçalho do painel — útil quando a mudança é arriscada o bastante para merecer um modelo mais forte. + +O seletor só oferece modelos capazes de devolver saída estruturada, porque sem ela o percurso não se monta. Se o modelo for pequeno demais para o diff, a geração é recusada com explicação em vez de cortar a entrada em silêncio: um percurso escrito sobre metade de um diff soa seguro e está errado. + +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. + +## 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á. + +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. + +## Honestidade sobre o que ficou velho + +Cada parada está ancorada ao conteúdo exato do código que descreve, então o painel consegue avisar quando esse código mudou: + +- **Etapas desatualizadas** — o código que a parada descrevia mudou ou sumiu. O percurso continua aparecendo, marcado, para você decidir se regenera. +- **Sem cobertura** — mudanças do diff atual que nenhuma parada descreve. Entram aí as edições feitas depois de gerar, as mudanças que o percurso considerou rotineiras e os arquivos de lock e outras saídas geradas, mantidas fora do modelo de propósito. Tudo aparece no fim, para que nada suma em silêncio. + +Regenerar não remenda, reescreve: o percurso anterior vai ao modelo como contexto, o que ainda é verdade permanece, e tudo é reancorado no código atual. + +## Notas + +- Você pode comentar qualquer linha como na visão de diff; os comentários se anexam ao campo do chat. +- Disponível em desktop e larguras de tablet. Não é oferecido na extensão do VS Code nem no app móvel. +- Percorrer um pull request exige uma conta do GitHub conectada — veja [Issues e PRs do GitHub](/github/). + +## Relacionado + +- [Git e GitHub](/git/) — o painel de mudanças de onde isso lê, e a ação Review, que de fato julga o código +- [Issues e PRs do GitHub](/github/) — conecte o GitHub para percorrer pull requests +- [Provedores, modelos e agentes](/providers/) — de onde vem o modelo pequeno diff --git a/packages/docs/content/docs/uk/walkthrough.mdx b/packages/docs/content/docs/uk/walkthrough.mdx new file mode 100644 index 00000000..ef87ab6d --- /dev/null +++ b/packages/docs/content/docs/uk/walkthrough.mdx @@ -0,0 +1,63 @@ +--- +title: Розбір змін +description: Читайте diff у порядку, який має сенс, а не в алфавітному. +--- + +# Розбір змін + +Diff упорядкований за шляхами файлів, а це майже ніколи не той порядок, у якому зміна стає зрозумілою. Розбір перебудовує його: пов'язані правки збираються в **кроки**, кожен крок пояснює, що саме код тепер робить інакше, а самі кроки йдуть так, щоб кожен спирався на попередній. + +Він пояснює й упорядковує. Він не оцінює ваш код і не виносить вердиктів — для цього є [Review](/git/). + +Відкрийте його іконкою **Розбір** у правому рейлі або кнопкою **AI-розбір** у панелях змін і pull request. Обидві лише відкривають панель; нічого не генерується, доки ви не натиснете **Створити розбір**. + +## Що можна розібрати + +| Область | Що охоплює | +| --- | --- | +| Усе незакомічене | Усе, що ще не в комітах: індекс, робоче дерево й нові файли | +| В індексі | Лише те, що зараз пішло б у коміт | +| Поза індексом | Робоче дерево й нові файли | +| Ця гілка | Усі коміти гілки, яких немає в базовій | +| Pull request | Зміна в тому вигляді, у якому вона є на GitHub | + +**Ця гілка** — це не «незапушені коміти», а все, що гілка додає до базової, незалежно від пушу. Тому після коміту, але до пушу, вона й pull request навмисно розходяться: одне показує, що ви зробили, друге — що зараз бачать рецензенти. + +Кожна область зберігається окремо, тож перемикання між ними нічого не втрачає. + +## Вибір моделі + +За замовчуванням розбір використовує вашу small model. Іншу можна обрати в **Налаштування → Сесії → Модель для розбору змін** або для одного разу в шапці панелі — корисно, коли зміна достатньо ризикована, щоб віддати її сильнішій моделі. + +У списку показані лише моделі, які вміють structured output, бо без нього розбір неможливо зібрати. Якщо модель замала для цього diff, генерація відхиляється з поясненням, а не обрізає вхід мовчки: розбір, написаний за половиною diff, звучить упевнено й при цьому помиляється. + +Відкривши панель знову, ви побачите модель, яка створила те, що перед вами, тож **Створити заново** повторить тією самою, доки ви її не зміните. + +## Витрати й кеш + +Ніщо не генерується саме. Генерація починається лише на ваш запит, і повторна теж робиться вручну. + +Результати кешуються за точним вмістом diff. Поверніть робоче дерево до попереднього стану — і попередній розбір повернеться безкоштовно, без звернення до моделі. Перемкніть модель і назад — розбір кожної з них лишиться на місці. + +Генерація виконується на сервері OpenChamber, а не у вкладці браузера. Перезавантажте сторінку чи закрийте панель — робота триває, а результат чекатиме на вас. Зупиняє її лише кнопка **Скасувати**. + +## Чесність щодо застарілого + +Кожен крок прив'язаний до точного вмісту коду, який він описує, тож панель може сказати, коли той код змінився: + +- **Застарілі кроки** — код, який описував крок, змінився або зник. Розбір усе одно показується, з позначкою, щоб ви самі вирішили, чи перегенеровувати. +- **Не описано** — зміни в поточному diff, яких не описує жоден крок. Сюди потрапляють правки, зроблені після генерації, зміни, які розбір визнав рутинними, а також lock-файли й інші згенеровані файли, які свідомо не потрапляють до моделі. Усе це перелічено в кінці, щоб нічого не зникло непомітно. + +Повторна генерація не латає, а переписує: попередній розбір іде в модель як контекст, тому точні частини зберігаються, а прив'язки заново перераховуються під поточний код. + +## Примітки + +- Коментувати можна будь-який рядок, так само як у вигляді diff; коментарі чіпляються до поля вводу в чаті. +- Доступно на десктопі та планшетних ширинах. У розширенні для VS Code і в мобільному застосунку не пропонується. +- Для розбору pull request потрібен під'єднаний акаунт GitHub — див. [Issues та PR на GitHub](/github/). + +## Пов'язане + +- [Git і GitHub](/git/) — панель змін, з якої це читається, і дія Review, яка таки оцінює код +- [Issues та PR на GitHub](/github/) — під'єднайте GitHub, щоб розбирати pull request +- [Провайдери, моделі та агенти](/providers/) — звідки береться small model diff --git a/packages/docs/content/docs/walkthrough.mdx b/packages/docs/content/docs/walkthrough.mdx new file mode 100644 index 00000000..f4b54a31 --- /dev/null +++ b/packages/docs/content/docs/walkthrough.mdx @@ -0,0 +1,63 @@ +--- +title: Changes Walkthrough +description: Read a diff in the order it makes sense, not in alphabetical order. +--- + +# Changes Walkthrough + +A diff is sorted by file path, which is almost never the order in which the change makes sense. The walkthrough reorders it: related edits are grouped into **stops**, each stop explains what the code now does differently, and the stops are ordered so each one builds on the last. + +It explains and orders. It does not judge your code or hand out verdicts — that is what [Review](/git/) is for. + +Open it from the **Walkthrough** icon in the right rail, or from the **AI walkthrough** button in the Changes and Pull Request panels. Both just open the panel; nothing is generated until you press **Generate walkthrough**. + +## What it can review + +| Scope | What it covers | +| --- | --- | +| All uncommitted | Everything not yet committed: staged, unstaged, and new files | +| Staged | Only what would go into a commit right now | +| Unstaged | Working tree and new files | +| This branch | Every commit on this branch that is not on its base | +| Pull request | The change as it exists on GitHub | + +**This branch** is not "unpushed commits" — it is everything the branch adds to its base, pushed or not. So after committing but before pushing, it and the pull request deliberately differ: one shows what you did, the other what reviewers currently see. + +Each scope is stored separately, so switching between them never loses anything. + +## Choosing the model + +Walkthroughs use your small model by default. Pick a different one in **Settings → Sessions → Changes Walkthrough Model**, or for a single review in the panel header — useful when a change is risky enough to deserve a stronger model. + +The picker only offers models that can return structured output, because the walkthrough cannot be assembled without it. If a model is too small for the diff, generation is refused with an explanation rather than silently truncating the input: a walkthrough written against half a diff reads as confident and is wrong. + +Reopening a panel shows the model that produced what you are looking at, so **Regenerate** repeats with the same one unless you change it. + +## 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. + +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. + +## Staying honest about staleness + +Every stop is anchored to the exact content of the code it describes, so the panel can tell you when that code has moved on: + +- **Outdated steps** — the code a stop described has changed or is gone. The walkthrough still shows, marked, so you can decide whether to regenerate. +- **Not covered** — changes in the current diff that no stop describes. That includes edits made after generating, changes the walkthrough judged routine, and lockfiles and other generated files, which are deliberately kept out of the model's input. They are all listed at the end of the stream so nothing disappears silently. + +Regenerating re-authors rather than patches: the previous walkthrough goes to the model as context so accurate parts survive, and everything is re-anchored to the current code. + +## Notes + +- Comment on any line in the walkthrough exactly as in the diff view; comments attach to the chat composer. +- Available on desktop and tablet widths. Not offered in the VS Code extension or the mobile app. +- A pull request review needs a connected GitHub account — see [GitHub Issues & PRs](/github/). + +## Related + +- [Git & GitHub](/git/) — the Changes panel this reads from, and the Review action that does judge code +- [GitHub Issues & PRs](/github/) — connect GitHub to review pull requests +- [Providers, Models & Agents](/providers/) — where the small model comes from diff --git a/packages/docs/content/docs/zh-cn/walkthrough.mdx b/packages/docs/content/docs/zh-cn/walkthrough.mdx new file mode 100644 index 00000000..99171d37 --- /dev/null +++ b/packages/docs/content/docs/zh-cn/walkthrough.mdx @@ -0,0 +1,63 @@ +--- +title: 改动导读 +description: 按讲得通的顺序读差异,而不是按字母顺序。 +--- + +# 改动导读 + +差异按文件路径排序,而这几乎从来不是让改动讲得通的顺序。导读会重新编排:相关的修改被归入一个个**步骤**,每个步骤说明代码现在有什么不同的行为,步骤的先后顺序保证后一步建立在前一步之上。 + +它负责解释和排序,不评判你的代码,也不给结论——那是 [Review](/git/) 的职责。 + +从右侧栏的**导读**图标打开,或在改动面板和拉取请求面板中点击 **AI 导读**按钮。两者都只是打开面板;在你按下**生成导读**之前不会生成任何内容。 + +## 可以导读的范围 + +| 范围 | 包含内容 | +| --- | --- | +| 全部未提交 | 尚未进入提交的一切:已暂存、未暂存和新文件 | +| 已暂存 | 只包含此刻提交会带上的内容 | +| 未暂存 | 工作区和新文件 | +| 当前分支 | 该分支上基线分支所没有的全部提交 | +| 拉取请求 | GitHub 上现有形态的改动 | + +**当前分支**不是“未推送的提交”,而是该分支相对基线新增的全部内容,无论是否推送。因此在提交之后、推送之前,它和拉取请求会有意不同:一个显示你做了什么,另一个显示评审者当前看到什么。 + +各个范围分别保存,来回切换不会丢失任何内容。 + +## 选择模型 + +导读默认使用你的小模型。可在**设置 → 会话 → 改动导读模型**中更换,或只为这一次在面板标题栏中选择——当改动的风险足以交给更强的模型时很有用。 + +选择列表只提供能返回结构化输出的模型,因为没有它就无法组装导读。如果模型对这份差异来说太小,生成会带着说明被拒绝,而不是悄悄截断输入:只看了半份差异写出的导读听起来笃定,实际却是错的。 + +重新打开面板时会显示生成当前内容的那个模型,所以只要你不更换,**重新生成**就会沿用它。 + +## 开销与缓存 + +不会自行生成。生成只在你请求时开始,重新生成同样需要手动触发。 + +结果按差异的确切内容缓存。把工作区恢复到先前状态,当时的导读就会免费回来,不会调用模型。切换模型再切回来,各自的导读都还在。 + +生成运行在 OpenChamber 服务器上,而不是浏览器标签页里。刷新页面或关闭面板,工作仍在继续;回来时结果已在等你。只有**取消**能停止它。 + +## 对过时保持诚实 + +每个步骤都锚定在它所描述代码的确切内容上,因此面板能告诉你那段代码何时发生了变化: + +- **过时的步骤**——步骤所描述的代码已改变或不存在。导读仍会显示并加以标记,由你决定是否重新生成。 +- **未涵盖**——当前差异中没有任何步骤描述的改动。其中包括生成之后所做的修改、导读判定为常规的改动,以及锁文件等特意不送入模型的生成产物。它们都列在末尾,不会有内容悄无声息地消失。 + +重新生成不是打补丁,而是重写:上一版导读会作为上下文交给模型,仍然成立的部分得以保留,整体则重新锚定到当前代码。 + +## 说明 + +- 可以像在差异视图中一样对任意行发表评论,评论会附加到聊天输入框。 +- 在桌面和平板宽度下可用。VS Code 扩展和移动应用中不提供。 +- 导读拉取请求需要已连接的 GitHub 账户,参见 [GitHub Issue 与 PR](/github/)。 + +## 相关 + +- [Git 与 GitHub](/git/)——它读取的改动面板,以及确实会评判代码的 Review 操作 +- [GitHub Issue 与 PR](/github/)——连接 GitHub 以导读拉取请求 +- [提供方、模型与代理](/providers/)——小模型从何而来 diff --git a/packages/docs/sidebar.config.json b/packages/docs/sidebar.config.json index a364bf71..22d8183d 100644 --- a/packages/docs/sidebar.config.json +++ b/packages/docs/sidebar.config.json @@ -240,6 +240,20 @@ "ja": "Git と GitHub" } }, + { + "label": "Changes Walkthrough", + "link": "/walkthrough/", + "translations": { + "uk": "Розбір змін", + "zh-CN": "改动导读", + "es": "Recorrido por los cambios", + "pt-BR": "Percurso pelas mudanças", + "ko": "변경 워크스루", + "pl": "Przewodnik po zmianach", + "fr": "Parcours des modifications", + "ja": "変更のウォークスルー" + } + }, { "label": "GitHub Issues & PRs", "link": "/github/", diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 8ecfbf11..a5d2dd76 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -192,6 +192,7 @@ export const iconSpriteData = { "robot": ``, "robot-2": ``, "rocket": ``, + "route": ``, "save-3": ``, "scales-3": ``, "scan-2": ``, diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 3cc8e813..574f8591 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -8,6 +8,7 @@ import { FilesView } from '@/components/views/FilesView'; import { GitView } from '@/components/views/GitView'; import { PullRequestView } from '@/components/views/PullRequestView'; import { TerminalView } from '@/components/views/TerminalView'; +import { WalkthroughView } from '@/components/views/walkthrough/WalkthroughView'; import { PlanView } from '@/components/views/PlanView'; import { ProjectContextPanel } from './RightSidebarTabs'; import { SidebarFilesTree } from './SidebarFilesTree'; @@ -158,6 +159,7 @@ const getModeLabel = ( if (mode === 'chat') return t('contextPanel.mode.chat'); if (mode === 'file') return t('contextPanel.mode.files'); if (mode === 'diff') return t('contextPanel.mode.diff'); + if (mode === 'walkthrough') return t('contextPanel.mode.walkthrough'); if (mode === 'plan') return t('contextPanel.mode.plan'); if (mode === 'preview') return t('contextPanel.mode.preview'); if (mode === 'browser') return t('contextPanel.mode.browser'); @@ -247,6 +249,10 @@ const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }): return ; } + if (tab.mode === 'walkthrough') { + return ; + } + if (tab.mode === 'git') { return ; } @@ -2717,6 +2723,12 @@ export const ContextPanel: React.FC = () => { () => tabs.some((tab) => tab.mode === 'terminal'), [tabs], ); + // Keep-alive: the walkthrough holds reading progress and scroll position that + // a remount would silently throw away. + const hasWalkthroughTab = React.useMemo( + () => tabs.some((tab) => tab.mode === 'walkthrough'), + [tabs], + ); const BrowserPane = isElectronBrowserRuntime() ? DesktopBrowserPane : IframeBrowserPane; const hasFileTabs = React.useMemo( () => tabs.some((tab) => tab.mode === 'file'), @@ -2979,7 +2991,12 @@ export const ContextPanel: React.FC = () => { ) : null} - {activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' ? activeNonChatContent : null} + {hasWalkthroughTab ? ( +
+ +
+ ) : null} + {activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null} diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index 12fcd12c..38ab1b64 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -19,6 +19,8 @@ import { CSS } from '@dnd-kit/utilities'; import { Icon } from '@/components/icon/Icon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useDeviceInfo } from '@/lib/device'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { sortContextSurfaces, @@ -30,6 +32,9 @@ import { useGitStatus } from '@/stores/useGitStore'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; const RAIL_TOOLTIP_DELAY_MS = 150; +// Tablet width and up: below this the walkthrough cannot show a stop and its +// code side by side, which is the whole point of the surface. +const WALKTHROUGH_MIN_WIDTH = 768; const EMPTY_TABS: never[] = []; type RailItemProps = { @@ -105,6 +110,7 @@ export const ContextPanelRail: React.FC = () => { const setContextRailOrder = useUIStore((state) => state.setContextRailOrder); const openContextSurface = useUIStore((state) => state.openContextSurface); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); + const { screenWidth } = useDeviceInfo(); const gitStatus = useGitStatus(directoryKey || null); const sensors = useSensors( @@ -124,12 +130,17 @@ export const ContextPanelRail: React.FC = () => { if (surface.id === 'plan' && !planModeEnabled) { return false; } + // The walkthrough needs room for a stop list beside real code, and its + // diffs come from OpenChamber's Git routes, which VS Code does not serve. + if (surface.id === 'walkthrough' && (isVSCodeRuntime() || screenWidth < WALKTHROUGH_MIN_WIDTH)) { + return false; + } if (surface.availability === 'has-content') { return tabs.some((tab) => tab.mode === surface.mode); } return true; }); - }, [contextRailOrder, planModeEnabled, tabs]); + }, [contextRailOrder, planModeEnabled, screenWidth, tabs]); const handleDragEnd = React.useCallback((event: DragEndEvent) => { const { active, over } = event; diff --git a/packages/ui/src/components/model-picker/ModelPickerList.tsx b/packages/ui/src/components/model-picker/ModelPickerList.tsx index 7d9314b2..f06dc190 100644 --- a/packages/ui/src/components/model-picker/ModelPickerList.tsx +++ b/packages/ui/src/components/model-picker/ModelPickerList.tsx @@ -339,6 +339,11 @@ interface ModelPickerListProps { selectedModel?: { providerID: string; modelID: string } | null; hiddenModels?: HiddenModel[]; allowedProviderIds?: string[]; + /** + * Per-model gate, for callers whose feature needs a capability rather than a + * provider (e.g. structured output). Applied on top of `allowedProviderIds`. + */ + isModelAllowed?: (providerID: string, modelID: string) => boolean; includeNotSelected?: boolean; onSelectNone?: () => void; selectionCount?: (entry: ModelPickerEntry) => number; @@ -379,6 +384,7 @@ export const ModelPickerList: React.FC = ({ selectedModel, hiddenModels = [], allowedProviderIds, + isModelAllowed, includeNotSelected = false, onSelectNone, selectionCount, @@ -448,17 +454,19 @@ export const ModelPickerList: React.FC = ({ const filteredFavorites = React.useMemo(() => favoriteModels.filter(({ model, providerID, modelID }) => { if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false; + if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false; if (isHidden(providerID, modelID)) return false; const providerName = providerById.get(providerID)?.name || providerID; return matchesQuery(getModelDisplayName(model), providerName); - }), [allowedProviderSet, favoriteModels, isHidden, matchesQuery, providerById]); + }), [allowedProviderSet, favoriteModels, isHidden, isModelAllowed, matchesQuery, providerById]); const filteredRecents = React.useMemo(() => recentModels.filter(({ model, providerID, modelID }) => { if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false; + if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false; if (isHidden(providerID, modelID)) return false; const providerName = providerById.get(providerID)?.name || providerID; return matchesQuery(getModelDisplayName(model), providerName); - }), [allowedProviderSet, isHidden, matchesQuery, providerById, recentModels]); + }), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, providerById, recentModels]); const orderedProviders = React.useMemo(() => { if (!providerOrder || providerOrder.length === 0) return providers; @@ -477,11 +485,12 @@ export const ModelPickerList: React.FC = ({ const filteredModels = models.filter((model) => { const modelID = typeof model.id === 'string' ? model.id : ''; if (!modelID || isHidden(provider.id, modelID)) return false; + if (isModelAllowed && !isModelAllowed(provider.id, modelID)) return false; return matchesQuery(getModelDisplayName(model), provider.name || provider.id); }); return { ...provider, models: filteredModels }; }) - .filter((provider) => provider.models.length > 0), [allowedProviderSet, isHidden, matchesQuery, orderedProviders]); + .filter((provider) => provider.models.length > 0), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, orderedProviders]); const flatModelList = React.useMemo(() => { const items: ModelPickerEntry[] = []; diff --git a/packages/ui/src/components/sections/agents/ModelSelector.tsx b/packages/ui/src/components/sections/agents/ModelSelector.tsx index 7d8cea8e..0d3841fa 100644 --- a/packages/ui/src/components/sections/agents/ModelSelector.tsx +++ b/packages/ui/src/components/sections/agents/ModelSelector.tsx @@ -23,6 +23,7 @@ interface ModelSelectorProps { onChange: (providerId: string, modelId: string) => void; className?: string; allowedProviderIds?: string[]; + isModelAllowed?: (providerId: string, modelId: string) => boolean; placeholder?: string; tooltipsEnabled?: boolean; dropdownPortalToBody?: boolean; @@ -34,6 +35,7 @@ export const ModelSelector: React.FC = ({ onChange, className, allowedProviderIds, + isModelAllowed, placeholder, tooltipsEnabled = true, dropdownPortalToBody = false, @@ -115,6 +117,7 @@ export const ModelSelector: React.FC = ({ selectedModel={selectedModel} hiddenModels={hiddenModels} allowedProviderIds={allowedProviderIds} + isModelAllowed={isModelAllowed} includeNotSelected onSelectNone={handleSelectNone} onEscape={closePicker} diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index 5018f63b..a3001a89 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -45,6 +45,7 @@ export const DefaultsSettings: React.FC = () => { const showDeletionDialog = useUIStore((state) => state.showDeletionDialog); const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog); const providers = useConfigStore((state) => state.providers); + const modelsMetadata = useConfigStore((state) => state.modelsMetadata); const [defaultModel, setDefaultModel] = React.useState(); const [defaultVariant, setDefaultVariant] = React.useState(); @@ -52,6 +53,7 @@ export const DefaultsSettings: React.FC = () => { const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true); const [smallModelOverride, setSmallModelOverride] = React.useState(); const [smallModelProviders, setSmallModelProviders] = React.useState(); + const [walkthroughModelOverride, setWalkthroughModelOverride] = React.useState(); const [isLoading, setIsLoading] = React.useState(true); const parsedModel = React.useMemo(() => getDisplayModel(defaultModel), [defaultModel]); @@ -65,6 +67,7 @@ export const DefaultsSettings: React.FC = () => { defaultAgent?: string; smallModelUseDefault?: boolean; smallModelOverride?: string; + walkthroughModelOverride?: string; } | null = null; if (!data) { @@ -84,6 +87,8 @@ export const DefaultsSettings: React.FC = () => { defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined, smallModelUseDefault: typeof raw.smallModelUseDefault === 'boolean' ? raw.smallModelUseDefault : undefined, smallModelOverride: typeof raw.smallModelOverride === 'string' ? raw.smallModelOverride : undefined, + walkthroughModelOverride: + typeof raw.walkthroughModelOverride === 'string' ? raw.walkthroughModelOverride : undefined, }; } } catch { @@ -123,6 +128,9 @@ export const DefaultsSettings: React.FC = () => { if (typeof data.smallModelOverride === 'string' && data.smallModelOverride.trim()) { setSmallModelOverride(data.smallModelOverride.trim()); } + if (typeof data.walkthroughModelOverride === 'string' && data.walkthroughModelOverride.trim()) { + setWalkthroughModelOverride(data.walkthroughModelOverride.trim()); + } } } catch (error) { console.warn('Failed to load defaults settings:', error); @@ -236,10 +244,43 @@ export const DefaultsSettings: React.FC = () => { [] ); + const handleWalkthroughModelOverrideChange = React.useCallback( + async (providerId: string, modelId: string) => { + const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined; + setWalkthroughModelOverride(newValue); + try { + // Clearing the picker is how the user goes back to the small model, so + // an empty value is a real choice rather than a no-op. + await updateDesktopSettings({ walkthroughModelOverride: newValue ?? '' }); + } catch (error) { + console.warn('Failed to save walkthrough model override:', error); + } + }, + [] + ); + + // The walkthrough cannot work at all without schema-shaped output, so models + // the catalog says cannot do it are hidden rather than offered and then + // refused. A missing capability is not a "no": roughly half the catalog omits + // the field, and those models usually work. + const isStructuredOutputCapable = React.useCallback( + (providerId: string, modelId: string) => + modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false, + [modelsMetadata] + ); + const parsedSmallModel = React.useMemo(() => getDisplayModel(smallModelOverride), [smallModelOverride]); + const parsedWalkthroughModel = React.useMemo( + () => getDisplayModel(walkthroughModelOverride), + [walkthroughModelOverride] + ); React.useEffect(() => { - if (smallModelUseDefault || smallModelProviders !== undefined) return; + // Both pickers filter by the same authenticated-provider list, so either + // one being open is reason enough to fetch it. + // Both pickers filter by the same authenticated-provider list, and the + // walkthrough picker is always visible, so this is always worth fetching. + if (smallModelProviders !== undefined) return; let cancelled = false; (async () => { try { @@ -256,7 +297,7 @@ export const DefaultsSettings: React.FC = () => { return () => { cancelled = true; }; - }, [smallModelUseDefault, smallModelProviders]); + }, [smallModelProviders]); const availableVariants = React.useMemo(() => { if (!parsedModel.providerId || !parsedModel.modelId) return []; @@ -396,6 +437,32 @@ export const DefaultsSettings: React.FC = () => { /> ) : null} + + +
+ + {t('settings.openchamber.defaults.walkthroughModel.title')} + + + {t('settings.openchamber.defaults.walkthroughModel.description')} + +
+ + + + +
diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 31d70c20..46facb8c 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -39,6 +39,8 @@ import type { I18nKey } from '@/lib/i18n/store'; import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff'; import { isVSCodeRuntime } from '@/lib/desktop'; import { startReviewFlow } from '@/lib/reviewFlow'; +import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction'; +import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionMessages } from '@/sync/sync-context'; import { getFirstChangedModifiedLineFromPatch } from './diffPatchUtils'; @@ -961,6 +963,8 @@ export const DiffView: React.FC = ({ const { t } = useI18n(); const { git, files } = useRuntimeAPIs(); const effectiveDirectory = useEffectiveDirectory(); + const openContextSurface = useUIStore((state) => state.openContextSurface); + const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource); const { screenWidth, isMobile } = useDeviceInfo(); const isGitRepo = useIsGitRepo(effectiveDirectory ?? null); @@ -1003,6 +1007,9 @@ export const DiffView: React.FC = ({ const isMobileLayout = isMobile || screenWidth <= 768; const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime(); + // Same runtime and width rules as the rail surface: no point offering an + // entry point to a surface that cannot open here. + const showWalkthroughAction = activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime(); const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024; const diffScrollRef = React.useRef(null); const fileSectionRefs = React.useRef(new Map()); @@ -1724,6 +1731,32 @@ export const DiffView: React.FC = ({ )} + {changedFiles.length > 0 && showWalkthroughAction && ( + + )} {changedFiles.length > 0 && ( diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 25703471..16fbdcd6 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -21,6 +21,9 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { Icon } from "@/components/icon/Icon"; import { useUIStore } from '@/stores/useUIStore'; +import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; +import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { formatDateTimeForPreference } from '@/lib/timeFormat'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInlineCommentDraftStore, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; @@ -327,7 +330,12 @@ export const PullRequestSection: React.FC<{ const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); - const { isMobile, hasTouchInput } = useDeviceInfo(); + const { isMobile, hasTouchInput, screenWidth } = useDeviceInfo(); + const openContextSurface = useUIStore((state) => state.openContextSurface); + const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource); + // Mirrors the rail's gating: the surface is not available on mobile widths or + // in VS Code, so neither is its entry point. + const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime(); const openGitHubSettings = React.useCallback(() => { setSettingsPage('github'); @@ -1487,7 +1495,7 @@ export const PullRequestSection: React.FC<{ {pr ? ( -
+
{prStatusText} {checks ? ( @@ -1503,6 +1511,23 @@ export const PullRequestSection: React.FC<{ ) : null}
+ {showWalkthroughAction ? ( + + ) : null} {canMerge && pr.draft && pr.state === 'open' ? ( diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx new file mode 100644 index 00000000..2f0af021 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { ModelSelector } from '@/components/sections/agents/ModelSelector'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/lib/i18n'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { updateDesktopSettings } from '@/lib/persistence'; +import type { WalkthroughBlockedReason, WalkthroughModel } from '@/lib/walkthrough/types'; + +interface WalkthroughBlockerProps { + reason: WalkthroughBlockedReason; + model?: WalkthroughModel; + requiredChars?: number; + availableChars?: number; + onRetry: () => void; +} + +const modelLabel = (model?: WalkthroughModel) => + model ? `${model.providerID}/${model.modelID}` : ''; + +/** + * A refusal the user can act on. Both blocking reasons come down to "this small + * model cannot do this job", so the remedy — pick a different one — is offered + * in place rather than sending the user to Settings to guess. + */ +export const WalkthroughBlocker = ({ + reason, + model, + requiredChars, + availableChars, + onRetry, +}: WalkthroughBlockerProps) => { + const { t } = useI18n(); + const modelsMetadata = useConfigStore((state) => state.modelsMetadata); + const [providers, setProviders] = useState(undefined); + const [saving, setSaving] = useState(false); + + // Every one of these means "this small model cannot do this job", so the + // remedy is the same: choose a different one, here, without a detour through + // Settings. + const canChooseModel = reason === 'context-too-small' + || reason === 'structured-output-unsupported' + || reason === 'output-exhausted'; + + useEffect(() => { + if (!canChooseModel || providers !== undefined) return; + let cancelled = false; + (async () => { + try { + const response = await runtimeFetch('/api/small-model', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return; + const payload = (await response.json().catch(() => null)) as + | { authenticatedProviders?: unknown } + | null; + if (!cancelled && Array.isArray(payload?.authenticatedProviders)) { + setProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string')); + } + } catch { + // Leave undefined: the picker then offers every provider, which is a + // worse experience but not a broken one. + } + })(); + return () => { + cancelled = true; + }; + }, [canChooseModel, providers]); + + const handleModelChange = useCallback( + async (providerId: string, modelId: string) => { + if (!providerId || !modelId || saving) return; + const value = `${providerId}/${modelId}`; + setSaving(true); + try { + // Scoped to this feature: fixing the walkthrough must not quietly + // change the model used for commit messages and recaps. + await updateDesktopSettings({ walkthroughModelOverride: value }); + onRetry(); + } catch (error) { + console.warn('Failed to save small model override:', error); + } finally { + setSaving(false); + } + }, + [onRetry, saving] + ); + + // Offering a model the catalog already says cannot do this would just move + // the same refusal one click later. + const isStructuredOutputCapable = useCallback( + (providerId: string, modelId: string) => + modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false, + [modelsMetadata] + ); + + const label = modelLabel(model); + + const description = () => { + if (reason === 'no-model') return t('walkthrough.blocked.noModel.description'); + if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description'); + if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description'); + if (reason === 'output-exhausted') { + return label + ? t('walkthrough.blocked.outputExhausted.description', { model: label }) + : t('walkthrough.blocked.outputExhausted.descriptionUnknownModel'); + } + if (reason === 'structured-output-unsupported') { + // Naming the model that was actually tried is the whole point of this + // screen; the unnamed variant is a defensive fallback, not the norm. + return label + ? t('walkthrough.blocked.structuredOutput.description', { model: label }) + : t('walkthrough.blocked.structuredOutput.descriptionUnknownModel'); + } + const required = Math.ceil((requiredChars ?? 0) / 1000); + const available = Math.ceil((availableChars ?? 0) / 1000); + return label + ? t('walkthrough.blocked.contextTooSmall.description', { model: label, required, available }) + : t('walkthrough.blocked.contextTooSmall.descriptionUnknownModel', { required, available }); + }; + + const title = () => { + if (reason === 'no-model') return t('walkthrough.blocked.noModel.title'); + if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title'); + if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title'); + if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title'); + if (reason === 'structured-output-unsupported') return t('walkthrough.blocked.structuredOutput.title'); + return t('walkthrough.blocked.contextTooSmall.title'); + }; + + return ( +
+ +

{title()}

+

{description()}

+ + {canChooseModel && ( +
+ + {t('walkthrough.blocked.chooseModel')} + + { + void handleModelChange(providerId, modelId); + }} + allowedProviderIds={providers} + isModelAllowed={isStructuredOutputCapable} + /> +
+ )} + + {(reason === 'empty-diff' || reason === 'only-generated') && ( + + )} +
+ ); +}; diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughHunkRun.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughHunkRun.tsx new file mode 100644 index 00000000..f26d9605 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughHunkRun.tsx @@ -0,0 +1,50 @@ +import { memo, useMemo } from 'react'; +import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; +import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff'; +import { getLanguageFromExtension } from '@/lib/toolHelpers'; +import { mergeRunPatch } from '@/lib/walkthrough/model'; +import type { WalkthroughHunk } from '@/lib/walkthrough/types'; + +interface WalkthroughHunkRunProps { + path: string; + hunks: WalkthroughHunk[]; + renderSideBySide: boolean; + wrapLines: boolean; +} + +/** + * One file's contribution to a stop. Consecutive hunks are merged back into a + * single patch so the reader sees continuous code rather than a stack of + * one-hunk cards. + * + * Inline comments are on: a review you cannot annotate is a reader, not a tool. + * They work here because the merged patch keeps the original `@@` headers, so + * the line numbers a comment captures are the file's real ones and not offsets + * into an excerpt. + */ +export const WalkthroughHunkRun = memo(function WalkthroughHunkRun({ + path, + hunks, + renderSideBySide, + wrapLines, +}: WalkthroughHunkRunProps) { + const fileDiff = useMemo(() => { + const patch = mergeRunPatch(hunks); + return patch ? fileDiffFromPatch(path, patch) : undefined; + }, [hunks, path]); + + if (!fileDiff) return null; + + return ( + + ); +}); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughStages.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughStages.tsx new file mode 100644 index 00000000..c601d278 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughStages.tsx @@ -0,0 +1,61 @@ +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import type { I18nKey } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import type { WalkthroughStageProgress } from './useWalkthroughStageProgress'; + +interface WalkthroughStagesProps { + progress: WalkthroughStageProgress; +} + +/** + * The wait is long and uneven — collecting a pull request diff is seconds of + * network, the model call is minutes — and a lone spinner makes those look + * identical. Naming the phase says which one you are waiting on, and that the + * cost has been committed once it reads "waiting on the model". + * + * No durations: a stopwatch on a step nobody can hurry adds pressure, not + * information. And no mention of the schema fallback — from out here it is the + * same wait, and naming our plumbing only invites the question of what it is. + */ +const STAGES: Array<{ labelKey: I18nKey }> = [ + { labelKey: 'walkthrough.stage.collecting' }, + { labelKey: 'walkthrough.stage.asking' }, + { labelKey: 'walkthrough.stage.assembling' }, +]; + +export const WalkthroughStages = ({ progress }: WalkthroughStagesProps) => { + const { t } = useI18n(); + const { completedCount, activeIndex } = progress; + + return ( +
    + {STAGES.map((entry, index) => { + const isDone = index < completedCount; + const isActive = activeIndex === index; + + return ( +
  • + + {isDone ? ( + + ) : isActive ? ( + + ) : ( + + )} + + + {t(entry.labelKey)} + +
  • + ); + })} +
+ ); +}; diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughStream.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughStream.tsx new file mode 100644 index 00000000..8b4e9f1c --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughStream.tsx @@ -0,0 +1,269 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/lib/i18n'; +import { groupHunksByFile } from '@/lib/walkthrough/model'; +import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model'; +import type { WalkthroughHunk, WalkthroughStopImportance } from '@/lib/walkthrough/types'; +import { cn } from '@/lib/utils'; +import { WalkthroughHunkRun } from './WalkthroughHunkRun'; +import { stopElementId } from './stopElementId'; + +interface WalkthroughStreamProps { + view: WalkthroughView; + activeStopId: string | null; + scrollToStopId: string | null; + onActiveStopChange: (stopId: string) => void; + onScrollHandled: () => void; + renderSideBySide: boolean; + wrapLines: boolean; +} + +const IMPORTANCE_CLASS: Record = { + critical: 'bg-status-error/10 text-status-error', + normal: 'bg-surface-muted text-muted-foreground', + context: 'bg-surface-muted text-muted-foreground', +}; + +const StopHeader = ({ stopView }: { stopView: WalkthroughStopView }) => { + const { t } = useI18n(); + const { stop } = stopView; + + return ( +
+
+ + {stopView.position} + +

{stop.title}

+ {/* Same height as the step badge, so a row with an importance pill is + exactly as tall as one without: vertical padding on a smaller type + size was pushing past the tallest element in the row. */} + {stop.importance !== 'normal' && ( + + {stop.importance === 'critical' + ? t('walkthrough.importance.critical') + : t('walkthrough.importance.context')} + + )} +
+

{stop.prose}

+ {stopView.isStale && ( +

+ + {stopView.hunks.length === 0 + ? t('walkthrough.stop.staleAll') + : t('walkthrough.stop.stalePartial', { count: stopView.missingHunkIds.length })} +

+ )} +
+ ); +}; + +/** + * Sticky so the file you are reading stays named while you scroll through its + * hunks — the path is the main orientation cue in a long stream, and as a plain + * caption it was easy to scroll straight past. + */ +const FileHeader = ({ path }: { path: string }) => ( +
+ + {path} +
+); + +const HunkRuns = ({ + hunks, + renderSideBySide, + wrapLines, +}: { + hunks: WalkthroughHunk[]; + renderSideBySide: boolean; + wrapLines: boolean; +}) => { + const runs = useMemo(() => groupHunksByFile(hunks), [hunks]); + + return ( + <> + {runs.map((run, index) => ( +
+ + +
+ ))} + + ); +}; + +/** + * Everything the walkthrough covers, plus everything it does not, in one + * continuous scroll: a stop's explanation sits directly above the code it + * explains. + */ +export const WalkthroughStream = memo(function WalkthroughStream({ + view, + activeStopId, + scrollToStopId, + onActiveStopChange, + onScrollHandled, + renderSideBySide, + wrapLines, +}: WalkthroughStreamProps) { + const { t } = useI18n(); + const scrollRef = useRef(null); + const [uncoveredOpen, setUncoveredOpen] = useState(false); + + // Set while a click-driven jump is in flight. Without it the observer reports + // every stop the viewport passes over on the way to the target and the + // highlight ends up on whichever one happened to be reported last — the + // sidebar showing step 5 while the stream shows step 6. + const navigatingRef = useRef(null); + const navigationTimerRef = useRef(null); + + useEffect(() => () => { + if (navigationTimerRef.current !== null) window.clearTimeout(navigationTimerRef.current); + }, []); + + // Scrolling is driven by the DOM rather than a virtualizer: only the visible + // stops mount their diff viewers, and each stop is its own element, so there + // is nothing to translate between index space and pixel space. + useEffect(() => { + if (!scrollToStopId) return; + const element = document.getElementById(stopElementId(scrollToStopId)); + if (element) { + navigatingRef.current = scrollToStopId; + // Instant, not smooth: picking a step is a jump to a known destination, + // and a long animation only creates a window for the highlight to drift + // through everything in between. + element.scrollIntoView({ behavior: 'auto', block: 'start' }); + + // The observer fires asynchronously after the jump, and an element that + // was already in view may not fire at all — so the mute is released on a + // timer as well as on arrival. + if (navigationTimerRef.current !== null) window.clearTimeout(navigationTimerRef.current); + navigationTimerRef.current = window.setTimeout(() => { + navigatingRef.current = null; + navigationTimerRef.current = null; + }, 250); + } + onScrollHandled(); + }, [scrollToStopId, onScrollHandled]); + + const handleIntersection = useCallback( + (entries: IntersectionObserverEntry[]) => { + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; + if (!visible) return; + const stopId = visible.target.getAttribute('data-stop-id'); + if (!stopId) return; + + const navigatingTo = navigatingRef.current; + if (navigatingTo) { + // Arrived: hand control back to free scrolling. + if (stopId === navigatingTo) navigatingRef.current = null; + return; + } + + onActiveStopChange(stopId); + }, + [onActiveStopChange] + ); + + useEffect(() => { + const root = scrollRef.current; + if (!root) return; + + const observer = new IntersectionObserver(handleIntersection, { + root, + // Only count a stop as active once its header reaches the upper band of + // the viewport, so scrolling through a long diff does not flicker the + // active step back and forth. + rootMargin: '0px 0px -70% 0px', + threshold: 0, + }); + + for (const stopView of view.stops) { + const element = document.getElementById(stopElementId(stopView.stop.id)); + if (element) observer.observe(element); + } + + return () => observer.disconnect(); + }, [handleIntersection, view.stops]); + + const uncoveredRuns = useMemo(() => groupHunksByFile(view.uncoveredHunks), [view.uncoveredHunks]); + + return ( +
+ {view.stops.map((stopView) => ( +
+ + {stopView.hunks.length > 0 ? ( + + ) : ( +

+ {t('walkthrough.stop.noCode')} +

+ )} +
+ ))} + + {view.uncoveredHunks.length > 0 && ( +
+ + {!uncoveredOpen && ( +

+ {t('walkthrough.uncovered.description')} +

+ )} + {uncoveredOpen + && uncoveredRuns.map((run, index) => ( +
+ + +
+ ))} +
+ )} +
+ ); +}); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughToc.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughToc.tsx new file mode 100644 index 00000000..6d06b21d --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughToc.tsx @@ -0,0 +1,150 @@ +import { memo, useEffect, useRef } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import type { IconName } from '@/components/icon/icons'; +import { useI18n } from '@/lib/i18n'; +import { summarizeHunkFiles } from '@/lib/walkthrough/model'; +import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model'; +import type { WalkthroughChapterIcon } from '@/lib/walkthrough/types'; +import { cn } from '@/lib/utils'; + +interface WalkthroughTocProps { + view: WalkthroughView; + activeStopId: string | null; + visitedStopIds: ReadonlySet; + onSelectStop: (stopId: string) => void; + width: number; +} + +const CHAPTER_ICONS: Record = { + bug: 'bug', + wrench: 'tools', + path: 'compass-3', + flask: 'flask', + doc: 'file-text', + gear: 'settings-3', +}; + +const TocStop = ({ + stopView, + isActive, + isVisited, + onSelect, + activeRef, +}: { + stopView: WalkthroughStopView; + isActive: boolean; + isVisited: boolean; + onSelect: () => void; + activeRef: React.Ref; +}) => { + const { t } = useI18n(); + const files = summarizeHunkFiles(stopView.hunks); + + return ( +
  • + +
  • + ); +}; + +export const WalkthroughToc = memo(function WalkthroughToc({ + view, + activeStopId, + visitedStopIds, + onSelectStop, + width, +}: WalkthroughTocProps) { + const { t } = useI18n(); + const activeRef = useRef(null); + + // Scrolling the stream moves the active step, and past a certain point the + // highlighted row leaves the contents column entirely — the reader loses + // their place in the very thing meant to hold it. `nearest` keeps the move + // minimal, so clicking a row that is already visible does not jolt the list. + useEffect(() => { + activeRef.current?.scrollIntoView({ block: 'nearest' }); + }, [activeStopId]); + + return ( + + ); +}); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx new file mode 100644 index 00000000..3120bd90 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -0,0 +1,621 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useI18n } from '@/lib/i18n'; +import { buildWalkthroughView } from '@/lib/walkthrough/model'; +import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types'; +import { ModelSelector } from '@/components/sections/agents/ModelSelector'; +import { deriveBaseBranch } from '@/components/views/git/baseBranch'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useGitBranches, useGitStatus } from '@/stores/useGitStore'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { + getFreshestPrStatusForBranch, + getGitHubPrStatusKey, + useGitHubPrStatusStore, +} from '@/stores/useGitHubPrStatusStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useUIStore } from '@/stores/useUIStore'; +import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; +import { cn } from '@/lib/utils'; +import { WalkthroughBlocker } from './WalkthroughBlocker'; +import { WALKTHROUGH_ACTION_CLASS } from './walkthroughAction'; +import { WalkthroughStages } from './WalkthroughStages'; +import { useWalkthroughStageProgress } from './useWalkthroughStageProgress'; +import { WalkthroughStream } from './WalkthroughStream'; +import { WalkthroughToc } from './WalkthroughToc'; + +interface WalkthroughViewProps { + directory: string; +} + +const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working']; + +// DropdownMenuLabel defaults to the same size and weight as its items, which +// makes a heading read as another choice. This matches SelectLabel, the +// treatment used by the worktree picker. +const SCOPE_GROUP_LABEL_CLASS = 'typography-meta font-normal text-muted-foreground'; + +// Below this the table of contents would squeeze the diff into uselessness, so +// the stream takes the whole panel and the header arrows carry navigation. +const TOC_MIN_PANEL_WIDTH = 720; +const TOC_MIN_WIDTH = 180; +// The diff is the point of the surface; the contents column may never take more +// than half the panel no matter how far the user drags. +const TOC_MAX_FRACTION = 0.5; + +export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { + const { t } = useI18n(); + const rootRef = useRef(null); + const [panelWidth, setPanelWidth] = useState(0); + + // Panel width, not viewport width: this surface is resizable independently of + // the window. + useEffect(() => { + const element = rootRef.current; + if (!element || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver((entries) => { + setPanelWidth(entries[0]?.contentRect.width ?? 0); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const storedTocWidth = useUIStore((state) => state.walkthroughTocWidth); + const setStoredTocWidth = useUIStore((state) => state.setWalkthroughTocWidth); + const [draggingToc, setDraggingToc] = useState(false); + + const showToc = panelWidth === 0 || panelWidth >= TOC_MIN_PANEL_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. + const tocWidth = Math.min( + Math.max(storedTocWidth, TOC_MIN_WIDTH), + Math.max(TOC_MIN_WIDTH, (panelWidth || TOC_MIN_PANEL_WIDTH) * TOC_MAX_FRACTION) + ); + + const handleTocResizeStart = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + const startX = event.clientX; + const startWidth = tocWidth; + const maxWidth = Math.max(TOC_MIN_WIDTH, (rootRef.current?.clientWidth ?? 0) * TOC_MAX_FRACTION); + setDraggingToc(true); + + const onMove = (moveEvent: PointerEvent) => { + const next = Math.min(maxWidth, Math.max(TOC_MIN_WIDTH, startWidth + moveEvent.clientX - startX)); + setStoredTocWidth(next); + }; + const onUp = () => { + setDraggingToc(false); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + }, + [setStoredTocWidth, tocWidth] + ); + + const handleTocResizeKey = useCallback( + (event: React.KeyboardEvent) => { + const step = event.shiftKey ? 40 : 10; + const delta = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0; + if (delta === 0) return; + event.preventDefault(); + const maxWidth = Math.max(TOC_MIN_WIDTH, (rootRef.current?.clientWidth ?? 0) * TOC_MAX_FRACTION); + setStoredTocWidth(Math.min(maxWidth, Math.max(TOC_MIN_WIDTH, tocWidth + delta))); + }, + [setStoredTocWidth, tocWidth] + ); + const [scope, setScope] = useState('all'); + const [activeStopId, setActiveStopId] = useState(null); + const [scrollToStopId, setScrollToStopId] = useState(null); + const [visitedStopIds, setVisitedStopIds] = useState>(() => new Set()); + + const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference); + const wrapLines = useUIStore((state) => state.diffWrapLines); + // The walkthrough column is narrower than the diff surface and stops are read + // top-to-bottom, so `dynamic` resolves to inline here rather than guessing + // from the window width. + const renderSideBySide = diffLayoutPreference === 'side-by-side'; + + const requestedSource = useWalkthroughStore((state) => state.requestedSource[directory]); + const clearRequestedSource = useWalkthroughStore((state) => state.clearRequestedSource); + + const status = useGitStatus(directory || null); + const branches = useGitBranches(directory || null); + + // The branch source reviews everything on this branch that is not on its + // base. Three-dot semantics server-side mean merges from the base are + // already excluded. + const currentBranch = status?.current ?? null; + const branchSource = useMemo(() => { + const headRef = currentBranch; + if (!headRef) return null; + const all = branches?.all ?? []; + const localBranches = all.filter((name) => !name.startsWith('remotes/')); + const remoteNames = new Set( + all + .filter((name) => name.startsWith('remotes/')) + .map((name) => name.slice('remotes/'.length).split('/')[0]) + .filter(Boolean) + ); + const baseRef = deriveBaseBranch({ remoteNames, localBranches }); + if (!baseRef || baseRef === headRef) return null; + return { kind: 'branch', baseRef, headRef }; + }, [branches, currentBranch]); + + // The pull request for this branch used to appear only after visiting the PR + // panel, because nothing else asked GitHub about it. Ask here too: the status + // store already dedupes by signature and throttles by TTL, so several panels + // wanting the same answer produce one request. + const { github } = useRuntimeAPIs(); + const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false); + const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); + const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry); + const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams); + const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets); + + useEffect(() => { + if (!directory || !currentBranch || !githubAuthChecked || !githubConnected) return; + const key = getGitHubPrStatusKey(directory, currentBranch); + ensurePrStatusEntry(key); + setPrStatusParams(key, { + directory, + branch: currentBranch, + remoteName: null, + canShow: true, + github, + githubAuthChecked, + githubConnected, + }); + void refreshPrStatusTargets([{ directory, branch: currentBranch, remoteName: null }]); + }, [ + currentBranch, + directory, + ensurePrStatusEntry, + github, + githubAuthChecked, + githubConnected, + refreshPrStatusTargets, + setPrStatusParams, + ]); + + // Selecting the number rather than the entry map: a primitive keeps this + // panel out of every unrelated PR status update. + const branchPrNumber = useGitHubPrStatusStore((state) => ( + directory && currentBranch + ? getFreshestPrStatusForBranch(state.entries, directory, currentBranch)?.pr?.number ?? null + : null + )); + + const source = useMemo( + () => requestedSource ?? { kind: 'working-tree', scope }, + [requestedSource, scope] + ); + + // Offer whichever pull request we know about: the one already selected, or + // the one this branch has. + const prSource = useMemo | null>(() => { + if (source.kind === 'pr') return source; + return branchPrNumber ? { kind: 'pr', number: branchPrNumber } : null; + }, [branchPrNumber, source]); + + const selectWorkingTree = useCallback( + (value: WalkthroughWorkingTreeScope) => { + clearRequestedSource(directory); + setScope(value); + }, + [clearRequestedSource, directory] + ); + const entry = useWalkthroughStore((state) => state.getEntry(directory, source)); + const load = useWalkthroughStore((state) => state.load); + const generate = useWalkthroughStore((state) => state.generate); + const cancel = useWalkthroughStore((state) => state.cancel); + const requestSource = useWalkthroughStore((state) => state.requestSource); + const selectModel = useWalkthroughStore((state) => state.selectModel); + const selectedModel = useWalkthroughStore((state) => state.getSelectedModel(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. + useEffect(() => { + void load(directory, source); + }, [directory, load, source, selectedModel]); + + const view = useMemo(() => buildWalkthroughView(entry.result), [entry.result]); + + // A new walkthrough is a new reading path: keeping the old progress would + // mark stops as visited that the user has never seen. + const generatedAt = entry.result?.generatedAt; + const lastGeneratedAt = useRef(undefined); + useEffect(() => { + if (lastGeneratedAt.current === generatedAt) return; + lastGeneratedAt.current = generatedAt; + setVisitedStopIds(new Set()); + setActiveStopId(view?.stops[0]?.stop.id ?? null); + }, [generatedAt, view]); + + const handleActiveStopChange = useCallback((stopId: string) => { + setActiveStopId(stopId); + setVisitedStopIds((visited) => { + if (visited.has(stopId)) return visited; + const next = new Set(visited); + next.add(stopId); + return next; + }); + }, []); + + const handleSelectStop = useCallback( + (stopId: string) => { + handleActiveStopChange(stopId); + setScrollToStopId(stopId); + }, + [handleActiveStopChange] + ); + + const step = useCallback( + (delta: number) => { + if (!view || view.stops.length === 0) return; + const currentIndex = view.stops.findIndex((stop) => stop.stop.id === activeStopId); + const nextIndex = Math.min(view.stops.length - 1, Math.max(0, (currentIndex < 0 ? 0 : currentIndex) + delta)); + handleSelectStop(view.stops[nextIndex].stop.id); + }, + [activeStopId, handleSelectStop, view] + ); + + const [sourceMenuOpen, setSourceMenuOpen] = useState(false); + const sourceValue = source.kind === 'working-tree' ? source.scope : source.kind; + const sourceLabel = source.kind === 'branch' + ? t('walkthrough.scope.branch') + : source.kind === 'pr' + ? t('walkthrough.scope.pullRequest', { number: source.number }) + : scope === 'all' + ? t('walkthrough.scope.all') + : scope === 'staged' + ? t('walkthrough.scope.staged') + : t('walkthrough.scope.working'); + + // Explicit pick first, then the model that actually produced what is on + // screen, then whatever settings resolve to. The middle step is what makes + // reopening a review show the model behind it rather than the default. + const activeModel = selectedModel + ?? (entry.result?.model ? `${entry.result.model.providerID}/${entry.result.model.modelID}` : undefined) + ?? (entry.readiness?.model ? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}` : undefined); + const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/'); + const activeModelId = activeModelParts.join('/'); + + const modelsMetadata = useConfigStore((state) => state.modelsMetadata); + const [modelProviders, setModelProviders] = useState(undefined); + + useEffect(() => { + if (modelProviders !== undefined) return; + let cancelled = false; + (async () => { + try { + const response = await runtimeFetch('/api/small-model', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return; + const payload = (await response.json().catch(() => null)) as { authenticatedProviders?: unknown } | null; + if (!cancelled && Array.isArray(payload?.authenticatedProviders)) { + setModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string')); + } + } catch { + // Leave undefined: the picker then offers every provider, which is + // worse but not broken. + } + })(); + return () => { + cancelled = true; + }; + }, [modelProviders]); + + const isStructuredOutputCapable = useCallback( + (providerId: string, modelId: string) => + modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false, + [modelsMetadata] + ); + + const isBusy = entry.status === 'loading' || entry.status === 'generating'; + + // 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 + // seen finishing — the user is told about a step they never observe. + const stageProgress = useWalkthroughStageProgress(entry.stage, entry.status === 'generating'); + + // Only a generation that started from an empty panel gets held: regenerating + // over an existing walkthrough keeps the stream on screen with a banner, and + // hiding readable content to show a progress list would be a downgrade. + const startedFromEmptyRef = useRef(false); + const previousStatusRef = useRef(entry.status); + useEffect(() => { + if (previousStatusRef.current !== 'generating' && entry.status === 'generating') { + startedFromEmptyRef.current = !view; + } + previousStatusRef.current = entry.status; + }, [entry.status, view]); + + const showStages = startedFromEmptyRef.current + && (entry.status === 'generating' || stageProgress.holding); + const blockedReason = entry.error?.code === 'context-too-small' + || entry.error?.code === 'structured-output-unsupported' + || entry.error?.code === 'no-model' + || entry.error?.code === 'empty-diff' + || entry.error?.code === 'only-generated' + || entry.error?.code === 'output-exhausted' + ? entry.error.code + : entry.readiness && !entry.readiness.ready && !view + ? entry.readiness.reason + : undefined; + + // Both sources carry the model that was tried; the error is the more specific + // one when generation actually ran. + const blockedModel = entry.error?.model ?? entry.readiness?.model; + const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars; + const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars; + + const handleGenerate = useCallback( + (force: boolean) => { + void generate(directory, source, { force }); + }, + [directory, generate, source] + ); + + return ( +
    +
    + + + + + + { + setSourceMenuOpen(false); + if (value === 'branch') { + if (branchSource) requestSource(directory, branchSource); + return; + } + if (value === 'pr') { + if (prSource) requestSource(directory, prSource); + return; + } + selectWorkingTree(value as WalkthroughWorkingTreeScope); + }} + > + {/* Grouped so "everything" is visibly scoped to uncommitted work: + on its own next to "This branch" it read as "all changes that + exist", which is the opposite of what it selects. */} + + {t('walkthrough.scope.group.workingTree')} + + {SCOPES.map((value) => ( + + {value === 'all' + ? t('walkthrough.scope.all') + : value === 'staged' + ? t('walkthrough.scope.staged') + : t('walkthrough.scope.working')} + + ))} + {(branchSource || prSource) && ( + <> + + + {t('walkthrough.scope.group.committed')} + + + )} + {branchSource && ( + + {t('walkthrough.scope.branch')} + + )} + {prSource && ( + + {t('walkthrough.scope.pullRequest', { number: prSource.number })} + + )} + + + + +
    + {/* Choosing a roomier model for a risky change is a per-review call, + so this is panel state rather than a settings edit. */} + { + selectModel(directory, source, providerId && modelId ? `${providerId}/${modelId}` : null); + }} + allowedProviderIds={modelProviders} + isModelAllowed={isStructuredOutputCapable} + tooltipsEnabled={false} + dropdownPortalToBody + className="h-7 min-w-0 max-w-48" + /> + {view && ( + <> + + + + )} + + {isBusy ? ( + + ) : ( + + )} +
    +
    + + {/* While regenerating over an existing walkthrough the stream keeps showing + the old content, so the only other signal would be the button swapping + to Cancel — far too quiet for something that runs for tens of seconds. */} + {entry.status === 'generating' && view && ( +
    + + + {entry.stage === 'collecting' + ? t('walkthrough.stage.collecting') + : entry.stage === 'assembling' + ? t('walkthrough.stage.assembling') + : t('walkthrough.stage.asking')} + +
    + )} + + {view?.isStale && entry.status !== 'generating' && ( +
    + + + {t('walkthrough.stale.banner', { count: view.staleStopCount })} + + +
    + )} + + {entry.error && !blockedReason && ( +
    + + {/* Provider errors arrive as raw JSON bodies. Show a readable amount + and keep the rest reachable rather than filling the panel. */} + + {entry.error.message} + +
    + )} + +
    + {blockedReason ? ( + void load(directory, source)} + /> + ) : showStages ? ( +
    + +
    + ) : view ? ( + <> + {showToc && ( + <> + +
    + + )} + setScrollToStopId(null)} + renderSideBySide={renderSideBySide} + wrapLines={wrapLines} + /> + + ) : ( +
    + {entry.status === 'loading' ? ( + + ) : ( + <> + +

    + {t('walkthrough.empty.title')} +

    +

    + {t('walkthrough.empty.description')} +

    + + )} +
    + )} +
    +
    + ); +}; diff --git a/packages/ui/src/components/views/walkthrough/stopElementId.ts b/packages/ui/src/components/views/walkthrough/stopElementId.ts new file mode 100644 index 00000000..3933f1c0 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/stopElementId.ts @@ -0,0 +1,2 @@ +/** DOM id for a stop section, shared by the stream and its scroll callers. */ +export const stopElementId = (stopId: string): string => `walkthrough-stop-${stopId}`; diff --git a/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.test.ts b/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.test.ts new file mode 100644 index 00000000..73c724b9 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { WALKTHROUGH_STAGE_ORDER, __testing } from './useWalkthroughStageProgress'; + +const { indexOfStage, nextIndex } = __testing; + +// The pacing itself is a React effect, but the two decisions it rests on are +// plain functions and are where the mistakes would live. +describe('stage ordering', () => { + test('maps every server stage onto a visible step', () => { + expect(indexOfStage('collecting')).toBe(0); + expect(indexOfStage('asking')).toBe(1); + expect(indexOfStage('assembling')).toBe(2); + }); + + test('folds the schema fallback into the same wait', () => { + // From out here it is still "waiting on the model"; the fallback is our + // plumbing and must not show up as its own step or as going backwards. + expect(indexOfStage('retrying')).toBe(indexOfStage('asking')); + }); + + test('treats an absent stage as not started', () => { + expect(indexOfStage(null)).toBe(-1); + }); +}); + +describe('advancing', () => { + test('moves one step at a time so none is skipped', () => { + expect(nextIndex(-1, 2)).toBe(0); + expect(nextIndex(0, 2)).toBe(1); + expect(nextIndex(1, 2)).toBe(2); + }); + + test('stops at the target', () => { + expect(nextIndex(2, 2)).toBe(2); + }); + + test('completion goes one past the last step so everything reads as done', () => { + expect(nextIndex(2, WALKTHROUGH_STAGE_ORDER.length)).toBe(WALKTHROUGH_STAGE_ORDER.length); + }); +}); diff --git a/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.ts b/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.ts new file mode 100644 index 00000000..eb05f8d7 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react'; +import type { WalkthroughStage } from '@/lib/walkthrough/types'; + +/** + * Paces the stage display so every step is actually seen. + * + * Assembling the walkthrough takes milliseconds, so on the real timeline it + * flickers past between "waiting on the model" and the finished result — the + * user is told about a step they never observe, which is worse than not + * naming it. Each step is therefore held for a floor before the next one is + * revealed, including the final all-done state. + * + * This delays the result by well under a second at the end of a wait measured + * in minutes, and buys a legible finish in exchange. + */ + +const ORDER: WalkthroughStage[] = ['collecting', 'asking', 'assembling']; +const MIN_STEP_MS = 450; + +/** `retrying` is the same wait from the user's side: still waiting on the model. */ +const indexOfStage = (stage: WalkthroughStage | null): number => { + if (stage === 'retrying') return ORDER.indexOf('asking'); + return stage ? ORDER.indexOf(stage) : -1; +}; + +/** One step at a time, so a fast stage is still shown rather than skipped. */ +const nextIndex = (current: number, target: number): number => Math.min(current + 1, target); + +export interface WalkthroughStageProgress { + /** Steps to render as finished. */ + completedCount: number; + /** Step to render as running, or `null` when everything is done. */ + activeIndex: number | null; + /** True while the display still owes the user time after the work finished. */ + holding: boolean; +} + +export const useWalkthroughStageProgress = ( + stage: WalkthroughStage | null, + active: boolean +): WalkthroughStageProgress => { + // `ORDER.length` means "all done"; -1 means nothing started. + const [shownIndex, setShownIndex] = useState(-1); + const shownAtRef = useRef(0); + + const target = active ? Math.max(indexOfStage(stage), 0) : (shownIndex < 0 ? -1 : ORDER.length); + + useEffect(() => { + if (target <= shownIndex) { + // Work restarted: fall back to the earlier step immediately rather than + // pretending the later one is still running. + if (target < shownIndex && active) { + setShownIndex(target); + shownAtRef.current = Date.now(); + } + return; + } + + const elapsed = Date.now() - shownAtRef.current; + const advance = () => { + shownAtRef.current = Date.now(); + setShownIndex((current) => nextIndex(current, target)); + }; + + if (shownIndex < 0 || elapsed >= MIN_STEP_MS) { + advance(); + return; + } + + const timer = setTimeout(advance, MIN_STEP_MS - elapsed); + return () => clearTimeout(timer); + }, [active, shownIndex, target]); + + // A fresh run resets the display so the next generation starts from the top. + useEffect(() => { + if (active || shownIndex < ORDER.length) return; + const timer = setTimeout(() => setShownIndex(-1), MIN_STEP_MS); + return () => clearTimeout(timer); + }, [active, shownIndex]); + + const done = shownIndex >= ORDER.length; + + return { + completedCount: Math.max(0, Math.min(shownIndex, ORDER.length)), + activeIndex: done || shownIndex < 0 ? null : shownIndex, + holding: !active && shownIndex >= 0, + }; +}; + +export const WALKTHROUGH_STAGE_ORDER = ORDER; + +export const __testing = { indexOfStage, nextIndex }; diff --git a/packages/ui/src/components/views/walkthrough/walkthroughAction.ts b/packages/ui/src/components/views/walkthrough/walkthroughAction.ts new file mode 100644 index 00000000..0b7303c7 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/walkthroughAction.ts @@ -0,0 +1,14 @@ +/** + * Shared chrome for every "open / generate the AI walkthrough" action. + * + * The walkthrough is informational rather than a primary or destructive act, so + * it carries the info status tint instead of competing with the primary button + * next to it (Review in the diff toolbar, Merge in the pull request header). + * Keeping it in one constant is what stops the three entry points from drifting + * apart. + * + * No sparkle iconography: "this is AI" is not what the button does, and the + * cliché tells the user nothing about the outcome. + */ +export const WALKTHROUGH_ACTION_CLASS = + 'border-[var(--status-info-border)] bg-[var(--status-info-background)] text-[var(--status-info)] hover:bg-[var(--status-info-background)] hover:text-[var(--status-info)]'; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index a3b72a93..13bf8da6 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -891,7 +891,20 @@ html:not(.dark) .chat-scroll { } } -/* Diff toolbar: drop low-priority labels only when the context panel is genuinely tight. */ +/* Diff toolbar: the walkthrough label goes first. Review is the primary action + in this row, so it keeps its label longest; the walkthrough is reachable from + the rail as well and reads fine as an icon. */ +@container diff-toolbar (max-width: 36rem) { + .diff-toolbar__walkthrough-label { + display: none; + } + + .diff-toolbar__walkthrough-button { + padding-inline: 0.5rem; + } +} + +/* Then the rest, once the panel is genuinely tight. */ @container diff-toolbar (max-width: 28rem) { .diff-toolbar__scope-count, .diff-toolbar__review-label, @@ -905,6 +918,18 @@ html:not(.dark) .chat-scroll { } } +/* Pull request actions: the walkthrough label goes first, since merge controls + are the reason the row exists. */ +@container pr-actions (max-width: 30rem) { + .pr-actions__walkthrough-label { + display: none; + } + + .pr-actions__walkthrough-button { + padding-inline: 0.5rem; + } +} + /* Status row: collapse optional text when narrow to keep both sides in one line. */ @container status-row (max-width: 30rem) { .status-row__active-todo { diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 04e329bf..9b1ae0b7 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -146,6 +146,17 @@ export interface GetGitDiffOptions { contextLines?: number; } +/** + * Diff between two refs. Uses three-dot (`base...head`) semantics server-side, so changes + * pulled into `head` by merging `base` are excluded — only the branch's own work is returned. + */ +export interface GetGitRangeDiffOptions { + base: string; + head: string; + path?: string; + contextLines?: number; +} + export interface GitFileDiffResponse { original: string; modified: string; @@ -453,6 +464,7 @@ export interface GitAPI { getGitStatus(directory: string, options?: { mode?: 'light' }): Promise; getGitDiff(directory: string, options: GetGitDiffOptions): Promise; getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise; + getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise; revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise; stageGitFile(directory: string, filePath: string): Promise; stageGitFiles?(directory: string, filePaths: string[]): Promise; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 8b9898ad..1ec9053d 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -131,6 +131,10 @@ export type DesktopSettings = { sessionGoalDefaultBudgetEnabled?: boolean; sessionGoalDefaultBudget?: number; smallModelOverride?: string; // format: "provider/model" + // The walkthrough needs structured output and a roomy context, which the + // small model is often deliberately not chosen for. Unset means "use the + // small model"; a value replaces it for this feature only. + walkthroughModelOverride?: string; // format: "provider/model" defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id openInAppId?: string; autoCreateWorktree?: boolean; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index bfa787de..1adb1c4d 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -135,6 +135,15 @@ export async function getGitFileDiff( return gitHttp.getGitFileDiff(directory, options); } +export async function getGitRangeDiff( + directory: string, + options: import('./api/types').GetGitRangeDiffOptions +): Promise { + const runtime = getRuntimeGit(); + if (runtime?.getGitRangeDiff) return runtime.getGitRangeDiff(directory, options); + return gitHttp.getGitRangeDiff(directory, options); +} + export async function revertGitFile( directory: string, filePath: string, diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 4d8786c8..24317b24 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -2,6 +2,7 @@ import type { GitStatus, GitDiffResponse, GetGitDiffOptions, + GetGitRangeDiffOptions, GitFileDiffResponse, GetGitFileDiffOptions, GitBranch, @@ -222,6 +223,31 @@ export async function getGitDiff(directory: string, options: GetGitDiffOptions): return response.json(); } +export async function getGitRangeDiff( + directory: string, + options: GetGitRangeDiffOptions +): Promise { + const { base, head, path, contextLines } = options; + if (!base || !head) { + throw new Error('base and head are required to fetch git range diff'); + } + + const response = await runtimeFetch( + buildUrl(`${API_BASE}/range-diff`, directory, { + base, + head, + path: path || undefined, + context: contextLines, + }) + ); + + if (!response.ok) { + throw new Error(`Failed to get git range diff: ${response.statusText}`); + } + + return response.json(); +} + export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise { const { path, staged } = options; if (!path) { diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 5eac5495..ece90e3f 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1554,6 +1554,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': 'Use default small model', 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Use default small model', 'settings.openchamber.defaults.smallModel.overrideModel': 'Override model', + 'settings.openchamber.defaults.walkthroughModel.title': 'Changes Walkthrough Model', + 'settings.openchamber.defaults.walkthroughModel.description': 'The AI review of your changes needs structured output and room for a whole diff, which a cheap small model often cannot give. Models the catalog reports as unable to produce structured output are hidden from this picker. Leave it unset and the small model is used.', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Walkthrough model', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Small model', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Open previewable files in preview mode', 'settings.openchamber.defaults.field.openFilesPreview': 'Open previewable files in preview mode', 'settings.openchamber.defaults.option.default': 'Default', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a8bdb155..0f04e76d 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1095,6 +1095,56 @@ export const dict = { 'contextRail.surface.git.description': 'Commits, branches, and pull requests', 'contextRail.surface.terminal.description': 'Built-in terminal', 'contextRail.surface.diff.description': 'Review working changes', + 'contextPanel.mode.walkthrough': 'Walkthrough', + 'contextRail.surface.walkthrough.description': 'An AI-guided walkthrough of your changes', + 'walkthrough.scope.all': 'All uncommitted', + 'walkthrough.scope.group.workingTree': 'Working tree', + 'walkthrough.scope.group.committed': 'Committed', + 'walkthrough.scope.staged': 'Staged', + 'walkthrough.scope.working': 'Unstaged', + 'walkthrough.scope.branch': 'This branch', + 'walkthrough.scope.selectorAria': 'Select what to review', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': 'Generate walkthrough', + 'walkthrough.action.regenerate': 'Regenerate', + 'walkthrough.action.cancel': 'Cancel', + 'walkthrough.action.next': 'Next step', + 'walkthrough.action.previous': 'Previous step', + 'walkthrough.action.refresh': 'Refresh', + 'walkthrough.action.open': 'AI walkthrough', + 'walkthrough.stage.collecting': 'Collecting the changes', + 'walkthrough.stage.asking': 'Waiting on the model', + 'walkthrough.stage.assembling': 'Assembling the walkthrough', + 'walkthrough.empty.title': 'No walkthrough yet', + 'walkthrough.empty.description': 'Build a guided reading path through these changes. This calls the small model and costs tokens, so it only ever runs when you ask.', + 'walkthrough.stale.banner': 'The code changed after this review. Outdated steps: {count}', + 'walkthrough.stop.staleAll': 'All of the code this step described has changed.', + 'walkthrough.stop.stalePartial': 'Part of the code this step described has changed. Missing pieces: {count}', + 'walkthrough.stop.staleShort': 'Outdated', + 'walkthrough.stop.noCode': 'None of the code for this step is in the current diff.', + 'walkthrough.uncovered.title': 'Changes the review left out: {count}', + 'walkthrough.uncovered.description': 'The review treated these as routine. Expand to check them yourself.', + 'walkthrough.toc.moreFiles': 'More files: {count}', + 'walkthrough.toc.uncovered': 'Not covered: {count}', + 'walkthrough.toc.resize': 'Resize the contents column', + 'walkthrough.importance.critical': 'Critical', + 'walkthrough.importance.context': 'Context', + 'walkthrough.blocked.noModel.title': 'No small model available', + 'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.', + 'walkthrough.blocked.emptyDiff.title': 'Nothing to review', + 'walkthrough.blocked.emptyDiff.description': 'There are no changes in this scope yet.', + 'walkthrough.blocked.contextTooSmall.title': 'This diff is too large for the current model', + 'walkthrough.blocked.contextTooSmall.description': '{model} fits about {available}K characters and this diff needs about {required}K. Nothing gets truncated — pick a model with a larger context instead.', + 'walkthrough.blocked.structuredOutput.title': 'This model cannot produce structured output', + 'walkthrough.blocked.structuredOutput.description': '{model} does not support the structured responses a walkthrough needs.', + 'walkthrough.blocked.chooseModel': 'Choose the small model', + 'walkthrough.blocked.outputExhausted.title': 'This model ran out of answer budget', + 'walkthrough.blocked.outputExhausted.description': '{model} spent its whole output allowance on reasoning and returned nothing. Reasoning models often do this on large diffs — a model that thinks less, or reviewing a narrower scope, will get through.', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'The small model spent its whole output allowance on reasoning and returned nothing. Reasoning models often do this on large diffs — a model that thinks less, or reviewing a narrower scope, will get through.', + 'walkthrough.blocked.onlyGenerated.title': 'Only generated files changed', + 'walkthrough.blocked.onlyGenerated.description': 'Every change here is a lockfile or other tool-produced output, which the review deliberately skips.', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'The small model fits about {available}K characters and this diff needs about {required}K. Nothing gets truncated — pick a model with a larger context instead.', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'The small model does not support the structured responses a walkthrough needs.', 'contextRail.surface.plan.description': 'View the current plan', 'contextRail.surface.pr.description': 'Create, review, and merge the pull request for the current branch', 'contextRail.surface.notes.description': 'Notes, todos, and plans for the project', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index dfaf6c11..657a936c 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1520,6 +1520,10 @@ export const settingsDict = { "settings.openchamber.defaults.smallModel.useDefault": "Usar el modelo pequeño predeterminado", "settings.openchamber.defaults.smallModel.useDefaultAria": "Usar el modelo pequeño predeterminado", "settings.openchamber.defaults.smallModel.overrideModel": "Modelo de anulación", + "settings.openchamber.defaults.walkthroughModel.title": "Modelo del recorrido de cambios", + "settings.openchamber.defaults.walkthroughModel.description": "La revisión con IA de tus cambios necesita salida estructurada y espacio para un diff completo, algo que un modelo pequeño y barato a menudo no ofrece. Los modelos que el catálogo declara incapaces de producir salida estructurada se ocultan aquí. Si no eliges nada, se usa el modelo pequeño.", + "settings.openchamber.defaults.walkthroughModel.overrideModel": "Modelo del recorrido", + "settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Modelo pequeño", "settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación", "settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir archivos previsualizables en modo vista previa", "settings.openchamber.defaults.field.openFilesPreview": "Abrir archivos previsualizables en modo vista previa", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 12f47d20..e6e95889 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1096,6 +1096,56 @@ export const dict: Record = { "contextRail.surface.git.description": "Commits, ramas y pull requests", "contextRail.surface.terminal.description": "Terminal integrada", "contextRail.surface.diff.description": "Revisar cambios en curso", + "contextPanel.mode.walkthrough": "Recorrido", + "contextRail.surface.walkthrough.description": "Un recorrido por tus cambios guiado por IA", + "walkthrough.scope.all": "Todo sin confirmar", + "walkthrough.scope.group.workingTree": "Árbol de trabajo", + "walkthrough.scope.group.committed": "Confirmado", + "walkthrough.scope.staged": "Preparados", + "walkthrough.scope.working": "Sin preparar", + "walkthrough.scope.branch": "Esta rama", + "walkthrough.scope.selectorAria": "Elegir qué revisar", + "walkthrough.scope.pullRequest": "PR n.º {number}", + "walkthrough.action.generate": "Generar recorrido", + "walkthrough.action.regenerate": "Regenerar", + "walkthrough.action.cancel": "Cancelar", + "walkthrough.action.next": "Paso siguiente", + "walkthrough.action.previous": "Paso anterior", + "walkthrough.action.refresh": "Actualizar", + "walkthrough.action.open": "Recorrido con IA", + "walkthrough.stage.collecting": "Reuniendo los cambios", + "walkthrough.stage.asking": "Esperando al modelo", + "walkthrough.stage.assembling": "Montando el recorrido", + "walkthrough.empty.title": "Todavía no hay recorrido", + "walkthrough.empty.description": "Crea una ruta de lectura guiada por estos cambios. Esto llama al modelo pequeño y consume tokens, así que solo se ejecuta cuando lo pides.", + "walkthrough.stale.banner": "El código cambió después de esta revisión. Pasos desactualizados: {count}", + "walkthrough.stop.staleAll": "Todo el código que describía este paso ha cambiado.", + "walkthrough.stop.stalePartial": "Parte del código que describía este paso ha cambiado. Fragmentos perdidos: {count}", + "walkthrough.stop.staleShort": "Desactualizado", + "walkthrough.stop.noCode": "El código de este paso no está en el diff actual.", + "walkthrough.uncovered.title": "Cambios que la revisión omitió: {count}", + "walkthrough.uncovered.description": "La revisión los consideró rutinarios. Despliega para revisarlos tú mismo.", + "walkthrough.toc.moreFiles": "Más archivos: {count}", + "walkthrough.toc.uncovered": "Sin cubrir: {count}", + "walkthrough.toc.resize": "Cambiar el ancho de la columna de contenidos", + "walkthrough.importance.critical": "Crítico", + "walkthrough.importance.context": "Contexto", + "walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible", + "walkthrough.blocked.noModel.description": "Inicia sesión en un proveedor de modelos para generar una revisión.", + "walkthrough.blocked.emptyDiff.title": "Nada que revisar", + "walkthrough.blocked.emptyDiff.description": "Todavía no hay cambios en este ámbito.", + "walkthrough.blocked.contextTooSmall.title": "Este diff es demasiado grande para el modelo actual", + "walkthrough.blocked.contextTooSmall.description": "{model} admite unos {available} mil caracteres y este diff necesita unos {required} mil. No se recorta nada: elige un modelo con más contexto.", + "walkthrough.blocked.structuredOutput.title": "Este modelo no puede generar salida estructurada", + "walkthrough.blocked.structuredOutput.description": "{model} no admite las respuestas estructuradas que necesita un recorrido.", + "walkthrough.blocked.chooseModel": "Elige el modelo pequeño", + "walkthrough.blocked.outputExhausted.title": "El modelo se quedó sin presupuesto de respuesta", + "walkthrough.blocked.outputExhausted.description": "{model} gastó todo su margen de salida razonando y no devolvió nada. Los modelos de razonamiento suelen hacerlo con diffs grandes: prueba con un modelo que razone menos o revisa un ámbito más reducido.", + "walkthrough.blocked.outputExhausted.descriptionUnknownModel": "El modelo pequeño gastó todo su margen de salida razonando y no devolvió nada. Los modelos de razonamiento suelen hacerlo con diffs grandes: prueba con un modelo que razone menos o revisa un ámbito más reducido.", + "walkthrough.blocked.onlyGenerated.title": "Solo cambiaron archivos generados", + "walkthrough.blocked.onlyGenerated.description": "Todos los cambios son archivos de bloqueo u otra salida generada por herramientas, que la revisión omite a propósito.", + "walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "El modelo pequeño admite unos {available} mil caracteres y este diff necesita unos {required} mil. No se recorta nada: elige un modelo con más contexto.", + "walkthrough.blocked.structuredOutput.descriptionUnknownModel": "El modelo pequeño no admite las respuestas estructuradas que necesita un recorrido.", "contextRail.surface.plan.description": "Ver el plan actual", "contextRail.surface.pr.description": "Crea, revisa y fusiona el pull request de la rama actual", "contextRail.surface.notes.description": "Notas, tareas y planes del proyecto", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 46dbd110..050f7904 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1441,6 +1441,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': 'Utiliser le petit modèle par défaut', 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Utiliser le petit modèle par défaut', 'settings.openchamber.defaults.smallModel.overrideModel': 'Modèle de remplacement', + 'settings.openchamber.defaults.walkthroughModel.title': 'Modèle du parcours des modifications', + 'settings.openchamber.defaults.walkthroughModel.description': 'La revue par IA de vos modifications exige une sortie structurée et de la place pour un diff entier, ce qu’un petit modèle économique ne fournit pas toujours. Les modèles que le catalogue déclare incapables de sortie structurée sont masqués ici. Sans choix explicite, le petit modèle est utilisé.', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Modèle du parcours', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Petit modèle', 'settings.openchamber.defaults.field.showDeletionDialog': 'Afficher la boîte de dialogue de suppression', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Ouvrir les fichiers prévisualisables en mode aperçu', 'settings.openchamber.defaults.field.openFilesPreview': 'Ouvrir les fichiers prévisualisables en mode aperçu', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 8574e4b9..91f62125 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -919,6 +919,56 @@ export const dict = { 'contextRail.surface.git.description': 'Commits, branches et pull requests', 'contextRail.surface.terminal.description': 'Terminal intégré', 'contextRail.surface.diff.description': 'Passer en revue les modifications', + 'contextPanel.mode.walkthrough': 'Parcours', + 'contextRail.surface.walkthrough.description': 'Un parcours de vos modifications guidé par l’IA', + 'walkthrough.scope.all': 'Tout non validé', + 'walkthrough.scope.group.workingTree': 'Copie de travail', + 'walkthrough.scope.group.committed': 'Validé', + 'walkthrough.scope.staged': 'Indexées', + 'walkthrough.scope.working': 'Non indexées', + 'walkthrough.scope.branch': 'Cette branche', + 'walkthrough.scope.selectorAria': 'Choisir ce qui est examiné', + 'walkthrough.scope.pullRequest': 'PR n° {number}', + 'walkthrough.action.generate': 'Générer le parcours', + 'walkthrough.action.regenerate': 'Régénérer', + 'walkthrough.action.cancel': 'Annuler', + 'walkthrough.action.next': 'Étape suivante', + 'walkthrough.action.previous': 'Étape précédente', + 'walkthrough.action.refresh': 'Actualiser', + 'walkthrough.action.open': 'Parcours IA', + 'walkthrough.stage.collecting': 'Collecte des modifications', + 'walkthrough.stage.asking': 'En attente du modèle', + 'walkthrough.stage.assembling': 'Assemblage du parcours', + 'walkthrough.empty.title': 'Aucun parcours pour l’instant', + 'walkthrough.empty.description': 'Construisez un parcours de lecture guidé à travers ces modifications. Cela appelle le petit modèle et consomme des jetons, donc rien ne se lance sans votre demande.', + 'walkthrough.stale.banner': 'Le code a changé après cette revue. Étapes obsolètes : {count}', + 'walkthrough.stop.staleAll': 'Tout le code décrit par cette étape a changé.', + 'walkthrough.stop.stalePartial': 'Une partie du code décrit par cette étape a changé. Fragments manquants : {count}', + 'walkthrough.stop.staleShort': 'Obsolète', + 'walkthrough.stop.noCode': 'Le code de cette étape est absent du diff actuel.', + 'walkthrough.uncovered.title': 'Modifications non traitées par la revue : {count}', + 'walkthrough.uncovered.description': 'La revue les a jugées courantes. Dépliez pour les vérifier vous-même.', + 'walkthrough.toc.moreFiles': 'Autres fichiers : {count}', + 'walkthrough.toc.uncovered': 'Non traité : {count}', + 'walkthrough.toc.resize': 'Redimensionner la colonne du sommaire', + 'walkthrough.importance.critical': 'Critique', + 'walkthrough.importance.context': 'Contexte', + 'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible', + 'walkthrough.blocked.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.', + 'walkthrough.blocked.emptyDiff.title': 'Rien à examiner', + 'walkthrough.blocked.emptyDiff.description': 'Il n’y a encore aucune modification dans cette portée.', + 'walkthrough.blocked.contextTooSmall.title': 'Ce diff est trop volumineux pour le modèle actuel', + 'walkthrough.blocked.contextTooSmall.description': '{model} accepte environ {available} k caractères et ce diff en demande environ {required} k. Rien n’est tronqué : choisissez un modèle au contexte plus large.', + 'walkthrough.blocked.structuredOutput.title': 'Ce modèle ne produit pas de sortie structurée', + 'walkthrough.blocked.structuredOutput.description': '{model} ne prend pas en charge les réponses structurées nécessaires à un parcours.', + 'walkthrough.blocked.chooseModel': 'Choisissez le petit modèle', + 'walkthrough.blocked.outputExhausted.title': 'Le modèle a épuisé son budget de réponse', + 'walkthrough.blocked.outputExhausted.description': '{model} a dépensé toute sa marge de sortie en raisonnement et n’a rien renvoyé. Les modèles de raisonnement le font souvent sur de gros diffs : essayez un modèle qui réfléchit moins, ou une portée plus étroite.', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Le petit modèle a dépensé toute sa marge de sortie en raisonnement et n’a rien renvoyé. Les modèles de raisonnement le font souvent sur de gros diffs : essayez un modèle qui réfléchit moins, ou une portée plus étroite.', + 'walkthrough.blocked.onlyGenerated.title': 'Seuls des fichiers générés ont changé', + 'walkthrough.blocked.onlyGenerated.description': 'Toutes les modifications concernent des fichiers de verrouillage ou d’autres sorties générées, que la revue ignore délibérément.', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Le petit modèle accepte environ {available} k caractères et ce diff en demande environ {required} k. Rien n’est tronqué : choisissez un modèle au contexte plus large.', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Le petit modèle ne prend pas en charge les réponses structurées nécessaires à un parcours.', 'contextRail.surface.plan.description': 'Voir le plan actuel', 'contextRail.surface.pr.description': 'Créer, relire et fusionner la pull request de la branche actuelle', 'contextRail.surface.notes.description': 'Notes, tâches et plans du projet', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index adfa17bb..be7fe2b3 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1553,6 +1553,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': 'デフォルトの小型モデルを使用', 'settings.openchamber.defaults.smallModel.useDefaultAria': 'デフォルトの小型モデルを使用', 'settings.openchamber.defaults.smallModel.overrideModel': '上書きモデル', + 'settings.openchamber.defaults.walkthroughModel.title': '変更ウォークスルーのモデル', + 'settings.openchamber.defaults.walkthroughModel.description': '変更の AI レビューには構造化出力と差分全体が収まる余裕が必要で、安価なスモールモデルでは足りないことがあります。カタログが構造化出力に非対応と示すモデルは、この一覧から除外されます。 未選択のままならスモールモデルが使われます。', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': 'ウォークスルーのモデル', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'スモールモデル', 'settings.openchamber.defaults.field.showDeletionDialog': '削除ダイアログを表示', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'プレビュー可能なファイルをプレビューモードで開く', 'settings.openchamber.defaults.field.openFilesPreview': 'プレビュー可能なファイルをプレビューモードで開く', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 2a33f0fb..4c011438 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1092,6 +1092,56 @@ export const dict: Record = { 'contextRail.surface.git.description': 'コミット・ブランチ・プルリクエスト', 'contextRail.surface.terminal.description': '内蔵ターミナル', 'contextRail.surface.diff.description': '作業中の変更をレビュー', + 'contextPanel.mode.walkthrough': 'ウォークスルー', + 'contextRail.surface.walkthrough.description': 'AI による変更のガイド付きウォークスルー', + 'walkthrough.scope.all': '未コミットすべて', + 'walkthrough.scope.group.workingTree': '作業ツリー', + 'walkthrough.scope.group.committed': 'コミット済み', + 'walkthrough.scope.staged': 'ステージ済み', + 'walkthrough.scope.working': '未ステージ', + 'walkthrough.scope.branch': 'このブランチ', + 'walkthrough.scope.selectorAria': 'レビュー対象を選択', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': 'ウォークスルーを生成', + 'walkthrough.action.regenerate': '再生成', + 'walkthrough.action.cancel': 'キャンセル', + 'walkthrough.action.next': '次のステップ', + 'walkthrough.action.previous': '前のステップ', + 'walkthrough.action.refresh': '更新', + 'walkthrough.action.open': 'AI ウォークスルー', + 'walkthrough.stage.collecting': '変更を集めています', + 'walkthrough.stage.asking': 'モデルの応答を待っています', + 'walkthrough.stage.assembling': 'ウォークスルーを組み立てています', + 'walkthrough.empty.title': 'ウォークスルーはまだありません', + 'walkthrough.empty.description': 'この変更を読み進めるためのガイドを作成します。スモールモデルを呼び出してトークンを消費するため、実行は明示的な操作時のみです。', + 'walkthrough.stale.banner': 'このレビュー後にコードが変更されました。古くなったステップ: {count}', + 'walkthrough.stop.staleAll': 'このステップが説明していたコードはすべて変更されました。', + 'walkthrough.stop.stalePartial': 'このステップが説明していたコードの一部が変更されました。失われた箇所: {count}', + 'walkthrough.stop.staleShort': '古い内容', + 'walkthrough.stop.noCode': 'このステップのコードは現在の差分にありません。', + 'walkthrough.uncovered.title': 'レビューが扱わなかった変更: {count}', + 'walkthrough.uncovered.description': 'レビューは定型的と判断しました。展開してご自身で確認できます。', + 'walkthrough.toc.moreFiles': 'その他のファイル: {count}', + 'walkthrough.toc.uncovered': '未対応: {count}', + 'walkthrough.toc.resize': '目次の列幅を変更', + 'walkthrough.importance.critical': '重要', + 'walkthrough.importance.context': '補足', + 'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません', + 'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。', + 'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません', + 'walkthrough.blocked.emptyDiff.description': 'この範囲にはまだ変更がありません。', + 'walkthrough.blocked.contextTooSmall.title': 'この差分は現在のモデルには大きすぎます', + 'walkthrough.blocked.contextTooSmall.description': '{model} が扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。', + 'walkthrough.blocked.structuredOutput.title': 'このモデルは構造化出力に対応していません', + 'walkthrough.blocked.structuredOutput.description': '{model} はウォークスルーに必要な構造化応答をサポートしていません。', + 'walkthrough.blocked.chooseModel': 'スモールモデルを選択', + 'walkthrough.blocked.outputExhausted.title': 'モデルの出力枠が尽きました', + 'walkthrough.blocked.outputExhausted.description': '{model} は出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'スモールモデルは出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。', + 'walkthrough.blocked.onlyGenerated.title': '生成ファイルのみが変更されています', + 'walkthrough.blocked.onlyGenerated.description': 'ここでの変更はロックファイルなどツールが生成した出力だけで、レビューは意図的にこれらを対象外にしています。', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'スモールモデルが扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'スモールモデルはウォークスルーに必要な構造化応答をサポートしていません。', 'contextRail.surface.plan.description': '現在のプランを表示', 'contextRail.surface.pr.description': '現在のブランチのプルリクエストを作成・確認・マージ', 'contextRail.surface.notes.description': 'プロジェクトのノート・ToDo・プラン', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 810af15d..accc0d40 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1520,6 +1520,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': '기본 소형 모델 사용', 'settings.openchamber.defaults.smallModel.useDefaultAria': '기본 소형 모델 사용', 'settings.openchamber.defaults.smallModel.overrideModel': '재정의 모델', + 'settings.openchamber.defaults.walkthroughModel.title': '변경 워크스루 모델', + 'settings.openchamber.defaults.walkthroughModel.description': '변경 사항의 AI 리뷰에는 구조화된 출력과 전체 diff를 담을 여유가 필요한데, 저렴한 스몰 모델로는 부족할 때가 많습니다. 카탈로그가 구조화된 출력을 지원하지 않는다고 알리는 모델은 이 목록에서 숨겨집니다. 선택하지 않으면 스몰 모델이 사용됩니다.', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': '워크스루 모델', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '스몰 모델', 'settings.openchamber.defaults.field.showDeletionDialog': '삭제 확인 대화상자 표시', 'settings.openchamber.defaults.field.openFilesPreviewAria': '미리보기 가능한 파일을 미리보기 모드로 열기', 'settings.openchamber.defaults.field.openFilesPreview': '미리보기 가능한 파일을 미리보기 모드로 열기', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 4087bfe5..be7f7374 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1096,6 +1096,56 @@ export const dict: Record = { 'contextRail.surface.git.description': '커밋, 브랜치, 풀 리퀘스트', 'contextRail.surface.terminal.description': '내장 터미널', 'contextRail.surface.diff.description': '작업 중인 변경 사항 검토', + 'contextPanel.mode.walkthrough': '워크스루', + 'contextRail.surface.walkthrough.description': 'AI가 안내하는 변경 사항 워크스루', + 'walkthrough.scope.all': '커밋되지 않은 전체', + 'walkthrough.scope.group.workingTree': '작업 트리', + 'walkthrough.scope.group.committed': '커밋됨', + 'walkthrough.scope.staged': '스테이지됨', + 'walkthrough.scope.working': '스테이지 안 됨', + 'walkthrough.scope.branch': '이 브랜치', + 'walkthrough.scope.selectorAria': '리뷰 대상 선택', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': '워크스루 생성', + 'walkthrough.action.regenerate': '다시 생성', + 'walkthrough.action.cancel': '취소', + 'walkthrough.action.next': '다음 단계', + 'walkthrough.action.previous': '이전 단계', + 'walkthrough.action.refresh': '새로 고침', + 'walkthrough.action.open': 'AI 워크스루', + 'walkthrough.stage.collecting': '변경 사항을 모으는 중', + 'walkthrough.stage.asking': '모델을 기다리는 중', + 'walkthrough.stage.assembling': '워크스루를 조립하는 중', + 'walkthrough.empty.title': '아직 워크스루가 없습니다', + 'walkthrough.empty.description': '이 변경 사항을 따라 읽을 수 있는 경로를 만듭니다. 스몰 모델을 호출해 토큰을 사용하므로 요청할 때만 실행됩니다.', + 'walkthrough.stale.banner': '이 리뷰 이후에 코드가 바뀌었습니다. 오래된 단계: {count}', + 'walkthrough.stop.staleAll': '이 단계가 설명하던 코드가 모두 바뀌었습니다.', + 'walkthrough.stop.stalePartial': '이 단계가 설명하던 코드 일부가 바뀌었습니다. 사라진 부분: {count}', + 'walkthrough.stop.staleShort': '오래됨', + 'walkthrough.stop.noCode': '이 단계의 코드는 현재 diff에 없습니다.', + 'walkthrough.uncovered.title': '리뷰가 다루지 않은 변경: {count}', + 'walkthrough.uncovered.description': '리뷰가 일상적인 변경으로 판단했습니다. 펼쳐서 직접 확인하세요.', + 'walkthrough.toc.moreFiles': '다른 파일: {count}', + 'walkthrough.toc.uncovered': '미포함: {count}', + 'walkthrough.toc.resize': '목차 열 너비 조절', + 'walkthrough.importance.critical': '중요', + 'walkthrough.importance.context': '참고', + 'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다', + 'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.', + 'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다', + 'walkthrough.blocked.emptyDiff.description': '이 범위에는 아직 변경 사항이 없습니다.', + 'walkthrough.blocked.contextTooSmall.title': '이 diff는 현재 모델에 너무 큽니다', + 'walkthrough.blocked.contextTooSmall.description': '{model}은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.', + 'walkthrough.blocked.structuredOutput.title': '이 모델은 구조화된 출력을 지원하지 않습니다', + 'walkthrough.blocked.structuredOutput.description': '{model}은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.', + 'walkthrough.blocked.chooseModel': '스몰 모델 선택', + 'walkthrough.blocked.outputExhausted.title': '모델의 응답 예산이 바닥났습니다', + 'walkthrough.blocked.outputExhausted.description': '{model}이(가) 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '스몰 모델이 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.', + 'walkthrough.blocked.onlyGenerated.title': '생성된 파일만 변경되었습니다', + 'walkthrough.blocked.onlyGenerated.description': '여기의 변경은 모두 잠금 파일이거나 도구가 만든 산출물이며, 리뷰는 이런 파일을 의도적으로 건너뜁니다.', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '스몰 모델은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '스몰 모델은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.', 'contextRail.surface.plan.description': '현재 계획 보기', 'contextRail.surface.pr.description': '현재 브랜치의 풀 리퀘스트를 생성, 검토, 병합', 'contextRail.surface.notes.description': '프로젝트의 노트, 할 일, 계획', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index f102e2ec..318072d8 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -739,6 +739,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': 'Używaj domyślnego małego modelu', 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Używaj domyślnego małego modelu', 'settings.openchamber.defaults.smallModel.overrideModel': 'Model zastępczy', + 'settings.openchamber.defaults.walkthroughModel.title': 'Model przewodnika po zmianach', + 'settings.openchamber.defaults.walkthroughModel.description': 'Przegląd zmian przez AI wymaga ustrukturyzowanej odpowiedzi i miejsca na całe różnice, czego tani mały model często nie zapewnia. Modele, które katalog zgłasza jako niezdolne do ustrukturyzowanej odpowiedzi, są tu ukryte. Bez wyboru używany jest mały model.', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Model przewodnika', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Mały model', 'settings.openchamber.defaults.field.thinkingPlaceholder': 'Myślenie', 'settings.openchamber.defaults.option.default': 'Domyślne', 'settings.openchamber.defaults.option.defaultLowercase': 'domyślne', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index de3803be..d6aad575 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1411,6 +1411,56 @@ export const dict: Record = { 'contextRail.surface.git.description': 'Commity, gałęzie i pull requesty', 'contextRail.surface.terminal.description': 'Wbudowany terminal', 'contextRail.surface.diff.description': 'Przeglądaj bieżące zmiany', + 'contextPanel.mode.walkthrough': 'Przewodnik', + 'contextRail.surface.walkthrough.description': 'Przewodnik po zmianach prowadzony przez AI', + 'walkthrough.scope.all': 'Wszystko niezatwierdzone', + 'walkthrough.scope.group.workingTree': 'Drzewo robocze', + 'walkthrough.scope.group.committed': 'Zatwierdzone', + 'walkthrough.scope.staged': 'W poczekalni', + 'walkthrough.scope.working': 'Poza poczekalnią', + 'walkthrough.scope.branch': 'Ta gałąź', + 'walkthrough.scope.selectorAria': 'Wybierz, co przejrzeć', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': 'Wygeneruj przewodnik', + 'walkthrough.action.regenerate': 'Wygeneruj ponownie', + 'walkthrough.action.cancel': 'Anuluj', + 'walkthrough.action.next': 'Następny krok', + 'walkthrough.action.previous': 'Poprzedni krok', + 'walkthrough.action.refresh': 'Odśwież', + 'walkthrough.action.open': 'Przewodnik AI', + 'walkthrough.stage.collecting': 'Zbieram zmiany', + 'walkthrough.stage.asking': 'Czekam na model', + 'walkthrough.stage.assembling': 'Składam przewodnik', + 'walkthrough.empty.title': 'Nie ma jeszcze przewodnika', + 'walkthrough.empty.description': 'Zbuduj prowadzoną ścieżkę czytania tych zmian. Wywołuje to mały model i zużywa tokeny, więc uruchamia się tylko na Twoje żądanie.', + 'walkthrough.stale.banner': 'Kod zmienił się po tym przeglądzie. Nieaktualne kroki: {count}', + 'walkthrough.stop.staleAll': 'Cały kod opisywany przez ten krok uległ zmianie.', + 'walkthrough.stop.stalePartial': 'Część kodu opisywanego przez ten krok uległa zmianie. Brakujące fragmenty: {count}', + 'walkthrough.stop.staleShort': 'Nieaktualne', + 'walkthrough.stop.noCode': 'Kodu z tego kroku nie ma w bieżących różnicach.', + 'walkthrough.uncovered.title': 'Zmiany pominięte w przeglądzie: {count}', + 'walkthrough.uncovered.description': 'Przegląd uznał je za rutynowe. Rozwiń, aby sprawdzić je samodzielnie.', + 'walkthrough.toc.moreFiles': 'Więcej plików: {count}', + 'walkthrough.toc.uncovered': 'Nieuwzględnione: {count}', + 'walkthrough.toc.resize': 'Zmień szerokość kolumny spisu treści', + 'walkthrough.importance.critical': 'Krytyczne', + 'walkthrough.importance.context': 'Kontekst', + 'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu', + 'walkthrough.blocked.noModel.description': 'Zaloguj się u dostawcy modeli, aby wygenerować przegląd.', + 'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać', + 'walkthrough.blocked.emptyDiff.description': 'W tym zakresie nie ma jeszcze zmian.', + 'walkthrough.blocked.contextTooSmall.title': 'Te różnice są za duże dla bieżącego modelu', + 'walkthrough.blocked.contextTooSmall.description': '{model} mieści około {available} tys. znaków, a te różnice potrzebują około {required} tys. Nic nie jest obcinane — wybierz model z większym kontekstem.', + 'walkthrough.blocked.structuredOutput.title': 'Ten model nie tworzy ustrukturyzowanych odpowiedzi', + 'walkthrough.blocked.structuredOutput.description': '{model} nie obsługuje ustrukturyzowanych odpowiedzi wymaganych przez przewodnik.', + 'walkthrough.blocked.chooseModel': 'Wybierz mały model', + 'walkthrough.blocked.outputExhausted.title': 'Modelowi zabrakło budżetu na odpowiedź', + 'walkthrough.blocked.outputExhausted.description': '{model} zużył cały limit wyjścia na rozumowanie i nic nie zwrócił. Modele rozumujące często tak robią przy dużych różnicach — pomoże model mniej „myślący” albo węższy zakres przeglądu.', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Mały model zużył cały limit wyjścia na rozumowanie i nic nie zwrócił. Modele rozumujące często tak robią przy dużych różnicach — pomoże model mniej „myślący” albo węższy zakres przeglądu.', + 'walkthrough.blocked.onlyGenerated.title': 'Zmieniły się tylko pliki generowane', + 'walkthrough.blocked.onlyGenerated.description': 'Wszystkie zmiany to pliki blokad lub inne wyniki pracy narzędzi, które przegląd celowo pomija.', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Mały model mieści około {available} tys. znaków, a te różnice potrzebują około {required} tys. Nic nie jest obcinane — wybierz model z większym kontekstem.', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Mały model nie obsługuje ustrukturyzowanych odpowiedzi wymaganych przez przewodnik.', 'contextRail.surface.plan.description': 'Zobacz bieżący plan', 'contextRail.surface.pr.description': 'Twórz, przeglądaj i scalaj pull request bieżącej gałęzi', 'contextRail.surface.notes.description': 'Notatki, zadania i plany projektu', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 2870ca26..a931c456 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1520,6 +1520,10 @@ export const settingsDict = { "settings.openchamber.defaults.smallModel.useDefault": "Usar o modelo pequeno padrão", "settings.openchamber.defaults.smallModel.useDefaultAria": "Usar o modelo pequeno padrão", "settings.openchamber.defaults.smallModel.overrideModel": "Modelo de substituição", + "settings.openchamber.defaults.walkthroughModel.title": "Modelo do percurso de mudanças", + "settings.openchamber.defaults.walkthroughModel.description": "A revisão das suas mudanças por IA precisa de saída estruturada e espaço para um diff inteiro, o que um modelo pequeno e barato muitas vezes não oferece. Modelos que o catálogo indica como incapazes de saída estruturada ficam ocultos aqui. Sem escolha, o modelo pequeno é usado.", + "settings.openchamber.defaults.walkthroughModel.overrideModel": "Modelo do percurso", + "settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Modelo pequeno", "settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación", "settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir arquivos com visualização em modo de prévia", "settings.openchamber.defaults.field.openFilesPreview": "Abrir arquivos com visualização em modo de prévia", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 68096fae..28bfbbc5 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1096,6 +1096,56 @@ export const dict: Record = { "contextRail.surface.git.description": "Commits, branches e pull requests", "contextRail.surface.terminal.description": "Terminal integrado", "contextRail.surface.diff.description": "Revisar alterações em andamento", + "contextPanel.mode.walkthrough": "Percurso", + "contextRail.surface.walkthrough.description": "Um percurso pelas suas mudanças guiado por IA", + "walkthrough.scope.all": "Tudo sem commit", + "walkthrough.scope.group.workingTree": "Árvore de trabalho", + "walkthrough.scope.group.committed": "Com commit", + "walkthrough.scope.staged": "No stage", + "walkthrough.scope.working": "Fora do stage", + "walkthrough.scope.branch": "Este branch", + "walkthrough.scope.selectorAria": "Escolher o que revisar", + "walkthrough.scope.pullRequest": "PR nº {number}", + "walkthrough.action.generate": "Gerar percurso", + "walkthrough.action.regenerate": "Gerar novamente", + "walkthrough.action.cancel": "Cancelar", + "walkthrough.action.next": "Próxima etapa", + "walkthrough.action.previous": "Etapa anterior", + "walkthrough.action.refresh": "Atualizar", + "walkthrough.action.open": "Percurso com IA", + "walkthrough.stage.collecting": "Reunindo as mudanças", + "walkthrough.stage.asking": "Aguardando o modelo", + "walkthrough.stage.assembling": "Montando o percurso", + "walkthrough.empty.title": "Ainda não há percurso", + "walkthrough.empty.description": "Monte um percurso de leitura por estas mudanças. Isso chama o modelo pequeno e consome tokens, então só roda quando você pede.", + "walkthrough.stale.banner": "O código mudou depois desta revisão. Etapas desatualizadas: {count}", + "walkthrough.stop.staleAll": "Todo o código que esta etapa descrevia mudou.", + "walkthrough.stop.stalePartial": "Parte do código que esta etapa descrevia mudou. Trechos perdidos: {count}", + "walkthrough.stop.staleShort": "Desatualizado", + "walkthrough.stop.noCode": "O código desta etapa não está no diff atual.", + "walkthrough.uncovered.title": "Mudanças que a revisão deixou de fora: {count}", + "walkthrough.uncovered.description": "A revisão considerou essas mudanças rotineiras. Expanda para conferir você mesmo.", + "walkthrough.toc.moreFiles": "Mais arquivos: {count}", + "walkthrough.toc.uncovered": "Sem cobertura: {count}", + "walkthrough.toc.resize": "Redimensionar a coluna de conteúdo", + "walkthrough.importance.critical": "Crítico", + "walkthrough.importance.context": "Contexto", + "walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível", + "walkthrough.blocked.noModel.description": "Entre em um provedor de modelos para gerar uma revisão.", + "walkthrough.blocked.emptyDiff.title": "Nada para revisar", + "walkthrough.blocked.emptyDiff.description": "Ainda não há mudanças neste escopo.", + "walkthrough.blocked.contextTooSmall.title": "Este diff é grande demais para o modelo atual", + "walkthrough.blocked.contextTooSmall.description": "{model} comporta cerca de {available} mil caracteres e este diff precisa de cerca de {required} mil. Nada é cortado — escolha um modelo com contexto maior.", + "walkthrough.blocked.structuredOutput.title": "Este modelo não produz saída estruturada", + "walkthrough.blocked.structuredOutput.description": "{model} não suporta as respostas estruturadas que um percurso exige.", + "walkthrough.blocked.chooseModel": "Escolha o modelo pequeno", + "walkthrough.blocked.outputExhausted.title": "O modelo ficou sem orçamento de resposta", + "walkthrough.blocked.outputExhausted.description": "{model} gastou toda a margem de saída raciocinando e não devolveu nada. Modelos de raciocínio costumam fazer isso em diffs grandes — escolha um modelo que raciocine menos ou revise um escopo menor.", + "walkthrough.blocked.outputExhausted.descriptionUnknownModel": "O modelo pequeno gastou toda a margem de saída raciocinando e não devolveu nada. Modelos de raciocínio costumam fazer isso em diffs grandes — escolha um modelo que raciocine menos ou revise um escopo menor.", + "walkthrough.blocked.onlyGenerated.title": "Só mudaram arquivos gerados", + "walkthrough.blocked.onlyGenerated.description": "Todas as mudanças são arquivos de lock ou outra saída gerada por ferramentas, que a revisão ignora de propósito.", + "walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "O modelo pequeno comporta cerca de {available} mil caracteres e este diff precisa de cerca de {required} mil. Nada é cortado — escolha um modelo com contexto maior.", + "walkthrough.blocked.structuredOutput.descriptionUnknownModel": "O modelo pequeno não suporta as respostas estruturadas que um percurso exige.", "contextRail.surface.plan.description": "Ver o plano atual", "contextRail.surface.pr.description": "Crie, revise e faça merge do pull request do branch atual", "contextRail.surface.notes.description": "Notas, tarefas e planos do projeto", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 09bab6b9..65655407 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1520,6 +1520,10 @@ export const settingsDict = { "settings.openchamber.defaults.smallModel.useDefault": "Використовувати типову малу модель", "settings.openchamber.defaults.smallModel.useDefaultAria": "Використовувати типову малу модель", "settings.openchamber.defaults.smallModel.overrideModel": "Модель заміни", + "settings.openchamber.defaults.walkthroughModel.title": "Модель для розбору змін", + "settings.openchamber.defaults.walkthroughModel.description": "AI-розбір ваших змін потребує structured output і місця під увесь diff, чого дешева small model часто дати не може. Моделі, які за каталогом не вміють structured output, у цьому списку приховані. Якщо нічого не обрано, використовується small model.", + "settings.openchamber.defaults.walkthroughModel.overrideModel": "Модель для розбору", + "settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Small model", "settings.openchamber.defaults.field.showDeletionDialog": "Показати діалогове вікно видалення", "settings.openchamber.defaults.field.openFilesPreviewAria": "Відкривати файли з підтримкою перегляду в режимі попереднього перегляду", "settings.openchamber.defaults.field.openFilesPreview": "Відкривати файли з підтримкою перегляду в режимі попереднього перегляду", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 97ab6b7f..a0ef8ea4 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1096,6 +1096,56 @@ export const dict: Record = { "contextRail.surface.git.description": "Коміти, гілки та pull request-и", "contextRail.surface.terminal.description": "Вбудований термінал", "contextRail.surface.diff.description": "Перегляд поточних змін", + "contextPanel.mode.walkthrough": "Розбір", + "contextRail.surface.walkthrough.description": "Покроковий розбір ваших змін за допомогою AI", + "walkthrough.scope.all": "Усе незакомічене", + "walkthrough.scope.group.workingTree": "Робоче дерево", + "walkthrough.scope.group.committed": "Закомічене", + "walkthrough.scope.staged": "В індексі", + "walkthrough.scope.working": "Поза індексом", + "walkthrough.scope.branch": "Ця гілка", + "walkthrough.scope.selectorAria": "Обрати, що розбирати", + "walkthrough.scope.pullRequest": "PR #{number}", + "walkthrough.action.generate": "Створити розбір", + "walkthrough.action.regenerate": "Створити заново", + "walkthrough.action.cancel": "Скасувати", + "walkthrough.action.next": "Наступний крок", + "walkthrough.action.previous": "Попередній крок", + "walkthrough.action.refresh": "Оновити", + "walkthrough.action.open": "AI-розбір", + "walkthrough.stage.collecting": "Збираю зміни", + "walkthrough.stage.asking": "Чекаю на модель", + "walkthrough.stage.assembling": "Складаю розбір", + "walkthrough.empty.title": "Розбору ще немає", + "walkthrough.empty.description": "Побудуйте маршрут читання цих змін. Це звертається до small model і витрачає токени, тому запускається лише на ваш запит.", + "walkthrough.stale.banner": "Код змінився після цього розбору. Застарілих кроків: {count}", + "walkthrough.stop.staleAll": "Увесь код, який описував цей крок, змінився.", + "walkthrough.stop.stalePartial": "Частина коду, який описував цей крок, змінилася. Втрачених фрагментів: {count}", + "walkthrough.stop.staleShort": "Застаріле", + "walkthrough.stop.noCode": "Коду для цього кроку немає в поточному diff.", + "walkthrough.uncovered.title": "Зміни, які розбір не описав: {count}", + "walkthrough.uncovered.description": "Розбір визнав їх рутинними. Розгорніть, щоб переглянути самостійно.", + "walkthrough.toc.moreFiles": "Ще файлів: {count}", + "walkthrough.toc.uncovered": "Не описано: {count}", + "walkthrough.toc.resize": "Змінити ширину колонки змісту", + "walkthrough.importance.critical": "Критично", + "walkthrough.importance.context": "Контекст", + "walkthrough.blocked.noModel.title": "Немає доступної small model", + "walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.", + "walkthrough.blocked.emptyDiff.title": "Немає що оглядати", + "walkthrough.blocked.emptyDiff.description": "У цій області поки що немає змін.", + "walkthrough.blocked.contextTooSmall.title": "Цей diff завеликий для поточної моделі", + "walkthrough.blocked.contextTooSmall.description": "{model} вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.", + "walkthrough.blocked.structuredOutput.title": "Ця модель не вміє structured output", + "walkthrough.blocked.structuredOutput.description": "{model} не підтримує структуровані відповіді, потрібні для розбору.", + "walkthrough.blocked.chooseModel": "Оберіть small model", + "walkthrough.blocked.outputExhausted.title": "Моделі забракло бюджету на відповідь", + "walkthrough.blocked.outputExhausted.description": "{model} витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.", + "walkthrough.blocked.outputExhausted.descriptionUnknownModel": "Small model витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.", + "walkthrough.blocked.onlyGenerated.title": "Змінились лише згенеровані файли", + "walkthrough.blocked.onlyGenerated.description": "Усі зміни тут — це lock-файли чи інший результат роботи інструментів, які розбір свідомо пропускає.", + "walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "Small model вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.", + "walkthrough.blocked.structuredOutput.descriptionUnknownModel": "Small model не підтримує структуровані відповіді, потрібні для розбору.", "contextRail.surface.plan.description": "Перегляд поточного плану", "contextRail.surface.pr.description": "Створюйте, переглядайте та зливайте pull request поточної гілки", "contextRail.surface.notes.description": "Нотатки, задачі та плани проєкту", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 43f8f964..3650a25a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1520,6 +1520,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': '使用默认小模型', 'settings.openchamber.defaults.smallModel.useDefaultAria': '使用默认小模型', 'settings.openchamber.defaults.smallModel.overrideModel': '覆盖模型', + 'settings.openchamber.defaults.walkthroughModel.title': '改动导读模型', + 'settings.openchamber.defaults.walkthroughModel.description': 'AI 评审改动需要结构化输出和容纳整份差异的空间,廉价的小模型往往做不到。目录中标注为不支持结构化输出的模型不会出现在此列表里。 未选择时将使用小模型。', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': '导读模型', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '小模型', 'settings.openchamber.defaults.field.showDeletionDialog': '显示删除对话框', 'settings.openchamber.defaults.field.openFilesPreviewAria': '以预览模式打开可预览文件', 'settings.openchamber.defaults.field.openFilesPreview': '以预览模式打开可预览文件', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index ba7502ed..f9a36b56 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1096,6 +1096,56 @@ export const dict: Record = { 'contextRail.surface.git.description': '提交、分支和拉取请求', 'contextRail.surface.terminal.description': '内置终端', 'contextRail.surface.diff.description': '查看工作区更改', + 'contextPanel.mode.walkthrough': '导读', + 'contextRail.surface.walkthrough.description': '由 AI 引导的改动导读', + 'walkthrough.scope.all': '全部未提交', + 'walkthrough.scope.group.workingTree': '工作区', + 'walkthrough.scope.group.committed': '已提交', + 'walkthrough.scope.staged': '已暂存', + 'walkthrough.scope.working': '未暂存', + 'walkthrough.scope.branch': '当前分支', + 'walkthrough.scope.selectorAria': '选择评审范围', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': '生成导读', + 'walkthrough.action.regenerate': '重新生成', + 'walkthrough.action.cancel': '取消', + 'walkthrough.action.next': '下一步', + 'walkthrough.action.previous': '上一步', + 'walkthrough.action.refresh': '刷新', + 'walkthrough.action.open': 'AI 导读', + 'walkthrough.stage.collecting': '正在收集改动', + 'walkthrough.stage.asking': '正在等待模型', + 'walkthrough.stage.assembling': '正在组装导读', + 'walkthrough.empty.title': '还没有导读', + 'walkthrough.empty.description': '为这些改动生成一条引导式阅读路线。该操作会调用小模型并消耗 token,因此只在你主动请求时运行。', + 'walkthrough.stale.banner': '本次评审之后代码有变动。过时的步骤:{count}', + 'walkthrough.stop.staleAll': '该步骤描述的代码已全部变动。', + 'walkthrough.stop.stalePartial': '该步骤描述的部分代码已变动。缺失片段:{count}', + 'walkthrough.stop.staleShort': '已过时', + 'walkthrough.stop.noCode': '当前差异中没有该步骤的代码。', + 'walkthrough.uncovered.title': '评审未涵盖的改动:{count}', + 'walkthrough.uncovered.description': '评审认为这些改动较常规。展开可自行查看。', + 'walkthrough.toc.moreFiles': '其他文件:{count}', + 'walkthrough.toc.uncovered': '未涵盖:{count}', + 'walkthrough.toc.resize': '调整目录栏宽度', + 'walkthrough.importance.critical': '关键', + 'walkthrough.importance.context': '背景', + 'walkthrough.blocked.noModel.title': '没有可用的小模型', + 'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。', + 'walkthrough.blocked.emptyDiff.title': '没有可评审的内容', + 'walkthrough.blocked.emptyDiff.description': '该范围内暂无改动。', + 'walkthrough.blocked.contextTooSmall.title': '当前模型无法容纳这份差异', + 'walkthrough.blocked.contextTooSmall.description': '{model} 大约可容纳 {available} 千字符,而这份差异约需 {required} 千字符。我们不会截断内容,请改选上下文更大的模型。', + 'walkthrough.blocked.structuredOutput.title': '该模型不支持结构化输出', + 'walkthrough.blocked.structuredOutput.description': '{model} 不支持导读所需的结构化响应。', + 'walkthrough.blocked.chooseModel': '选择小模型', + 'walkthrough.blocked.outputExhausted.title': '模型的回答额度已用尽', + 'walkthrough.blocked.outputExhausted.description': '{model} 把全部输出额度用在了推理上,没有返回结果。推理模型在大差异上经常如此——可以换一个少推理的模型,或缩小评审范围。', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部输出额度用在了推理上,没有返回结果。推理模型在大差异上经常如此——可以换一个少推理的模型,或缩小评审范围。', + 'walkthrough.blocked.onlyGenerated.title': '只有生成文件发生了改动', + 'walkthrough.blocked.onlyGenerated.description': '这里的改动全部是锁文件或其他工具生成的产物,评审会有意跳过它们。', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大约可容纳 {available} 千字符,而这份差异约需 {required} 千字符。我们不会截断内容,请改选上下文更大的模型。', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支持导读所需的结构化响应。', 'contextRail.surface.plan.description': '查看当前计划', 'contextRail.surface.pr.description': '创建、审查并合并当前分支的拉取请求', 'contextRail.surface.notes.description': '项目的笔记、待办和计划', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 332d0f91..c027b3ad 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1426,6 +1426,10 @@ 'settings.openchamber.defaults.smallModel.useDefault': '使用預設小模型', 'settings.openchamber.defaults.smallModel.useDefaultAria': '使用預設小模型', 'settings.openchamber.defaults.smallModel.overrideModel': '覆寫模型', + 'settings.openchamber.defaults.walkthroughModel.title': '變更導讀模型', + 'settings.openchamber.defaults.walkthroughModel.description': 'AI 審閱變更需要結構化輸出與容納整份差異的空間,便宜的小模型常常做不到。目錄標示為不支援結構化輸出的模型不會出現在這份清單中。 未選擇時將使用小模型。', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': '導讀模型', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '小模型', 'settings.openchamber.defaults.field.showDeletionDialog': '顯示刪除對話方塊', 'settings.openchamber.defaults.field.openFilesPreviewAria': '以預覽模式開啟可預覽檔案', 'settings.openchamber.defaults.field.openFilesPreview': '以預覽模式開啟可預覽檔案', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index a48f4f65..862c7df1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1108,6 +1108,56 @@ export const dict: Record = { 'contextRail.surface.git.description': '提交、分支與拉取請求', 'contextRail.surface.terminal.description': '內建終端機', 'contextRail.surface.diff.description': '檢視工作區變更', + 'contextPanel.mode.walkthrough': '導讀', + 'contextRail.surface.walkthrough.description': '由 AI 引導的變更導讀', + 'walkthrough.scope.all': '全部未提交', + 'walkthrough.scope.group.workingTree': '工作區', + 'walkthrough.scope.group.committed': '已提交', + 'walkthrough.scope.staged': '已暫存', + 'walkthrough.scope.working': '未暫存', + 'walkthrough.scope.branch': '目前分支', + 'walkthrough.scope.selectorAria': '選擇審閱範圍', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': '產生導讀', + 'walkthrough.action.regenerate': '重新產生', + 'walkthrough.action.cancel': '取消', + 'walkthrough.action.next': '下一步', + 'walkthrough.action.previous': '上一步', + 'walkthrough.action.refresh': '重新整理', + 'walkthrough.action.open': 'AI 導讀', + 'walkthrough.stage.collecting': '正在收集變更', + 'walkthrough.stage.asking': '正在等待模型', + 'walkthrough.stage.assembling': '正在組裝導讀', + 'walkthrough.empty.title': '尚未有導讀', + 'walkthrough.empty.description': '為這些變更建立一條引導式閱讀路線。此操作會呼叫小模型並消耗 token,因此只在你主動要求時執行。', + 'walkthrough.stale.banner': '本次審閱之後程式碼有變動。過時的步驟:{count}', + 'walkthrough.stop.staleAll': '此步驟描述的程式碼已全部變動。', + 'walkthrough.stop.stalePartial': '此步驟描述的部分程式碼已變動。缺少的片段:{count}', + 'walkthrough.stop.staleShort': '已過時', + 'walkthrough.stop.noCode': '目前差異中沒有此步驟的程式碼。', + 'walkthrough.uncovered.title': '審閱未涵蓋的變更:{count}', + 'walkthrough.uncovered.description': '審閱認為這些變更較為例行。展開即可自行檢視。', + 'walkthrough.toc.moreFiles': '其他檔案:{count}', + 'walkthrough.toc.uncovered': '未涵蓋:{count}', + 'walkthrough.toc.resize': '調整目錄欄寬度', + 'walkthrough.importance.critical': '關鍵', + 'walkthrough.importance.context': '背景', + 'walkthrough.blocked.noModel.title': '沒有可用的小模型', + 'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。', + 'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容', + 'walkthrough.blocked.emptyDiff.description': '此範圍目前沒有變更。', + 'walkthrough.blocked.contextTooSmall.title': '目前模型無法容納這份差異', + 'walkthrough.blocked.contextTooSmall.description': '{model} 大約可容納 {available} 千字元,而這份差異約需 {required} 千字元。我們不會截斷內容,請改選上下文更大的模型。', + 'walkthrough.blocked.structuredOutput.title': '此模型不支援結構化輸出', + 'walkthrough.blocked.structuredOutput.description': '{model} 不支援導讀所需的結構化回應。', + 'walkthrough.blocked.chooseModel': '選擇小模型', + 'walkthrough.blocked.outputExhausted.title': '模型的回答額度已用完', + 'walkthrough.blocked.outputExhausted.description': '{model} 把全部輸出額度用在推理上,沒有回傳結果。推理模型在大型差異上經常如此——可以改用較少推理的模型,或縮小審閱範圍。', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部輸出額度用在推理上,沒有回傳結果。推理模型在大型差異上經常如此——可以改用較少推理的模型,或縮小審閱範圍。', + 'walkthrough.blocked.onlyGenerated.title': '只有產生的檔案有變動', + 'walkthrough.blocked.onlyGenerated.description': '這裡的變更全部是鎖定檔或其他工具產生的輸出,審閱會刻意略過它們。', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大約可容納 {available} 千字元,而這份差異約需 {required} 千字元。我們不會截斷內容,請改選上下文更大的模型。', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支援導讀所需的結構化回應。', 'contextRail.surface.plan.description': '檢視目前計畫', 'contextRail.surface.pr.description': '建立、審查並合併目前分支的提取請求', 'contextRail.surface.notes.description': '專案的筆記、待辦與計畫', diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index e89cddbf..5dcc40ab 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1169,6 +1169,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) { result.smallModelOverride = candidate.smallModelOverride; } + if (typeof candidate.walkthroughModelOverride === 'string' && candidate.walkthroughModelOverride.length > 0) { + result.walkthroughModelOverride = candidate.walkthroughModelOverride; + } if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree; } diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 33db680a..50288fc7 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -378,6 +378,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.openchamber.defaults.smallModel.description', keywords: ['small model', 'utility', 'summary', 'recap', 'cheap', 'override'], }, + { + id: 'sessions.walkthrough-model', + page: 'sessions', + titleKey: 'settings.openchamber.defaults.walkthroughModel.title', + descriptionKey: 'settings.openchamber.defaults.walkthroughModel.description', + keywords: ['walkthrough', 'diff', 'review', 'changes', 'structured output', 'model', 'override'], + }, { id: 'sessions.auto-cleanup', page: 'sessions', diff --git a/packages/ui/src/lib/surfaces/registry.ts b/packages/ui/src/lib/surfaces/registry.ts index 57aa8744..e2aa44b2 100644 --- a/packages/ui/src/lib/surfaces/registry.ts +++ b/packages/ui/src/lib/surfaces/registry.ts @@ -7,6 +7,7 @@ export type ContextSurfaceId = | 'git' | 'pr' | 'diff' + | 'walkthrough' | 'terminal' | 'plan' | 'notes' @@ -74,6 +75,15 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [ labelKey: 'contextPanel.mode.diff', availability: 'always', }, + { + id: 'walkthrough', + descriptionKey: 'contextRail.surface.walkthrough.description', + defaultWidthFraction: 3 / 5, + mode: 'walkthrough', + icon: 'route', + labelKey: 'contextPanel.mode.walkthrough', + availability: 'always', + }, { id: 'editor', descriptionKey: 'contextRail.surface.editor.description', diff --git a/packages/ui/src/lib/walkthrough/api.ts b/packages/ui/src/lib/walkthrough/api.ts new file mode 100644 index 00000000..482c033a --- /dev/null +++ b/packages/ui/src/lib/walkthrough/api.ts @@ -0,0 +1,106 @@ +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { + WalkthroughError, + type WalkthroughResult, + type WalkthroughSource, + type WalkthroughStage, +} from './types'; + +const BASE = '/api/walkthrough'; + +interface ErrorPayload { + error?: unknown; + code?: unknown; + model?: unknown; + requiredChars?: unknown; + availableChars?: unknown; +} + +// An authoritative read that fails must never look like "there is nothing +// here" — the caller would clear a perfectly good walkthrough off the screen. +const throwFromResponse = async (response: Response, fallback: string): Promise => { + const payload = (await response.json().catch(() => null)) as ErrorPayload | null; + throw new WalkthroughError(typeof payload?.error === 'string' ? payload.error : fallback, { + code: typeof payload?.code === 'string' ? (payload.code as WalkthroughError['code']) : undefined, + model: (payload?.model as WalkthroughResult['model']) ?? undefined, + requiredChars: typeof payload?.requiredChars === 'number' ? payload.requiredChars : undefined, + availableChars: typeof payload?.availableChars === 'number' ? payload.availableChars : undefined, + }); +}; + +export async function fetchWalkthrough( + directory: string, + source: WalkthroughSource, + options: { model?: string; signal?: AbortSignal } = {} +): Promise { + const response = await runtimeFetch(BASE, { + query: { + directory, + source: JSON.stringify(source), + ...(options.model ? { model: options.model } : {}), + }, + signal: options.signal, + }); + if (!response.ok) { + return throwFromResponse(response, 'Failed to load walkthrough'); + } + return response.json(); +} + +export async function generateWalkthrough( + directory: string, + source: WalkthroughSource, + options: { force?: boolean; model?: string; signal?: AbortSignal } = {} +): Promise { + const response = await runtimeFetch(`${BASE}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + directory, + source, + force: options.force === true, + ...(options.model ? { model: options.model } : {}), + }), + signal: options.signal, + }); + if (!response.ok) { + return throwFromResponse(response, 'Failed to generate walkthrough'); + } + return response.json(); +} + +/** + * Stop a running generation. Explicit, because merely leaving the page must not + * throw away work the user is paying for. + */ +export async function cancelWalkthroughGeneration( + directory: string, + source: WalkthroughSource +): Promise { + const response = await runtimeFetch(`${BASE}/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory, source }), + }); + if (!response.ok) { + await throwFromResponse(response, 'Failed to cancel walkthrough generation'); + } +} + +/** + * Current stage of a running generation. Reads server memory only, so this is + * safe to poll — unlike the full read, which re-runs the whole git pipeline. + */ +export async function fetchWalkthroughStage( + directory: string, + source: WalkthroughSource, + signal?: AbortSignal +): Promise { + const response = await runtimeFetch(`${BASE}/progress`, { + query: { directory, source: JSON.stringify(source) }, + signal, + }); + if (!response.ok) return null; + const payload = (await response.json().catch(() => null)) as { stage?: unknown } | null; + return typeof payload?.stage === 'string' ? (payload.stage as WalkthroughStage) : null; +} diff --git a/packages/ui/src/lib/walkthrough/model.test.ts b/packages/ui/src/lib/walkthrough/model.test.ts new file mode 100644 index 00000000..8492fc12 --- /dev/null +++ b/packages/ui/src/lib/walkthrough/model.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test as it } from 'bun:test'; +import { buildWalkthroughView, groupHunksByFile, mergeRunPatch, summarizeHunkFiles } from './model'; +import type { WalkthroughHunk, WalkthroughResult } from './types'; + +const hunk = (id: string, path: string, overrides: Partial = {}): WalkthroughHunk => ({ + id, + path, + oldPath: null, + status: 'modified', + scope: 'working', + header: '@@ -1,2 +1,3 @@', + newStart: 1, + added: 1, + deleted: 0, + patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1,2 +1,3 @@\n+line\n`, + ...overrides, +}); + +const result = (overrides: Partial = {}): WalkthroughResult => ({ + source: { kind: 'working-tree', scope: 'all' }, + walkthrough: { + title: 'Change', + focus: 'why', + chapters: [ + { + id: 'chapter-1', + title: 'Data', + icon: 'doc', + blurb: '', + stops: [ + { id: 'stop-1-1', title: 'First', hunkIds: ['a', 'b'], importance: 'critical', prose: 'p1' }, + { id: 'stop-1-2', title: 'Second', hunkIds: ['c'], importance: 'normal', prose: 'p2' }, + ], + }, + ], + }, + hunks: [hunk('a', 'src/a.ts'), hunk('b', 'src/a.ts'), hunk('c', 'src/b.ts')], + hunkCount: 3, + ...overrides, +}); + +describe('buildWalkthroughView', () => { + it('resolves stops and numbers them globally', () => { + const view = buildWalkthroughView(result())!; + + expect(view.stops).toHaveLength(2); + expect(view.stops.map((stop) => stop.position)).toEqual([1, 2]); + expect(view.stops[0].hunks.map((h) => h.id)).toEqual(['a', 'b']); + expect(view.isStale).toBe(false); + expect(view.uncoveredHunks).toEqual([]); + }); + + it('marks only the stops whose code changed', () => { + const view = buildWalkthroughView(result({ + hunks: [hunk('a', 'src/a.ts'), hunk('c', 'src/b.ts')], + }))!; + + expect(view.stops[0].isStale).toBe(true); + expect(view.stops[0].missingHunkIds).toEqual(['b']); + expect(view.stops[0].hunks.map((h) => h.id)).toEqual(['a']); + expect(view.stops[1].isStale).toBe(false); + expect(view.staleStopCount).toBe(1); + expect(view.isStale).toBe(true); + }); + + it('surfaces hunks no stop covers instead of dropping them', () => { + const view = buildWalkthroughView(result({ + hunks: [hunk('a', 'src/a.ts'), hunk('b', 'src/a.ts'), hunk('c', 'src/b.ts'), hunk('d', 'src/c.ts')], + }))!; + + expect(view.uncoveredHunks.map((h) => h.id)).toEqual(['d']); + }); + + it('returns null without a walkthrough', () => { + expect(buildWalkthroughView(null)).toBeNull(); + expect(buildWalkthroughView(result({ walkthrough: null }))).toBeNull(); + }); +}); + +describe('groupHunksByFile', () => { + it('coalesces consecutive hunks from the same file only', () => { + const runs = groupHunksByFile([ + hunk('a', 'src/a.ts'), + hunk('b', 'src/a.ts'), + hunk('c', 'src/b.ts'), + hunk('d', 'src/a.ts'), + ]); + + expect(runs.map((run) => [run.path, run.hunks.length])).toEqual([ + ['src/a.ts', 2], + ['src/b.ts', 1], + ['src/a.ts', 1], + ]); + }); +}); + +describe('mergeRunPatch', () => { + it('keeps a single patch untouched', () => { + const single = hunk('a', 'src/a.ts'); + expect(mergeRunPatch([single])).toBe(single.patch); + }); + + it('joins hunks under one file header', () => { + const first = hunk('a', 'src/a.ts'); + const second = hunk('b', 'src/a.ts', { + patch: 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -10,2 +11,3 @@\n+second\n', + }); + + const merged = mergeRunPatch([first, second]); + + expect(merged.match(/^diff --git/gm)).toHaveLength(1); + expect(merged.match(/^@@/gm)).toHaveLength(2); + expect(merged).toContain('+line'); + expect(merged).toContain('+second'); + }); + + it('returns nothing for an empty run', () => { + expect(mergeRunPatch([])).toBe(''); + }); +}); + +describe('summarizeHunkFiles', () => { + it('totals per file in first-appearance order', () => { + const files = summarizeHunkFiles([ + hunk('a', 'src/b.ts', { added: 2, deleted: 1 }), + hunk('b', 'src/a.ts', { added: 1, deleted: 0 }), + hunk('c', 'src/b.ts', { added: 3, deleted: 4 }), + ]); + + expect(files).toEqual([ + { path: 'src/b.ts', added: 5, deleted: 5 }, + { path: 'src/a.ts', added: 1, deleted: 0 }, + ]); + }); +}); diff --git a/packages/ui/src/lib/walkthrough/model.ts b/packages/ui/src/lib/walkthrough/model.ts new file mode 100644 index 00000000..ed84f626 --- /dev/null +++ b/packages/ui/src/lib/walkthrough/model.ts @@ -0,0 +1,151 @@ +import type { + Walkthrough, + WalkthroughChapter, + WalkthroughHunk, + WalkthroughResult, + WalkthroughStop, +} from './types'; + +/** + * Flattens a walkthrough plus the current hunk index into the ordered stream + * the view renders: every stop with its resolved hunks, followed by everything + * the walkthrough did not cover. + * + * Resolution is pure id matching — the server owns hunk identity, so a stop + * whose hunks are missing is not a bug to paper over, it is a stop whose code + * has changed. + */ + +export interface WalkthroughStopView { + stop: WalkthroughStop; + chapter: WalkthroughChapter; + chapterIndex: number; + /** Global 1-based position, used for the stepper and keyboard navigation. */ + position: number; + hunks: WalkthroughHunk[]; + /** Anchors that no longer resolve: their code changed or was removed. */ + missingHunkIds: string[]; + isStale: boolean; +} + +export interface WalkthroughView { + walkthrough: Walkthrough; + stops: WalkthroughStopView[]; + chapters: Array<{ chapter: WalkthroughChapter; stops: WalkthroughStopView[] }>; + /** Current hunks no stop covers. Never dropped — rendered as a collapsed tail. */ + uncoveredHunks: WalkthroughHunk[]; + staleStopCount: number; + isStale: boolean; +} + +export const buildWalkthroughView = (result: WalkthroughResult | null): WalkthroughView | null => { + if (!result?.walkthrough) return null; + + const index = new Map(result.hunks.map((hunk) => [hunk.id, hunk])); + const covered = new Set(); + const stops: WalkthroughStopView[] = []; + const chapters: WalkthroughView['chapters'] = []; + + for (const [chapterIndex, chapter] of result.walkthrough.chapters.entries()) { + const chapterStops: WalkthroughStopView[] = []; + + for (const stop of chapter.stops) { + const hunks: WalkthroughHunk[] = []; + const missingHunkIds: string[] = []; + + for (const id of stop.hunkIds) { + const hunk = index.get(id); + if (hunk) { + hunks.push(hunk); + covered.add(id); + } else { + missingHunkIds.push(id); + } + } + + const view: WalkthroughStopView = { + stop, + chapter, + chapterIndex, + position: stops.length + 1, + hunks, + missingHunkIds, + isStale: missingHunkIds.length > 0, + }; + stops.push(view); + chapterStops.push(view); + } + + chapters.push({ chapter, stops: chapterStops }); + } + + return { + walkthrough: result.walkthrough, + stops, + chapters, + uncoveredHunks: result.hunks.filter((hunk) => !covered.has(hunk.id)), + staleStopCount: stops.filter((stop) => stop.isStale).length, + isStale: stops.some((stop) => stop.isStale), + }; +}; + +/** + * Files touched by a set of hunks, in first-appearance order, with their + * per-file totals. Used for the table of contents rows. + */ +export const summarizeHunkFiles = ( + hunks: WalkthroughHunk[] +): Array<{ path: string; added: number; deleted: number }> => { + const byPath = new Map(); + for (const hunk of hunks) { + const existing = byPath.get(hunk.path); + if (existing) { + existing.added += hunk.added; + existing.deleted += hunk.deleted; + continue; + } + byPath.set(hunk.path, { path: hunk.path, added: hunk.added, deleted: hunk.deleted }); + } + return [...byPath.values()]; +}; + +/** + * Consecutive hunks from the same file are rendered as one diff block so the + * reader sees continuous code instead of a stack of one-hunk cards. + */ +export const groupHunksByFile = ( + hunks: WalkthroughHunk[] +): Array<{ path: string; hunks: WalkthroughHunk[] }> => { + const runs: Array<{ path: string; hunks: WalkthroughHunk[] }> = []; + for (const hunk of hunks) { + const last = runs.at(-1); + if (last && last.path === hunk.path) { + last.hunks.push(hunk); + continue; + } + runs.push({ path: hunk.path, hunks: [hunk] }); + } + return runs; +}; + +/** + * Merge a file's hunk patches back into one patch so a run renders as a single + * diff. All hunks in a run share a file header, so only the first one's header + * is kept. + */ +export const mergeRunPatch = (hunks: WalkthroughHunk[]): string => { + if (hunks.length === 0) return ''; + if (hunks.length === 1) return hunks[0].patch; + + const first = hunks[0].patch; + const headerEnd = first.indexOf('\n@@'); + if (headerEnd === -1) return first; + + const header = first.slice(0, headerEnd); + const bodies = hunks.map((hunk) => { + const start = hunk.patch.indexOf('\n@@'); + return start === -1 ? '' : hunk.patch.slice(start + 1); + }); + + return `${header}\n${bodies.join('')}`; +}; diff --git a/packages/ui/src/lib/walkthrough/types.ts b/packages/ui/src/lib/walkthrough/types.ts new file mode 100644 index 00000000..b6240fd2 --- /dev/null +++ b/packages/ui/src/lib/walkthrough/types.ts @@ -0,0 +1,132 @@ +/** + * Contract for the AI diff walkthrough, mirrored from + * `packages/web/server/lib/walkthrough`. + * + * Hunk ids are opaque here on purpose: the server owns how they are derived, + * and the client only ever matches them against the index it is handed. + */ + +export type WalkthroughWorkingTreeScope = 'all' | 'staged' | 'working'; + +export type WalkthroughSource = + | { kind: 'working-tree'; scope: WalkthroughWorkingTreeScope } + | { kind: 'branch'; baseRef: string; headRef: string } + | { kind: 'pr'; number: number }; + +export type WalkthroughChapterIcon = 'bug' | 'wrench' | 'path' | 'flask' | 'doc' | 'gear'; +export type WalkthroughStopImportance = 'critical' | 'normal' | 'context'; + +export interface WalkthroughStop { + id: string; + title: string; + hunkIds: string[]; + importance: WalkthroughStopImportance; + prose: string; +} + +export interface WalkthroughChapter { + id: string; + title: string; + icon: WalkthroughChapterIcon; + blurb: string; + stops: WalkthroughStop[]; +} + +export interface Walkthrough { + title: string; + focus: string; + chapters: WalkthroughChapter[]; +} + +export interface WalkthroughHunk { + id: string; + path: string; + oldPath: string | null; + status: 'added' | 'deleted' | 'modified' | 'renamed'; + scope: string; + header: string; + newStart: number; + added: number; + deleted: number; + /** Standalone patch for this hunk, including the file header. */ + patch: string; +} + +export interface WalkthroughModel { + providerID: string; + modelID: string; + source?: string; +} + +export interface WalkthroughResult { + source: WalkthroughSource; + walkthrough: Walkthrough | null; + model?: WalkthroughModel; + generatedAt?: string; + fromCache?: boolean; + hunks: WalkthroughHunk[]; + hunkCount: number; + /** True when at least one stop points at code that has since changed. */ + isStale?: boolean; + missingHunkIds?: string[]; + staleStopIds?: string[]; + /** Hunks in the current diff that no stop covers. Rendered as a tail. */ + uncoveredHunkIds?: string[]; + /** Whether generating is possible at all, computed from the same diff. */ + readiness?: WalkthroughReadiness; + /** A generation is already running on the server for this source. */ + generating?: boolean; +} + +/** + * Only phases a person can wait on. Building the digest and reading the cache + * take milliseconds; naming them would imply progress that is not happening. + */ +export type WalkthroughStage = 'collecting' | 'asking' | 'retrying' | 'assembling'; + +export type WalkthroughBlockedReason = + | 'no-model' + | 'empty-diff' + | 'only-generated' + | 'context-too-small' + | 'structured-output-unsupported' + | 'output-exhausted'; + +export interface WalkthroughReadiness { + ready: boolean; + reason?: WalkthroughBlockedReason; + model?: WalkthroughModel & { + inputCharBudget?: number; + contextTokens?: number; + structuredOutput?: boolean | null; + }; + requiredChars?: number; + availableChars?: number; + hunkCount?: number; + fileCount?: number; + generatedFileCount?: number; +} + +export class WalkthroughError extends Error { + readonly code?: WalkthroughBlockedReason | 'invalid-walkthrough' | 'github-not-connected' | 'no-github-remote'; + readonly model?: WalkthroughModel; + readonly requiredChars?: number; + readonly availableChars?: number; + + constructor( + message: string, + details: { + code?: WalkthroughError['code']; + model?: WalkthroughModel; + requiredChars?: number; + availableChars?: number; + } = {} + ) { + super(message); + this.name = 'WalkthroughError'; + this.code = details.code; + this.model = details.model; + this.requiredChars = details.requiredChars; + this.availableChars = details.availableChars; + } +} diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index a8c1d3f4..4fe82ee2 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -441,6 +441,7 @@ interface ModelsDevModelEntry { reasoning?: boolean; temperature?: boolean; attachment?: boolean; + structured_output?: boolean; modalities?: { input?: string[]; output?: string[]; @@ -577,6 +578,8 @@ const transformModelsDevResponse = (payload: unknown): Map { touchedAt?: unknown; }; - if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') { + if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') { continue; } @@ -654,6 +654,8 @@ interface UIStore { diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side'; diffFileLayout: Record; diffWrapLines: boolean; + /** Width of the walkthrough table of contents, in pixels. */ + walkthroughTocWidth: number; gitChangesViewMode: 'flat' | 'tree'; isTimelineDialogOpen: boolean; isPromptNavigatorPanelOpen: boolean; @@ -825,6 +827,7 @@ interface UIStore { setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void; setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void; setDiffWrapLines: (wrap: boolean) => void; + setWalkthroughTocWidth: (width: number) => void; setGitChangesViewMode: (mode: 'flat' | 'tree') => void; setMultiRunLauncherOpen: (open: boolean) => void; setTimelineDialogOpen: (open: boolean) => void; @@ -966,6 +969,7 @@ export const useUIStore = create()( diffLayoutPreference: 'inline', diffFileLayout: {}, diffWrapLines: false, + walkthroughTocWidth: 224, gitChangesViewMode: 'flat', isTimelineDialogOpen: false, isPromptNavigatorPanelOpen: false, @@ -1828,6 +1832,10 @@ export const useUIStore = create()( set({ diffWrapLines: wrap }); }, + setWalkthroughTocWidth: (width) => { + set({ walkthroughTocWidth: Math.round(width) }); + }, + setGitChangesViewMode: (mode) => { set({ gitChangesViewMode: mode }); }, @@ -2434,6 +2442,7 @@ export const useUIStore = create()( recentEfforts: state.recentEfforts, diffLayoutPreference: state.diffLayoutPreference, diffWrapLines: state.diffWrapLines, + walkthroughTocWidth: state.walkthroughTocWidth, gitChangesViewMode: state.gitChangesViewMode, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, diff --git a/packages/ui/src/stores/useWalkthroughStore.test.ts b/packages/ui/src/stores/useWalkthroughStore.test.ts new file mode 100644 index 00000000..643bc9a2 --- /dev/null +++ b/packages/ui/src/stores/useWalkthroughStore.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { WalkthroughResult, WalkthroughSource } from '@/lib/walkthrough/types'; + +const SOURCE: WalkthroughSource = { kind: 'working-tree', scope: 'all' }; + +const result = (overrides: Partial = {}): WalkthroughResult => ({ + source: SOURCE, + walkthrough: null, + hunks: [], + hunkCount: 0, + ...overrides, +}); + +const finished = result({ + walkthrough: { + title: 'Change', + focus: '', + chapters: [{ + id: 'chapter-1', + title: 'Data', + icon: 'doc', + blurb: '', + stops: [{ id: 'stop-1-1', title: 'A', hunkIds: ['h'], importance: 'normal', prose: 'p' }], + }], + }, + generatedAt: '2026-08-02T00:00:00.000Z', +}); + +// Plain closures rather than mock helpers: bun's `mock()` is not typed with +// vitest's `mockResolvedValue` family, and the repo already prefers this style. +let readResult: WalkthroughResult = result(); +let generateCalls = 0; +let releaseGeneration: (() => void) | undefined; +let lastReadModel: string | undefined; +let lastGenerateModel: string | undefined; + +mock.module('@/lib/walkthrough/api', () => ({ + fetchWalkthrough: async ( + _directory: string, + _source: WalkthroughSource, + options: { model?: string } = {}, + ) => { + lastReadModel = options.model; + return readResult; + }, + generateWalkthrough: async ( + _directory: string, + _source: WalkthroughSource, + options: { model?: string } = {}, + ) => { + generateCalls += 1; + lastGenerateModel = options.model; + return new Promise((resolve) => { + releaseGeneration = () => resolve(finished); + }); + }, + cancelWalkthroughGeneration: async () => {}, +})); +mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'local' })); + +const { useWalkthroughStore } = await import('./useWalkthroughStore'); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('useWalkthroughStore — reattaching to a running generation', () => { + beforeEach(() => { + useWalkthroughStore.getState().reset(); + readResult = result(); + generateCalls = 0; + releaseGeneration = undefined; + }); + + afterEach(() => { + useWalkthroughStore.getState().reset(); + }); + + test('a reload that finds work in progress ends up showing the finished result', async () => { + // What a refresh looks like: the read says a job is running, and the + // generation the client re-attaches to finishes a moment later. + readResult = result({ generating: true }); + + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + + expect(generateCalls).toBe(1); + expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('generating'); + + releaseGeneration?.(); + await flush(); + + const entry = useWalkthroughStore.getState().getEntry('/repo', SOURCE); + expect(entry.status).toBe('ready'); + expect(entry.result?.walkthrough?.title).toBe('Change'); + }); + + test('does not re-attach when nothing is running', async () => { + readResult = result({ generating: false }); + + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + + expect(generateCalls).toBe(0); + expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('ready'); + }); + + test('a load while generating does not overwrite the pending state', async () => { + readResult = result({ generating: true }); + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + + expect(generateCalls).toBe(1); + expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('generating'); + }); +}); + +describe('useWalkthroughStore — model selection', () => { + beforeEach(() => { + useWalkthroughStore.getState().reset(); + readResult = result(); + generateCalls = 0; + lastReadModel = undefined; + lastGenerateModel = undefined; + }); + + afterEach(() => { + useWalkthroughStore.getState().reset(); + }); + + test('sends the chosen model with both the read and the generation', async () => { + useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5'); + + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + expect(lastReadModel).toBe('anthropic/claude-haiku-4-5'); + + void useWalkthroughStore.getState().generate('/repo', SOURCE); + await flush(); + expect(lastGenerateModel).toBe('anthropic/claude-haiku-4-5'); + releaseGeneration?.(); + await flush(); + }); + + test('clearing the choice falls back to whatever the server resolves', async () => { + useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5'); + useWalkthroughStore.getState().selectModel('/repo', SOURCE, null); + + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + + expect(lastReadModel).toBe(undefined); + }); + + test('keeps choices apart per source', async () => { + const branch: WalkthroughSource = { kind: 'branch', baseRef: 'main', headRef: 'feature' }; + useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5'); + + expect(useWalkthroughStore.getState().getSelectedModel("/repo", branch)).toBe(undefined); + expect(useWalkthroughStore.getState().getSelectedModel('/repo', SOURCE)) + .toBe('anthropic/claude-haiku-4-5'); + }); +}); diff --git a/packages/ui/src/stores/useWalkthroughStore.ts b/packages/ui/src/stores/useWalkthroughStore.ts new file mode 100644 index 00000000..452db8fe Binary files /dev/null and b/packages/ui/src/stores/useWalkthroughStore.ts differ diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts index 6a9f49bb..02719c9e 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -13,6 +13,13 @@ export interface ModelMetadata { reasoning?: boolean; temperature?: boolean; attachment?: boolean; + /** + * Whether the model can be constrained to a JSON schema. Tri-state on + * purpose: `undefined` means the catalog does not say — common for + * aggregators and proxies — and must be treated as "worth trying", never as + * unsupported. + */ + structured_output?: boolean; modalities?: { input?: string[]; output?: string[]; diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts index 87a4fd9c..20f4219e 100644 --- a/packages/vscode/src/bridge-settings-runtime.ts +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -291,7 +291,7 @@ export const persistSettings = async (changes: Record, ctx?: Br const keysToClear = new Set(); - for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride']) { + for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride', 'walkthroughModelOverride']) { const value = restChanges[key]; if (typeof value === 'string' && value.trim().length === 0) { keysToClear.add(key); diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index c2bd324d..7dbd31ce 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -26,9 +26,11 @@ The following functions are exported and used by the web server: ### Status and Diff Operations - `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state. - `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree. Untracked symbolic links are represented as link entries without following their targets. -- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs. +- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs. Uses three-dot `base...head` semantics, so work merged into `head` from `base` is excluded and only the branch's own changes are returned. Prefers `origin/` when that remote-tracking ref exists, so a stale local base branch does not resurface already-merged commits. Exposed as `GET /api/git/range-diff` (`path` optional; omit it for the whole range). - `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs. - `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs and symbolic links as their link-target text). +- `listUntrackedPaths(directory)`: List individual untracked file paths honoring ignore rules. Much cheaper than `getStatus` when that is all a caller needs. Deliberately not `--directory`: collapsed directory entries end in a slash and are rejected by the per-file diff helpers, so a caller would silently lose every file inside a new directory. +- `getUntrackedDiffs(directory, filePaths, { concurrency, contextLines })`: Diffs for untracked files against an empty tree. Resolves the repository context once instead of per file (`getDiff` re-resolves every call, costing an extra `rev-parse` each time) and bounds how many diff processes run at once. Returns one entry per input path in order; unreadable paths yield `''` rather than failing the batch. - `collectDiffs(directory, files)`: Collect diff output for multiple files. - `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes. - `stageFile(directory, filePath)`: Add one file path to the index. @@ -109,6 +111,9 @@ The following functions are internal helpers used by exported functions: - `mergeInProgress`: Object with `{ head, message }` if merge in progress. - `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress. +### Runtime availability of range diffs +- `GET /api/git/range-diff` is served by the OpenChamber web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code. + ### Staged and unstaged change handling - `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files. - A file with both staged and unstaged changes can appear in both UI sections. Staged rows request diffs with `staged: true`; unstaged rows request normal working-tree diffs. diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index bbcc9e2f..3766fea2 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -397,6 +397,37 @@ export function registerGitRoutes(app) { } }); + app.get('/api/git/range-diff', async (req, res) => { + const { getRangeDiff } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const base = req.query.base; + const head = req.query.head; + if (!base || typeof base !== 'string' || !head || typeof head !== 'string') { + return res.status(400).json({ error: 'base and head parameters are required' }); + } + + const pathParam = typeof req.query.path === 'string' && req.query.path ? req.query.path : undefined; + const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined; + + const diff = await getRangeDiff(directory, { + base, + head, + path: pathParam, + contextLines: Number.isFinite(context) ? context : 3, + }); + + res.json({ diff }); + } catch (error) { + console.error('Failed to get git range diff:', error); + res.status(500).json({ error: error.message || 'Failed to get git range diff' }); + } + }); + app.post('/api/git/revert', async (req, res) => { const { revertFile } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 8d11486a..4ece0521 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -488,6 +488,16 @@ const createRepositoryGitContext = async (directory) => { return { directoryPath, directoryGit, repoRoot, git }; }; +/** + * Absolute repository root for a directory anywhere inside it. Callers that key + * persisted data by repository need this so two directories in the same + * repository do not address different records. + */ +export async function getRepositoryRoot(directory) { + const { repoRoot } = await createRepositoryGitContext(directory); + return repoRoot; +} + const resolveGitInternalPath = async (repoRoot, git, gitPath) => { const resolved = await git.raw(['rev-parse', '--git-path', gitPath]); return path.resolve(repoRoot, resolved.trim()); @@ -2427,6 +2437,78 @@ export async function getDiff(directory, { path: filePath, staged = false, conte } } +/** + * Individual untracked file paths, honoring ignore rules. + * + * Deliberately not `--directory`: collapsed directory entries end in a slash + * and are not valid inputs to the per-file diff helpers, so a caller would + * silently lose every file inside a new directory. Listing files costs more + * entries but each one is usable. + * + * Callers that only need this list should not pay for `getStatus`, which also + * computes ahead/behind, diff stats, and merge state — an order of magnitude + * more work for an answer they throw away. + */ +export async function listUntrackedPaths(directory) { + const { repoRoot } = await createRepositoryGitContext(directory); + const result = await runGitCommand(repoRoot, [ + 'ls-files', + '--others', + '--exclude-standard', + ]); + if (!result.success) return []; + return String(result.stdout || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +/** + * Diffs for untracked files, produced against an empty tree. + * + * `getDiff` re-resolves the repository context on every call, which costs an + * extra `rev-parse` per file; a walkthrough of a branch with thirty new files + * pays that thirty times. This resolves once and reuses it, with a bounded pool + * so a repository full of new files cannot flood the process table. + * + * Returns one entry per input path, in order; unreadable paths yield `''` + * rather than failing the batch. + */ +export async function getUntrackedDiffs(directory, filePaths = [], { concurrency = 8, contextLines = 3 } = {}) { + const paths = (Array.isArray(filePaths) ? filePaths : []).filter((value) => typeof value === 'string' && value); + if (paths.length === 0) return []; + + const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); + const results = new Array(paths.length).fill(''); + let cursor = 0; + + const worker = async () => { + while (cursor < paths.length) { + const index = cursor++; + try { + const fileContext = await resolveGitFileContext(directoryPath, directoryGit, paths[index], repoRoot); + const args = ['diff', '--no-color']; + if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { + args.push(`-U${Math.max(0, contextLines)}`); + } + args.push('--no-index', '--', '/dev/null', fileContext.repoPath); + try { + results[index] = await git.raw(args); + } catch (error) { + // `git diff --no-index` exits 1 whenever there are differences, which + // for a new file is always. + results[index] = error?.exitCode === 1 && error?.message ? error.message : ''; + } + } catch { + results[index] = ''; + } + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, paths.length) }, worker)); + return results; +} + export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) { const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); const baseRef = typeof base === 'string' ? base.trim() : ''; diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 8c6ba3a3..e9fd8de2 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -1072,6 +1072,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => { req.path.startsWith('/api/permission-auto-accept') || req.path.startsWith('/api/session-folders') || req.path.startsWith('/api/small-model') || + req.path.startsWith('/api/walkthrough') || req.path.startsWith('/api/goals') || req.path.startsWith('/api/text') || req.path.startsWith('/api/voice') || diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index c0b55e99..d127515d 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -1,6 +1,7 @@ import { registerFsRoutes } from '../fs/routes.js'; import { registerQuotaRoutes } from '../quota/routes.js'; import { registerSmallModelRoutes } from '../small-model/routes.js'; +import { registerWalkthroughRoutes } from '../walkthrough/routes.js'; import { registerSessionGoalRoutes } from '../session-goal/routes.js'; import { registerGitHubRoutes } from '../github/routes.js'; import { registerGitRoutes } from '../git/routes.js'; @@ -67,6 +68,18 @@ export const createFeatureRoutesRuntime = (dependencies) => { return smallModelService; }; + let walkthroughService = null; + const getWalkthroughService = async () => { + if (!walkthroughService) { + const [service, pullRequest] = await Promise.all([ + import('../walkthrough/index.js'), + import('../walkthrough/pull-request.js'), + ]); + walkthroughService = { ...service, getPullRequestDiff: pullRequest.getPullRequestDiff }; + } + return walkthroughService; + }; + const registerRoutes = async (app, routeDependencies) => { const { crypto, @@ -264,6 +277,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { registerQuotaRoutes(app, { getQuotaProviders }); registerSmallModelRoutes(app, { getSmallModelService }); + registerWalkthroughRoutes(app, { getWalkthroughService }); registerSessionGoalRoutes(app); registerGitHubRoutes(app); registerGitRoutes(app); diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 4c799991..9393375b 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -443,6 +443,10 @@ export const createSettingsHelpers = (dependencies) => { const trimmed = candidate.smallModelOverride.trim(); result.smallModelOverride = trimmed.length > 0 ? trimmed : undefined; } + if (typeof candidate.walkthroughModelOverride === 'string') { + const trimmed = candidate.walkthroughModelOverride.trim(); + result.walkthroughModelOverride = trimmed.length > 0 ? trimmed : undefined; + } if (typeof candidate.defaultGitIdentityId === 'string') { const trimmed = candidate.defaultGitIdentityId.trim(); result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index c71801fe..2498fa82 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -31,10 +31,43 @@ other runtime API. and as a final utility fallback. 4. Last resort: the session's own model (`preferredModelID`) when no small model resolves anywhere — costlier, but always valid. -- Input clamp: the prompt is truncated to the resolved model's catalog +- Input clamp: the prompt is measured against the resolved model's catalog `limit.context` (minus an output reserve, ~4 chars/token estimate; - conservative default when the model is not in the catalog). Truncation is - reported as `inputTruncated: true` in the response. + conservative default when the model is not in the catalog). `onOverflow` + decides what an oversized prompt means: + - `truncate` (default) clips the tail and reports `inputTruncated: true`. + Correct for callers that degrade gracefully (summaries, commit messages). + - `error` throws a `413` with `code: 'context-too-small'` plus + `requiredChars`/`availableChars`. Correct for callers whose output would be + quietly wrong on a clipped input, so they can ask the user for a roomier + model instead of returning confident nonsense. +- Structured output: pass `responseSchema` (a JSON Schema) to get + schema-shaped JSON back as `text`. Wire support differs per format — + `response_format: {type: 'json_schema'}` for OpenAI-compatible chat, + `text.format` for the Responses API, a forced single tool call for the + Anthropic messages API, and `generationConfig.responseSchema` for Google + (whose OpenAPI-flavored dialect drops unknown JSON Schema keywords). The + ChatGPT-plan codex backend has no equivalent and rejects a schema request + with `code: 'structured-output-unsupported'` rather than silently returning + prose. +- Output budget: `maxOutputTokens` is capped at the catalog's `limit.output` for + the model, and the **same number** is reserved from the input allowance. The + two must not drift — a caller that asks for a large answer while the reserve + 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. +- 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 + `code: 'output-exhausted'` so callers can offer a different model instead of + showing a transport error. +- `timeoutMs` overrides the 60s default per call; `signal` lets a caller abort + a request that is no longer wanted. Both apply to every wire format. +- `describeSmallModel()` additionally reports `inputCharBudget`, + `contextTokens`, `contextKnown`, and `structuredOutput`. The last is + tri-state: `true`/`false` from the catalog, `null` when the catalog omits the + field — which it does for roughly half of all models, aggregators and proxies + especially. Callers must treat `null` as "try it", not "unsupported". - `call.js` — wire formats and per-provider auth, replicating OpenCode's plugin auth loaders: - **GitHub Copilot**: fetches the requested model's authenticated `/models` diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 3eb12cb5..17c8949c 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -25,7 +25,44 @@ const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses'; const httpError = async (response, provider) => { const body = await response.text().catch(() => ''); const snippet = body ? `: ${body.slice(0, 300)}` : ''; - return new Error(`${provider} request failed with ${response.status}${snippet}`); + // Callers need the status to tell "this provider rejected the request shape" + // (retryable with a different shape) from "this provider is down". + return Object.assign(new Error(`${provider} request failed with ${response.status}${snippet}`), { + status: response.status, + provider, + }); +}; + +// Callers own two independent reasons to stop: their own abort signal (user +// navigated away, request cancelled) and a per-call deadline. Long-running +// callers such as the diff walkthrough need a deadline well past the default. +const requestSignal = (timeoutMs, signal) => { + const deadline = AbortSignal.timeout(Number(timeoutMs) > 0 ? Number(timeoutMs) : REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([deadline, signal]) : deadline; +}; + +const STRUCTURED_OUTPUT_NAME = 'response'; + +// Google's schema dialect is OpenAPI-flavored and rejects JSON Schema keywords +// it does not know, so unsupported keys are dropped rather than passed through. +const GOOGLE_UNSUPPORTED_SCHEMA_KEYS = new Set([ + '$schema', + 'additionalProperties', + 'definitions', + '$defs', + '$ref', + 'strict', +]); + +const toGoogleSchema = (schema) => { + if (Array.isArray(schema)) return schema.map(toGoogleSchema); + if (!schema || typeof schema !== 'object') return schema; + const result = {}; + for (const [key, value] of Object.entries(schema)) { + if (GOOGLE_UNSUPPORTED_SCHEMA_KEYS.has(key)) continue; + result[key] = toGoogleSchema(value); + } + return result; }; // --------------------------------------------------------------------------- @@ -106,7 +143,7 @@ const ensureFreshOpenaiOauth = async (entry) => { // Wire formats // --------------------------------------------------------------------------- -const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, extraBody }) => { +const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, extraBody, responseSchema, timeoutMs, signal }) => { const trimmedBase = baseURL.replace(/\/+$/, ''); console.log('[small-model:diagnostic] request', { provider: providerLabel, @@ -132,9 +169,17 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, ], max_tokens: maxOutputTokens, stream: false, + ...(responseSchema + ? { + response_format: { + type: 'json_schema', + json_schema: { name: STRUCTURED_OUTPUT_NAME, strict: true, schema: responseSchema }, + }, + } + : {}), ...(extraBody || {}), }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: requestSignal(timeoutMs, signal), }); console.log('[small-model:diagnostic] response', { provider: providerLabel, @@ -171,11 +216,16 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, .map((part) => (typeof part?.text === 'string' ? part.text : '')) .join(''); } - if (!text.trim() && typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()) { - const finishReason = payload?.choices?.[0]?.finish_reason; - throw new Error( - `${providerLabel} spent the output budget on reasoning and returned no answer` - + (finishReason ? ` (finish_reason: ${finishReason})` : ''), + const finishReason = payload?.choices?.[0]?.finish_reason; + if (!text.trim() && (finishReason === 'length' || (typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()))) { + // The model produced only reasoning, or was cut off before answering. This + // is a budget problem, not a transport problem, and callers can act on it. + throw Object.assign( + new Error( + `${providerLabel} spent the output budget on reasoning and returned no answer` + + (finishReason ? ` (finish_reason: ${finishReason})` : ''), + ), + { code: 'output-exhausted', provider: providerLabel }, ); } if (!text.trim()) { @@ -184,7 +234,7 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, return text; }; -const callOpenaiResponses = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel }) => { +const callOpenaiResponses = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, responseSchema, timeoutMs, signal }) => { const trimmedBase = baseURL.replace(/\/+$/, ''); const response = await fetch(`${trimmedBase}/responses`, { method: 'POST', @@ -201,10 +251,22 @@ const callOpenaiResponses = async ({ baseURL, headers, modelID, prompt, system, content: [{ type: 'input_text', text: prompt }], }], max_output_tokens: maxOutputTokens, + ...(responseSchema + ? { + text: { + format: { + type: 'json_schema', + name: STRUCTURED_OUTPUT_NAME, + strict: true, + schema: responseSchema, + }, + }, + } + : {}), stream: false, store: false, }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: requestSignal(timeoutMs, signal), }); if (!response.ok) { throw await httpError(response, providerLabel); @@ -224,7 +286,7 @@ const callOpenaiResponses = async ({ baseURL, headers, modelID, prompt, system, return text; }; -const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTokens, providerLabel }) => { +const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTokens, providerLabel, responseSchema, timeoutMs, signal }) => { const response = await fetch(url, { method: 'POST', headers: { @@ -237,13 +299,36 @@ const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTo max_tokens: maxOutputTokens, ...(system ? { system } : {}), messages: [{ role: 'user', content: prompt }], + // The messages API has no response_format; a forced single-tool call is + // the supported way to get schema-shaped output. + ...(responseSchema + ? { + tools: [{ + name: STRUCTURED_OUTPUT_NAME, + description: 'Return the answer in the required structure.', + input_schema: responseSchema, + }], + tool_choice: { type: 'tool', name: STRUCTURED_OUTPUT_NAME }, + } + : {}), }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: requestSignal(timeoutMs, signal), }); if (!response.ok) { throw await httpError(response, providerLabel); } const payload = await response.json(); + + if (responseSchema) { + const toolUse = (payload?.content || []).find( + (part) => part?.type === 'tool_use' && part.name === STRUCTURED_OUTPUT_NAME, + ); + if (!toolUse || typeof toolUse.input !== 'object' || toolUse.input === null) { + throw new Error(`${providerLabel} returned no structured output`); + } + return JSON.stringify(toolUse.input); + } + const text = (payload?.content || []) .filter((part) => part?.type === 'text' && typeof part.text === 'string') .map((part) => part.text) @@ -254,7 +339,7 @@ const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTo return text; }; -const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => callMessages({ +const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => callMessages({ url: 'https://api.anthropic.com/v1/messages', headers: { 'x-api-key': apiKey, @@ -265,6 +350,9 @@ const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens system, maxOutputTokens, providerLabel: 'Anthropic', + responseSchema, + timeoutMs, + signal, }); const getCopilotEndpoint = async ({ baseURL, headers, modelID }) => { @@ -312,7 +400,7 @@ const getCopilotEndpoint = async ({ baseURL, headers, modelID }) => { throw new Error(`GitHub Copilot model "${modelID}" has no supported text endpoint`); }; -const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => { +const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => { const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`; const thinkingConfig = modelID.toLowerCase().startsWith('gemini-3') ? { thinkingLevel: modelID.toLowerCase().includes('flash') ? 'minimal' : 'low' } @@ -327,9 +415,15 @@ const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }], ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}), - generationConfig: { maxOutputTokens, thinkingConfig }, + generationConfig: { + maxOutputTokens, + thinkingConfig, + ...(responseSchema + ? { responseMimeType: 'application/json', responseSchema: toGoogleSchema(responseSchema) } + : {}), + }, }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: requestSignal(timeoutMs, signal), }); if (!response.ok) { throw await httpError(response, 'Google'); @@ -346,7 +440,7 @@ const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) // ChatGPT-plan traffic goes to the codex backend, which only speaks the // streaming Responses API — collect the output_text deltas from the SSE body. -const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, system }) => { +const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, system, timeoutMs, signal }) => { const response = await fetch(CODEX_RESPONSES_URL, { method: 'POST', headers: { @@ -372,7 +466,7 @@ const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, sys stream: true, store: false, }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: requestSignal(timeoutMs, signal), }); if (!response.ok) { throw await httpError(response, 'OpenAI (ChatGPT plan)'); @@ -472,7 +566,7 @@ const readProviderConfig = (workingDirectory, providerID) => { // Dispatch // --------------------------------------------------------------------------- -export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens }) { +export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) { const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; const providerConfig = readProviderConfig(workingDirectory, providerID); // Match OpenCode's resolveSDK precedence: @@ -516,6 +610,9 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider system, maxOutputTokens: tokens, providerLabel: 'GitHub Copilot', + responseSchema, + timeoutMs, + signal, }; if (endpoint === 'messages') { return callMessages({ @@ -534,6 +631,15 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider } if (providerID === 'openai' && entry.type === 'oauth') { + // The codex backend speaks only the streaming Responses API and rejects + // the structured-output fields, so a schema request fails loudly here + // instead of silently returning free-form prose. + if (responseSchema) { + throw Object.assign( + new Error('The ChatGPT-plan OpenAI login does not support structured output — choose another small model'), + { code: 'structured-output-unsupported' }, + ); + } const fresh = await ensureFreshOpenaiOauth(entry); return callCodexResponses({ accessToken: fresh.access, @@ -541,6 +647,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider modelID, prompt, system, + timeoutMs, + signal, }); } @@ -552,10 +660,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider } if (providerID === 'anthropic') { - return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens }); + return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal }); } if (providerID === 'google') { - return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens }); + return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal }); } // Everything else: OpenAI-compatible chat completions against the catalog's @@ -600,5 +708,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider maxOutputTokens: tokens, providerLabel: provider?.name || providerID, extraBody, + responseSchema, + timeoutMs, + signal, }); } diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 0b1a45ec..78ef733f 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -635,3 +635,156 @@ describe('callSmallModel — GitHub Copilot endpoint routing', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); + +// Structured output has no single wire format: each provider family needs its +// own request shape and its own extraction, and one family cannot do it at all. +// These lock the per-format translation so a provider is never silently sent a +// schema it will ignore. +describe('callSmallModel — structured output', () => { + let fetchMock; + let originalFetch; + + const SCHEMA = { + type: 'object', + properties: { title: { type: 'string' } }, + required: ['title'], + additionalProperties: false, + }; + + beforeEach(() => { + fetchMock = vi.fn(); + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + readConfig.mockReset(); + readConfig.mockReturnValue({}); + readConfigLayers.mockReset(); + readConfigLayers.mockReturnValue({ mergedConfig: {} }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('sends a json_schema response_format on OpenAI-compatible chat', async () => { + fetchMock.mockResolvedValue(ok('{"title":"ok"}')); + + const text = await callSmallModel({ + auth: { openai: { type: 'api', key: 'sk-test' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'openai', + modelID: 'gpt-5.4-mini', + prompt: 'summarize', + responseSchema: SCHEMA, + }); + + expect(text).toBe('{"title":"ok"}'); + const body = JSON.parse(lastCall(fetchMock).init.body); + expect(body.response_format).toEqual({ + type: 'json_schema', + json_schema: { name: 'response', strict: true, schema: SCHEMA }, + }); + }); + + it('omits response_format entirely when no schema is requested', async () => { + fetchMock.mockResolvedValue(ok('plain text')); + + await callSmallModel({ + auth: { openai: { type: 'api', key: 'sk-test' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'openai', + modelID: 'gpt-5.4-mini', + prompt: 'summarize', + }); + + const body = JSON.parse(lastCall(fetchMock).init.body); + expect(body.response_format).toBeUndefined(); + }); + + it('forces a single tool call on the Anthropic messages API and returns its input', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + content: [ + { type: 'text', text: 'thinking out loud' }, + { type: 'tool_use', name: 'response', input: { title: 'ok' } }, + ], + }), + }); + + const text = await callSmallModel({ + auth: { anthropic: { type: 'api', key: 'sk-ant' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + prompt: 'summarize', + responseSchema: SCHEMA, + }); + + expect(JSON.parse(text)).toEqual({ title: 'ok' }); + const body = JSON.parse(lastCall(fetchMock).init.body); + expect(body.tool_choice).toEqual({ type: 'tool', name: 'response' }); + expect(body.tools[0].input_schema).toEqual(SCHEMA); + }); + + it('fails loudly when Anthropic answers with prose instead of the tool call', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ content: [{ type: 'text', text: 'here you go' }] }), + }); + + await expect(callSmallModel({ + auth: { anthropic: { type: 'api', key: 'sk-ant' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + prompt: 'summarize', + responseSchema: SCHEMA, + })).rejects.toThrow('returned no structured output'); + }); + + it('strips JSON Schema keywords Google rejects', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ candidates: [{ content: { parts: [{ text: '{"title":"ok"}' }] } }] }), + }); + + await callSmallModel({ + auth: { google: { type: 'api', key: 'google-key' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'google', + modelID: 'gemini-2.5-flash', + prompt: 'summarize', + responseSchema: { ...SCHEMA, $schema: 'https://json-schema.org/draft/2020-12/schema' }, + }); + + const body = JSON.parse(lastCall(fetchMock).init.body); + expect(body.generationConfig.responseMimeType).toBe('application/json'); + expect(body.generationConfig.responseSchema).toEqual({ + type: 'object', + properties: { title: { type: 'string' } }, + required: ['title'], + }); + }); + + it('refuses a schema on the ChatGPT-plan backend instead of returning prose', async () => { + await expect(callSmallModel({ + auth: { openai: { type: 'oauth', access: 'token', refresh: 'refresh' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'openai', + modelID: 'gpt-5.4-mini', + prompt: 'summarize', + responseSchema: SCHEMA, + })).rejects.toThrow('does not support structured output'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 92abbad8..20e469c0 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -36,14 +36,46 @@ const readSmallModelSettingsOverride = () => { const DEFAULT_CONTEXT_TOKENS = 64_000; const OUTPUT_RESERVE_TOKENS = 4_000; -const clampPromptToModelLimit = ({ prompt, catalog, providerID, modelID }) => { +/** + * Input budget in characters, given how much of the context the caller intends + * to leave for the answer. The reserve must match the output budget the caller + * will actually request, or the two disagree and the model overruns its context. + */ +export const getModelInputCharBudget = ({ catalog, providerID, modelID, outputReserveTokens }) => { const limit = catalog?.[providerID]?.models?.[modelID]?.limit; - const contextTokens = Number(limit?.context) > 0 ? Number(limit.context) : DEFAULT_CONTEXT_TOKENS; - const inputBudgetTokens = Math.max(1_000, contextTokens - OUTPUT_RESERVE_TOKENS); - const maxChars = inputBudgetTokens * 4; + const known = Number(limit?.context) > 0; + const contextTokens = known ? Number(limit.context) : DEFAULT_CONTEXT_TOKENS; + const reserve = Number(outputReserveTokens) > 0 ? Number(outputReserveTokens) : OUTPUT_RESERVE_TOKENS; + const inputBudgetTokens = Math.max(1_000, contextTokens - reserve); + return { maxChars: inputBudgetTokens * 4, contextTokens, contextKnown: known }; +}; + +/** + * The output budget to actually request: what the caller asked for, capped by + * what the model admits it can emit. Asking for more than `limit.output` is + * rejected outright by some providers and silently ignored by others. + */ +const resolveOutputTokens = ({ catalog, providerID, modelID, maxOutputTokens }) => { + const requested = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : 0; + if (!requested) return undefined; + const limit = Number(catalog?.[providerID]?.models?.[modelID]?.limit?.output); + return limit > 0 ? Math.min(requested, limit) : requested; +}; + +// `truncate` keeps the historical behavior for callers whose prompt losing its +// tail is survivable (summaries, commit messages). `error` is for callers whose +// output would be quietly wrong on a clipped input — they need the failure. +const clampPromptToModelLimit = ({ prompt, catalog, providerID, modelID, onOverflow, outputReserveTokens }) => { + const { maxChars } = getModelInputCharBudget({ catalog, providerID, modelID, outputReserveTokens }); if (prompt.length <= maxChars) { return { prompt, truncated: false }; } + if (onOverflow === 'error') { + throw Object.assign( + new Error(`Input is too large for ${providerID}/${modelID}: ${prompt.length} characters exceeds the ${maxChars} the model's context allows`), + { statusCode: 413, code: 'context-too-small', providerID, modelID, requiredChars: prompt.length, availableChars: maxChars }, + ); + } return { prompt: `${prompt.slice(0, maxChars)}…`, truncated: true }; }; @@ -61,7 +93,7 @@ const readConfiguredSmallModel = (workingDirectory) => { * Generates text with the user's small model, resolved and authenticated * entirely server-side from the OpenCode config and auth store. */ -export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false }) { +export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) { if (typeof prompt !== 'string' || !prompt.trim()) { throw Object.assign(new Error('prompt is required'), { statusCode: 400 }); } @@ -101,11 +133,20 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens, ); } + const outputTokens = resolveOutputTokens({ + catalog, + providerID: resolved.providerID, + modelID: resolved.modelID, + maxOutputTokens, + }); + const clamped = clampPromptToModelLimit({ prompt: prompt.trim(), catalog, providerID: resolved.providerID, modelID: resolved.modelID, + onOverflow, + outputReserveTokens: outputTokens, }); const text = await callSmallModel({ @@ -116,7 +157,10 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens, modelID: resolved.modelID, prompt: clamped.prompt, system: typeof system === 'string' && system.trim() ? system.trim() : undefined, - maxOutputTokens, + maxOutputTokens: outputTokens, + responseSchema, + timeoutMs, + signal, }); return { @@ -152,17 +196,47 @@ export function listAuthenticatedProviders() { /** * Reports which model would be used, without calling it. + * + * `inputCharBudget` and `structuredOutput` let callers refuse work before + * spending a request: the walkthrough needs both a big enough context and + * schema-shaped output, and would rather tell the user to pick another model + * than send a doomed prompt. `structuredOutput` is deliberately tri-state — + * the catalog omits the field for roughly half of all models (aggregators and + * proxies especially), and treating "unknown" as "unsupported" would hide + * models that work fine. */ -export async function describeSmallModel({ directory, preferredProviderID, preferredModelID } = {}) { +export async function describeSmallModel({ directory, preferredProviderID, preferredModelID, outputReserveTokens, overrideModel } = {}) { const auth = readAuthFile(); const catalog = await getModelCatalog().catch(() => ({})); - const resolved = resolveSmallModel({ - auth, + // A caller with its own model setting (the diff walkthrough) outranks the + // small-model chain entirely — it asked for this model on purpose. + const explicit = parseModelRef(overrideModel); + const resolved = explicit + ? { ...explicit, source: 'request' } + : resolveSmallModel({ + auth, + catalog, + settingsSmallModel: readSmallModelSettingsOverride(), + configSmallModel: readConfiguredSmallModel(directory), + preferredProviderID, + preferredModelID, + }); + if (!resolved) return resolved; + + const entry = catalog?.[resolved.providerID]?.models?.[resolved.modelID]; + const { maxChars, contextTokens, contextKnown } = getModelInputCharBudget({ catalog, - settingsSmallModel: readSmallModelSettingsOverride(), - configSmallModel: readConfiguredSmallModel(directory), - preferredProviderID, - preferredModelID, + providerID: resolved.providerID, + modelID: resolved.modelID, + outputReserveTokens, }); - return resolved; + + return { + ...resolved, + inputCharBudget: maxChars, + contextTokens, + contextKnown, + structuredOutput: typeof entry?.structured_output === 'boolean' ? entry.structured_output : null, + outputTokenLimit: Number(entry?.limit?.output) > 0 ? Number(entry.limit.output) : null, + }; } diff --git a/packages/web/server/lib/small-model/index.test.js b/packages/web/server/lib/small-model/index.test.js new file mode 100644 index 00000000..f20ab692 --- /dev/null +++ b/packages/web/server/lib/small-model/index.test.js @@ -0,0 +1,221 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The settings override is read straight from disk at module load, so without +// this the suite would resolve whatever small model the developer running it +// happens to have configured. +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'small-model-settings-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +vi.mock('../opencode/auth.js', () => ({ readAuthFile: vi.fn() })); +vi.mock('../opencode/shared.js', () => ({ + readConfig: vi.fn(), + readConfigLayers: vi.fn(), +})); +vi.mock('./catalog.js', () => ({ + getModelCatalog: vi.fn(), + getCatalogProvider: vi.fn(), +})); +vi.mock('./call.js', () => ({ callSmallModel: vi.fn() })); + +const { generateSmallModelText, describeSmallModel } = await import('./index.js'); +const { readAuthFile } = await import('../opencode/auth.js'); +const { readConfigLayers } = await import('../opencode/shared.js'); +const { getModelCatalog } = await import('./catalog.js'); +const { callSmallModel } = await import('./call.js'); + +// 8k context leaves 4k input tokens after the output reserve → 16k chars. +const CATALOG = { + anthropic: { + id: 'anthropic', + models: { + 'claude-haiku-4-5': { id: 'claude-haiku-4-5', limit: { context: 8_000 }, structured_output: true }, + 'legacy-tiny': { id: 'legacy-tiny', limit: { context: 8_000 }, structured_output: false }, + 'unlisted-capability': { id: 'unlisted-capability', limit: { context: 8_000 } }, + }, + }, +}; + +const request = (overrides = {}) => ({ + prompt: 'x'.repeat(20_000), + model: 'anthropic/claude-haiku-4-5', + directory: '/proj', + ...overrides, +}); + +describe('generateSmallModelText — oversized input', () => { + beforeEach(() => { + readAuthFile.mockReturnValue({ anthropic: { type: 'api', key: 'sk-ant' } }); + readConfigLayers.mockReturnValue({ mergedConfig: {} }); + getModelCatalog.mockResolvedValue(CATALOG); + callSmallModel.mockReset(); + callSmallModel.mockResolvedValue('generated'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('truncates and flags the response by default', async () => { + const result = await generateSmallModelText(request()); + + expect(result.inputTruncated).toBe(true); + const sent = callSmallModel.mock.calls.at(-1)[0].prompt; + expect(sent.length).toBeLessThan(20_000); + expect(sent.endsWith('…')).toBe(true); + }); + + it('refuses without calling the provider when the caller cannot survive truncation', async () => { + await expect(generateSmallModelText(request({ onOverflow: 'error' }))) + .rejects.toMatchObject({ + statusCode: 413, + code: 'context-too-small', + requiredChars: 20_000, + availableChars: 16_000, + }); + + expect(callSmallModel).not.toHaveBeenCalled(); + }); + + it('leaves an input that fits untouched under either policy', async () => { + const result = await generateSmallModelText(request({ prompt: 'short prompt', onOverflow: 'error' })); + + expect(result.inputTruncated).toBeUndefined(); + expect(callSmallModel.mock.calls.at(-1)[0].prompt).toBe('short prompt'); + }); + + it('forwards schema, timeout, and abort signal to the transport', async () => { + const controller = new AbortController(); + const schema = { type: 'object' }; + + await generateSmallModelText(request({ + prompt: 'short', + responseSchema: schema, + timeoutMs: 240_000, + signal: controller.signal, + })); + + expect(callSmallModel.mock.calls.at(-1)[0]).toMatchObject({ + responseSchema: schema, + timeoutMs: 240_000, + signal: controller.signal, + }); + }); +}); + +describe('describeSmallModel — capability reporting', () => { + beforeEach(() => { + readAuthFile.mockReturnValue({ anthropic: { type: 'api', key: 'sk-ant' } }); + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/claude-haiku-4-5' } }); + getModelCatalog.mockResolvedValue(CATALOG); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('reports the input budget and a known structured-output capability', async () => { + const described = await describeSmallModel({ directory: '/proj' }); + + expect(described).toMatchObject({ + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + inputCharBudget: 16_000, + contextTokens: 8_000, + contextKnown: true, + structuredOutput: true, + }); + }); + + it('reports an explicit false so callers can block the model', async () => { + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/legacy-tiny' } }); + + const described = await describeSmallModel({ directory: '/proj' }); + + expect(described.structuredOutput).toBe(false); + }); + + it('reports null — not false — when the catalog omits the capability', async () => { + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/unlisted-capability' } }); + + const described = await describeSmallModel({ directory: '/proj' }); + + expect(described.structuredOutput).toBeNull(); + }); +}); + +// The input reserve and the requested output budget are the same number seen +// from two sides; if they drift, a caller that asks for a large answer overruns +// the model's context and the failure looks like a truncation bug. +describe('output budget and input reserve', () => { + beforeEach(() => { + readAuthFile.mockReturnValue({ anthropic: { type: 'api', key: 'sk-ant' } }); + readConfigLayers.mockReturnValue({ mergedConfig: {} }); + getModelCatalog.mockResolvedValue({ + anthropic: { + id: 'anthropic', + models: { + roomy: { id: 'roomy', limit: { context: 100_000, output: 8_000 } }, + unlisted: { id: 'unlisted', limit: { context: 100_000 } }, + }, + }, + }); + callSmallModel.mockReset(); + callSmallModel.mockResolvedValue('generated'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('caps the request at the model\'s advertised output limit', async () => { + await generateSmallModelText({ + prompt: 'short', + model: 'anthropic/roomy', + maxOutputTokens: 24_000, + }); + + expect(callSmallModel.mock.calls.at(-1)[0].maxOutputTokens).toBe(8_000); + }); + + it('honours the requested budget when the catalog states no output limit', async () => { + await generateSmallModelText({ + prompt: 'short', + model: 'anthropic/unlisted', + maxOutputTokens: 24_000, + }); + + expect(callSmallModel.mock.calls.at(-1)[0].maxOutputTokens).toBe(24_000); + }); + + it('reserves exactly the requested output budget from the input allowance', async () => { + // 100k context − 24k reserved for the answer = 76k tokens ≈ 304k chars. + await expect(generateSmallModelText({ + prompt: 'x'.repeat(304_001), + model: 'anthropic/unlisted', + maxOutputTokens: 24_000, + onOverflow: 'error', + })).rejects.toMatchObject({ code: 'context-too-small', availableChars: 304_000 }); + + await expect(generateSmallModelText({ + prompt: 'x'.repeat(303_999), + model: 'anthropic/unlisted', + maxOutputTokens: 24_000, + onOverflow: 'error', + })).resolves.toBeTruthy(); + }); + + it('reports the same budget through describeSmallModel', async () => { + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/unlisted' } }); + + const described = await describeSmallModel({ directory: '/proj', outputReserveTokens: 24_000 }); + + expect(described.inputCharBudget).toBe(304_000); + }); +}); + +afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); +}); diff --git a/packages/web/server/lib/small-model/routes.js b/packages/web/server/lib/small-model/routes.js index c53143de..2e66063f 100644 --- a/packages/web/server/lib/small-model/routes.js +++ b/packages/web/server/lib/small-model/routes.js @@ -38,7 +38,10 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) { if (statusCode >= 500) { console.error('Small model generation failed:', error); } - res.status(statusCode).json({ error: error.message || 'Small model generation failed' }); + res.status(statusCode).json({ + error: error.message || 'Small model generation failed', + ...(error?.code ? { code: error.code } : {}), + }); } }); } diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md new file mode 100644 index 00000000..ec1c8ccc --- /dev/null +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -0,0 +1,298 @@ +# Walkthrough + +Generates a guided, ordered reading path through a diff: the small model groups +related hunks into stops and chapters and explains each group, and the UI +renders those stops interleaved with the code they describe. + +Generation is **always user-initiated**. Nothing here runs on a timer, on a file +change, or as a side effect of opening a panel — it spends tokens, so a person +has to ask for it. + +## Files + +- `hunks.js` — parses a unified diff into files and hunks and assigns each hunk + a stable id. +- `generated.js` — recognises tool-produced files that are kept out of the + model's input. +- `sources.js` — turns a source descriptor into diff *sections*. +- `digest.js` — builds the model-facing digest and the alias↔id mapping. +- `prompt.js` — system prompt, size guidance, previous-walkthrough section, and + `PROMPT_VERSION`. +- `schema.js` — response schema, response normalization, tolerant JSON parsing. +- `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. +- `index.js` — orchestration. +- `routes.js` — `/api/walkthrough*`. + +## Hunk identity + +`hunks.js` is the only place that decides what a hunk is or what its id is. The +client never recomputes ids; it receives the current hunk index (id → patch) +alongside the walkthrough and matches ids to ids. Two implementations of the +same hash would have to agree byte-for-byte forever, and the first one to drift +would silently mis-anchor every stop. + +An id is `::`, with a `-2`, `-3`, … suffix +for byte-identical hunks repeated inside one file. + +Two consequences fall out of hashing the content: + +- Editing a hunk changes its id, so an anchor that no longer resolves is + **proof** the code it described changed. Staleness needs no heuristics. +- Editing one hunk does not disturb its neighbours, so a small edit invalidates + only the stops that actually covered it. + +`scope` keeps staged and unstaged versions of the same lines apart, so a stop +written against staged code never silently re-anchors onto an unstaged edit. + +## Sources + +| Kind | Sections | Notes | +|---|---|---| +| `working-tree` (`all` \| `staged` \| `working`) | `staged`, `working` | Untracked files are fetched individually because `git diff` omits them | +| `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded | +| `pr` | `pr:` | GitHub returns the merge-base diff, matching the branch semantics | + +The panel offers the current branch's pull request on its own: it registers with +the shared GitHub PR status store (`useGitHubPrStatusStore`) rather than waiting +for the pull request panel to have been visited. That store already dedupes +concurrent requests by signature and throttles by TTL, so several panels asking +the same question produce one call to GitHub. + +## No truncation + +Within what it covers, the digest is complete. When it does not fit the resolved +model's context, generation is **refused** (`409`, `code: 'context-too-small'`) +so the user can pick a roomier model. A walkthrough written against a silently +clipped diff is confidently wrong in a way no reader can detect, which is worse +than no walkthrough. + +## Generated files + +`generated.js` excludes tool-produced files — lockfiles, minified bundles, +codegen, snapshots — from the digest by **name, never by size**. A lockfile can +be larger than the entire change around it and carries no intent, so sending it +wastes context that real code needs. + +They are excluded, not hidden: they carry no hunk aliases (so nothing can anchor +to them), but they are still parsed, still returned to the client, and still +appear in the uncovered tail. The matcher is deliberately conservative — +`src/lock.ts` and `src/generator.ts` are authored code — because a false +positive silently drops real code from a review, which is the exact failure this +feature exists to prevent. + +When a change consists only of generated files, generation is refused with +`code: 'only-generated'` rather than the misleading "nothing changed". + +## Model selection + +The walkthrough has its own model setting (Settings → Sessions → Changes +Walkthrough Model), read by `model-settings.js`: + +`walkthroughModelOverride` (`provider/model`) is the whole contract: set, that +model is used for this feature and nothing else; unset or empty, generation +falls back to whatever the small-model chain resolves to. Choosing a model *is* +the opt-out, so there is no separate toggle to disagree with the picker — the +settings picker simply shows "Small model will be used" until a choice is made, +and clearing it restores the fallback. + +The separation exists because the two roles pull in opposite directions: the +small model is chosen to be cheap and fast for recaps and commit messages, while +this one needs schema-shaped output and enough context for a whole diff. Forcing +one setting to serve both means degrading one feature to fix the other. + +A review can also override the model for itself: `GET`/`POST` accept a `model` +(`provider/model`) that outranks both the setting and the small-model chain. +That choice is panel state, not a settings edit — picking a roomier model for +one risky change should not silently redefine the default for every future one. +It needs no storage: the model that produced a walkthrough is already recorded +in its cache entry, so reopening a panel resolves the picker as *explicit choice +→ model that generated what is on screen → settings*. Because the model is part +of the cache key, switching models and back returns the earlier review for free. + +The picker hides models the catalog reports as `structured_output: false` — +offering them would move the same refusal one click later — and, like the small +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. + +## Structured output, and what happens when it is refused + +`structured_output: false` in the catalog blocks generation up front. A +**missing** capability field does not block — the catalog omits it for roughly +half of all models, and treating unknown as unsupported would hide models that +work. + +Providers that do not declare the capability sometimes reject the schema at +request time (a plain `400`, or Alibaba/Qwen's "'messages' must contain the word +'json'"). A rejected request shape is not a dead end, so a `4xx` on a schema +request triggers exactly one retry with the schema moved into the prompt and the +tolerant parser handling the result. Only if *that* fails to yield usable JSON is +`structured-output-unsupported` reported — at which point it is a real capability +problem the user can fix by switching model. + +The refusal is then remembered per `provider/model` and the fallback goes first +from then on. Without that, every generation on such a provider pays for a call +whose failure is already known. The memory is process-lifetime only on purpose: +a provider that gains structured-output support should not need a settings +change to be tried again, and one wasted first attempt after a restart is cheap. + +The system prompt states "respond with a single JSON object" explicitly, which +also satisfies the providers that scan the request for the word `json` before +honouring `response_format`. That keeps them on the fast path instead of paying +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. + +## 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 +*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. + +**Pointers** (`pointers/.json`) are mutable and hold +`{ cacheKey, generatedAt, repoRoot, sourceKey }`. They answer what the cache +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. + +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 +against the current digest. Splicing partially-regenerated chapters into an old +narrative was considered and rejected — the seams produce stops that contradict +each other, and the failure is invisible. + +## Hygiene + +- Entries are bounded by count and total size (200 / 50 MB) and evicted + least-recently-used after a write that crosses a limit. Nothing is dropped for + being merely old: an entry costs kilobytes and stays reachable if the working + tree ever returns to that state. +- Writes are tmp+rename; reads enforce a size limit and validate the version, so + a corrupt file is a miss rather than a crash. +- Pointers are never evicted by size. They are pruned only when their repository + is **provably gone**, deferred off the request path, fully asynchronous, and + capped. + +That last point is deliberate rather than incidental. The desktop app hosts this +server inside the Electron main process, so a synchronous loop here would stall +IPC and the window rather than a single request — and the paths being checked +are user repositories, where a worktree on an unplugged drive or an unreachable +share can make one existence check hang for seconds. Only `ENOENT` deletes a +pointer: unreachable is not the same as gone, and a dead share must not cost the +user their walkthroughs. + +## Coverage + +The model is told it may leave mechanical changes out. Whatever it does not +anchor is computed as `uncoveredHunkIds` and rendered as a collapsed tail, so +the reader can always answer "have I seen everything that changed". No hunk +disappears from the view. + +## Cost of reading + +Opening the panel is a `GET` that runs the whole git pipeline, so it is kept as +cheap as the data allows: + +- Untracked files come from `listUntrackedPaths` (a plain `ls-files`) rather + than `getStatus`, which also computes ahead/behind, diff stats, and merge + state — roughly 180ms of work for an answer this module discards. +- Their diffs go through `getUntrackedDiffs`, which resolves the repository once + for the whole batch and bounds concurrency, instead of one `getDiff` per file + each re-resolving the repository. +- Readiness is computed from the same diff as the walkthrough itself. It used to + be its own endpoint that the client called in parallel, which meant every + panel open ran the entire pipeline twice. + +On a working tree of 80 files and 138 hunks this took a panel open from ~800ms +to ~340ms. Parsing and digest building are ~3ms of that; everything else is git. + +## Generation outlives its request + +A dropped connection and a deliberate cancel are indistinguishable at the +socket, so tying generation to the request lifetime meant an accidental refresh +threw away a minute of paid-for work. Instead: + +- Jobs live in a module-level map keyed by repository + source. A second + `generate` for the same source **attaches to the running job** rather than + starting a rival one — pressing the button again after a refresh costs + nothing extra. +- Leaving the page detaches the client; the job finishes and writes its cache + entry, so coming back finds the result waiting. +- `GET /api/walkthrough` reports `generating`, letting a returning client show + progress instead of an empty panel, and the client re-attaches so the result + lands somewhere. +- Stopping is an explicit `POST /api/walkthrough/cancel`. That is the only thing + that aborts the model call. + +The cost of this is that a job everyone abandoned keeps spending until it +finishes; the generation timeout bounds it. + +That timeout is a hang guard, not a pace-setter, and it scales with the diff: +120s plus 1s per hunk, capped at 15 minutes. A fixed number made a three-hunk +edit and a 500-hunk pull request wait the same, which guarded nothing in the +small case and risked killing the big one just short of the finish line. It errs +long on purpose — losing a nearly-complete generation costs real money, while an +over-long deadline only holds a job slot. Note that the schema fallback can use +the deadline twice, once per attempt. + +## Progress + +A running job records a coarse stage: `collecting` (reading the diff, which for +a pull request is seconds of network), `asking`, `retrying` when a provider +rejects the schema and the prompt-side fallback runs, and `assembling`. + +Only phases a person can wait on are named. Building the digest and reading the +cache take single-digit milliseconds; giving them rows would imply progress that +is not happening. + +`retrying` exists for diagnostics but is **not shown**: from outside it is the +same wait on the same model, and naming our fallback only raises the question of +what it is. The client folds it into `asking`. + +The client also paces the display, holding each step for a floor before +revealing the next and keeping the list on screen briefly after the work ends. +Assembling takes milliseconds, so without that the result replaces the list +before the final step is ever seen finishing — naming a step the user never +observes is worse than not naming it. The cost is well under a second at the end +of a wait measured in minutes. + +`GET /api/walkthrough/progress` reads the job registry and nothing else — no git, +no network — so the client can poll it once a second. The full read must never +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 + client disconnects; a concurrent call for the same source joins the running + job. +- `GET /api/walkthrough/progress?directory&source` — the current stage, or + `null`. Memory-only and safe to poll. +- `POST /api/walkthrough/cancel` — `{ directory, source }`; aborts a running + generation. + +There is deliberately no delete route: regeneration covers the need, and an +endpoint nothing calls is a maintenance surface that rots untested. + +Registered lazily from `feature-routes-runtime.js`. `/api/walkthrough` is in the +JSON body-parser allowlist in `core-routes.js`. + +## Runtime availability + +Web, desktop, and hosted mobile reach these routes normally. VS Code serves Git +through its own bridge rather than the OpenChamber Git routes, so the feature is +not offered there; the surface is also gated to tablet width and above. diff --git a/packages/web/server/lib/walkthrough/digest.js b/packages/web/server/lib/walkthrough/digest.js new file mode 100644 index 00000000..e74903d7 --- /dev/null +++ b/packages/web/server/lib/walkthrough/digest.js @@ -0,0 +1,73 @@ +import { isGeneratedArtifact } from './generated.js'; +import { parseDiffFiles } from './hunks.js'; + +// The digest is what the model actually reads. Within what it covers there is +// no truncation: a diff that does not fit the model's context is refused +// upstream so the user can pick a roomier model, because a walkthrough written +// against a silently clipped diff is confidently wrong in a way nobody can see. +// +// The one thing it does not cover is tool-produced files (lockfiles, minified +// bundles, codegen). Those are excluded by name, not by size, and they are not +// hidden — they carry no hunk aliases, so nothing can anchor to them, and they +// surface in the uncovered tail like any other unreviewed change. + +/** + * Parse sections into files and build the model-facing digest. + * + * Hunks are exposed to the model as request-local aliases (`h1`, `h2`, …) + * rather than their real ids: the aliases are far cheaper in tokens, and a + * model cannot invent a plausible-looking id for a hunk that does not exist. + */ +export function buildDigest(sections) { + const files = []; + for (const section of sections) { + const parsed = parseDiffFiles(section.patch, section.scope); + for (const file of parsed.files) { + files.push({ ...file, scope: section.scope, generated: isGeneratedArtifact(file.path) }); + } + } + + const idByAlias = new Map(); + const aliasById = new Map(); + let counter = 0; + + const digestFiles = files + .filter((file) => !file.generated) + .map((file) => ({ + path: file.path, + ...(file.oldPath ? { oldPath: file.oldPath } : {}), + status: file.status, + ...(file.scope !== 'branch' && !file.scope.startsWith('pr:') ? { scope: file.scope } : {}), + ...(file.binary ? { binary: true } : {}), + hunks: file.hunks.map((hunk) => { + counter += 1; + const alias = `h${counter}`; + idByAlias.set(alias, hunk.id); + aliasById.set(hunk.id, alias); + return { + alias, + header: hunk.header, + oldLines: `${hunk.oldStart}-${hunk.oldStart + Math.max(0, hunk.oldLines - 1)}`, + newLines: `${hunk.newStart}-${hunk.newStart + Math.max(0, hunk.newLines - 1)}`, + added: hunk.added, + deleted: hunk.deleted, + patch: hunk.body, + }; + }), + })); + + const generatedFiles = files.filter((file) => file.generated); + + return { + digest: { files: digestFiles }, + files, + idByAlias, + aliasById, + // Reviewable counts: what the model is actually asked about. The excluded + // files still reach the client through `files`. + hunkCount: counter, + fileCount: digestFiles.length, + generatedFileCount: generatedFiles.length, + generatedPaths: generatedFiles.map((file) => file.path), + }; +} diff --git a/packages/web/server/lib/walkthrough/digest.test.js b/packages/web/server/lib/walkthrough/digest.test.js new file mode 100644 index 00000000..468e9fef --- /dev/null +++ b/packages/web/server/lib/walkthrough/digest.test.js @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { buildDigest } from './digest.js'; +import { isGeneratedArtifact } from './generated.js'; + +const fileDiff = (path, body = '+const a = 1;') => `diff --git a/${path} b/${path} +--- a/${path} ++++ b/${path} +@@ -1,1 +1,2 @@ +${body} +`; + +describe('isGeneratedArtifact', () => { + it('matches lockfiles by exact name anywhere in the tree', () => { + expect(isGeneratedArtifact('bun.lock')).toBe(true); + expect(isGeneratedArtifact('packages/web/package-lock.json')).toBe(true); + expect(isGeneratedArtifact('Cargo.lock')).toBe(true); + expect(isGeneratedArtifact('go.sum')).toBe(true); + }); + + it('matches conventional generated output', () => { + expect(isGeneratedArtifact('dist/app.min.js')).toBe(true); + expect(isGeneratedArtifact('src/api.generated.ts')).toBe(true); + expect(isGeneratedArtifact('proto/user.pb.go')).toBe(true); + expect(isGeneratedArtifact('src/__snapshots__/App.test.tsx.snap')).toBe(true); + expect(isGeneratedArtifact('src/generated/client.ts')).toBe(true); + }); + + it('does not match authored source that merely looks similar', () => { + // A false positive silently removes real code from the review, so these + // near-misses matter more than the hits. + expect(isGeneratedArtifact('src/lock.ts')).toBe(false); + expect(isGeneratedArtifact('src/useLockfile.ts')).toBe(false); + expect(isGeneratedArtifact('src/generator.ts')).toBe(false); + expect(isGeneratedArtifact('src/minifier.ts')).toBe(false); + expect(isGeneratedArtifact('packages/ui/src/lib/i18n/messages/en.ts')).toBe(false); + }); +}); + +describe('buildDigest', () => { + const sections = [{ + scope: 'working', + patch: [fileDiff('src/a.ts'), fileDiff('bun.lock', '+ "version": "2",'), fileDiff('src/b.ts')].join(''), + }]; + + it('keeps generated files out of what the model sees', () => { + const built = buildDigest(sections); + + expect(built.digest.files.map((file) => file.path)).toEqual(['src/a.ts', 'src/b.ts']); + expect(JSON.stringify(built.digest)).not.toContain('bun.lock'); + expect(built.fileCount).toBe(2); + expect(built.hunkCount).toBe(2); + expect(built.generatedFileCount).toBe(1); + expect(built.generatedPaths).toEqual(['bun.lock']); + }); + + it('still returns generated files to the client so nothing disappears', () => { + const built = buildDigest(sections); + + expect(built.files.map((file) => file.path)).toEqual(['src/a.ts', 'bun.lock', 'src/b.ts']); + expect(built.files.find((file) => file.path === 'bun.lock')?.generated).toBe(true); + }); + + it('gives aliases only to reviewable hunks', () => { + const built = buildDigest(sections); + const aliased = [...built.idByAlias.values()]; + + expect([...built.idByAlias.keys()]).toEqual(['h1', 'h2']); + expect(aliased.some((id) => id.includes('bun.lock'))).toBe(false); + }); + + it('reports zero reviewable hunks when only generated files changed', () => { + const built = buildDigest([{ scope: 'working', patch: fileDiff('bun.lock', '+ "version": "2",') }]); + + expect(built.hunkCount).toBe(0); + expect(built.files).toHaveLength(1); + expect(built.generatedFileCount).toBe(1); + }); +}); diff --git a/packages/web/server/lib/walkthrough/generated.js b/packages/web/server/lib/walkthrough/generated.js new file mode 100644 index 00000000..c4f825e9 --- /dev/null +++ b/packages/web/server/lib/walkthrough/generated.js @@ -0,0 +1,58 @@ +// Files that are produced by a tool rather than written by a person. Their +// diffs are enormous, carry no intent, and are exactly the kind of content a +// reviewer scrolls past — but they are still part of the change, so they are +// never hidden: they are kept out of the model's input and shown in the +// uncovered tail instead. + +const LOCKFILES = new Set([ + 'bun.lock', + 'bun.lockb', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'composer.lock', + 'Gemfile.lock', + 'Pipfile.lock', + 'poetry.lock', + 'uv.lock', + 'Cargo.lock', + 'go.sum', + 'mix.lock', + 'pubspec.lock', + 'flake.lock', + 'gradle.lockfile', + 'packages.lock.json', + 'deno.lock', +]); + +const GENERATED_PATTERNS = [ + // Minified or bundled output committed to the repository. + /\.min\.(js|css)$/i, + /\.(js|css)\.map$/i, + // Conventional "this file is generated" naming. + /\.generated\.[^/]+$/i, + /\.gen\.[^/]+$/i, + /(^|\/)generated\//i, + // Protocol buffers and similar codegen. + /\.pb\.(go|ts|js)$/i, + /_pb2(_grpc)?\.py$/i, + /\.pb\.cc$|\.pb\.h$/i, + // Test snapshots. + /(^|\/)__snapshots__\//, + /\.snap$/, +]; + +/** + * Whether a path is a tool-produced artifact rather than authored source. + * + * Deliberately conservative: a false positive silently removes real code from + * the review, which is the failure this whole feature exists to prevent. Only + * unambiguous, conventional names qualify. + */ +export function isGeneratedArtifact(filePath) { + if (typeof filePath !== 'string' || !filePath) return false; + const name = filePath.split('/').pop() || ''; + if (LOCKFILES.has(name)) return true; + return GENERATED_PATTERNS.some((pattern) => pattern.test(filePath)); +} diff --git a/packages/web/server/lib/walkthrough/hunks.js b/packages/web/server/lib/walkthrough/hunks.js new file mode 100644 index 00000000..32e5b53d --- /dev/null +++ b/packages/web/server/lib/walkthrough/hunks.js @@ -0,0 +1,166 @@ +import crypto from 'crypto'; + +// Parsing a unified diff into addressable hunks lives here and only here. The +// model anchors its narrative to hunk ids, the client resolves those ids back +// to rendered code, and staleness is "an id the current diff no longer has" — +// all three break the moment two implementations disagree about what an id is, +// so the client is never given the algorithm, only the results. + +const FILE_HEADER = /^diff --git /; +const HUNK_HEADER = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$/; + +const shortHash = (value) => crypto.createHash('sha1').update(value).digest('hex').slice(0, 8); + +const parsePathsFromFileHeader = (line) => { + // `diff --git a/old b/new`, with either side quoted when it contains spaces. + const match = /^diff --git (?:"?a\/(.+?)"?) (?:"?b\/(.+?)"?)$/.exec(line); + if (!match) return null; + return { oldPath: match[1], newPath: match[2] }; +}; + +const statusFromHeaderLines = (lines) => { + if (lines.some((line) => line.startsWith('new file mode'))) return 'added'; + if (lines.some((line) => line.startsWith('deleted file mode'))) return 'deleted'; + if (lines.some((line) => line.startsWith('rename from'))) return 'renamed'; + return 'modified'; +}; + +const isBinaryHeader = (lines) => lines.some((line) => line.startsWith('Binary files ') || line.startsWith('GIT binary patch')); + +/** + * Split a unified diff covering any number of files into files and hunks. + * + * @param {string} patch raw `git diff` output + * @param {string} scope opaque namespace for the ids (e.g. 'staged', 'branch'). + * Two scopes of the same repository can contain byte-identical hunks; the + * scope keeps their ids distinct so a walkthrough written against staged + * changes never silently resolves against unstaged ones. + * @returns {{files: Array<{path: string, oldPath: string|null, status: string, binary: boolean, hunks: Array}>}} + */ +export function parseDiffFiles(patch, scope = 'diff') { + const text = typeof patch === 'string' ? patch : ''; + if (!text.trim()) return { files: [] }; + + const lines = text.split(/\r?\n/); + const files = []; + let current = null; + let headerLines = []; + let hunk = null; + + const closeHunk = () => { + if (!current || !hunk) return; + const body = hunk.lines.join('\n'); + // The id covers the header and the body, so any edit to the hunk — even one + // that keeps its line numbers — produces a different id. That is what makes + // "this stop is stale" detectable without diffing narratives. + const digest = shortHash(`${hunk.header}\n${body}`); + const seen = current.hunkDigests.get(digest) ?? 0; + current.hunkDigests.set(digest, seen + 1); + // A file can legitimately contain byte-identical hunks (repeated boilerplate + // edits). Disambiguate by occurrence so ids stay unique without becoming + // positional for the common case. + const suffix = seen === 0 ? '' : `-${seen + 1}`; + + current.hunks.push({ + id: `${scope}:${current.path}:${digest}${suffix}`, + header: hunk.header, + oldStart: hunk.oldStart, + oldLines: hunk.oldLines, + newStart: hunk.newStart, + newLines: hunk.newLines, + added: hunk.added, + deleted: hunk.deleted, + patch: `${current.headerText}\n${hunk.header}\n${body}\n`, + body, + }); + hunk = null; + }; + + const closeFile = () => { + closeHunk(); + if (!current) return; + current.binary = current.binary || isBinaryHeader(headerLines); + delete current.hunkDigests; + files.push(current); + current = null; + }; + + for (const line of lines) { + if (FILE_HEADER.test(line)) { + closeFile(); + headerLines = [line]; + const paths = parsePathsFromFileHeader(line); + current = { + path: paths?.newPath || paths?.oldPath || '', + oldPath: paths && paths.oldPath !== paths.newPath ? paths.oldPath : null, + status: 'modified', + binary: false, + headerText: line, + hunks: [], + hunkDigests: new Map(), + }; + continue; + } + + if (!current) continue; + + const hunkMatch = HUNK_HEADER.exec(line); + if (hunkMatch) { + closeHunk(); + current.status = statusFromHeaderLines(headerLines); + current.headerText = headerLines.join('\n'); + hunk = { + header: line, + oldStart: Number.parseInt(hunkMatch[1], 10), + oldLines: hunkMatch[2] === undefined ? 1 : Number.parseInt(hunkMatch[2], 10), + newStart: Number.parseInt(hunkMatch[3], 10), + newLines: hunkMatch[4] === undefined ? 1 : Number.parseInt(hunkMatch[4], 10), + added: 0, + deleted: 0, + lines: [], + }; + continue; + } + + if (!hunk) { + headerLines.push(line); + continue; + } + + hunk.lines.push(line); + if (line.startsWith('+')) hunk.added += 1; + else if (line.startsWith('-')) hunk.deleted += 1; + } + + closeFile(); + + return { + files: files.filter((file) => file.path), + }; +} + +/** + * Flatten parsed files into an id-keyed index for resolution and staleness + * checks. + */ +export function indexHunks(files) { + const index = new Map(); + for (const file of files) { + for (const hunk of file.hunks) { + index.set(hunk.id, { ...hunk, path: file.path, status: file.status }); + } + } + return index; +} + +/** + * Every hunk id in the diff, in file-then-position order. Used to compute the + * "not covered by any stop" tail. + */ +export function listHunkIds(files) { + const ids = []; + for (const file of files) { + for (const hunk of file.hunks) ids.push(hunk.id); + } + return ids; +} diff --git a/packages/web/server/lib/walkthrough/hunks.test.js b/packages/web/server/lib/walkthrough/hunks.test.js new file mode 100644 index 00000000..fc250723 --- /dev/null +++ b/packages/web/server/lib/walkthrough/hunks.test.js @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; +import { parseDiffFiles, indexHunks, listHunkIds } from './hunks.js'; + +const TWO_FILE_DIFF = `diff --git a/src/a.ts b/src/a.ts +index 1111111..2222222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,3 +1,4 @@ + const a = 1; ++const b = 2; + const c = 3; + const d = 4; +@@ -20,2 +21,2 @@ +-const old = true; ++const next = true; +diff --git a/src/b.ts b/src/b.ts +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/src/b.ts +@@ -0,0 +1,2 @@ ++export const x = 1; ++export const y = 2; +`; + +describe('parseDiffFiles', () => { + it('splits files and hunks with line ranges and counts', () => { + const { files } = parseDiffFiles(TWO_FILE_DIFF, 'working'); + + expect(files.map((file) => file.path)).toEqual(['src/a.ts', 'src/b.ts']); + expect(files[0].hunks).toHaveLength(2); + expect(files[0].hunks[0]).toMatchObject({ + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 4, + added: 1, + deleted: 0, + }); + expect(files[0].hunks[1]).toMatchObject({ added: 1, deleted: 1 }); + expect(files[1].status).toBe('added'); + expect(files[1].hunks[0]).toMatchObject({ added: 2, deleted: 0 }); + }); + + it('produces a standalone applicable patch per hunk', () => { + const { files } = parseDiffFiles(TWO_FILE_DIFF, 'working'); + const patch = files[0].hunks[1].patch; + + expect(patch.startsWith('diff --git a/src/a.ts b/src/a.ts')).toBe(true); + expect(patch).toContain('--- a/src/a.ts'); + expect(patch).toContain('+++ b/src/a.ts'); + expect((patch.match(/^@@/gm) || [])).toHaveLength(1); + expect(patch).toContain('+const next = true;'); + expect(patch).not.toContain('const b = 2;'); + }); + + it('keeps ids stable across reparses of identical input', () => { + const first = listHunkIds(parseDiffFiles(TWO_FILE_DIFF, 'working').files); + const second = listHunkIds(parseDiffFiles(TWO_FILE_DIFF, 'working').files); + + expect(first).toEqual(second); + expect(new Set(first).size).toBe(first.length); + }); + + it('keeps an untouched hunk addressable when a neighbour changes', () => { + const before = parseDiffFiles(TWO_FILE_DIFF, 'working').files[0].hunks; + const edited = TWO_FILE_DIFF.replace('+const next = true;', '+const next = false;'); + const after = parseDiffFiles(edited, 'working').files[0].hunks; + + // The unrelated first hunk survives; only the edited one loses its id. + expect(after[0].id).toBe(before[0].id); + expect(after[1].id).not.toBe(before[1].id); + }); + + it('separates identical hunks in different scopes', () => { + const staged = parseDiffFiles(TWO_FILE_DIFF, 'staged').files[0].hunks[0].id; + const working = parseDiffFiles(TWO_FILE_DIFF, 'working').files[0].hunks[0].id; + + expect(staged).not.toBe(working); + }); + + it('disambiguates byte-identical hunks inside one file', () => { + const repeated = `diff --git a/src/dup.ts b/src/dup.ts +--- a/src/dup.ts ++++ b/src/dup.ts +@@ -1,1 +1,2 @@ ++import { thing } from './thing'; +@@ -1,1 +1,2 @@ ++import { thing } from './thing'; +`; + + const ids = listHunkIds(parseDiffFiles(repeated, 'working').files); + + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); + }); + + it('records renames and deletions', () => { + const renamed = `diff --git a/src/old.ts b/src/new.ts +similarity index 90% +rename from src/old.ts +rename to src/new.ts +--- a/src/old.ts ++++ b/src/new.ts +@@ -1,1 +1,1 @@ +-const a = 1; ++const a = 2; +diff --git a/src/gone.ts b/src/gone.ts +deleted file mode 100644 +--- a/src/gone.ts ++++ /dev/null +@@ -1,1 +0,0 @@ +-const gone = true; +`; + + const { files } = parseDiffFiles(renamed, 'working'); + + expect(files[0]).toMatchObject({ path: 'src/new.ts', oldPath: 'src/old.ts', status: 'renamed' }); + expect(files[1]).toMatchObject({ path: 'src/gone.ts', status: 'deleted' }); + }); + + it('marks binary files and gives them no hunks', () => { + const binary = `diff --git a/logo.png b/logo.png +index 1111111..2222222 100644 +Binary files a/logo.png and b/logo.png differ +`; + + const { files } = parseDiffFiles(binary, 'working'); + + expect(files[0]).toMatchObject({ path: 'logo.png', binary: true }); + expect(files[0].hunks).toHaveLength(0); + }); + + it('returns nothing for empty or whitespace input', () => { + expect(parseDiffFiles('', 'working').files).toEqual([]); + expect(parseDiffFiles(' \n', 'working').files).toEqual([]); + expect(parseDiffFiles(undefined, 'working').files).toEqual([]); + }); +}); + +describe('indexHunks', () => { + it('maps every id to its hunk with the owning file path', () => { + const { files } = parseDiffFiles(TWO_FILE_DIFF, 'working'); + const index = indexHunks(files); + + expect(index.size).toBe(3); + for (const [id, hunk] of index) { + expect(hunk.id).toBe(id); + expect(hunk.path).toBeTruthy(); + } + }); +}); diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js new file mode 100644 index 00000000..3ef99d35 --- /dev/null +++ b/packages/web/server/lib/walkthrough/index.js @@ -0,0 +1,509 @@ +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 { buildPrompt, JSON_SHAPE_INSTRUCTION } from './prompt.js'; +import { normalizeWalkthrough, parseModelJson, responseSchema } from './schema.js'; +import { + buildCacheKey, + pruneMissingRepositories, + readCachedWalkthrough, + readPointer, + writeCachedWalkthrough, + writePointer, +} from './store.js'; +import { readWalkthroughModelOverride } from './model-settings.js'; +import { loadSourceSections, parseSource, sourceKey, WalkthroughSourceError } from './sources.js'; + +// Walkthrough generation is always user-initiated and never automatic: it costs +// tokens, and a background regeneration on every keystroke would be a way to +// spend a budget without anyone deciding to. + +// This module is imported lazily, which means module-level work lands on the +// first walkthrough request. Housekeeping has no business being there, so it is +// deferred and never awaited: the request proceeds immediately and the prune +// interleaves behind it. +setTimeout(() => { + void pruneMissingRepositories().catch(() => { + // Housekeeping failing is not worth surfacing or retrying. + }); +}, 0).unref?.(); + +// A hang guard, not a pace-setter. Losing a nearly-finished generation wastes +// real money and minutes, while an over-long deadline only holds a job slot, so +// this errs long. It scales because a three-hunk edit and a 500-hunk pull +// request have no business sharing a deadline. +const GENERATION_TIMEOUT_BASE_MS = 120_000; +const GENERATION_TIMEOUT_PER_HUNK_MS = 1_000; +const GENERATION_TIMEOUT_MAX_MS = 900_000; + +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; + +const fail = (message, statusCode, extra = {}) => + Object.assign(new Error(message), { statusCode, ...extra }); + +// Generation outlives the request that started it. +// +// A dropped connection and a deliberate cancel look identical at the socket, so +// tying the work to the request lifetime meant an accidental refresh threw away +// a minute of paid-for work. Jobs are keyed by repository + source, so a client +// that comes back attaches to the running job instead of starting a second one, +// and cancelling is an explicit request rather than a side effect of leaving. +const jobs = new Map(); + +// Providers that answered a schema request with a 4xx. Retrying the schema on +// every generation means paying for a call we already know will fail, so the +// refusal is remembered and the fallback goes first next time. +// +// Process-lifetime only, on purpose: a provider that gains structured-output +// support should not need a settings change to be tried again — a restart is +// enough, and the cost of one wasted first attempt after that is small. +const schemaRefusedBy = new Set(); + +const modelKey = (model) => `${model.providerID}/${model.modelID}`; + +const jobKey = (repoRoot, sourceKeyValue) => `${repoRoot}\0${sourceKeyValue}`; + +/** + * Coarse stages, reported so a long wait is legible. + * + * Only phases a person can actually wait on are named. Building the digest and + * reading the cache take single-digit milliseconds; giving them their own rows + * would imply progress where there is none. `retrying` appears only when a + * provider rejects the schema and the prompt-side fallback runs. + */ +export const GENERATION_STAGES = ['collecting', 'asking', 'retrying', 'assembling']; + +const setStage = (repoRoot, sourceKeyValue, stage) => { + const job = jobs.get(jobKey(repoRoot, sourceKeyValue)); + if (job) job.stage = stage; +}; + +/** + * Current stage of a running generation, or `null` when nothing is running. + * Reads memory only — no git, no network — so it is cheap to poll. + */ +export function getGenerationStage(repoRoot, sourceKeyValue) { + return jobs.get(jobKey(repoRoot, sourceKeyValue))?.stage ?? null; +} + +/** + * Whether a generation is currently running for a source. Lets a reconnecting + * client show progress instead of an empty panel. + */ +export function isGenerating(repoRoot, sourceKeyValue) { + return jobs.has(jobKey(repoRoot, sourceKeyValue)); +} + +/** + * Resolve the pair the job registry is keyed by, for callers that need to look + * a job up without doing any diff work. + */ +export async function getRepositoryRootFor(directory, rawSource) { + const source = parseSource(rawSource); + return { repoRoot: await getRepositoryRoot(directory), sourceKey: sourceKey(source) }; +} + +/** + * Stop a running generation. Only an explicit request does this — leaving the + * page does not. + */ +export async function cancelWalkthroughGeneration({ directory, source: rawSource }) { + const source = parseSource(rawSource); + const repoRoot = await getRepositoryRoot(directory); + const job = jobs.get(jobKey(repoRoot, sourceKey(source))); + if (!job) return { cancelled: false }; + job.controller.abort(); + return { cancelled: true }; +} + +const modelLabel = (model) => `${model.providerID}/${model.modelID}`; + +/** + * Resolve the model for this feature: the walkthrough override when set, + * otherwise whatever the small-model chain resolves to. + */ +/** + * Resolve the model for this feature. An explicit per-review choice outranks the + * saved setting, which in turn outranks the small-model chain — the user picking + * a roomier model for a risky change is the most specific intent there is. + */ +const resolveModel = (directory, explicitModel) => describeSmallModel({ + directory, + outputReserveTokens: MAX_OUTPUT_TOKENS, + overrideModel: explicitModel || readWalkthroughModelOverride(), +}); + +export const __testing = { generationTimeoutMs }; + +/** + * Current diff for a source, parsed into files and hunks. + */ +async function loadCurrentDiff(directory, source, deps) { + const { sections } = await loadSourceSections(directory, source, deps); + const built = buildDigest(sections); + return built; +} + +const stopHunkIds = (walkthrough) => + walkthrough.chapters.flatMap((chapter) => chapter.stops.flatMap((stop) => stop.hunkIds)); + +/** + * Compare a stored walkthrough against the diff as it is right now. + * + * Staleness is not a heuristic here: a hunk id is a hash of the hunk's content, + * so an anchor that no longer resolves is proof that the code it described has + * changed or gone. Anchors that still resolve are still accurate. + */ +function resolveAgainstCurrent(walkthrough, hunkIndex) { + const missingHunkIds = []; + const staleStopIds = []; + + for (const chapter of walkthrough.chapters) { + for (const stop of chapter.stops) { + const missing = stop.hunkIds.filter((id) => !hunkIndex.has(id)); + if (missing.length === 0) continue; + missingHunkIds.push(...missing); + staleStopIds.push(stop.id); + } + } + + const covered = new Set(stopHunkIds(walkthrough)); + const uncoveredHunkIds = [...hunkIndex.keys()].filter((id) => !covered.has(id)); + + return { + isStale: missingHunkIds.length > 0, + missingHunkIds, + staleStopIds, + uncoveredHunkIds, + }; +} + +const serializeHunks = (files) => files.flatMap((file) => file.hunks.map((hunk) => ({ + id: hunk.id, + path: file.path, + oldPath: file.oldPath || null, + status: file.status, + scope: file.scope, + header: hunk.header, + newStart: hunk.newStart, + added: hunk.added, + deleted: hunk.deleted, + patch: hunk.patch, +}))); + +/** + * 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 = {}) { + const source = parseSource(rawSource); + const repoRoot = await getRepositoryRoot(directory); + const key = sourceKey(source); + + const pointer = readPointer(repoRoot, key); + // One diff, one model lookup, both answers. These used to be separate + // endpoints the client called in parallel, which meant every panel open ran + // the whole git pipeline twice. + const [built, model] = await Promise.all([ + loadCurrentDiff(directory, source, deps), + resolveModel(directory, explicitModel).catch(() => null), + ]); + const { files } = built; + const hunkIndex = indexHunks(files); + const readiness = computeReadiness({ ...built, model, source }); + + const base = { + source, + hunks: serializeHunks(files), + hunkCount: hunkIndex.size, + readiness, + generating: isGenerating(repoRoot, key), + }; + + const entry = 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 + // for the next generation to overwrite. + return { ...base, walkthrough: null }; + } + + return { + ...base, + walkthrough: entry.walkthrough, + model: entry.model, + generatedAt: entry.generatedAt, + ...resolveAgainstCurrent(entry.walkthrough, hunkIndex), + }; +} + +/** + * Whether the resolved model can do this job, computed from a digest the caller + * already built. + * + * Folded into the walkthrough read rather than living on its own endpoint: both + * 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 }) { + if (!model) return { ready: false, reason: 'no-model' }; + + if (hunkCount === 0) { + // "Only a lockfile changed" is a different answer from "nothing changed", + // and the user can act on it (commit and move on) rather than wonder why + // the review refuses. + const reason = files.length > 0 && generatedFileCount === files.length ? 'only-generated' : 'empty-diff'; + return { ready: false, reason, model, generatedFileCount }; + } + + const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source }); + const requiredChars = prompt.length + system.length; + + if (model.structuredOutput === false) { + return { ready: false, reason: 'structured-output-unsupported', model, requiredChars }; + } + + if (requiredChars > model.inputCharBudget) { + return { + ready: false, + reason: 'context-too-small', + model, + requiredChars, + availableChars: model.inputCharBudget, + }; + } + + return { ready: true, model, requiredChars, availableChars: model.inputCharBudget, hunkCount, fileCount }; +} + +/** + * Generate a walkthrough for a source. + * + * Returns the cached entry when the diff, model, and prompt are all unchanged — + * 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 = {}) { + const source = parseSource(rawSource); + const repoRoot = await getRepositoryRoot(directory); + const key = sourceKey(source); + + // 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. + const existing = jobs.get(jobKey(repoRoot, key)); + if (existing) return existing.promise; + + const controller = new AbortController(); + const promise = runGeneration({ directory, source, repoRoot, key, force, explicitModel, signal: controller.signal }, deps) + .finally(() => { + if (jobs.get(jobKey(repoRoot, key))?.controller === controller) { + jobs.delete(jobKey(repoRoot, key)); + } + }); + + jobs.set(jobKey(repoRoot, key), { controller, promise, stage: 'collecting' }); + return promise; +} + +async function runGeneration({ directory, source, repoRoot, key, force, explicitModel, signal }, deps) { + + const model = await resolveModel(directory, explicitModel); + if (!model) { + throw fail('No model is available — sign in to a provider first', 404, { code: 'no-model' }); + } + + const { digest, files, idByAlias, fileCount, hunkCount, generatedFileCount } = await loadCurrentDiff(directory, source, deps); + setStage(repoRoot, key, 'asking'); + if (hunkCount === 0) { + if (files.length > 0 && generatedFileCount === files.length) { + throw fail('Only generated files changed — there is nothing to review', 400, { code: 'only-generated' }); + } + throw fail('There are no changes to review', 400, { code: 'empty-diff' }); + } + + const cacheKey = buildCacheKey({ + repoRoot, + sourceKey: key, + providerID: model.providerID, + modelID: model.modelID, + files, + }); + + const hunkIndex = indexHunks(files); + + if (!force) { + const cached = readCachedWalkthrough(cacheKey); + if (cached) { + writePointer(repoRoot, key, { + repoRoot, + sourceKey: key, + cacheKey, + generatedAt: cached.generatedAt, + }); + return { + source, + walkthrough: cached.walkthrough, + model: cached.model, + generatedAt: cached.generatedAt, + fromCache: true, + hunks: serializeHunks(files), + hunkCount, + ...resolveAgainstCurrent(cached.walkthrough, hunkIndex), + }; + } + } + + // A forced regeneration hands the model its own previous narrative so it can + // keep what is still true instead of starting from a blank page. The old + // anchors are deliberately not included — they belong to code that has moved. + let previousWalkthrough = null; + const pointer = readPointer(repoRoot, key); + if (pointer) { + const previousEntry = readCachedWalkthrough(pointer.cacheKey); + if (previousEntry && previousEntry.cacheKey !== cacheKey) { + previousWalkthrough = previousEntry.walkthrough; + } + } + + const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source, previousWalkthrough }); + + if (model.structuredOutput === false) { + throw fail( + `${modelLabel(model)} cannot produce structured output — choose a different small model`, + 409, + { code: 'structured-output-unsupported', model }, + ); + } + + const run = (options) => generateSmallModelText({ + prompt: options.prompt, + system: options.system, + directory, + model: `${model.providerID}/${model.modelID}`, + responseSchema: options.responseSchema, + onOverflow: 'error', + timeoutMs: generationTimeoutMs(hunkCount), + maxOutputTokens: MAX_OUTPUT_TOKENS, + signal, + }); + + // Roughly half the catalog does not declare `structured_output`, and some of + // those providers reject the schema outright. A rejected request shape is not + // a dead end: the shape can travel in the prompt instead, and the response + // parser is already tolerant of imperfect JSON. + const withSchema = () => run({ prompt, system, responseSchema }); + const withoutSchema = () => run({ + prompt, + system: `${system}\n${JSON_SHAPE_INSTRUCTION}`, + responseSchema: undefined, + }); + + const asRequestFailure = (error) => { + if (error?.code === 'context-too-small') { + return fail(error.message, 409, { + code: 'context-too-small', + model, + requiredChars: error.requiredChars, + availableChars: error.availableChars, + }); + } + if (error?.code === 'output-exhausted') { + return fail(error.message, 409, { code: 'output-exhausted', model }); + } + return null; + }; + + const refusesSchema = (error) => error?.code === 'structured-output-unsupported' + || (Number(error?.status) >= 400 && Number(error?.status) < 500); + + let raw; + let usedSchema = false; + + if (schemaRefusedBy.has(modelKey(model))) { + // Already known to refuse: skip straight to the fallback rather than pay + // for a call whose failure is a foregone conclusion. + setStage(repoRoot, key, 'retrying'); + try { + raw = await withoutSchema(); + } catch (error) { + throw asRequestFailure(error) ?? error; + } + } else { + try { + raw = await withSchema(); + usedSchema = true; + } catch (error) { + const failure = asRequestFailure(error); + if (failure) throw failure; + if (!refusesSchema(error)) throw error; + + schemaRefusedBy.add(modelKey(model)); + setStage(repoRoot, key, 'retrying'); + try { + raw = await withoutSchema(); + } catch (fallbackError) { + throw asRequestFailure(fallbackError) ?? fallbackError; + } + } + } + + setStage(repoRoot, key, 'assembling'); + + let walkthrough; + try { + walkthrough = normalizeWalkthrough(parseModelJson(raw.text), idByAlias); + } catch (error) { + // Without schema support the model was asked for JSON in prose and did not + // deliver: that is a capability problem the user can fix by switching model, + // so it gets the picker rather than a parser error. + if (!usedSchema) { + throw fail( + `${modelLabel(model)} could not return the structured response a walkthrough needs`, + 409, + { code: 'structured-output-unsupported', model }, + ); + } + throw fail( + `${modelLabel(model)} did not return a usable walkthrough — try a different small model`, + 502, + { code: 'invalid-walkthrough', model, cause: error?.message }, + ); + } + + const generatedAt = new Date().toISOString(); + const entry = { + cacheKey, + generatedAt, + repoRoot, + sourceKey: key, + model: { providerID: model.providerID, modelID: model.modelID, source: model.source }, + walkthrough, + }; + + // A failed write costs a regeneration next time; it must never fail the + // request that already produced a good walkthrough. + writeCachedWalkthrough(cacheKey, entry); + writePointer(repoRoot, key, { repoRoot, sourceKey: key, cacheKey, generatedAt }); + + return { + source, + walkthrough, + model: entry.model, + generatedAt, + fromCache: false, + hunks: serializeHunks(files), + hunkCount, + ...resolveAgainstCurrent(walkthrough, hunkIndex), + }; +} + +export { WalkthroughSourceError }; diff --git a/packages/web/server/lib/walkthrough/jobs.test.js b/packages/web/server/lib/walkthrough/jobs.test.js new file mode 100644 index 00000000..bbef1f5e --- /dev/null +++ b/packages/web/server/lib/walkthrough/jobs.test.js @@ -0,0 +1,256 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'walkthrough-jobs-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +// Mocking git rather than this module's own source loading: fewer of our own +// seams faked means the test exercises the real digest and prompt path. +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 { + generateWalkthrough, + cancelWalkthroughGeneration, + isGenerating, + getGenerationStage, + __testing: walkthroughTesting, +} = await import('./index.js'); +const { describeSmallModel, generateSmallModelText } = await import('../small-model/index.js'); +const { getDiff } = await import('../git/service.js'); + +// bun's vitest shim has no `vi.waitFor`. +const waitFor = async (predicate, { timeout = 2_000, interval = 5 } = {}) => { + const deadline = Date.now() + timeout; + for (;;) { + if (predicate()) return; + if (Date.now() > deadline) throw new Error('waitFor timed out'); + await new Promise((resolve) => setTimeout(resolve, interval)); + } +}; + +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.' }], + }], +}); + +describe('generation jobs', () => { + 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(); + }); + + afterEach(async () => { + if (isGenerating('/repo', 'working-tree:all')) { + await cancelWalkthroughGeneration({ directory: '/repo', source: SOURCE }).catch(() => {}); + } + }); + + afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); + }); + + it('runs a second request against the same job instead of paying twice', async () => { + let release; + generateSmallModelText.mockImplementation(() => new Promise((resolve) => { + release = () => resolve({ text: RESPONSE }); + })); + + const first = generateWalkthrough({ directory: '/repo', source: SOURCE }); + // Let the first call reach the model before the second arrives, which is + // what a refresh-then-press-again actually looks like. + await waitFor(() => generateSmallModelText.mock.calls.length === 1); + const second = generateWalkthrough({ directory: '/repo', source: SOURCE }); + + release(); + const [a, b] = await Promise.all([first, second]); + + expect(generateSmallModelText).toHaveBeenCalledTimes(1); + expect(a.walkthrough.title).toBe('Change'); + expect(b).toBe(a); + }); + + it('reports a running job so a returning client can show progress', async () => { + let release; + generateSmallModelText.mockImplementation(() => new Promise((resolve) => { + release = () => resolve({ text: RESPONSE }); + })); + + const running = generateWalkthrough({ directory: '/repo', source: SOURCE }); + await waitFor(() => isGenerating('/repo', 'working-tree:all')); + + release(); + await running; + + expect(isGenerating('/repo', 'working-tree:all')).toBe(false); + }); + + it('stops only on an explicit cancel', async () => { + generateSmallModelText.mockImplementation(({ signal }) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))); + })); + + const running = generateWalkthrough({ directory: '/repo', source: SOURCE }); + await waitFor(() => isGenerating('/repo', 'working-tree:all')); + + expect(await cancelWalkthroughGeneration({ directory: '/repo', source: SOURCE })) + .toEqual({ cancelled: true }); + await expect(running).rejects.toThrow(); + expect(isGenerating('/repo', 'working-tree:all')).toBe(false); + }); + + it('reports nothing to cancel when no job is running', async () => { + expect(await cancelWalkthroughGeneration({ directory: '/repo', source: SOURCE })) + .toEqual({ cancelled: false }); + }); + + it('serves the cache once the job has finished, without calling the model again', async () => { + generateSmallModelText.mockResolvedValue({ text: RESPONSE }); + + await generateWalkthrough({ directory: '/repo', source: SOURCE }); + generateSmallModelText.mockClear(); + + const second = await generateWalkthrough({ directory: '/repo', source: SOURCE }); + + expect(second.fromCache).toBe(true); + expect(generateSmallModelText).not.toHaveBeenCalled(); + }); +}); + +// A fixed deadline made a three-hunk edit and a 500-hunk pull request wait the +// same, so the small case guarded nothing and the big case died just short of +// the finish line. +describe('generation timeout', () => { + const { generationTimeoutMs } = walkthroughTesting; + + it('gives a small diff a floor rather than a proportional sliver', () => { + expect(generationTimeoutMs(0)).toBe(120_000); + expect(generationTimeoutMs(3)).toBe(123_000); + }); + + it('grows with the work', () => { + expect(generationTimeoutMs(515)).toBeGreaterThan(generationTimeoutMs(138)); + expect(generationTimeoutMs(515)).toBe(635_000); + }); + + it('stays bounded so a hung connection cannot hold a job forever', () => { + expect(generationTimeoutMs(100_000)).toBe(900_000); + }); +}); + +describe('generation stages', () => { + beforeEach(() => { + // Without this the previous suite's cache entry is a hit for the same + // content and the model is never called. + 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(); + }); + + it('reports asking while the model runs and clears when the job ends', async () => { + let release; + generateSmallModelText.mockImplementation(() => new Promise((resolve) => { + release = () => resolve({ text: RESPONSE }); + })); + + const running = generateWalkthrough({ directory: '/repo', source: SOURCE }); + await waitFor(() => getGenerationStage('/repo', 'working-tree:all') === 'asking'); + + release(); + await running; + + expect(getGenerationStage('/repo', 'working-tree:all')).toBeNull(); + }); + + it('reports retrying only when a provider rejects the schema', async () => { + let seen = []; + let attempt = 0; + generateSmallModelText.mockImplementation(async () => { + attempt += 1; + seen.push(getGenerationStage('/repo', 'working-tree:all')); + if (attempt === 1) throw Object.assign(new Error('bad request'), { status: 400 }); + return { text: RESPONSE }; + }); + + await generateWalkthrough({ directory: '/repo', source: SOURCE }); + + expect(seen).toEqual(['asking', 'retrying']); + }); +}); + +// Retrying the schema on every generation means paying for a call already known +// to fail; the refusal has to be remembered. +describe('schema refusal memory', () => { + beforeEach(() => { + fs.rmSync(path.join(TEMP_DATA_DIR, 'walkthroughs'), { recursive: true, force: true }); + describeSmallModel.mockResolvedValue({ + providerID: 'opencode-go', + modelID: 'deepseek-v4-flash', + source: 'config', + inputCharBudget: 1_000_000, + structuredOutput: null, + }); + getDiff.mockImplementation(async (_dir, options) => (options?.staged ? '' : PATCH)); + generateSmallModelText.mockReset(); + }); + + it('stops sending a schema to a model that already rejected one', async () => { + const sentSchema = []; + generateSmallModelText.mockImplementation(async ({ responseSchema }) => { + sentSchema.push(Boolean(responseSchema)); + if (responseSchema) throw Object.assign(new Error('bad request'), { status: 400 }); + return { text: RESPONSE }; + }); + + await generateWalkthrough({ directory: '/repo', source: SOURCE }); + expect(sentSchema).toEqual([true, false]); + + // A different diff, so the cache cannot answer instead. + getDiff.mockImplementation(async (_dir, options) => ( + options?.staged ? '' : PATCH.replace('const added = true;', 'const added = false;') + )); + await generateWalkthrough({ directory: '/repo', source: SOURCE }); + + expect(sentSchema).toEqual([true, false, false]); + }); +}); diff --git a/packages/web/server/lib/walkthrough/model-settings.js b/packages/web/server/lib/walkthrough/model-settings.js new file mode 100644 index 00000000..84009b62 --- /dev/null +++ b/packages/web/server/lib/walkthrough/model-settings.js @@ -0,0 +1,38 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// The walkthrough may run on a different model than the rest of the small-model +// callers. Those callers want cheap and fast; this one needs structured output +// and enough context for a whole diff, and forcing one setting to serve both +// means the user has to degrade one feature to fix the other. + +const SETTINGS_FILE = path.join( + process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'), + 'settings.json', +); + +/** + * The explicit walkthrough model, or `null` to fall back to normal small-model + * resolution. + * + * Having chosen a model *is* the opt-out; a separate toggle would let the two + * disagree, and then clearing the picker would leave a setting that says "do + * not use the small model" with nothing to use instead. + */ +export function readWalkthroughModelOverride() { + try { + const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); + if (!settings || typeof settings !== 'object') return null; + const override = typeof settings.walkthroughModelOverride === 'string' + ? settings.walkthroughModelOverride.trim() + : ''; + return override || null; + } catch { + // No settings file, unreadable, or malformed all mean the same thing: no + // override, use the small model. + return null; + } +} diff --git a/packages/web/server/lib/walkthrough/model-settings.test.js b/packages/web/server/lib/walkthrough/model-settings.test.js new file mode 100644 index 00000000..984010d9 --- /dev/null +++ b/packages/web/server/lib/walkthrough/model-settings.test.js @@ -0,0 +1,58 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; + +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'walkthrough-model-settings-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +const { readWalkthroughModelOverride } = await import('./model-settings.js'); + +const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json'); + +const write = (value) => fs.writeFileSync(SETTINGS_FILE, JSON.stringify(value), 'utf8'); + +describe('readWalkthroughModelOverride', () => { + beforeEach(() => { + fs.rmSync(SETTINGS_FILE, { force: true }); + }); + + it('returns the chosen model', () => { + write({ walkthroughModelOverride: 'anthropic/claude-haiku-4-5' }); + expect(readWalkthroughModelOverride()).toBe('anthropic/claude-haiku-4-5'); + }); + + it('defers to the small model when nothing is chosen', () => { + write({}); + expect(readWalkthroughModelOverride()).toBeNull(); + + // Clearing the picker writes an empty string; that must read as "use the + // small model", not as an override of ''. + write({ walkthroughModelOverride: '' }); + expect(readWalkthroughModelOverride()).toBeNull(); + + write({ walkthroughModelOverride: ' ' }); + expect(readWalkthroughModelOverride()).toBeNull(); + }); + + it('never throws on a missing or corrupt settings file', () => { + expect(readWalkthroughModelOverride()).toBeNull(); + + fs.writeFileSync(SETTINGS_FILE, '{ not json', 'utf8'); + expect(readWalkthroughModelOverride()).toBeNull(); + }); + + it('is independent of the small model override', () => { + write({ + smallModelUseDefault: false, + smallModelOverride: 'google/gemini-2.5-flash', + walkthroughModelOverride: 'anthropic/claude-haiku-4-5', + }); + + expect(readWalkthroughModelOverride()).toBe('anthropic/claude-haiku-4-5'); + }); +}); + +afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); +}); diff --git a/packages/web/server/lib/walkthrough/prompt.js b/packages/web/server/lib/walkthrough/prompt.js new file mode 100644 index 00000000..5387fe6f --- /dev/null +++ b/packages/web/server/lib/walkthrough/prompt.js @@ -0,0 +1,83 @@ +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. + +Your job is to impose a reading order the diff itself does not have. A diff is ordered by file path, which is almost never the order in which the change makes sense. Group related hunks — across files — into stops, and order the stops so that each one is understandable given the ones before it. + +What a good stop says: +- what this code now does differently, in terms of behavior, not syntax +- why the surrounding hunks belong together +- what a reviewer should check or be suspicious about, when there is something + +What a bad stop says: +- "Renamed X to Y", "Added a parameter", "Updated the imports" — restating the diff in prose is worthless; the reader can already see it +- speculation about intent you cannot support from the code + +Rules: +- Anchor every stop to hunk aliases from the digest, exactly as given (h1, h2, …). Never invent an alias. +- Anchor each hunk at most once, in the stop where it matters most. +- You do not have to cover every hunk. Mechanical changes are better left out than padded into a stop; whatever you omit is still shown to the reader separately. +- Order stops so the reader builds understanding: entry points and data shape before the code that consumes them. +- importance: "critical" for changes that carry real risk or drive the rest, "context" for supporting changes, "normal" otherwise. +- Stop titles name the thing the stop is about, not the act of reviewing it. "Hardware keyboard bridge" and "Overflow menu removed" tell a reader scanning the contents what they will find; "Exercise the boundaries" and "Describe the contract" do not. +- Write prose as plain sentences. No markdown, no bullet lists, no code fences. + +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.)`; + +const sizing = ({ fileCount, hunkCount }) => { + const targetStops = Math.max(1, Math.min(MAX_STOPS, Math.round(hunkCount / 2.5) || 1)); + const targetChapters = hunkCount <= 4 + ? 1 + : Math.max(1, Math.min(MAX_CHAPTERS, Math.ceil(targetStops / 3))); + + return `This change has ${fileCount} file(s) and ${hunkCount} reviewable hunk(s). + +Aim for about ${targetStops} stop(s) across about ${targetChapters} chapter(s); never exceed ${MAX_STOPS} stops, ${MAX_CHAPTERS} chapters, or ${MAX_HUNKS_PER_STOP} hunks in one stop. Fewer, denser stops beat many thin ones. + +Chapter titles render in a narrow column: at most ${MAX_CHAPTER_TITLE_CHARS} characters, one or two words.`; +}; + +const previousWalkthroughSection = (previous) => { + if (!previous || !Array.isArray(previous.chapters) || previous.chapters.length === 0) return ''; + + const outline = previous.chapters + .map((chapter) => { + const stops = (chapter.stops || []) + .map((stop) => ` - ${stop.title}: ${stop.prose}`) + .join('\n'); + return `- ${chapter.title}${chapter.blurb ? ` — ${chapter.blurb}` : ''}\n${stops}`; + }) + .join('\n'); + + return ` +A previous walkthrough of an earlier state of this change is below. The code has moved on since it was written, so its anchors are gone — deliberately, so you re-anchor everything against the current digest. + +Keep the stops that are still accurate and phrased well, revise the ones whose code changed, drop the ones whose code no longer exists, and add stops for work that is new. Do not preserve its structure out of loyalty; preserve it only where it still fits. + +Previous walkthrough — "${previous.title}": +${outline} +`; +}; + +// Used only when a provider rejects a schema request: the shape has to travel +// in the prompt instead of the request body. +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 }) { + const sourceLine = source.kind === 'working-tree' + ? `Uncommitted local changes (${source.scope === 'all' ? 'staged and unstaged' : source.scope}).` + : source.kind === 'branch' + ? `All work on branch "${source.headRef}" that is not in "${source.baseRef}". Changes merged in from ${source.baseRef} are already excluded.` + : `Pull request #${source.number}.`; + + const prompt = `Reviewing: ${sourceLine} + +${sizing({ fileCount, hunkCount })} +${previousWalkthroughSection(previousWalkthrough)} +Change digest: +${JSON.stringify(digest)}`; + + return { system: SYSTEM, prompt }; +} diff --git a/packages/web/server/lib/walkthrough/pull-request.js b/packages/web/server/lib/walkthrough/pull-request.js new file mode 100644 index 00000000..e05a5f51 --- /dev/null +++ b/packages/web/server/lib/walkthrough/pull-request.js @@ -0,0 +1,46 @@ +import { getOctokitOrNull } from '../github/octokit.js'; +import { resolveGitHubRepoFromDirectory } from '../github/repo/index.js'; + +/** + * Raw unified diff for a pull request. + * + * GitHub already returns the merge-base diff for a PR, so this matches the + * three-dot semantics used for local branch reviews: work merged in from the + * base branch is not part of it. + */ +export async function getPullRequestDiff(directory, number) { + const octokit = getOctokitOrNull(); + if (!octokit) { + throw Object.assign(new Error('Connect a GitHub account to review pull requests'), { + statusCode: 401, + code: 'github-not-connected', + }); + } + + // The resolver returns `{ repo, remoteUrl }`, not the repo itself. Reading + // `.owner` off the wrapper made this check fail for every repository. + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo?.owner || !repo?.repo) { + throw Object.assign(new Error('This directory has no GitHub remote'), { + statusCode: 400, + code: 'no-github-remote', + }); + } + + const response = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { + owner: repo.owner, + repo: repo.repo, + pull_number: number, + headers: { accept: 'application/vnd.github.v3.diff' }, + }); + + const patch = typeof response?.data === 'string' ? response.data : ''; + if (!patch.trim()) { + throw Object.assign(new Error(`Pull request #${number} has no diff`), { + statusCode: 404, + code: 'empty-diff', + }); + } + + return { patch, meta: { owner: repo.owner, repo: repo.repo, number } }; +} diff --git a/packages/web/server/lib/walkthrough/pull-request.test.js b/packages/web/server/lib/walkthrough/pull-request.test.js new file mode 100644 index 00000000..6a76a337 --- /dev/null +++ b/packages/web/server/lib/walkthrough/pull-request.test.js @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../github/octokit.js', () => ({ getOctokitOrNull: vi.fn() })); +vi.mock('../github/repo/index.js', () => ({ resolveGitHubRepoFromDirectory: vi.fn() })); + +const { getPullRequestDiff } = await import('./pull-request.js'); +const { getOctokitOrNull } = await import('../github/octokit.js'); +const { resolveGitHubRepoFromDirectory } = await import('../github/repo/index.js'); + +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; +`; + +describe('getPullRequestDiff', () => { + let request; + + beforeEach(() => { + request = vi.fn().mockResolvedValue({ data: PATCH }); + getOctokitOrNull.mockReturnValue({ request }); + // The resolver hands back a wrapper, not the repo. Reading `.owner` off the + // wrapper made every repository look remote-less, which is what this suite + // exists to prevent. + resolveGitHubRepoFromDirectory.mockResolvedValue({ + repo: { owner: 'openchamber', repo: 'openchamber' }, + remoteUrl: 'git@github.com:openchamber/openchamber.git', + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('requests the diff for the resolved repository', async () => { + const result = await getPullRequestDiff('/repo', 2122); + + expect(result.patch).toBe(PATCH); + expect(result.meta).toEqual({ owner: 'openchamber', repo: 'openchamber', number: 2122 }); + expect(request).toHaveBeenCalledWith('GET /repos/{owner}/{repo}/pulls/{pull_number}', { + owner: 'openchamber', + repo: 'openchamber', + pull_number: 2122, + headers: { accept: 'application/vnd.github.v3.diff' }, + }); + }); + + it('reports a missing GitHub remote only when there really is none', async () => { + resolveGitHubRepoFromDirectory.mockResolvedValue({ repo: null, remoteUrl: null }); + + await expect(getPullRequestDiff('/repo', 2122)).rejects.toMatchObject({ + code: 'no-github-remote', + statusCode: 400, + }); + expect(request).not.toHaveBeenCalled(); + }); + + it('asks the user to connect GitHub before anything else', async () => { + getOctokitOrNull.mockReturnValue(null); + + await expect(getPullRequestDiff('/repo', 2122)).rejects.toMatchObject({ + code: 'github-not-connected', + statusCode: 401, + }); + expect(resolveGitHubRepoFromDirectory).not.toHaveBeenCalled(); + }); + + it('treats an empty diff as a missing pull request rather than an empty review', async () => { + request.mockResolvedValue({ data: ' ' }); + + await expect(getPullRequestDiff('/repo', 2122)).rejects.toMatchObject({ + code: 'empty-diff', + statusCode: 404, + }); + }); +}); diff --git a/packages/web/server/lib/walkthrough/routes.js b/packages/web/server/lib/walkthrough/routes.js new file mode 100644 index 00000000..c6932f1e --- /dev/null +++ b/packages/web/server/lib/walkthrough/routes.js @@ -0,0 +1,107 @@ +// `req.destroyed` is true for every healthy request once the body parser has +// consumed the stream, so using it as a disconnect check silently swallows every +// response. The response socket is the one that actually reflects whether the +// client is still there. +const clientIsGone = (res) => res.writableEnded || res.destroyed; + +export function registerWalkthroughRoutes(app, { getWalkthroughService }) { + const respondWithError = (res, error, fallback) => { + const statusCode = Number(error?.statusCode) || 500; + if (statusCode >= 500) { + console.error(`${fallback}:`, error); + } + res.status(statusCode).json({ + error: error?.message || fallback, + ...(error?.code ? { code: error.code } : {}), + ...(error?.model ? { model: error.model } : {}), + ...(Number.isFinite(error?.requiredChars) ? { requiredChars: error.requiredChars } : {}), + ...(Number.isFinite(error?.availableChars) ? { availableChars: error.availableChars } : {}), + }); + }; + + const readSource = (value) => { + if (typeof value !== 'string' || !value) return null; + try { + return JSON.parse(value); + } catch { + return null; + } + }; + + app.get('/api/walkthrough', async (req, res) => { + try { + const { getWalkthrough, getPullRequestDiff } = await getWalkthroughService(); + const directory = typeof req.query.directory === 'string' ? req.query.directory : ''; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await getWalkthrough( + { + directory, + source: readSource(req.query.source), + model: typeof req.query.model === 'string' ? req.query.model : undefined, + }, + { getPullRequestDiff }, + ); + res.json(result); + } catch (error) { + respondWithError(res, error, 'Failed to load walkthrough'); + } + }); + + // Deliberately not aborted when the client disconnects: generation runs for + // minutes and a refresh must not throw the work away. Leaving detaches the + // client; the job finishes and caches its result. Stopping is an explicit + // request below. + app.post('/api/walkthrough/generate', async (req, res) => { + try { + const { generateWalkthrough, getPullRequestDiff } = await getWalkthroughService(); + const { directory, source, force, model } = 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 }, + { getPullRequestDiff }, + ); + if (clientIsGone(res)) return; + res.json(result); + } catch (error) { + if (clientIsGone(res)) return; + respondWithError(res, error, 'Failed to generate walkthrough'); + } + }); + + // Memory-only, so it is safe to poll while a generation runs. The full read + // re-runs the whole git pipeline and must not be used for this. + app.get('/api/walkthrough/progress', async (req, res) => { + try { + const { getGenerationStage, getRepositoryRootFor } = await getWalkthroughService(); + const directory = typeof req.query.directory === 'string' ? req.query.directory : ''; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { repoRoot, sourceKey } = await getRepositoryRootFor(directory, readSource(req.query.source)); + res.json({ stage: getGenerationStage(repoRoot, sourceKey) }); + } catch (error) { + respondWithError(res, error, 'Failed to read walkthrough progress'); + } + }); + + app.post('/api/walkthrough/cancel', async (req, res) => { + try { + const { cancelWalkthroughGeneration } = await getWalkthroughService(); + const { directory, source } = req.body || {}; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory is required' }); + } + + res.json(await cancelWalkthroughGeneration({ directory, source })); + } catch (error) { + respondWithError(res, error, 'Failed to cancel walkthrough generation'); + } + }); +} diff --git a/packages/web/server/lib/walkthrough/routes.test.js b/packages/web/server/lib/walkthrough/routes.test.js new file mode 100644 index 00000000..42187f2a --- /dev/null +++ b/packages/web/server/lib/walkthrough/routes.test.js @@ -0,0 +1,110 @@ +import express from 'express'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { registerWalkthroughRoutes } from './routes.js'; + +// These run over real HTTP on purpose. The bug this file exists for was +// invisible to unit tests: the service and the store were both correct, and the +// response was dropped by a disconnect check that misread a healthy request. + +const SOURCE = { kind: 'working-tree', scope: 'all' }; + +describe('walkthrough routes', () => { + let server; + let base; + let releaseJob; + let job; + + const service = { + async getWalkthrough() { + return { walkthrough: null, hunks: [], hunkCount: 0, generating: Boolean(job) }; + }, + async generateWalkthrough() { + if (job) return job; + job = new Promise((resolve) => { + releaseJob = () => resolve({ walkthrough: { title: 'DONE' }, hunks: [], hunkCount: 1 }); + }).finally(() => { job = null; }); + return job; + }, + async cancelWalkthroughGeneration() { + return { cancelled: Boolean(job) }; + }, + }; + + const generate = (signal) => fetch(`${base}/api/walkthrough/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/repo', source: SOURCE }), + signal, + }); + + beforeEach(async () => { + job = null; + releaseJob = undefined; + const app = express(); + app.use(express.json()); + registerWalkthroughRoutes(app, { getWalkthroughService: async () => service }); + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + base = `http://127.0.0.1:${server.address().port}`; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it('answers a generation request that nobody interrupted', async () => { + const pending = generate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + releaseJob(); + + const body = await (await pending).json(); + + expect(body.walkthrough).toEqual({ title: 'DONE' }); + }); + + it('delivers the result to a client that reconnected after a refresh', async () => { + const controller = new AbortController(); + generate(controller.signal).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.abort(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // The reloaded page sees work in progress and re-attaches to it. + const read = await (await fetch( + `${base}/api/walkthrough?directory=/repo&source=${encodeURIComponent(JSON.stringify(SOURCE))}`, + )).json(); + expect(read.generating).toBe(true); + + const reattached = generate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + releaseJob(); + + const body = await (await reattached).json(); + expect(body.walkthrough).toEqual({ title: 'DONE' }); + }); + + it('rejects a request without a directory before touching the service', async () => { + const response = await fetch(`${base}/api/walkthrough/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: SOURCE }), + }); + + expect(response.status).toBe(400); + expect(job).toBeNull(); + }); + + it('cancels through its own endpoint rather than a dropped connection', async () => { + generate().catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const response = await fetch(`${base}/api/walkthrough/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/repo', source: SOURCE }), + }); + + expect(await response.json()).toEqual({ cancelled: true }); + releaseJob(); + }); +}); diff --git a/packages/web/server/lib/walkthrough/schema.js b/packages/web/server/lib/walkthrough/schema.js new file mode 100644 index 00000000..b7e9805c --- /dev/null +++ b/packages/web/server/lib/walkthrough/schema.js @@ -0,0 +1,175 @@ +// Shape of the walkthrough the model must produce, plus normalization of what +// it actually produced. The model is only ever trusted for prose and grouping — +// every anchor it returns is re-resolved against the digest here, and anything +// that does not resolve is dropped rather than rendered as a broken stop. + +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 MAX_CHAPTERS = 6; +export const MAX_STOPS = 16; +export const MAX_HUNKS_PER_STOP = 14; +export const MAX_CHAPTER_TITLE_CHARS = 24; + +const CHAPTER_ICONS = ['bug', 'wrench', 'path', 'flask', 'doc', 'gear']; +const STOP_IMPORTANCE = ['critical', 'normal', 'context']; + +export const responseSchema = { + type: 'object', + properties: { + title: { type: 'string' }, + focus: { type: 'string' }, + chapters: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + icon: { type: 'string', enum: CHAPTER_ICONS }, + blurb: { type: 'string' }, + stops: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + hunks: { type: 'array', items: { type: 'string' } }, + importance: { type: 'string', enum: STOP_IMPORTANCE }, + prose: { type: 'string' }, + }, + required: ['title', 'hunks', 'importance', 'prose'], + additionalProperties: false, + }, + }, + }, + required: ['title', 'icon', 'blurb', 'stops'], + additionalProperties: false, + }, + }, + }, + required: ['title', 'focus', 'chapters'], + additionalProperties: false, +}; + +const asString = (value, max) => { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + return max && trimmed.length > max ? trimmed.slice(0, max) : trimmed; +}; + +/** + * Turn a raw model response into a walkthrough anchored to real hunk ids. + * + * @param {object} raw parsed model JSON + * @param {Map} idByAlias alias → real hunk id, from the digest + * @returns {{title: string, focus: string, chapters: Array, droppedAnchors: number}} + */ +export function normalizeWalkthrough(raw, idByAlias) { + if (!raw || typeof raw !== 'object') { + throw Object.assign(new Error('Model returned no walkthrough object'), { code: 'invalid-walkthrough' }); + } + + const usedIds = new Set(); + let droppedAnchors = 0; + let stopCount = 0; + + const chapters = []; + for (const [chapterIndex, rawChapter] of (Array.isArray(raw.chapters) ? raw.chapters : []).entries()) { + if (chapters.length >= MAX_CHAPTERS) break; + if (!rawChapter || typeof rawChapter !== 'object') continue; + + const stops = []; + for (const rawStop of Array.isArray(rawChapter.stops) ? rawChapter.stops : []) { + if (stopCount >= MAX_STOPS) break; + if (!rawStop || typeof rawStop !== 'object') continue; + + const hunkIds = []; + for (const alias of Array.isArray(rawStop.hunks) ? rawStop.hunks : []) { + const id = idByAlias.get(typeof alias === 'string' ? alias.trim() : ''); + if (!id) { + droppedAnchors += 1; + continue; + } + // One hunk belongs to exactly one stop; a model that anchors the same + // code twice would otherwise render it twice in the stream. + if (usedIds.has(id)) continue; + if (hunkIds.length >= MAX_HUNKS_PER_STOP) break; + usedIds.add(id); + hunkIds.push(id); + } + + const prose = asString(rawStop.prose); + if (hunkIds.length === 0 || !prose) continue; + + stopCount += 1; + stops.push({ + id: `stop-${chapterIndex + 1}-${stops.length + 1}`, + title: asString(rawStop.title) || `Step ${stopCount}`, + hunkIds, + importance: STOP_IMPORTANCE.includes(rawStop.importance) ? rawStop.importance : 'normal', + prose, + }); + } + + if (stops.length === 0) continue; + + chapters.push({ + id: `chapter-${chapters.length + 1}`, + title: asString(rawChapter.title, MAX_CHAPTER_TITLE_CHARS) || `Part ${chapters.length + 1}`, + icon: CHAPTER_ICONS.includes(rawChapter.icon) ? rawChapter.icon : 'doc', + blurb: asString(rawChapter.blurb), + stops, + }); + } + + if (chapters.length === 0) { + throw Object.assign( + new Error('Model returned no usable stops for this diff'), + { code: 'invalid-walkthrough' }, + ); + } + + return { + title: asString(raw.title) || 'Change walkthrough', + focus: asString(raw.focus), + chapters, + droppedAnchors, + }; +} + +/** + * Extract a JSON object from a model response that may or may not honour the + * schema — some providers wrap it in prose or a fenced block. + */ +export function parseModelJson(text) { + if (typeof text !== 'string' || !text.trim()) { + throw Object.assign(new Error('Model returned an empty response'), { code: 'invalid-walkthrough' }); + } + + const withoutFence = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); + + try { + return JSON.parse(withoutFence); + } catch { + // Fall through to a bounded scan for the outermost object. + } + + const start = withoutFence.indexOf('{'); + if (start === -1) { + throw Object.assign(new Error('Model response contained no JSON object'), { code: 'invalid-walkthrough' }); + } + + for (let end = withoutFence.lastIndexOf('}'); end > start; end = withoutFence.lastIndexOf('}', end - 1)) { + try { + return JSON.parse(withoutFence.slice(start, end + 1)); + } catch { + // Keep shrinking from the right. + } + } + + throw Object.assign(new Error('Model response was not valid JSON'), { code: 'invalid-walkthrough' }); +} diff --git a/packages/web/server/lib/walkthrough/schema.test.js b/packages/web/server/lib/walkthrough/schema.test.js new file mode 100644 index 00000000..ae889698 --- /dev/null +++ b/packages/web/server/lib/walkthrough/schema.test.js @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeWalkthrough, parseModelJson, MAX_STOPS } from './schema.js'; + +const ALIASES = new Map([ + ['h1', 'working:src/a.ts:aaaa1111'], + ['h2', 'working:src/a.ts:bbbb2222'], + ['h3', 'working:src/b.ts:cccc3333'], +]); + +const walkthrough = (chapters) => ({ title: 'Change', focus: 'why', chapters }); + +describe('normalizeWalkthrough', () => { + it('maps aliases to real hunk ids and assigns stable local ids', () => { + const result = normalizeWalkthrough(walkthrough([ + { + title: 'Data', + icon: 'doc', + blurb: 'shape first', + stops: [ + { title: 'New field', hunks: ['h1', 'h2'], importance: 'critical', prose: 'Adds a field.' }, + ], + }, + ]), ALIASES); + + expect(result.chapters[0].id).toBe('chapter-1'); + expect(result.chapters[0].stops[0]).toMatchObject({ + id: 'stop-1-1', + hunkIds: ['working:src/a.ts:aaaa1111', 'working:src/a.ts:bbbb2222'], + importance: 'critical', + }); + }); + + it('drops invented aliases instead of rendering a broken anchor', () => { + const result = normalizeWalkthrough(walkthrough([ + { + title: 'Data', + icon: 'doc', + blurb: '', + stops: [ + { title: 'Mixed', hunks: ['h1', 'h99', 'nonsense'], importance: 'normal', prose: 'Something.' }, + ], + }, + ]), ALIASES); + + expect(result.chapters[0].stops[0].hunkIds).toEqual(['working:src/a.ts:aaaa1111']); + expect(result.droppedAnchors).toBe(2); + }); + + it('anchors each hunk to a single stop', () => { + const result = normalizeWalkthrough(walkthrough([ + { + title: 'Data', + icon: 'doc', + blurb: '', + stops: [ + { title: 'First', hunks: ['h1'], importance: 'normal', prose: 'One.' }, + { title: 'Second', hunks: ['h1', 'h2'], importance: 'normal', prose: 'Two.' }, + ], + }, + ]), ALIASES); + + expect(result.chapters[0].stops[0].hunkIds).toEqual(['working:src/a.ts:aaaa1111']); + expect(result.chapters[0].stops[1].hunkIds).toEqual(['working:src/a.ts:bbbb2222']); + }); + + it('discards stops left with no anchor or no prose', () => { + const result = normalizeWalkthrough(walkthrough([ + { + title: 'Data', + icon: 'doc', + blurb: '', + stops: [ + { title: 'Ghost', hunks: ['h99'], importance: 'normal', prose: 'About nothing.' }, + { title: 'Silent', hunks: ['h1'], importance: 'normal', prose: ' ' }, + { title: 'Real', hunks: ['h2'], importance: 'normal', prose: 'Actual explanation.' }, + ], + }, + ]), ALIASES); + + expect(result.chapters[0].stops.map((stop) => stop.title)).toEqual(['Real']); + }); + + it('rejects a response whose stops all fall away', () => { + expect(() => normalizeWalkthrough(walkthrough([ + { title: 'Empty', icon: 'doc', blurb: '', stops: [{ title: 'Ghost', hunks: ['h99'], importance: 'normal', prose: 'x' }] }, + ]), ALIASES)).toThrow('no usable stops'); + }); + + it('clamps chapter titles and falls back on unknown enums', () => { + const result = normalizeWalkthrough(walkthrough([ + { + title: 'An extremely long chapter title that will not fit the column', + icon: 'rocket', + blurb: '', + stops: [{ title: 'A', hunks: ['h1'], importance: 'urgent', prose: 'Text.' }], + }, + ]), ALIASES); + + expect(result.chapters[0].title.length).toBeLessThanOrEqual(24); + expect(result.chapters[0].icon).toBe('doc'); + expect(result.chapters[0].stops[0].importance).toBe('normal'); + }); + + it('caps the total number of stops', () => { + const many = Array.from({ length: 30 }, (_, index) => ({ + title: `Stop ${index}`, + hunks: [['h1', 'h2', 'h3'][index % 3]], + importance: 'normal', + prose: 'Text.', + })); + + const result = normalizeWalkthrough( + walkthrough([{ title: 'All', icon: 'doc', blurb: '', stops: many }]), + ALIASES, + ); + + const total = result.chapters.reduce((sum, chapter) => sum + chapter.stops.length, 0); + expect(total).toBeLessThanOrEqual(MAX_STOPS); + // Only three aliases exist and each is used once, so the real cap here is + // the alias pool, not the stop limit. + expect(total).toBe(3); + }); +}); + +describe('parseModelJson', () => { + it('parses a clean object', () => { + expect(parseModelJson('{"title":"x"}')).toEqual({ title: 'x' }); + }); + + it('unwraps a fenced block', () => { + expect(parseModelJson('```json\n{"title":"x"}\n```')).toEqual({ title: 'x' }); + }); + + it('recovers an object followed by stray prose', () => { + expect(parseModelJson('{"title":"x"}\n\nHope that helps!')).toEqual({ title: 'x' }); + }); + + it('fails loudly on unusable output', () => { + expect(() => parseModelJson('')).toThrow('empty response'); + expect(() => parseModelJson('no json at all')).toThrow('no JSON object'); + expect(() => parseModelJson('{"broken":')).toThrow('not valid JSON'); + }); +}); diff --git a/packages/web/server/lib/walkthrough/sources.js b/packages/web/server/lib/walkthrough/sources.js new file mode 100644 index 00000000..ab705995 --- /dev/null +++ b/packages/web/server/lib/walkthrough/sources.js @@ -0,0 +1,116 @@ +import { getDiff, getRangeDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js'; + +// A walkthrough source resolves to one or more diff *sections*. A section is a +// patch plus the scope its hunk ids live in; keeping staged and working-tree +// changes in separate scopes means a stop written against staged code never +// silently re-anchors onto an unstaged edit of the same lines. + +const WORKING_TREE_SCOPES = new Set(['all', 'staged', 'working']); + +export class WalkthroughSourceError extends Error { + constructor(message, statusCode = 400, code = undefined) { + super(message); + this.statusCode = statusCode; + if (code) this.code = code; + } +} + +/** + * Normalize and validate an untrusted source descriptor from the client. + */ +export function parseSource(raw) { + if (!raw || typeof raw !== 'object') { + throw new WalkthroughSourceError('source is required'); + } + + if (raw.kind === 'working-tree') { + const scope = typeof raw.scope === 'string' ? raw.scope : 'all'; + if (!WORKING_TREE_SCOPES.has(scope)) { + throw new WalkthroughSourceError(`Unknown working-tree scope "${scope}"`); + } + return { kind: 'working-tree', scope }; + } + + if (raw.kind === 'branch') { + const baseRef = typeof raw.baseRef === 'string' ? raw.baseRef.trim() : ''; + const headRef = typeof raw.headRef === 'string' ? raw.headRef.trim() : ''; + if (!baseRef || !headRef) { + throw new WalkthroughSourceError('branch sources require baseRef and headRef'); + } + return { kind: 'branch', baseRef, headRef }; + } + + if (raw.kind === 'pr') { + const number = Number(raw.number); + if (!Number.isInteger(number) || number <= 0) { + throw new WalkthroughSourceError('pr sources require a positive number'); + } + return { kind: 'pr', number }; + } + + throw new WalkthroughSourceError(`Unknown source kind "${String(raw.kind)}"`); +} + +/** + * Stable string form of a source, used as the pointer key and as part of the + * cache key. Must not change shape casually — it addresses persisted files. + */ +export function sourceKey(source) { + if (source.kind === 'working-tree') return `working-tree:${source.scope}`; + if (source.kind === 'branch') return `branch:${source.baseRef}...${source.headRef}`; + return `pr:${source.number}`; +} + +// `git diff` never reports untracked files, so a brand-new file would be +// invisible in a walkthrough of local work. The batch helper resolves the +// repository once and bounds how many diff processes run at a time. +const untrackedSections = async (directory) => { + const untracked = await listUntrackedPaths(directory); + if (untracked.length === 0) return []; + + const patches = await getUntrackedDiffs(directory, untracked); + return patches.filter((patch) => typeof patch === 'string' && patch.trim()); +}; + +/** + * Resolve a source into diff sections. + * + * @returns {Promise<{sections: Array<{scope: string, patch: string}>, meta: object}>} + */ +export async function loadSourceSections(directory, source, { getPullRequestDiff } = {}) { + if (source.kind === 'working-tree') { + const sections = []; + + if (source.scope === 'all' || source.scope === 'staged') { + const patch = await getDiff(directory, { staged: true }); + if (patch && patch.trim()) sections.push({ scope: 'staged', patch }); + } + + if (source.scope === 'all' || source.scope === 'working') { + const patch = await getDiff(directory, { staged: false }); + const untracked = await untrackedSections(directory); + const combined = [patch, ...untracked].filter((value) => value && value.trim()).join('\n'); + if (combined.trim()) sections.push({ scope: 'working', patch: combined }); + } + + return { sections, meta: {} }; + } + + if (source.kind === 'branch') { + const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef }); + return { + sections: patch && patch.trim() ? [{ scope: 'branch', patch }] : [], + meta: { baseRef: source.baseRef, headRef: source.headRef }, + }; + } + + if (typeof getPullRequestDiff !== 'function') { + throw new WalkthroughSourceError('Pull request diffs are unavailable', 500); + } + + const { patch, meta } = await getPullRequestDiff(directory, source.number); + return { + sections: patch && patch.trim() ? [{ scope: `pr:${source.number}`, patch }] : [], + meta: meta || {}, + }; +} diff --git a/packages/web/server/lib/walkthrough/store.js b/packages/web/server/lib/walkthrough/store.js new file mode 100644 index 00000000..90f3304d --- /dev/null +++ b/packages/web/server/lib/walkthrough/store.js @@ -0,0 +1,226 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { PROMPT_VERSION, WALKTHROUGH_VERSION } from './schema.js'; + +// Two artifacts with two different jobs. +// +// Cache entries are content-addressed and immutable: the key is derived from +// the *current* diff, so a hit means "this walkthrough was written about +// exactly this code". There is no freshness question to ask of an entry — +// staleness is a miss. +// +// The pointer is mutable and keyed by repository + source only. It answers the +// questions the cache cannot: which walkthrough was the last one here, what was +// it written about, and has the code moved since. It is also what feeds the +// previous walkthrough into a regeneration. + +const DATA_DIR = process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'); + +const WALKTHROUGH_DIR = path.join(DATA_DIR, 'walkthroughs'); +const ENTRIES_DIR = path.join(WALKTHROUGH_DIR, 'entries'); +const POINTERS_DIR = path.join(WALKTHROUGH_DIR, 'pointers'); + +const MAX_ENTRIES = 200; +const MAX_TOTAL_BYTES = 50 * 1024 * 1024; +const MAX_FILE_BYTES = 4 * 1024 * 1024; + +const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); + +const ensureDir = (dir) => { + try { + fs.mkdirSync(dir, { recursive: true }); + return true; + } catch (error) { + console.error('[walkthrough] failed to create store directory:', error?.message || error); + return false; + } +}; + +// Atomic so a crash mid-write leaves the previous entry intact rather than a +// half-written file that later fails to parse. +const writeJsonAtomic = (filePath, value) => { + if (!ensureDir(path.dirname(filePath))) return false; + const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`; + try { + fs.writeFileSync(tmp, JSON.stringify(value), 'utf8'); + fs.renameSync(tmp, filePath); + return true; + } catch (error) { + console.error('[walkthrough] failed to write store file:', error?.message || error); + try { + fs.unlinkSync(tmp); + } catch { + // Nothing else to do; the temp file is already orphaned. + } + return false; + } +}; + +const readJson = (filePath) => { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null; + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + // Missing, unreadable, or corrupt all mean the same thing to callers: no + // usable cached walkthrough. Never throw — a bad cache file must not break + // the feature. + return null; + } +}; + +/** + * 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 }) { + const canonical = JSON.stringify({ + walkthroughVersion: WALKTHROUGH_VERSION, + promptVersion: PROMPT_VERSION, + repoRoot, + sourceKey, + providerID, + modelID, + 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)), + }); + return sha256(canonical); +} + +const entryPath = (cacheKey) => path.join(ENTRIES_DIR, `${cacheKey}.json`); +const pointerPath = (repoRoot, sourceKey) => path.join(POINTERS_DIR, `${sha256(`${repoRoot}\0${sourceKey}`)}.json`); + +const isWalkthroughEntry = (value) => Boolean( + value + && typeof value === 'object' + && value.walkthroughVersion === WALKTHROUGH_VERSION + && value.walkthrough + && Array.isArray(value.walkthrough.chapters), +); + +export function readCachedWalkthrough(cacheKey) { + const value = readJson(entryPath(cacheKey)); + return isWalkthroughEntry(value) ? value : null; +} + +export function writeCachedWalkthrough(cacheKey, entry) { + const written = writeJsonAtomic(entryPath(cacheKey), { + walkthroughVersion: WALKTHROUGH_VERSION, + ...entry, + }); + if (written) evictEntries(); + return written; +} + +export function readPointer(repoRoot, sourceKey) { + const value = readJson(pointerPath(repoRoot, sourceKey)); + if (!value || typeof value !== 'object' || typeof value.cacheKey !== 'string') return null; + return value; +} + +export function writePointer(repoRoot, sourceKey, pointer) { + return writeJsonAtomic(pointerPath(repoRoot, sourceKey), pointer); +} + +/** + * Bound the cache by count and total size, dropping least-recently-used + * entries. Pointers are tiny and are left alone; a pointer to an evicted entry + * simply reads as "no walkthrough", which is the truthful answer. + */ +function evictEntries() { + let files; + try { + files = fs.readdirSync(ENTRIES_DIR) + .filter((name) => name.endsWith('.json')) + .map((name) => { + const full = path.join(ENTRIES_DIR, name); + try { + const stat = fs.statSync(full); + return { full, size: stat.size, atime: stat.atimeMs }; + } catch { + return null; + } + }) + .filter(Boolean); + } catch { + return; + } + + let totalBytes = files.reduce((sum, file) => sum + file.size, 0); + if (files.length <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) return; + + files.sort((a, b) => a.atime - b.atime); + let count = files.length; + for (const file of files) { + if (count <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) break; + try { + fs.unlinkSync(file.full); + count -= 1; + totalBytes -= file.size; + } catch { + // Skip files we cannot remove; the next write retries. + } + } +} + +// Housekeeping runs off the request path and never synchronously. +// +// The Electron desktop app hosts this server inside the main process, so a +// blocking loop here stalls IPC and the window, not just one request. Worse, +// the paths being checked are user repositories: a worktree on an unplugged +// drive or an unreachable network share can make a single existence check hang +// for seconds. Async calls wait without holding the loop, and the cap keeps a +// pathological directory from turning into a long tail of work. +const PRUNE_LIMIT = 500; + +/** + * Drop pointers for repositories that no longer exist. Only ever removes + * entries whose subject is provably gone. + */ +export async function pruneMissingRepositories() { + let names; + try { + names = (await fsp.readdir(POINTERS_DIR)).filter((name) => name.endsWith('.json')); + } catch { + return 0; + } + + let removed = 0; + for (const name of names.slice(0, PRUNE_LIMIT)) { + const full = path.join(POINTERS_DIR, name); + let repoRoot = null; + try { + const value = JSON.parse(await fsp.readFile(full, 'utf8')); + repoRoot = value && typeof value.repoRoot === 'string' ? value.repoRoot : null; + } catch { + continue; + } + if (!repoRoot) continue; + + try { + await fsp.stat(repoRoot); + continue; + } catch (error) { + // Unreachable is not the same as gone. Only a definite "no such file" + // justifies deleting: a disconnected share or a permissions error must + // not cost the user their walkthroughs. + if (error?.code !== 'ENOENT') continue; + } + + try { + await fsp.unlink(full); + removed += 1; + } catch { + // Leave it; the next prune retries. + } + } + return removed; +} + +export const __testing = { WALKTHROUGH_DIR, ENTRIES_DIR, POINTERS_DIR, MAX_ENTRIES, MAX_TOTAL_BYTES }; diff --git a/packages/web/server/lib/walkthrough/store.test.js b/packages/web/server/lib/walkthrough/store.test.js new file mode 100644 index 00000000..4e1a80f4 --- /dev/null +++ b/packages/web/server/lib/walkthrough/store.test.js @@ -0,0 +1,206 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; + +// The store resolves its directory at import time from the environment, so the +// temp dir has to be in place before the module is loaded. +const TEMP_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'walkthrough-store-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_ROOT; + +const store = await import('./store.js'); +const { + buildCacheKey, + readCachedWalkthrough, + writeCachedWalkthrough, + readPointer, + writePointer, + pruneMissingRepositories, + __testing, +} = store; + +const files = (overrides = []) => ([ + { + path: 'src/a.ts', + status: 'modified', + hunks: [{ id: 'working:src/a.ts:aaaa1111' }, { id: 'working:src/a.ts:bbbb2222' }], + }, + { + path: 'src/b.ts', + status: 'added', + hunks: [{ id: 'working:src/b.ts:cccc3333' }], + }, + ...overrides, +]); + +const baseKeyInput = { + repoRoot: '/repo', + sourceKey: 'working-tree:all', + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + files: files(), +}; + +const entry = (cacheKey) => ({ + cacheKey, + generatedAt: '2026-08-02T00:00:00.000Z', + repoRoot: '/repo', + sourceKey: 'working-tree:all', + model: { providerID: 'anthropic', modelID: 'claude-haiku-4-5' }, + walkthrough: { title: 'x', focus: '', chapters: [{ id: 'chapter-1', stops: [] }] }, +}); + +describe('buildCacheKey', () => { + it('is stable for identical input', () => { + expect(buildCacheKey(baseKeyInput)).toBe(buildCacheKey(baseKeyInput)); + }); + + it('ignores file ordering', () => { + const reordered = { ...baseKeyInput, files: [...baseKeyInput.files].reverse() }; + expect(buildCacheKey(reordered)).toBe(buildCacheKey(baseKeyInput)); + }); + + it('changes when any hunk changes', () => { + const edited = { + ...baseKeyInput, + files: [ + { ...baseKeyInput.files[0], hunks: [{ id: 'working:src/a.ts:aaaa1111' }, { id: 'working:src/a.ts:dddd4444' }] }, + baseKeyInput.files[1], + ], + }; + expect(buildCacheKey(edited)).not.toBe(buildCacheKey(baseKeyInput)); + }); + + it('separates repositories, sources, and models', () => { + const original = buildCacheKey(baseKeyInput); + expect(buildCacheKey({ ...baseKeyInput, repoRoot: '/other' })).not.toBe(original); + expect(buildCacheKey({ ...baseKeyInput, sourceKey: 'working-tree:staged' })).not.toBe(original); + expect(buildCacheKey({ ...baseKeyInput, modelID: 'other-model' })).not.toBe(original); + expect(buildCacheKey({ ...baseKeyInput, providerID: 'google' })).not.toBe(original); + }); +}); + +describe('cache entries', () => { + beforeEach(() => { + fs.rmSync(__testing.ENTRIES_DIR, { recursive: true, force: true }); + fs.rmSync(__testing.POINTERS_DIR, { recursive: true, force: true }); + }); + + it('round-trips a walkthrough', () => { + const key = buildCacheKey(baseKeyInput); + expect(writeCachedWalkthrough(key, entry(key))).toBe(true); + + const read = readCachedWalkthrough(key); + expect(read.walkthrough.title).toBe('x'); + expect(read.cacheKey).toBe(key); + }); + + it('reports a miss for an unknown key', () => { + expect(readCachedWalkthrough('0'.repeat(64))).toBeNull(); + }); + + it('treats a corrupt entry as a miss rather than throwing', () => { + const key = buildCacheKey(baseKeyInput); + writeCachedWalkthrough(key, entry(key)); + fs.writeFileSync(path.join(__testing.ENTRIES_DIR, `${key}.json`), '{ not json', 'utf8'); + + expect(readCachedWalkthrough(key)).toBeNull(); + }); + + it('rejects an entry written by an incompatible version', () => { + const key = buildCacheKey(baseKeyInput); + writeCachedWalkthrough(key, entry(key)); + const file = path.join(__testing.ENTRIES_DIR, `${key}.json`); + const stored = JSON.parse(fs.readFileSync(file, 'utf8')); + fs.writeFileSync(file, JSON.stringify({ ...stored, walkthroughVersion: 999 }), 'utf8'); + + expect(readCachedWalkthrough(key)).toBeNull(); + }); + + it('leaves no temp files behind', () => { + const key = buildCacheKey(baseKeyInput); + writeCachedWalkthrough(key, entry(key)); + + const leftovers = fs.readdirSync(__testing.ENTRIES_DIR).filter((name) => name.includes('.tmp')); + expect(leftovers).toEqual([]); + }); + + it('evicts least-recently-used entries past the count limit', () => { + for (let index = 0; index < __testing.MAX_ENTRIES + 10; index += 1) { + const key = buildCacheKey({ ...baseKeyInput, sourceKey: `source-${index}` }); + writeCachedWalkthrough(key, entry(key)); + } + + const remaining = fs.readdirSync(__testing.ENTRIES_DIR).filter((name) => name.endsWith('.json')); + expect(remaining.length).toBeLessThanOrEqual(__testing.MAX_ENTRIES); + }); +}); + +describe('pointers', () => { + beforeEach(() => { + fs.rmSync(__testing.POINTERS_DIR, { recursive: true, force: true }); + }); + + it('round-trips and stays scoped to its source', () => { + writePointer('/repo', 'working-tree:all', { + repoRoot: '/repo', + sourceKey: 'working-tree:all', + cacheKey: 'abc', + generatedAt: 'now', + }); + + expect(readPointer('/repo', 'working-tree:all')).toMatchObject({ cacheKey: 'abc' }); + expect(readPointer('/repo', 'working-tree:staged')).toBeNull(); + }); + + it('keeps different repositories apart', () => { + writePointer('/repo-a', 'working-tree:all', { repoRoot: '/repo-a', cacheKey: 'a' }); + writePointer('/repo-b', 'working-tree:all', { repoRoot: '/repo-b', cacheKey: 'b' }); + + expect(readPointer('/repo-a', 'working-tree:all').cacheKey).toBe('a'); + expect(readPointer('/repo-b', 'working-tree:all').cacheKey).toBe('b'); + }); + + it('prunes only pointers whose repository is gone', async () => { + const liveRepo = fs.mkdtempSync(path.join(TEMP_ROOT, 'live-repo-')); + writePointer(liveRepo, 'working-tree:all', { repoRoot: liveRepo, cacheKey: 'live' }); + writePointer('/definitely/not/here', 'working-tree:all', { + repoRoot: '/definitely/not/here', + cacheKey: 'dead', + }); + + expect(await pruneMissingRepositories()).toBe(1); + expect(readPointer(liveRepo, 'working-tree:all')).toMatchObject({ cacheKey: 'live' }); + expect(readPointer('/definitely/not/here', 'working-tree:all')).toBeNull(); + }); + + it('keeps a pointer whose repository is merely unreachable', async () => { + // A path we cannot stat for a reason other than absence — an unplugged + // drive or a dead share behaves this way. Deleting then would cost the user + // walkthroughs for a repository that still exists. + const blocked = fs.mkdtempSync(path.join(TEMP_ROOT, 'blocked-')); + const inaccessible = path.join(blocked, 'inner', 'repo'); + fs.mkdirSync(path.join(blocked, 'inner'), { recursive: true }); + fs.mkdirSync(inaccessible); + writePointer(inaccessible, 'working-tree:all', { repoRoot: inaccessible, cacheKey: 'blocked' }); + fs.chmodSync(path.join(blocked, 'inner'), 0o000); + + try { + expect(await pruneMissingRepositories()).toBe(0); + expect(readPointer(inaccessible, 'working-tree:all')).toMatchObject({ cacheKey: 'blocked' }); + } finally { + fs.chmodSync(path.join(blocked, 'inner'), 0o755); + } + }); + + it('survives a pointer file it cannot parse', async () => { + fs.mkdirSync(__testing.POINTERS_DIR, { recursive: true }); + fs.writeFileSync(path.join(__testing.POINTERS_DIR, 'broken.json'), '{ not json', 'utf8'); + + await expect(pruneMissingRepositories()).resolves.toBeGreaterThanOrEqual(0); + }); +}); + +afterAll(() => { + fs.rmSync(TEMP_ROOT, { recursive: true, force: true }); +}); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 41d57577..80c48a46 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -10,6 +10,7 @@ export const createWebGitAPI = (): GitAPI => ({ getGitStatus: gitApiHttp.getGitStatus, getGitDiff: gitApiHttp.getGitDiff, getGitFileDiff: gitApiHttp.getGitFileDiff, + getGitRangeDiff: gitApiHttp.getGitRangeDiff, revertGitFile: gitApiHttp.revertGitFile, stageGitFile: gitApiHttp.stageGitFile, stageGitFiles: gitApiHttp.stageGitFiles,