{
scrollRef = el
@@ -411,8 +718,19 @@ function FileNameWithPicker(props: {
-
- {props.filePicker!({ onSelect })}
+
{
+ requestAnimationFrame(() => {
+ const left = el.getBoundingClientRect().left
+ const available = window.innerWidth - left - 16
+ el.style.maxWidth = `${Math.max(200, available)}px`
+ })
+ }}
+ class="session-review-v2-file-picker-dropdown"
+ >
+
+ {props.filePicker!({ onSelect })}
+
diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.css b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.css
index 1f7e469e..829d02de 100644
--- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.css
+++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.css
@@ -432,6 +432,27 @@
--line-comment-open-z: 6;
}
+[data-component="session-review-v2"] [data-slot="session-review-v2-lock-indicator"] {
+ position: absolute;
+ top: 8px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4px;
+ background: var(--accent);
+ color: var(--accent-ink);
+ border-radius: var(--radius-md, 8px);
+ pointer-events: none;
+ animation: session-review-v2-lock-fade-in 200ms ease both;
+}
+
+@keyframes session-review-v2-lock-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
[data-component="session-review-v2"] .session-review-v2-toolbar-group--segments {
gap: 12px;
}
@@ -573,16 +594,23 @@
z-index: var(--z-overlay, 100);
padding: 4px;
max-height: 320px;
- min-width: 200px;
- max-width: 400px;
overflow-x: auto;
overflow-y: auto;
+ overscroll-behavior: contain;
border-radius: var(--radius-md, 8px);
background-color: var(--surface-raised-stronger-non-alpha, var(--v2-background-bg-layer-01));
border: 1px solid color-mix(in oklch, var(--border-base, var(--v2-border-border-base)) 50%, transparent);
box-shadow: var(--shadow-md, 0 4px 12px rgba(0,0,0,0.15));
}
+/* Scroll-inner wrapper: min-width: max-content ensures the wrapper is at
+ least as wide as its widest child. When that exceeds the dropdown's
+ capped width, overflow-x: auto on the dropdown provides horizontal
+ scrolling. Block items inside fill the wrapper width naturally. */
+.session-review-v2-file-picker-scroll-inner {
+ min-width: max-content;
+}
+
/* File picker popover content (legacy, kept for compat) */
.session-review-v2-file-picker-content {
padding: 4px !important;
@@ -605,8 +633,6 @@
line-height: 1.4;
color: var(--v2-text-text-muted);
white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
border-bottom: 1px solid color-mix(in oklch, var(--border-base, var(--v2-border-border-weak)) 30%, transparent);
margin-bottom: 2px;
}
@@ -626,7 +652,6 @@
display: flex;
align-items: center;
gap: 6px;
- width: 100%;
padding: 4px 8px;
border: none;
border-radius: var(--radius-xs, 4px);
diff --git a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.tsx
index c2699119..e4e141ab 100644
--- a/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.tsx
+++ b/packages/app-bundle/overlay/packages/session-ui/src/v2/components/session-review-v2.tsx
@@ -151,7 +151,8 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const i18n = useI18n()
createEffect(() => {
- getWorkerPool(props.diffStyle)
+ const style = props.diffStyle
+ getWorkerPool(style === "preview" ? undefined : style)
})
const fileIndex = () => {
@@ -265,8 +266,8 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
{
- if (value !== "unified" && value !== "split") return
- props.onDiffStyleChange?.(value)
+ if (value !== "unified" && value !== "split" && value !== "preview") return
+ props.onDiffStyleChange?.(value as any)
}}
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
@@ -281,6 +282,13 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
+
+
+
+
+
+
+
>
diff --git a/packages/app-bundle/overlay/packages/ui/src/context/marked-math.test.ts b/packages/app-bundle/overlay/packages/ui/src/context/marked-math.test.ts
index 4e7d7c78..05338bcb 100644
--- a/packages/app-bundle/overlay/packages/ui/src/context/marked-math.test.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/context/marked-math.test.ts
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { Marked } from "marked"
import { markedCodeSpanBoundary } from "./marked-code-span"
-import { katexExtension, renderMathInText } from "./marked-parser"
+import { katexExtension, renderMathInText } from "./marked"
const parse = (src: string) => new Marked(markedCodeSpanBoundary, katexExtension).parse(src)
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/ar.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/ar.ts
index 9cfafbe9..c00cf7e7 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/ar.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/ar.ts
@@ -191,45 +191,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}ث",
"ui.message.duration.minutesSeconds": "{{minutes}}د {{seconds}}ث",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "أضف سياقًا لهذا التغيير",
- "ui.sessionTurn.diffs.changed.zero": "الملفات المعدلة: {{count}}",
- "ui.sessionTurn.diffs.changed.two": "عدد الملفات المعدلة: {{count}}",
- "ui.sessionTurn.diffs.changed.few": "الملفات المعدلة: {{count}}",
- "ui.sessionTurn.diffs.changed.many": "الملفات المعدلة: {{count}}",
- "ui.messagePart.context.read.zero": "{{count}} قراءة",
- "ui.messagePart.context.read.two": "عدد القراءات: {{count}}",
- "ui.messagePart.context.read.few": "{{count}} قراءات",
- "ui.messagePart.context.read.many": "{{count}} قراءةً",
- "ui.messagePart.context.search.zero": "{{count}} عملية بحث",
- "ui.messagePart.context.search.two": "عدد عمليات البحث: {{count}}",
- "ui.messagePart.context.search.few": "{{count}} عمليات بحث",
- "ui.messagePart.context.search.many": "{{count}} عملية بحث",
- "ui.messagePart.context.list.zero": "{{count}} عملية سرد",
- "ui.messagePart.context.list.two": "عدد عمليات السرد: {{count}}",
- "ui.messagePart.context.list.few": "{{count}} عمليات سرد",
- "ui.messagePart.context.list.many": "{{count}} عملية سرد",
- "ui.promptInput.noMatchingItems": "لا توجد عناصر مطابقة",
- "ui.promptInput.commands": "الأوامر",
- "ui.promptInput.dropFiles": "أفلت الملفات لإرفاقها",
- "ui.promptInput.removeAttachment": "إزالة المرفق",
- "ui.promptInput.label": "الموجّه",
- "ui.promptInput.placeholder.shell": "أدخل أمر shell...",
- "ui.promptInput.placeholder.normal": "اسأل عن أي شيء، {{slash}} للأوامر، {{at}} للسياق...",
- "ui.promptInput.add": "إضافة صور وملفات",
- "ui.promptInput.attachments": "الصور والملفات",
- "ui.promptInput.context": "السياق",
- "ui.promptInput.shell": "أمر shell",
- "ui.promptInput.chooseAgent": "اختيار وكيل",
- "ui.promptInput.chooseModel": "اختيار نموذج",
- "ui.promptInput.chooseVariant": "اختيار متغير النموذج",
- "ui.promptInput.send": "إرسال",
- "ui.promptInput.stop": "إيقاف",
- "ui.tabs.close": "إغلاق علامة التبويب",
- "ui.tool.websearch.provider": "{{provider}} بحث الويب",
- "ui.tool.questions.numbered": "أسئلة {{number}}",
- "ui.common.clear": "مسح",
- "ui.common.file": "ملف",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/br.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/br.ts
index d6b039fa..cb3069e0 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/br.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/br.ts
@@ -191,33 +191,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}s",
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "Adicionar contexto para esta alteração",
- "ui.sessionTurn.diffs.changed.many": "Arquivos alterados: {{count}}",
- "ui.messagePart.context.read.many": "{{count}} de leituras",
- "ui.messagePart.context.search.many": "{{count}} de pesquisas",
- "ui.messagePart.context.list.many": "{{count}} de listas",
- "ui.promptInput.noMatchingItems": "Nenhum item correspondente",
- "ui.promptInput.commands": "Comandos",
- "ui.promptInput.dropFiles": "Solte os arquivos para anexá-los",
- "ui.promptInput.removeAttachment": "Remover anexo",
- "ui.promptInput.label": "Prompt",
- "ui.promptInput.placeholder.shell": "Digite um comando do shell...",
- "ui.promptInput.placeholder.normal": "Pergunte qualquer coisa, {{slash}} para comandos, {{at}} para contexto...",
- "ui.promptInput.add": "Adicionar imagens e arquivos",
- "ui.promptInput.attachments": "Imagens e arquivos",
- "ui.promptInput.context": "Contexto",
- "ui.promptInput.shell": "Comando do shell",
- "ui.promptInput.chooseAgent": "Escolher agente",
- "ui.promptInput.chooseModel": "Escolher modelo",
- "ui.promptInput.chooseVariant": "Escolher variante do modelo",
- "ui.promptInput.send": "Enviar",
- "ui.promptInput.stop": "Parar",
- "ui.tabs.close": "Fechar aba",
- "ui.tool.websearch.provider": "{{provider}} Pesquisa na web",
- "ui.tool.questions.numbered": "Perguntas {{number}}",
- "ui.common.clear": "Limpar",
- "ui.common.file": "Arquivo",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/bs.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/bs.ts
index 93d4a7a2..888834b1 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/bs.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/bs.ts
@@ -195,33 +195,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}s",
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "Dodaj kontekst za ovu izmjenu",
- "ui.sessionTurn.diffs.changed.few": "{{count}} izmijenjene datoteke",
- "ui.messagePart.context.read.few": "{{count}} čitanja",
- "ui.messagePart.context.search.few": "{{count}} pretrage",
- "ui.messagePart.context.list.few": "{{count}} listanja",
- "ui.promptInput.noMatchingItems": "Nema odgovarajućih stavki",
- "ui.promptInput.commands": "Komande",
- "ui.promptInput.dropFiles": "Ispusti datoteke da ih priložiš",
- "ui.promptInput.removeAttachment": "Ukloni prilog",
- "ui.promptInput.label": "Upit",
- "ui.promptInput.placeholder.shell": "Unesi shell komandu...",
- "ui.promptInput.placeholder.normal": "Pitaj bilo šta, {{slash}} za komande, {{at}} za kontekst...",
- "ui.promptInput.add": "Dodaj slike i datoteke",
- "ui.promptInput.attachments": "Slike i datoteke",
- "ui.promptInput.context": "Kontekst",
- "ui.promptInput.shell": "Shell komanda",
- "ui.promptInput.chooseAgent": "Odaberi agenta",
- "ui.promptInput.chooseModel": "Odaberi model",
- "ui.promptInput.chooseVariant": "Odaberi varijantu modela",
- "ui.promptInput.send": "Pošalji",
- "ui.promptInput.stop": "Zaustavi",
- "ui.tabs.close": "Zatvori karticu",
- "ui.tool.websearch.provider": "{{provider}} Pretraga weba",
- "ui.tool.questions.numbered": "Pitanja {{number}}",
- "ui.common.clear": "Očisti",
- "ui.common.file": "Datoteka",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/da.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/da.ts
index 57df9891..39540243 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/da.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/da.ts
@@ -190,29 +190,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}s",
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.tool.websearch.provider": "{{provider}} Websøgning",
- "ui.tool.questions.numbered": "Spørgsmål {{number}}",
- "ui.common.clear": "Ryd",
- "ui.common.file": "Fil",
- "ui.lineComment.contextPlaceholder": "Tilføj kontekst til denne ændring",
- "ui.promptInput.noMatchingItems": "Ingen matchende elementer",
- "ui.promptInput.commands": "Kommandoer",
- "ui.promptInput.dropFiles": "Slip filer for at vedhæfte dem",
- "ui.promptInput.removeAttachment": "Fjern vedhæftet fil",
- "ui.promptInput.label": "Prompt",
- "ui.promptInput.placeholder.shell": "Indtast shell-kommando...",
- "ui.promptInput.placeholder.normal": "Spørg om hvad som helst, {{slash}} for kommandoer, {{at}} for kontekst...",
- "ui.promptInput.add": "Tilføj billeder og filer",
- "ui.promptInput.attachments": "Billeder og filer",
- "ui.promptInput.context": "Kontekst",
- "ui.promptInput.shell": "Shell-kommando",
- "ui.promptInput.chooseAgent": "Vælg agent",
- "ui.promptInput.chooseModel": "Vælg model",
- "ui.promptInput.chooseVariant": "Vælg modelvariant",
- "ui.promptInput.send": "Send",
- "ui.promptInput.stop": "Stop",
- "ui.tabs.close": "Luk fane",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/de.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/de.ts
index d4acfba2..68a1e050 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/de.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/de.ts
@@ -197,29 +197,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}s",
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.tool.websearch.provider": "{{provider}} Web-Suche",
- "ui.tool.questions.numbered": "Fragen {{number}}",
- "ui.common.clear": "Leeren",
- "ui.common.file": "Datei",
- "ui.lineComment.contextPlaceholder": "Kontext zu dieser Änderung hinzufügen",
- "ui.promptInput.noMatchingItems": "Keine passenden Einträge",
- "ui.promptInput.commands": "Befehle",
- "ui.promptInput.dropFiles": "Dateien zum Anhängen hier ablegen",
- "ui.promptInput.removeAttachment": "Anhang entfernen",
- "ui.promptInput.label": "Prompt",
- "ui.promptInput.placeholder.shell": "Shell-Befehl eingeben…",
- "ui.promptInput.placeholder.normal": "Beliebige Frage stellen, {{slash}} für Befehle, {{at}} für Kontext…",
- "ui.promptInput.add": "Bilder und Dateien hinzufügen",
- "ui.promptInput.attachments": "Bilder und Dateien",
- "ui.promptInput.context": "Kontext",
- "ui.promptInput.shell": "Shell-Befehl",
- "ui.promptInput.chooseAgent": "Agenten auswählen",
- "ui.promptInput.chooseModel": "Modell auswählen",
- "ui.promptInput.chooseVariant": "Modellvariante auswählen",
- "ui.promptInput.send": "Senden",
- "ui.promptInput.stop": "Stoppen",
- "ui.tabs.close": "Tab schließen",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/en.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/en.ts
index e36b1f7d..fbbfa2fc 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/en.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/en.ts
@@ -194,29 +194,4 @@ export const dict: Record
= {
"ui.question.multiHint": "Select all answers that apply",
"ui.question.singleHint": "Select one answer",
"ui.question.custom.placeholder": "Type your answer...",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // absent from the fork-era branded dict. English values verbatim.
- "ui.lineComment.contextPlaceholder": "Add context for this change",
- "ui.promptInput.noMatchingItems": "No matching items",
- "ui.promptInput.commands": "Commands",
- "ui.promptInput.dropFiles": "Drop files to attach",
- "ui.promptInput.removeAttachment": "Remove attachment",
- "ui.promptInput.label": "Prompt",
- "ui.promptInput.placeholder.shell": "Enter shell command...",
- "ui.promptInput.placeholder.normal": "Ask Amico anything, {{slash}} for commands, {{at}} for context...",
- "ui.promptInput.add": "Add images and files",
- "ui.promptInput.attachments": "Images and files",
- "ui.promptInput.context": "Context",
- "ui.promptInput.shell": "Shell command",
- "ui.promptInput.chooseAgent": "Choose agent",
- "ui.promptInput.chooseModel": "Choose model",
- "ui.promptInput.chooseVariant": "Choose model variant",
- "ui.promptInput.send": "Send",
- "ui.promptInput.stop": "Stop",
- "ui.tabs.close": "Close tab",
- "ui.tool.websearch.provider": "{{provider}} Web Search",
- "ui.tool.questions.numbered": "Questions {{number}}",
- "ui.common.clear": "Clear",
- "ui.common.file": "File",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/es.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/es.ts
index 5f62c1c2..2ba67a7d 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/es.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/es.ts
@@ -191,33 +191,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}s",
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "Añadir contexto para este cambio",
- "ui.sessionTurn.diffs.changed.many": "{{count}} de archivos modificados",
- "ui.messagePart.context.read.many": "{{count}} de lecturas",
- "ui.messagePart.context.search.many": "{{count}} de búsquedas",
- "ui.messagePart.context.list.many": "{{count}} de listados",
- "ui.promptInput.noMatchingItems": "No hay elementos coincidentes",
- "ui.promptInput.commands": "Comandos",
- "ui.promptInput.dropFiles": "Suelta los archivos para adjuntarlos",
- "ui.promptInput.removeAttachment": "Eliminar adjunto",
- "ui.promptInput.label": "Prompt",
- "ui.promptInput.placeholder.shell": "Introduce un comando de shell...",
- "ui.promptInput.placeholder.normal": "Pregunta lo que quieras, {{slash}} para comandos, {{at}} para contexto...",
- "ui.promptInput.add": "Añadir imágenes y archivos",
- "ui.promptInput.attachments": "Imágenes y archivos",
- "ui.promptInput.context": "Contexto",
- "ui.promptInput.shell": "Comando de shell",
- "ui.promptInput.chooseAgent": "Elegir agente",
- "ui.promptInput.chooseModel": "Elegir modelo",
- "ui.promptInput.chooseVariant": "Elegir variante del modelo",
- "ui.promptInput.send": "Enviar",
- "ui.promptInput.stop": "Detener",
- "ui.tabs.close": "Cerrar pestaña",
- "ui.tool.websearch.provider": "{{provider}} Búsqueda web",
- "ui.tool.questions.numbered": "Preguntas {{number}}",
- "ui.common.clear": "Borrar",
- "ui.common.file": "Archivo",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/fr.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/fr.ts
index e1ea9465..44a8ad13 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/fr.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/fr.ts
@@ -191,33 +191,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}s",
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "Ajouter du contexte à cette modification",
- "ui.sessionTurn.diffs.changed.many": "Fichiers modifiés : {{count}}",
- "ui.messagePart.context.read.many": "{{count}} de lectures",
- "ui.messagePart.context.search.many": "{{count}} de recherches",
- "ui.messagePart.context.list.many": "{{count}} de listes",
- "ui.promptInput.noMatchingItems": "Aucun élément correspondant",
- "ui.promptInput.commands": "Commandes",
- "ui.promptInput.dropFiles": "Déposez des fichiers pour les joindre",
- "ui.promptInput.removeAttachment": "Supprimer la pièce jointe",
- "ui.promptInput.label": "Invite",
- "ui.promptInput.placeholder.shell": "Entrez une commande shell...",
- "ui.promptInput.placeholder.normal": "Demandez n'importe quoi, {{slash}} pour les commandes, {{at}} pour le contexte...",
- "ui.promptInput.add": "Ajouter des images et des fichiers",
- "ui.promptInput.attachments": "Images et fichiers",
- "ui.promptInput.context": "Contexte",
- "ui.promptInput.shell": "Commande shell",
- "ui.promptInput.chooseAgent": "Choisir l'agent",
- "ui.promptInput.chooseModel": "Choisir le modèle",
- "ui.promptInput.chooseVariant": "Choisir la variante du modèle",
- "ui.promptInput.send": "Envoyer",
- "ui.promptInput.stop": "Arrêter",
- "ui.tabs.close": "Fermer l'onglet",
- "ui.tool.websearch.provider": "{{provider}} Recherche Web",
- "ui.tool.questions.numbered": "Questions {{number}}",
- "ui.common.clear": "Effacer",
- "ui.common.file": "Fichier",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/ja.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/ja.ts
index 2c14a915..f7a895bb 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/ja.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/ja.ts
@@ -190,29 +190,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}秒",
"ui.message.duration.minutesSeconds": "{{minutes}}分 {{seconds}}秒",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "この変更に関するコンテキストを追加",
- "ui.promptInput.noMatchingItems": "一致する項目がありません",
- "ui.promptInput.commands": "コマンド",
- "ui.promptInput.dropFiles": "ファイルをドロップして添付",
- "ui.promptInput.removeAttachment": "添付ファイルを削除",
- "ui.promptInput.label": "プロンプト",
- "ui.promptInput.placeholder.shell": "シェルコマンドを入力...",
- "ui.promptInput.placeholder.normal": "何でも質問できます。 {{slash}} でコマンド、 {{at}} でコンテキストを追加...",
- "ui.promptInput.add": "画像やファイルを追加",
- "ui.promptInput.attachments": "画像とファイル",
- "ui.promptInput.context": "コンテキスト",
- "ui.promptInput.shell": "シェルコマンド",
- "ui.promptInput.chooseAgent": "エージェントを選択",
- "ui.promptInput.chooseModel": "モデルを選択",
- "ui.promptInput.chooseVariant": "モデルバリアントを選択",
- "ui.promptInput.send": "送信",
- "ui.promptInput.stop": "停止",
- "ui.tabs.close": "タブを閉じる",
- "ui.tool.websearch.provider": "{{provider}} Web検索",
- "ui.tool.questions.numbered": "質問 {{number}}",
- "ui.common.clear": "消去",
- "ui.common.file": "ファイル",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/ko.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/ko.ts
index a07d12be..b10fe560 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/ko.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/ko.ts
@@ -192,29 +192,4 @@ export const dict = {
"ui.sessionTurn.diffs.showLess": "간략히 표시",
"ui.sessionTurn.diffs.more": "+{{count}}개 더 보기",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "이 변경 사항에 대한 컨텍스트 추가",
- "ui.promptInput.noMatchingItems": "일치하는 항목 없음",
- "ui.promptInput.commands": "명령어",
- "ui.promptInput.dropFiles": "첨부할 파일을 놓으세요",
- "ui.promptInput.removeAttachment": "첨부 파일 제거",
- "ui.promptInput.label": "프롬프트",
- "ui.promptInput.placeholder.shell": "셸 명령어 입력...",
- "ui.promptInput.placeholder.normal": "무엇이든 물어보세요. {{slash}} 명령어, {{at}} 컨텍스트...",
- "ui.promptInput.add": "이미지 및 파일 추가",
- "ui.promptInput.attachments": "이미지 및 파일",
- "ui.promptInput.context": "컨텍스트",
- "ui.promptInput.shell": "셸 명령",
- "ui.promptInput.chooseAgent": "에이전트 선택",
- "ui.promptInput.chooseModel": "모델 선택",
- "ui.promptInput.chooseVariant": "모델 변형 선택",
- "ui.promptInput.send": "전송",
- "ui.promptInput.stop": "중지",
- "ui.tabs.close": "탭 닫기",
- "ui.tool.websearch.provider": "{{provider}} 웹 검색",
- "ui.tool.questions.numbered": "질문 {{number}}",
- "ui.common.clear": "지우기",
- "ui.common.file": "파일",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/no.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/no.ts
index 7536e8d4..ae0d85ff 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/no.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/no.ts
@@ -195,29 +195,4 @@ export const dict: Record = {
"ui.sessionTurn.diffs.showLess": "Vis færre",
"ui.sessionTurn.diffs.more": "+{{count}} til",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "Legg til kontekst for denne endringen",
- "ui.promptInput.noMatchingItems": "Ingen treff",
- "ui.promptInput.commands": "Kommandoer",
- "ui.promptInput.dropFiles": "Slipp filer for å legge dem ved",
- "ui.promptInput.removeAttachment": "Fjern vedlegg",
- "ui.promptInput.label": "Prompt",
- "ui.promptInput.placeholder.shell": "Skriv inn shell-kommando...",
- "ui.promptInput.placeholder.normal": "Spør om hva som helst, {{slash}} for kommandoer, {{at}} for kontekst...",
- "ui.promptInput.add": "Legg til bilder og filer",
- "ui.promptInput.attachments": "Bilder og filer",
- "ui.promptInput.context": "Kontekst",
- "ui.promptInput.shell": "Shell-kommando",
- "ui.promptInput.chooseAgent": "Velg agent",
- "ui.promptInput.chooseModel": "Velg modell",
- "ui.promptInput.chooseVariant": "Velg modellvariant",
- "ui.promptInput.send": "Send",
- "ui.promptInput.stop": "Stopp",
- "ui.tabs.close": "Lukk fane",
- "ui.tool.websearch.provider": "{{provider}} Nettsøk",
- "ui.tool.questions.numbered": "Spørsmål {{number}}",
- "ui.common.clear": "Tøm",
- "ui.common.file": "Fil",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/pl.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/pl.ts
index 3742f59e..a4de5714 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/pl.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/pl.ts
@@ -190,37 +190,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}s",
"ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "Dodaj kontekst tej zmiany",
- "ui.sessionTurn.diffs.changed.few": "{{count}} zmienione pliki",
- "ui.sessionTurn.diffs.changed.many": "{{count}} zmienionych plików",
- "ui.messagePart.context.read.few": "Liczba odczytów: {{count}}",
- "ui.messagePart.context.read.many": "Liczba odczytów: {{count}}",
- "ui.messagePart.context.search.few": "Liczba wyszukiwań: {{count}}",
- "ui.messagePart.context.search.many": "Liczba wyszukiwań: {{count}}",
- "ui.messagePart.context.list.few": "Liczba list: {{count}}",
- "ui.messagePart.context.list.many": "Liczba list: {{count}}",
- "ui.promptInput.noMatchingItems": "Brak pasujących elementów",
- "ui.promptInput.commands": "Polecenia",
- "ui.promptInput.dropFiles": "Upuść pliki, aby je załączyć",
- "ui.promptInput.removeAttachment": "Usuń załącznik",
- "ui.promptInput.label": "Prompt",
- "ui.promptInput.placeholder.shell": "Wpisz polecenie powłoki...",
- "ui.promptInput.placeholder.normal": "Zapytaj o cokolwiek, {{slash}} aby wyświetlić polecenia, {{at}} aby wyświetlić kontekst...",
- "ui.promptInput.add": "Dodaj obrazy i pliki",
- "ui.promptInput.attachments": "Obrazy i pliki",
- "ui.promptInput.context": "Kontekst",
- "ui.promptInput.shell": "Polecenie powłoki",
- "ui.promptInput.chooseAgent": "Wybierz agenta",
- "ui.promptInput.chooseModel": "Wybierz model",
- "ui.promptInput.chooseVariant": "Wybierz wariant modelu",
- "ui.promptInput.send": "Wyślij",
- "ui.promptInput.stop": "Zatrzymaj",
- "ui.tabs.close": "Zamknij kartę",
- "ui.tool.websearch.provider": "{{provider}} Wyszukiwanie w sieci",
- "ui.tool.questions.numbered": "Pytania {{number}}",
- "ui.common.clear": "Wyczyść",
- "ui.common.file": "Plik",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/ru.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/ru.ts
index c2d08be7..448c2075 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/ru.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/ru.ts
@@ -190,37 +190,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}с",
"ui.message.duration.minutesSeconds": "{{minutes}}м {{seconds}}с",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "Добавить контекст для этого изменения",
- "ui.sessionTurn.diffs.changed.few": "{{count}} изменённых файла",
- "ui.sessionTurn.diffs.changed.many": "{{count}} изменённых файлов",
- "ui.messagePart.context.read.few": "{{count}} чтения",
- "ui.messagePart.context.read.many": "{{count}} чтений",
- "ui.messagePart.context.search.few": "{{count}} поиска",
- "ui.messagePart.context.search.many": "{{count}} поисков",
- "ui.messagePart.context.list.few": "{{count}} списка",
- "ui.messagePart.context.list.many": "{{count}} списков",
- "ui.promptInput.noMatchingItems": "Нет совпадений",
- "ui.promptInput.commands": "Команды",
- "ui.promptInput.dropFiles": "Перетащите файлы, чтобы прикрепить их",
- "ui.promptInput.removeAttachment": "Удалить вложение",
- "ui.promptInput.label": "Промпт",
- "ui.promptInput.placeholder.shell": "Введите команду оболочки...",
- "ui.promptInput.placeholder.normal": "Спросите что угодно, {{slash}} — команды, {{at}} — контекст...",
- "ui.promptInput.add": "Добавить изображения и файлы",
- "ui.promptInput.attachments": "Изображения и файлы",
- "ui.promptInput.context": "Контекст",
- "ui.promptInput.shell": "Команда оболочки",
- "ui.promptInput.chooseAgent": "Выбрать агента",
- "ui.promptInput.chooseModel": "Выбрать модель",
- "ui.promptInput.chooseVariant": "Выбрать вариант модели",
- "ui.promptInput.send": "Отправить",
- "ui.promptInput.stop": "Остановить",
- "ui.tabs.close": "Закрыть вкладку",
- "ui.tool.websearch.provider": "{{provider}} Веб-поиск",
- "ui.tool.questions.numbered": "Вопросы {{number}}",
- "ui.common.clear": "Очистить",
- "ui.common.file": "Файл",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/th.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/th.ts
index cf1dd849..8bece1b5 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/th.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/th.ts
@@ -192,29 +192,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}วิ",
"ui.message.duration.minutesSeconds": "{{minutes}}นาที {{seconds}}วิ",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "เพิ่มบริบทสำหรับการเปลี่ยนแปลงนี้",
- "ui.promptInput.noMatchingItems": "ไม่พบรายการที่ตรงกัน",
- "ui.promptInput.commands": "คำสั่ง",
- "ui.promptInput.dropFiles": "วางไฟล์เพื่อแนบ",
- "ui.promptInput.removeAttachment": "เอาไฟล์แนบออก",
- "ui.promptInput.label": "พรอมต์",
- "ui.promptInput.placeholder.shell": "ป้อนคำสั่งเชลล์...",
- "ui.promptInput.placeholder.normal": "ถามอะไรก็ได้ {{slash}} สำหรับคำสั่ง {{at}} สำหรับบริบท...",
- "ui.promptInput.add": "เพิ่มรูปภาพและไฟล์",
- "ui.promptInput.attachments": "รูปภาพและไฟล์",
- "ui.promptInput.context": "บริบท",
- "ui.promptInput.shell": "คำสั่งเชลล์",
- "ui.promptInput.chooseAgent": "เลือกเอเจนต์",
- "ui.promptInput.chooseModel": "เลือกโมเดล",
- "ui.promptInput.chooseVariant": "เลือกรูปแบบโมเดล",
- "ui.promptInput.send": "ส่ง",
- "ui.promptInput.stop": "หยุด",
- "ui.tabs.close": "ปิดแท็บ",
- "ui.tool.websearch.provider": "{{provider}} ค้นหาเว็บ",
- "ui.tool.questions.numbered": "คำถาม {{number}}",
- "ui.common.clear": "ล้าง",
- "ui.common.file": "ไฟล์",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/tr.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/tr.ts
index 976a7cfa..574edbb0 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/tr.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/tr.ts
@@ -197,29 +197,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}sn",
"ui.message.duration.minutesSeconds": "{{minutes}}dk {{seconds}}sn",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "Bu değişiklik için bağlam ekle",
- "ui.promptInput.noMatchingItems": "Eşleşen öğe yok",
- "ui.promptInput.commands": "Komutlar",
- "ui.promptInput.dropFiles": "Eklemek için dosyaları bırakın",
- "ui.promptInput.removeAttachment": "Eki kaldır",
- "ui.promptInput.label": "İstem",
- "ui.promptInput.placeholder.shell": "Kabuk komutu girin...",
- "ui.promptInput.placeholder.normal": "Bir şey sorun, {{slash}} komutlar için, {{at}} bağlam için...",
- "ui.promptInput.add": "Görsel ve dosya ekle",
- "ui.promptInput.attachments": "Görseller ve dosyalar",
- "ui.promptInput.context": "Bağlam",
- "ui.promptInput.shell": "Kabuk komutu",
- "ui.promptInput.chooseAgent": "Ajan seç",
- "ui.promptInput.chooseModel": "Model seç",
- "ui.promptInput.chooseVariant": "Model varyantı seç",
- "ui.promptInput.send": "Gönder",
- "ui.promptInput.stop": "Durdur",
- "ui.tabs.close": "Sekmeyi kapat",
- "ui.tool.websearch.provider": "{{provider}} Web araması",
- "ui.tool.questions.numbered": "Sorular {{number}}",
- "ui.common.clear": "Temizle",
- "ui.common.file": "Dosya",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/uk.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/uk.ts
index 6dd1a5ec..18451f38 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/uk.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/uk.ts
@@ -194,37 +194,4 @@ export const dict: Record = {
"ui.question.singleHint": "Виберіть одну відповідь",
"ui.question.custom.placeholder": "Введіть свою відповідь...",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "Додати контекст для цієї зміни",
- "ui.sessionTurn.diffs.changed.few": "Змінені файли: {{count}}",
- "ui.sessionTurn.diffs.changed.many": "Змінених файлів: {{count}}",
- "ui.messagePart.context.read.few": "{{count}} читання",
- "ui.messagePart.context.read.many": "{{count}} читань",
- "ui.messagePart.context.search.few": "{{count}} пошуки",
- "ui.messagePart.context.search.many": "{{count}} пошуків",
- "ui.messagePart.context.list.few": "{{count}} списки",
- "ui.messagePart.context.list.many": "{{count}} списків",
- "ui.promptInput.noMatchingItems": "Відповідних елементів немає",
- "ui.promptInput.commands": "Команди",
- "ui.promptInput.dropFiles": "Перетягніть файли, щоб прикріпити",
- "ui.promptInput.removeAttachment": "Видалити вкладення",
- "ui.promptInput.label": "Запит",
- "ui.promptInput.placeholder.shell": "Введіть команду оболонки...",
- "ui.promptInput.placeholder.normal": "Запитайте що завгодно, {{slash}} для команд, {{at}} для контексту...",
- "ui.promptInput.add": "Додати зображення та файли",
- "ui.promptInput.attachments": "Зображення та файли",
- "ui.promptInput.context": "Контекст",
- "ui.promptInput.shell": "Команда оболонки",
- "ui.promptInput.chooseAgent": "Вибрати агента",
- "ui.promptInput.chooseModel": "Вибрати модель",
- "ui.promptInput.chooseVariant": "Вибрати варіант моделі",
- "ui.promptInput.send": "Надіслати",
- "ui.promptInput.stop": "Зупинити",
- "ui.tabs.close": "Закрити вкладку",
- "ui.tool.websearch.provider": "{{provider}} Веб-пошук",
- "ui.tool.questions.numbered": "Запитання {{number}}",
- "ui.common.clear": "Очистити",
- "ui.common.file": "Файл",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/zh.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/zh.ts
index acf2b5fc..7b2b4c9f 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/zh.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/zh.ts
@@ -194,29 +194,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}秒",
"ui.message.duration.minutesSeconds": "{{minutes}}分 {{seconds}}秒",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "添加此更改的上下文",
- "ui.promptInput.noMatchingItems": "没有匹配项",
- "ui.promptInput.commands": "命令",
- "ui.promptInput.dropFiles": "拖放文件以添加附件",
- "ui.promptInput.removeAttachment": "移除附件",
- "ui.promptInput.label": "提示词",
- "ui.promptInput.placeholder.shell": "输入 shell 命令...",
- "ui.promptInput.placeholder.normal": "随便问点什么, {{slash}} 可查看命令, {{at}} 可添加上下文...",
- "ui.promptInput.add": "添加图片和文件",
- "ui.promptInput.attachments": "图片和文件",
- "ui.promptInput.context": "上下文",
- "ui.promptInput.shell": "Shell 命令",
- "ui.promptInput.chooseAgent": "选择智能体",
- "ui.promptInput.chooseModel": "选择模型",
- "ui.promptInput.chooseVariant": "选择模型变体",
- "ui.promptInput.send": "发送",
- "ui.promptInput.stop": "停止",
- "ui.tabs.close": "关闭标签页",
- "ui.tool.websearch.provider": "{{provider}} 网页搜索",
- "ui.tool.questions.numbered": "问题 {{number}}",
- "ui.common.clear": "清除",
- "ui.common.file": "文件",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/i18n/zht.ts b/packages/app-bundle/overlay/packages/ui/src/i18n/zht.ts
index 098f8c27..8b674f96 100644
--- a/packages/app-bundle/overlay/packages/ui/src/i18n/zht.ts
+++ b/packages/app-bundle/overlay/packages/ui/src/i18n/zht.ts
@@ -194,29 +194,4 @@ export const dict = {
"ui.message.duration.seconds": "{{count}}秒",
"ui.message.duration.minutesSeconds": "{{minutes}}分 {{seconds}}秒",
"ui.message.copyTrace": "Copy trace",
-
- // Keys adopted from upstream v1.18.29 at the overlay re-base (#796) —
- // upstream translations, absent from the fork-era dict.
- "ui.lineComment.contextPlaceholder": "新增此變更的相關資訊",
- "ui.promptInput.noMatchingItems": "沒有符合的項目",
- "ui.promptInput.commands": "命令",
- "ui.promptInput.dropFiles": "拖放檔案以附加",
- "ui.promptInput.removeAttachment": "移除附件",
- "ui.promptInput.label": "提示詞",
- "ui.promptInput.placeholder.shell": "輸入 shell 命令...",
- "ui.promptInput.placeholder.normal": "想問什麼都可以, {{slash}} 可使用命令, {{at}} 可加入上下文...",
- "ui.promptInput.add": "新增圖片和檔案",
- "ui.promptInput.attachments": "圖片和檔案",
- "ui.promptInput.context": "上下文",
- "ui.promptInput.shell": "shell 命令",
- "ui.promptInput.chooseAgent": "選擇代理程式",
- "ui.promptInput.chooseModel": "選擇模型",
- "ui.promptInput.chooseVariant": "選擇模型變體",
- "ui.promptInput.send": "傳送",
- "ui.promptInput.stop": "停止",
- "ui.tabs.close": "關閉分頁",
- "ui.tool.websearch.provider": "{{provider}} 網頁搜尋",
- "ui.tool.questions.numbered": "問題 {{number}}",
- "ui.common.clear": "清除",
- "ui.common.file": "檔案",
}
diff --git a/packages/app-bundle/overlay/packages/ui/src/v2/components/icon.tsx b/packages/app-bundle/overlay/packages/ui/src/v2/components/icon.tsx
index 45073e10..6b73fd5d 100644
--- a/packages/app-bundle/overlay/packages/ui/src/v2/components/icon.tsx
+++ b/packages/app-bundle/overlay/packages/ui/src/v2/components/icon.tsx
@@ -115,6 +115,14 @@ const icons = {
viewBox: "0 0 16 16",
body: ``,
},
+ eye: {
+ viewBox: "0 0 20 20",
+ body: ``,
+ },
+ lock: {
+ viewBox: "0 0 16 16",
+ body: ``,
+ },
filetree: {
viewBox: "0 0 16 16",
body: ``,
diff --git a/packages/app-bundle/scripts/overlay-sync.mjs b/packages/app-bundle/scripts/overlay-sync.mjs
new file mode 100644
index 00000000..541e0c77
--- /dev/null
+++ b/packages/app-bundle/scripts/overlay-sync.mjs
@@ -0,0 +1,181 @@
+#!/usr/bin/env node
+// Overlay ↔ fork sync check and apply.
+//
+// The fork (~/harmoniqs/opencode or AMICODE_OPENCODE_SRC) is the source of
+// truth for all app source files. The overlay (packages/app-bundle/overlay/)
+// is a tracking copy. This script detects and fixes drift between them.
+//
+// node scripts/overlay-sync.mjs --check exit 0 if in sync, 1 if drifted
+// node scripts/overlay-sync.mjs --apply copy fork → overlay + update hashes
+//
+// Resolves the fork path in order:
+// 1. AMICODE_OPENCODE_SRC env var
+// 2. ../opencode sibling (relative to repo root)
+// 3. ~/harmoniqs/opencode
+// Skips with exit 0 if no fork clone is found.
+
+import { createHash } from "node:crypto"
+import {
+ copyFileSync,
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ readFileSync,
+ readdirSync,
+ readlinkSync,
+ writeFileSync,
+} from "node:fs"
+import { homedir } from "node:os"
+import { dirname, join, relative } from "node:path"
+
+const PKG_ROOT = join(import.meta.dirname, "..")
+const REPO_ROOT = join(PKG_ROOT, "..", "..")
+const OVERLAY_DIR = join(PKG_ROOT, "overlay")
+const MANIFEST_PATH = join(PKG_ROOT, "manifest.json")
+
+// ── Resolve fork path ───────────────────────────────────────────────────────
+
+function resolveForkDir() {
+ const candidates = [
+ process.env.AMICODE_OPENCODE_SRC,
+ join(REPO_ROOT, "..", "opencode"),
+ join(homedir(), "harmoniqs", "opencode"),
+ ].filter(Boolean)
+
+ for (const dir of candidates) {
+ if (dir && existsSync(join(dir, ".git"))) return dir
+ }
+ return null
+}
+
+// ── Helpers ─────────────────────────────────────────────────────────────────
+
+function sha256(filepath) {
+ const st = lstatSync(filepath)
+ if (st.isSymbolicLink()) {
+ return createHash("sha256").update(readlinkSync(filepath)).digest("hex")
+ }
+ return createHash("sha256").update(readFileSync(filepath)).digest("hex")
+}
+
+function walkDir(dir) {
+ const results = []
+ for (const entry of readdirSync(dir, { recursive: true })) {
+ const full = join(dir, entry.toString())
+ const st = lstatSync(full)
+ if (st.isFile() || st.isSymbolicLink()) {
+ results.push(entry.toString())
+ }
+ }
+ return results
+}
+
+// ── Check mode ──────────────────────────────────────────────────────────────
+
+function check(forkDir) {
+ const overlayFiles = walkDir(OVERLAY_DIR)
+ const drifted = []
+ const missingInFork = []
+
+ for (const rel of overlayFiles) {
+ const overlayPath = join(OVERLAY_DIR, rel)
+ const forkPath = join(forkDir, rel)
+
+ if (!existsSync(forkPath)) {
+ missingInFork.push(rel)
+ continue
+ }
+
+ const overlayHash = sha256(overlayPath)
+ const forkHash = sha256(forkPath)
+ if (overlayHash !== forkHash) {
+ drifted.push({ rel, overlayHash, forkHash })
+ }
+ }
+
+ return { drifted, missingInFork }
+}
+
+// ── Apply mode ──────────────────────────────────────────────────────────────
+
+function apply(forkDir) {
+ const { drifted, missingInFork } = check(forkDir)
+
+ if (drifted.length === 0 && missingInFork.length === 0) {
+ console.log("[overlay-sync] already in sync — nothing to do")
+ return 0
+ }
+
+ // Read manifest
+ const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8"))
+ let updated = 0
+
+ for (const { rel } of drifted) {
+ const forkPath = join(forkDir, rel)
+ const overlayPath = join(OVERLAY_DIR, rel)
+
+ // Ensure parent directory exists
+ mkdirSync(dirname(overlayPath), { recursive: true })
+
+ // Copy fork → overlay
+ copyFileSync(forkPath, overlayPath)
+
+ // Update manifest hash
+ const newHash = sha256(overlayPath)
+ if (manifest.files && manifest.files[rel] !== undefined) {
+ manifest.files[rel] = newHash
+ }
+
+ console.log(` updated: ${rel}`)
+ updated++
+ }
+
+ for (const rel of missingInFork) {
+ console.log(` warning: ${rel} exists in overlay but not in fork (class A amicode-only?)`)
+ }
+
+ if (updated > 0) {
+ writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + "\n")
+ console.log(`[overlay-sync] applied ${updated} file(s), manifest.json updated`)
+ }
+
+ return 0
+}
+
+// ── Main ────────────────────────────────────────────────────────────────────
+
+const mode = process.argv.includes("--apply") ? "apply" : "check"
+
+const forkDir = resolveForkDir()
+if (!forkDir) {
+ console.log("[overlay-sync] SKIP: no fork clone found (set AMICODE_OPENCODE_SRC)")
+ process.exit(0)
+}
+
+console.log(`[overlay-sync] fork: ${forkDir}`)
+
+if (mode === "apply") {
+ process.exit(apply(forkDir))
+} else {
+ const { drifted, missingInFork } = check(forkDir)
+
+ if (missingInFork.length > 0) {
+ console.log(`[overlay-sync] ${missingInFork.length} file(s) in overlay but not in fork:`)
+ for (const rel of missingInFork.slice(0, 5)) {
+ console.log(` missing: ${rel}`)
+ }
+ if (missingInFork.length > 5) console.log(` ... and ${missingInFork.length - 5} more`)
+ }
+
+ if (drifted.length > 0) {
+ console.log(`[overlay-sync] DRIFT: ${drifted.length} file(s) differ between overlay and fork:`)
+ for (const { rel } of drifted) {
+ console.log(` ${rel}`)
+ }
+ console.log(`\nRun: pnpm --filter @amicode/app-bundle sync:apply`)
+ process.exit(1)
+ }
+
+ console.log(`[overlay-sync] PASS: all ${walkDir(OVERLAY_DIR).length} overlay files match the fork`)
+ process.exit(0)
+}
diff --git a/packages/extension/scripts/opencode_dev.mjs b/packages/extension/scripts/opencode_dev.mjs
index 3d9422f5..c717e76e 100755
--- a/packages/extension/scripts/opencode_dev.mjs
+++ b/packages/extension/scripts/opencode_dev.mjs
@@ -148,6 +148,18 @@ export function pinFromRelease({ root = PKG_ROOT, tag, ref, download = ghDownloa
function build() {
const cloneDir = resolveCloneDir(PKG_ROOT);
+ // Warn if the overlay is stale relative to the fork (non-blocking).
+ try {
+ execFileSync("node", [join(PKG_ROOT, "..", "app-bundle", "scripts", "overlay-sync.mjs"), "--check"], {
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ } catch (e) {
+ console.warn(
+ `[opencode:build] ⚠ overlay drift detected — the overlay does not match the fork.\n` +
+ ` Run: pnpm --filter @amicode/app-bundle sync:apply\n` +
+ ` Building anyway (the binary uses the fork, not the overlay).\n`,
+ );
+ }
console.log(`[opencode:build] building from ${cloneDir} with OPENCODE_CHANNEL=dev, re-vendoring…`);
// --any-ref: during active dev your clone is off the pinned ref by design.
execFileSync("node", [join(PKG_ROOT, "scripts", "fetch_opencode.mjs"), "--local", "--any-ref"], {
diff --git a/packages/extension/src/chat_panel.ts b/packages/extension/src/chat_panel.ts
index 9062c2a9..9613ee42 100644
--- a/packages/extension/src/chat_panel.ts
+++ b/packages/extension/src/chat_panel.ts
@@ -430,7 +430,7 @@ export class ChatPanel {
// (webview-internal origin, never the opencode origin). Forward only
// our own envelopes, pinned to the opencode origin. #351 adds
// run:*/device:* envelopes for the Work Column inspector tabs.
- if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects")) {
+ if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify")) {
var f = document.querySelector("iframe");
if (f && f.contentWindow) f.contentWindow.postMessage(d, ${origin});
}
@@ -587,7 +587,7 @@ export class ChatPanel {
}
return;
}
- if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects")) {
+ if (d && d.source === "amicode" && (d.kind === "theme" || d.kind === "clipboard" || d.kind === "navigate" || d.kind === "open-compute-connect" || d.kind === "open-bug-report" || d.kind === "close-bug-report" || d.kind === "dev-tools-status" || d.kind === "dev-tools-rebuild-status" || d.kind === "dev-tools-build-vsix-status" || d.kind === "data-storage-defaults" || d.kind === "data-storage-status" || d.kind === "connections-credential-result" || d.kind === "connections-disconnect-result" || d.kind === "connections-revalidate-result" || d.kind === "connections-auth-result" || d.kind === "connections-choose-project-result" || d.kind === "connections-add-custom-result" || d.kind === "connections-remove-result" || d.kind === "skill-providers-data" || d.kind === "skill-providers-discovered" || (typeof d.kind === "string" && (d.kind.indexOf("run:") === 0 || d.kind.indexOf("device:") === 0)) || d.kind === "clipboard-image" || d.kind === "workspace-projects" || d.kind === "file-op-notify")) {
var f = document.querySelector("iframe");
if (f && f.contentWindow) f.contentWindow.postMessage(d, origin);
}
diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts
index bb870eb4..945e217b 100644
--- a/packages/extension/src/extension.ts
+++ b/packages/extension/src/extension.ts
@@ -374,7 +374,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise {
// 1. UI surfaces — Workspace sidebar (webview, #673)
const sidebarProvider = new SidebarViewProvider(ctx.extensionUri);
ctx.subscriptions.push(
- vscode.window.registerWebviewViewProvider("amicode.workspace", sidebarProvider),
+ vscode.window.registerWebviewViewProvider("amicode.workspace", sidebarProvider, {
+ webviewOptions: { retainContextWhenHidden: true },
+ }),
);
// Mute the "Chat with Amico" button when a chat panel is open
ChatPanel.onLiveChange((count) => sidebarProvider.setChatActive(count > 0));
diff --git a/packages/extension/src/sidebar_bridge.ts b/packages/extension/src/sidebar_bridge.ts
index 5547ff29..78f99763 100644
--- a/packages/extension/src/sidebar_bridge.ts
+++ b/packages/extension/src/sidebar_bridge.ts
@@ -23,7 +23,7 @@ export interface TreeEntry {
// ── File operation types ─────────────────────────────────────────────────────
export interface FileOpRequest {
- op: "rename" | "delete" | "new-file" | "new-folder" | "copy-path" | "copy-relative-path" | "reveal-in-os" | "open-in-terminal" | "open-to-side" | "remove-from-workspace" | "new-session" | "move";
+ op: "rename" | "delete" | "new-file" | "new-folder" | "copy-path" | "copy-relative-path" | "reveal-in-os" | "open-in-terminal" | "open-to-side" | "remove-from-workspace" | "new-session" | "move" | "restore";
path: string;
newName?: string;
name?: string;
@@ -33,6 +33,8 @@ export interface FileOpRequest {
export interface FileOpResult {
ok: boolean;
message?: string;
+ /** Set by move/rename ops — the destination path, for file-op-notify. */
+ newPath?: string;
}
// ── Host → Webview (down) ────────────────────────────────────────────────────
@@ -120,6 +122,8 @@ export interface SidebarMessageHandlers {
postMessage: (msg: SidebarDownMessage) => void;
setSectionOrder: (order: string[]) => void;
reorderRoot: (sourcePath: string, targetPath: string, position: "before" | "after") => void;
+ /** Notify the chat panel that a file was moved/renamed so Files Changed updates. */
+ notifyFileMove?: (oldPath: string, newPath: string, op: string) => void;
}
/**
@@ -181,6 +185,11 @@ export function handleSidebarMessage(
op: req.op,
path: req.path,
});
+ // Notify the session page about file moves/renames so Files Changed
+ // updates the file's path instead of showing a stale ghost entry.
+ if (result.newPath && (req.op === "move" || req.op === "rename")) {
+ handlers.notifyFileMove?.(req.path, result.newPath, req.op);
+ }
}
});
}
diff --git a/packages/extension/src/sidebar_view.ts b/packages/extension/src/sidebar_view.ts
index 2c3a7a27..7f063d66 100644
--- a/packages/extension/src/sidebar_view.ts
+++ b/packages/extension/src/sidebar_view.ts
@@ -13,6 +13,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import { handleSidebarMessage, type SidebarMessageHandlers, type SidebarDownMessage, type FileOpRequest, type FileOpResult, type TreeEntry } from "./sidebar_bridge";
import { SidebarTreeService, type RawDirEntry } from "./sidebar_tree_service";
+import { ChatPanel } from "./chat_panel";
import { detectProjectType } from "./project/detect";
// ── Icon theme resolution ────────────────────────────────────────────────────
@@ -284,6 +285,8 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
private chatActive = false;
private activeProjectPath: string | null | undefined = undefined;
private watcher?: vscode.FileSystemWatcher;
+ private fsDebounceTimer?: ReturnType;
+ private fsPendingFolders = new Set();
private workspaceSub?: vscode.Disposable;
private gitSubs: vscode.Disposable[] = [];
private treeService: SidebarTreeService;
@@ -353,7 +356,7 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
},
getChildren: async (p) => {
const entries = await this.treeService.getChildren(p);
- return annotateGitStatus(entries);
+ return annotateGitStatus(entries, p);
},
openFile: (p) => {
const uri = vscode.Uri.file(p);
@@ -365,6 +368,19 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
},
setSectionOrder: (order) => this.setSectionOrder(order),
reorderRoot: (sourcePath, targetPath, position) => reorderWorkspaceFolder(sourcePath, targetPath, position),
+ notifyFileMove: (oldPath, newPath, op) => {
+ const panel = ChatPanel.peek();
+ if (panel) {
+ void panel.postMessage({
+ source: "amicode",
+ kind: "file-op-notify",
+ op,
+ oldPath,
+ newPath,
+ home: os.homedir(),
+ });
+ }
+ },
};
void handleSidebarMessage(msg, handlers);
});
@@ -382,6 +398,8 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
});
webviewView.onDidDispose(() => {
+ clearTimeout(this.fsDebounceTimer);
+ this.fsPendingFolders.clear();
this.watcher?.dispose();
this.workspaceSub?.dispose();
for (const sub of this.gitSubs) sub.dispose();
@@ -465,10 +483,17 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
const onFsEvent = (uri: vscode.Uri) => {
const folder = vscode.workspace.getWorkspaceFolder(uri);
if (folder) {
- void webviewView.webview.postMessage({
- kind: "fs-changed",
- folder: folder.uri.fsPath,
- });
+ this.fsPendingFolders.add(folder.uri.fsPath);
+ clearTimeout(this.fsDebounceTimer);
+ this.fsDebounceTimer = setTimeout(() => {
+ for (const f of this.fsPendingFolders) {
+ void webviewView.webview.postMessage({
+ kind: "fs-changed",
+ folder: f,
+ });
+ }
+ this.fsPendingFolders.clear();
+ }, 300);
}
};
this.watcher.onDidCreate(onFsEvent);
@@ -739,6 +764,8 @@ export class SidebarViewProvider implements vscode.WebviewViewProvider {
.git-modified { color: var(--vscode-gitDecoration-modifiedResourceForeground, #e2c08d); }
.git-added { color: var(--vscode-gitDecoration-addedResourceForeground, #81b88b); }
.git-deleted { color: var(--vscode-gitDecoration-deletedResourceForeground, #c74e39); text-decoration: line-through; }
+ /* Directories inherit the deleted color but not the strikethrough */
+ [data-type="directory"] > .tree-node .git-deleted { text-decoration: none; }
.git-untracked { color: var(--vscode-gitDecoration-untrackedResourceForeground, #73c991); }
.git-ignored { color: var(--vscode-gitDecoration-ignoredResourceForeground, #8c8c8c); opacity: 0.6; }
.git-conflict { color: var(--vscode-gitDecoration-conflictingResourceForeground, #e4676b); }
@@ -1131,7 +1158,7 @@ export function buildGitStatusMap(api: any): Record {
* Annotate tree entries with git status from the Git extension.
* Falls back gracefully if the git extension is unavailable.
*/
-function annotateGitStatus(entries: TreeEntry[]): TreeEntry[] {
+function annotateGitStatus(entries: TreeEntry[], parentDir: string): TreeEntry[] {
try {
const gitExt = vscode.extensions.getExtension("vscode.git");
if (!gitExt?.isActive) return entries;
@@ -1143,8 +1170,11 @@ function annotateGitStatus(entries: TreeEntry[]): TreeEntry[] {
const statusMap = new Map(Object.entries(statusRecord));
+ // Inject ghost entries for files deleted from disk but still tracked by git
+ const withGhosts = injectDeletedEntries(entries, statusMap, parentDir);
+
// Annotate files with exact matches, then propagate to directories
- const annotated = entries.map((entry) => {
+ const annotated = withGhosts.map((entry) => {
const gitStatus = statusMap.get(entry.path);
return gitStatus ? { ...entry, gitStatus: gitStatus as TreeEntry["gitStatus"] } : entry;
});
@@ -1154,6 +1184,40 @@ function annotateGitStatus(entries: TreeEntry[]): TreeEntry[] {
}
}
+/**
+ * Pure function: inject ghost entries for files that git reports as "deleted"
+ * but are no longer on disk (and therefore missing from the directory listing).
+ * Only injects direct children of `parentDir` — not nested files.
+ */
+export function injectDeletedEntries(
+ entries: TreeEntry[],
+ statusMap: Map,
+ parentDir: string,
+): TreeEntry[] {
+ const existingPaths = new Set(entries.map((e) => e.path));
+ const prefix = parentDir + "/";
+ const ghosts: TreeEntry[] = [];
+
+ for (const [filePath, status] of statusMap) {
+ if (status !== "deleted") continue;
+ if (!filePath.startsWith(prefix)) continue;
+ // Only direct children: no further "/" after the prefix
+ const remainder = filePath.slice(prefix.length);
+ if (remainder.includes("/")) continue;
+ // Don't duplicate an entry that already exists on disk
+ if (existingPaths.has(filePath)) continue;
+
+ ghosts.push({
+ name: remainder,
+ type: "file",
+ path: filePath,
+ gitStatus: "deleted",
+ });
+ }
+
+ return [...entries, ...ghosts];
+}
+
/** Priority rank for git statuses (higher = more notable). */
const GIT_STATUS_PRIORITY: Record = {
conflict: 5, modified: 4, deleted: 3, untracked: 2, added: 1, ignored: 0,
@@ -1247,7 +1311,7 @@ export async function executeFileOp(req: FileOpRequest): Promise {
// Target doesn't exist — safe to rename
}
await vscode.workspace.fs.rename(uri, newUri);
- return { ok: true };
+ return { ok: true, newPath: newUri.fsPath };
}
case "move": {
if (!req.targetDir) return { ok: false, message: "No target directory" };
@@ -1261,7 +1325,7 @@ export async function executeFileOp(req: FileOpRequest): Promise {
// Target doesn't exist — safe to move
}
await vscode.workspace.fs.rename(uri, targetUri);
- return { ok: true };
+ return { ok: true, newPath: targetUri.fsPath };
}
case "delete": {
// Confirmation dialog — same pattern as VS Code's Explorer.
@@ -1322,6 +1386,17 @@ export async function executeFileOp(req: FileOpRequest): Promise {
void vscode.commands.executeCommand("amicode.newChat");
return { ok: true };
}
+ case "restore": {
+ // Restore a git-deleted file by checking it out from HEAD.
+ const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
+ const dirPath = require("node:path").dirname(req.path);
+ execFileSync("git", ["checkout", "HEAD", "--", req.path], {
+ cwd: dirPath,
+ encoding: "utf8",
+ timeout: 10_000,
+ });
+ return { ok: true };
+ }
default:
return { ok: false, message: `Unknown operation: ${req.op}` };
}
diff --git a/packages/extension/src/sidebar_webview.ts b/packages/extension/src/sidebar_webview.ts
index 32fa7792..dd96587c 100644
--- a/packages/extension/src/sidebar_webview.ts
+++ b/packages/extension/src/sidebar_webview.ts
@@ -408,32 +408,43 @@ function createIconEl(icon: string): HTMLElement {
// Determine if this is a workspace root
const isRoot = currentRoots.some((r) => r.path === nodePath);
+ // Detect ghost entries (deleted from disk, still tracked by git)
+ const isGhost = dataEl?.dataset.gitStatus === "deleted";
+
// Build menu items
interface MenuItem { label?: string; op?: string; separator?: boolean; inline?: boolean }
const items: MenuItem[] = [];
- if (nodeType === "directory") {
- items.push({ label: "New File", op: "new-file", inline: true });
- items.push({ label: "New Folder", op: "new-folder", inline: true });
+ if (isGhost) {
+ // Ghost entries get a reduced menu — only restore and copy paths
+ items.push({ label: "Restore", op: "restore" });
items.push({ separator: true });
- }
-
- items.push({ label: "Rename", op: "rename", inline: true });
- items.push({ label: "Delete", op: "delete" });
- items.push({ separator: true });
- items.push({ label: "Copy Path", op: "copy-path" });
- items.push({ label: "Copy Relative Path", op: "copy-relative-path" });
- items.push({ separator: true });
- items.push({ label: "Reveal in Finder", op: "reveal-in-os" });
- items.push({ label: "Open in Terminal", op: "open-in-terminal" });
-
- if (nodeType === "file") {
- items.push({ label: "Open to the Side", op: "open-to-side" });
- }
+ items.push({ label: "Copy Path", op: "copy-path" });
+ items.push({ label: "Copy Relative Path", op: "copy-relative-path" });
+ } else {
+ if (nodeType === "directory") {
+ items.push({ label: "New File", op: "new-file", inline: true });
+ items.push({ label: "New Folder", op: "new-folder", inline: true });
+ items.push({ separator: true });
+ }
- if (isRoot) {
+ items.push({ label: "Rename", op: "rename", inline: true });
+ items.push({ label: "Delete", op: "delete" });
+ items.push({ separator: true });
+ items.push({ label: "Copy Path", op: "copy-path" });
+ items.push({ label: "Copy Relative Path", op: "copy-relative-path" });
items.push({ separator: true });
- items.push({ label: "Remove from Workspace", op: "remove-from-workspace" });
+ items.push({ label: "Reveal in Finder", op: "reveal-in-os" });
+ items.push({ label: "Open in Terminal", op: "open-in-terminal" });
+
+ if (nodeType === "file") {
+ items.push({ label: "Open to the Side", op: "open-to-side" });
+ }
+
+ if (isRoot) {
+ items.push({ separator: true });
+ items.push({ label: "Remove from Workspace", op: "remove-from-workspace" });
+ }
}
// Render menu
@@ -482,11 +493,57 @@ function createIconEl(icon: string): HTMLElement {
let dragSourcePath: string | null = null;
let currentDropTarget: HTMLElement | null = null;
+ // ── Drag auto-scroll ────────────────────────────────────────────────────
+ // When dragging near the top/bottom edge of a scrollable section, auto-scroll
+ // so off-screen drop targets become reachable.
+ let autoScrollRAF: number | null = null;
+ let autoScrollSpeed = 0;
+ let autoScrollTarget: HTMLElement | null = null;
+
+ function startAutoScroll(container: HTMLElement, speed: number): void {
+ autoScrollTarget = container;
+ autoScrollSpeed = speed;
+ if (autoScrollRAF === null) autoScrollTick();
+ }
+
+ function autoScrollTick(): void {
+ if (autoScrollTarget && autoScrollSpeed !== 0) {
+ autoScrollTarget.scrollTop += autoScrollSpeed;
+ autoScrollRAF = requestAnimationFrame(autoScrollTick);
+ }
+ }
+
+ function clearAutoScroll(): void {
+ if (autoScrollRAF !== null) { cancelAnimationFrame(autoScrollRAF); autoScrollRAF = null; }
+ autoScrollSpeed = 0;
+ autoScrollTarget = null;
+ }
+
+ function handleDragAutoScroll(e: DragEvent): void {
+ const container = (e.target as HTMLElement)?.closest(".section-body") as HTMLElement | null;
+ if (!container) { clearAutoScroll(); return; }
+
+ const rect = container.getBoundingClientRect();
+ const threshold = 30;
+ const maxSpeed = 12;
+
+ if (e.clientY < rect.top + threshold && container.scrollTop > 0) {
+ const proximity = Math.max(0, 1 - (e.clientY - rect.top) / threshold);
+ startAutoScroll(container, -maxSpeed * proximity);
+ } else if (e.clientY > rect.bottom - threshold && container.scrollTop < container.scrollHeight - container.clientHeight) {
+ const proximity = Math.max(0, 1 - (rect.bottom - e.clientY) / threshold);
+ startAutoScroll(container, maxSpeed * proximity);
+ } else {
+ clearAutoScroll();
+ }
+ }
+
function clearDropTarget(): void {
if (currentDropTarget) {
currentDropTarget.classList.remove("drop-target");
currentDropTarget = null;
}
+ clearAutoScroll();
}
// ── Sash resize between sections ────────────────────────────────────────────
@@ -732,9 +789,14 @@ function createIconEl(icon: string): HTMLElement {
const directStatus = statusMap[nodePath];
if (directStatus) {
label.classList.add(`git-${directStatus}`);
+ // Keep dataset.gitStatus in sync so click/context-menu guards read current state
+ el.dataset.gitStatus = directStatus;
continue;
}
+ // No direct status — clear stale dataset.gitStatus (e.g. file was restored)
+ delete el.dataset.gitStatus;
+
// Directory propagation: find the most notable child status
const isDir = el.dataset.type === "directory";
if (isDir) {
@@ -1239,6 +1301,7 @@ function createIconEl(icon: string): HTMLElement {
label.textContent = entry.name;
if (entry.gitStatus) {
label.classList.add(`git-${entry.gitStatus}`);
+ row.dataset.gitStatus = entry.gitStatus;
}
row.appendChild(iconEl);
@@ -1249,6 +1312,9 @@ function createIconEl(icon: string): HTMLElement {
setupFileDropTarget(row);
row.addEventListener("click", () => {
+ // Ghost entries (deleted from disk) can't be opened — read from DOM so
+ // the guard reflects applyGitStatus updates (not a stale closure value)
+ if (row.dataset.gitStatus === "deleted") return;
vscode.postMessage({ kind: "open-file", path: entry.path });
});
@@ -1297,6 +1363,7 @@ function createIconEl(icon: string): HTMLElement {
el.classList.remove("dragging");
dragSourcePath = null;
clearDropTarget();
+ clearAutoScroll();
if (dragImage) {
dragImage.remove();
dragImage = null;
@@ -1315,6 +1382,7 @@ function createIconEl(icon: string): HTMLElement {
if (dragSourcePath.startsWith(targetDir + "/")) return;
e.preventDefault();
e.dataTransfer!.dropEffect = "move";
+ handleDragAutoScroll(e);
if (currentDropTarget !== highlight) {
clearDropTarget();
currentDropTarget = highlight;
@@ -1353,6 +1421,7 @@ function createIconEl(icon: string): HTMLElement {
if (dragSourcePath.startsWith(dirPath + "/")) return;
e.preventDefault();
e.dataTransfer!.dropEffect = "move";
+ handleDragAutoScroll(e);
const dirRow = dirContainer.querySelector(":scope > .tree-node") as HTMLElement | null;
if (!dirRow) return;
if (currentDropTarget !== dirRow) {
@@ -1413,6 +1482,7 @@ function createIconEl(icon: string): HTMLElement {
e.preventDefault();
e.stopImmediatePropagation(); // Prevent setupDirectoryDropTarget on the SAME element
e.dataTransfer!.dropEffect = "move";
+ handleDragAutoScroll(e);
// Don't show indicator for self-drop
if (sourceRoot.path === root.path) {
@@ -1453,18 +1523,20 @@ function createIconEl(icon: string): HTMLElement {
});
row.addEventListener("drop", (e) => {
- e.preventDefault();
- e.stopImmediatePropagation();
- clearRootInsertIndicator();
- clearDropTarget();
-
const sourcePath = e.dataTransfer?.getData("text/plain");
if (!sourcePath) return;
// Is the source a root?
const sourceRoot = currentRoots.find((r) => r.path === sourcePath);
- if (!sourceRoot) return; // Not a root — setupDirectoryDropTarget handles file move
+ if (!sourceRoot) return; // Not a root — let setupDirectoryDropTarget handle file move
if (sourceRoot.projectType !== root.projectType) return;
+
+ // Confirmed root-to-root reorder — now block other handlers
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ clearRootInsertIndicator();
+ clearDropTarget();
+
if (sourcePath === root.path) return; // Self-drop no-op
const rect = row.getBoundingClientRect();
@@ -1613,6 +1685,35 @@ function createIconEl(icon: string): HTMLElement {
// Reactive git coloring: cache + apply.
lastGitStatusMap = msg.statusMap ?? {};
applyGitStatus(lastGitStatusMap);
+
+ // Re-request children for expanded directories that contain orphaned
+ // deleted entries (deleted files in the status map with no matching
+ // DOM node). This handles the race where the filesystem watcher fires
+ // before git has updated its working-tree status — by the time this
+ // git-status push arrives, annotateGitStatus on the host side will
+ // inject the ghost entries.
+ const renderedPaths = new Set(
+ Array.from(treeRoot?.querySelectorAll("[data-path]") ?? [])
+ .map((el) => (el as HTMLElement).dataset.path)
+ .filter(Boolean),
+ );
+ const orphanedDirs = new Set();
+ for (const [filePath, status] of Object.entries(lastGitStatusMap)) {
+ if (status !== "deleted") continue;
+ if (renderedPaths.has(filePath)) continue;
+ // Find the parent directory of this orphaned deleted file
+ const lastSlash = filePath.lastIndexOf("/");
+ if (lastSlash < 0) continue;
+ const parentDir = filePath.slice(0, lastSlash);
+ // Only re-request if the parent is expanded (visible in the tree)
+ if (expanded[parentDir]) {
+ orphanedDirs.add(parentDir);
+ }
+ }
+ for (const dir of orphanedDirs) {
+ delete childrenCache[dir];
+ vscode.postMessage({ kind: "get-children", path: dir });
+ }
break;
}
@@ -1640,8 +1741,11 @@ function createIconEl(icon: string): HTMLElement {
}
case "section-order": {
- // Host replays the persisted section order on webview resolve
+ // Host replays the persisted section order on webview resolve.
+ // Skip the re-render when the order is unchanged — avoids a
+ // redundant full DOM wipe on sidebar show and section-reorder echo.
if (Array.isArray(msg.order)) {
+ if (JSON.stringify(msg.order) === JSON.stringify(currentSectionOrder)) break;
currentSectionOrder = msg.order;
saveExpandedState(); // persist to webview state for tab-switch survival
// Re-render with the new order if we already have roots
diff --git a/packages/extension/test/agents_md.test.ts b/packages/extension/test/agents_md.test.ts
index 8cafb514..4cc377d3 100644
--- a/packages/extension/test/agents_md.test.ts
+++ b/packages/extension/test/agents_md.test.ts
@@ -278,3 +278,25 @@ describe("HP solver-mode guidance: both imports", () => {
}
});
});
+
+// The overlay is a tracking copy of the fork — edits there silently vanish on
+// sync:apply and never reach the compiled binary. An agent that edits the overlay
+// instead of the fork wastes a whole implementation cycle. The rule must be in
+// the ROOT AGENTS.md (the dev-agent instruction set) so every agent sees it
+// before touching app-layer files.
+describe("AGENTS.md overlay prohibition", () => {
+ const ROOT_AGENTS = readFileSync(join(__dirname, "..", "..", "..", "AGENTS.md"), "utf8");
+
+ it("explicitly prohibits editing the overlay directly", () => {
+ expect(ROOT_AGENTS).toMatch(/[Nn]ever edit the overlay directly/);
+ });
+
+ it("names the fork as the correct edit target for app-layer changes", () => {
+ expect(ROOT_AGENTS).toMatch(/edit.*fork/i);
+ expect(ROOT_AGENTS).toMatch(/~\/harmoniqs\/opencode|AMICODE_OPENCODE_SRC/);
+ });
+
+ it("warns that overlay edits silently vanish", () => {
+ expect(ROOT_AGENTS).toMatch(/silently (vanish|overwritten|lost)/i);
+ });
+});
diff --git a/packages/extension/test/edit_to_context.test.ts b/packages/extension/test/edit_to_context.test.ts
new file mode 100644
index 00000000..9524eb0f
--- /dev/null
+++ b/packages/extension/test/edit_to_context.test.ts
@@ -0,0 +1,242 @@
+import { describe, expect, test, vi } from "vitest"
+
+/**
+ * Tests for #771: Edit-to-context feedback system.
+ *
+ * Tests the edit context manager that tracks per-file edit snapshots
+ * and produces diff context items for the agent.
+ */
+
+// ---------------------------------------------------------------------------
+// Simple line diff for tests (the real impl uses the `diff` npm package)
+// ---------------------------------------------------------------------------
+
+function simpleDiff(path: string, original: string, current: string): string {
+ const origLines = original.split("\n")
+ const currLines = current.split("\n")
+ const lines: string[] = [`--- ${path}\t(original)`, `+++ ${path}\t(edited)`, "@@ diff @@"]
+ const maxLen = Math.max(origLines.length, currLines.length)
+ for (let i = 0; i < maxLen; i++) {
+ const o = i < origLines.length ? origLines[i] : undefined
+ const c = i < currLines.length ? currLines[i] : undefined
+ if (o === c) {
+ lines.push(` ${o}`)
+ } else {
+ if (o !== undefined) lines.push(`-${o}`)
+ if (c !== undefined) lines.push(`+${c}`)
+ }
+ }
+ return lines.join("\n")
+}
+
+// ---------------------------------------------------------------------------
+// Edit context manager
+// ---------------------------------------------------------------------------
+
+interface EditContextItem {
+ type: "file"
+ path: string
+ comment: string
+ commentID: string
+ commentOrigin: "review"
+}
+
+function createEditContextManager() {
+ const snapshots = new Map() // path → original content
+ const items = new Map() // path → context item
+
+ return {
+ get size() {
+ return items.size
+ },
+
+ /**
+ * Record a file's original content (snapshot taken on first edit).
+ */
+ snapshot(path: string, originalContent: string) {
+ if (!snapshots.has(path)) {
+ snapshots.set(path, originalContent)
+ }
+ },
+
+ /**
+ * Update the edit context for a file with its current content.
+ * Produces a unified diff between original and current.
+ */
+ update(path: string, currentContent: string) {
+ const original = snapshots.get(path)
+ if (original === undefined) return
+
+ // If content matches original, remove the context item
+ if (original === currentContent) {
+ items.delete(path)
+ return
+ }
+
+ const diff = simpleDiff(path, original, currentContent)
+ const comment = `The user made the following edits to ${path}:\n\n${diff}`
+
+ items.set(path, {
+ type: "file",
+ path,
+ comment,
+ commentID: `edit:${path}`,
+ commentOrigin: "review",
+ })
+ },
+
+ /**
+ * Get context items for message send.
+ */
+ getItems(): EditContextItem[] {
+ return Array.from(items.values())
+ },
+
+ /**
+ * Clear a single file's edit context (e.g., on revert).
+ */
+ clearFile(path: string) {
+ items.delete(path)
+ snapshots.delete(path)
+ },
+
+ /**
+ * Consume all items (remove after send).
+ */
+ consumeAll(): EditContextItem[] {
+ const result = Array.from(items.values())
+ items.clear()
+ snapshots.clear()
+ return result
+ },
+
+ /**
+ * Check if a file has pending edits.
+ */
+ hasEdits(path: string): boolean {
+ return items.has(path)
+ },
+ }
+}
+
+describe("Edit context manager", () => {
+ test("starts empty", () => {
+ const mgr = createEditContextManager()
+ expect(mgr.size).toBe(0)
+ expect(mgr.getItems()).toEqual([])
+ })
+
+ test("first edit creates a snapshot and context item", () => {
+ const mgr = createEditContextManager()
+ mgr.snapshot("src/foo.ts", "original")
+ mgr.update("src/foo.ts", "modified")
+
+ expect(mgr.size).toBe(1)
+ const items = mgr.getItems()
+ expect(items.length).toBe(1)
+ expect(items[0].path).toBe("src/foo.ts")
+ expect(items[0].commentOrigin).toBe("review")
+ expect(items[0].comment).toContain("The user made the following edits to src/foo.ts")
+ expect(items[0].comment).toContain("original")
+ expect(items[0].comment).toContain("modified")
+ })
+
+ test("continued edits update the same item (one per file)", () => {
+ const mgr = createEditContextManager()
+ mgr.snapshot("src/foo.ts", "original")
+ mgr.update("src/foo.ts", "edit-v1")
+ mgr.update("src/foo.ts", "edit-v2")
+
+ expect(mgr.size).toBe(1)
+ const items = mgr.getItems()
+ expect(items[0].comment).toContain("edit-v2")
+ expect(items[0].comment).not.toContain("edit-v1")
+ })
+
+ test("editing back to original removes the item", () => {
+ const mgr = createEditContextManager()
+ mgr.snapshot("src/foo.ts", "original")
+ mgr.update("src/foo.ts", "modified")
+ expect(mgr.size).toBe(1)
+
+ mgr.update("src/foo.ts", "original")
+ expect(mgr.size).toBe(0)
+ })
+
+ test("multiple files each get their own item", () => {
+ const mgr = createEditContextManager()
+ mgr.snapshot("a.ts", "orig-a")
+ mgr.snapshot("b.ts", "orig-b")
+ mgr.update("a.ts", "mod-a")
+ mgr.update("b.ts", "mod-b")
+
+ expect(mgr.size).toBe(2)
+ const paths = mgr.getItems().map((i) => i.path).sort()
+ expect(paths).toEqual(["a.ts", "b.ts"])
+ })
+
+ test("clearFile removes snapshot and item for one file", () => {
+ const mgr = createEditContextManager()
+ mgr.snapshot("a.ts", "orig")
+ mgr.update("a.ts", "mod")
+ mgr.snapshot("b.ts", "orig")
+ mgr.update("b.ts", "mod")
+
+ mgr.clearFile("a.ts")
+ expect(mgr.size).toBe(1)
+ expect(mgr.hasEdits("a.ts")).toBe(false)
+ expect(mgr.hasEdits("b.ts")).toBe(true)
+ })
+
+ test("consumeAll returns items and clears everything", () => {
+ const mgr = createEditContextManager()
+ mgr.snapshot("a.ts", "orig")
+ mgr.update("a.ts", "mod")
+
+ const consumed = mgr.consumeAll()
+ expect(consumed.length).toBe(1)
+ expect(mgr.size).toBe(0)
+ expect(mgr.getItems()).toEqual([])
+ })
+
+ test("snapshot only records on first call (doesn't overwrite)", () => {
+ const mgr = createEditContextManager()
+ mgr.snapshot("a.ts", "first-original")
+ mgr.snapshot("a.ts", "second-original")
+ mgr.update("a.ts", "modified")
+
+ const items = mgr.getItems()
+ // The diff should be against "first-original", not "second-original"
+ expect(items[0].comment).toContain("first-original")
+ })
+
+ test("context items have unique commentIDs per file", () => {
+ const mgr = createEditContextManager()
+ mgr.snapshot("a.ts", "o")
+ mgr.snapshot("b.ts", "o")
+ mgr.update("a.ts", "m")
+ mgr.update("b.ts", "m")
+
+ const ids = mgr.getItems().map((i) => i.commentID)
+ expect(new Set(ids).size).toBe(2)
+ expect(ids).toContain("edit:a.ts")
+ expect(ids).toContain("edit:b.ts")
+ })
+
+ test("hasEdits returns false for unknown files", () => {
+ const mgr = createEditContextManager()
+ expect(mgr.hasEdits("unknown.ts")).toBe(false)
+ })
+
+ test("context item comment contains unified diff format", () => {
+ const mgr = createEditContextManager()
+ mgr.snapshot("file.ts", "line1\nline2\nline3\n")
+ mgr.update("file.ts", "line1\nline2-changed\nline3\nline4\n")
+
+ const items = mgr.getItems()
+ // Should contain diff markers
+ expect(items[0].comment).toContain("---")
+ expect(items[0].comment).toContain("+++")
+ expect(items[0].comment).toContain("@@")
+ })
+})
diff --git a/packages/extension/test/editable_diffs_wiring.test.ts b/packages/extension/test/editable_diffs_wiring.test.ts
new file mode 100644
index 00000000..4b13a107
--- /dev/null
+++ b/packages/extension/test/editable_diffs_wiring.test.ts
@@ -0,0 +1,251 @@
+import { describe, expect, test, vi, beforeEach } from "vitest"
+
+/**
+ * Tests for #768: Wire editable diffs into Files Changed + auto-save.
+ *
+ * These tests verify the overlay's integration of EditableDiffView:
+ * - Save utility logic (debounce, immediate, status transitions)
+ * - ReviewDiffStyle type widening
+ * - File status → readOnly mapping
+ */
+
+// ---------------------------------------------------------------------------
+// Save utility logic
+// ---------------------------------------------------------------------------
+
+type SaveStatus = "idle" | "saving" | "saved" | "error"
+
+/**
+ * Minimal reproduction of the save utility for testable isolation.
+ * The actual implementation lives in the overlay component.
+ */
+function createSaveController(opts: {
+ onSave: (path: string, content: string) => Promise
+ debounceMs?: number
+ savedDisplayMs?: number
+}) {
+ const debounceMs = opts.debounceMs ?? 1000
+ const savedDisplayMs = opts.savedDisplayMs ?? 2000
+ let status: SaveStatus = "idle"
+ let saveTimer: ReturnType | undefined
+ let savedTimer: ReturnType | undefined
+ const listeners: Array<(s: SaveStatus) => void> = []
+
+ function setStatus(s: SaveStatus) {
+ status = s
+ for (const l of listeners) l(s)
+ }
+
+ return {
+ get status() {
+ return status
+ },
+ onStatusChange(cb: (s: SaveStatus) => void) {
+ listeners.push(cb)
+ },
+ debouncedSave(path: string, content: string) {
+ if (saveTimer) clearTimeout(saveTimer)
+ saveTimer = setTimeout(() => {
+ setStatus("saving")
+ opts
+ .onSave(path, content)
+ .then(() => {
+ setStatus("saved")
+ if (savedTimer) clearTimeout(savedTimer)
+ savedTimer = setTimeout(() => setStatus("idle"), savedDisplayMs)
+ })
+ .catch(() => {
+ setStatus("error")
+ if (savedTimer) clearTimeout(savedTimer)
+ savedTimer = setTimeout(() => setStatus("idle"), savedDisplayMs)
+ })
+ }, debounceMs)
+ },
+ immediateSave(path: string, content: string) {
+ if (saveTimer) clearTimeout(saveTimer)
+ setStatus("saving")
+ opts
+ .onSave(path, content)
+ .then(() => {
+ setStatus("saved")
+ if (savedTimer) clearTimeout(savedTimer)
+ savedTimer = setTimeout(() => setStatus("idle"), savedDisplayMs)
+ })
+ .catch(() => {
+ setStatus("error")
+ if (savedTimer) clearTimeout(savedTimer)
+ savedTimer = setTimeout(() => setStatus("idle"), savedDisplayMs)
+ })
+ },
+ cleanup() {
+ if (saveTimer) clearTimeout(saveTimer)
+ if (savedTimer) clearTimeout(savedTimer)
+ },
+ }
+}
+
+describe("Save controller", () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ test("starts at idle", () => {
+ const ctrl = createSaveController({ onSave: async () => {} })
+ expect(ctrl.status).toBe("idle")
+ ctrl.cleanup()
+ })
+
+ test("debouncedSave transitions to saving after debounce period", async () => {
+ const onSave = vi.fn(async () => {})
+ const ctrl = createSaveController({ onSave, debounceMs: 100 })
+ const statuses: SaveStatus[] = []
+ ctrl.onStatusChange((s) => statuses.push(s))
+
+ ctrl.debouncedSave("test.ts", "content")
+
+ // Not called yet (within debounce)
+ expect(onSave).not.toHaveBeenCalled()
+
+ vi.advanceTimersByTime(100)
+ expect(onSave).toHaveBeenCalledWith("test.ts", "content")
+ expect(statuses).toContain("saving")
+
+ ctrl.cleanup()
+ })
+
+ test("immediateSave cancels pending debounced save and fires immediately", async () => {
+ const calls: string[] = []
+ const onSave = vi.fn(async (_path: string, content: string) => {
+ calls.push(content)
+ })
+ const ctrl = createSaveController({ onSave, debounceMs: 1000 })
+
+ ctrl.debouncedSave("test.ts", "debounced-content")
+ ctrl.immediateSave("test.ts", "immediate-content")
+
+ expect(onSave).toHaveBeenCalledTimes(1)
+ expect(onSave).toHaveBeenCalledWith("test.ts", "immediate-content")
+
+ // Advance past debounce — should NOT fire the debounced save
+ vi.advanceTimersByTime(1500)
+ expect(onSave).toHaveBeenCalledTimes(1)
+
+ ctrl.cleanup()
+ })
+
+ test("transitions to saved after successful save, then back to idle", async () => {
+ let resolvePromise!: () => void
+ const onSave = vi.fn(
+ () => new Promise((r) => (resolvePromise = r)),
+ )
+ const ctrl = createSaveController({
+ onSave,
+ debounceMs: 0,
+ savedDisplayMs: 100,
+ })
+ const statuses: SaveStatus[] = []
+ ctrl.onStatusChange((s) => statuses.push(s))
+
+ ctrl.immediateSave("test.ts", "content")
+ expect(ctrl.status).toBe("saving")
+
+ // Resolve the save
+ resolvePromise()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(ctrl.status).toBe("saved")
+
+ // After savedDisplayMs, back to idle
+ vi.advanceTimersByTime(100)
+ expect(ctrl.status).toBe("idle")
+
+ ctrl.cleanup()
+ })
+
+ test("transitions to error on save failure, then back to idle", async () => {
+ const onSave = vi.fn(async () => {
+ throw new Error("network error")
+ })
+ const ctrl = createSaveController({
+ onSave,
+ debounceMs: 0,
+ savedDisplayMs: 100,
+ })
+
+ ctrl.immediateSave("test.ts", "content")
+ await vi.advanceTimersByTimeAsync(0)
+ expect(ctrl.status).toBe("error")
+
+ vi.advanceTimersByTime(100)
+ expect(ctrl.status).toBe("idle")
+
+ ctrl.cleanup()
+ })
+
+ test("multiple rapid debouncedSave calls only fires once", () => {
+ const onSave = vi.fn(async () => {})
+ const ctrl = createSaveController({ onSave, debounceMs: 100 })
+
+ ctrl.debouncedSave("test.ts", "v1")
+ vi.advanceTimersByTime(50)
+ ctrl.debouncedSave("test.ts", "v2")
+ vi.advanceTimersByTime(50)
+ ctrl.debouncedSave("test.ts", "v3")
+ vi.advanceTimersByTime(100)
+
+ expect(onSave).toHaveBeenCalledTimes(1)
+ expect(onSave).toHaveBeenCalledWith("test.ts", "v3")
+
+ ctrl.cleanup()
+ })
+})
+
+// ---------------------------------------------------------------------------
+// File status → readOnly mapping
+// ---------------------------------------------------------------------------
+
+describe("File status → readOnly mapping", () => {
+ function isReadOnly(status: "added" | "modified" | "deleted"): boolean {
+ return status === "deleted"
+ }
+
+ test("added files are editable", () => {
+ expect(isReadOnly("added")).toBe(false)
+ })
+
+ test("modified files are editable", () => {
+ expect(isReadOnly("modified")).toBe(false)
+ })
+
+ test("deleted files are read-only", () => {
+ expect(isReadOnly("deleted")).toBe(true)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// ReviewDiffStyle type widening
+// ---------------------------------------------------------------------------
+
+describe("ReviewDiffStyleExtended", () => {
+ type ReviewDiffStyleExtended = "unified" | "split" | "preview"
+
+ test("accepts unified", () => {
+ const s: ReviewDiffStyleExtended = "unified"
+ expect(s).toBe("unified")
+ })
+
+ test("accepts split", () => {
+ const s: ReviewDiffStyleExtended = "split"
+ expect(s).toBe("split")
+ })
+
+ test("accepts preview", () => {
+ const s: ReviewDiffStyleExtended = "preview"
+ expect(s).toBe("preview")
+ })
+
+ test("base type is assignable to extended", () => {
+ const base: "unified" | "split" = "split"
+ const extended: ReviewDiffStyleExtended = base
+ expect(extended).toBe("split")
+ })
+})
diff --git a/packages/extension/test/markdown_preview.test.ts b/packages/extension/test/markdown_preview.test.ts
new file mode 100644
index 00000000..be3c7ac5
--- /dev/null
+++ b/packages/extension/test/markdown_preview.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, test } from "vitest"
+
+/**
+ * Tests for #772: Markdown preview toggle.
+ *
+ * Tests the preprocessMarkdown utility extraction and the
+ * diff style fallback logic for preview mode.
+ */
+
+// ---------------------------------------------------------------------------
+// preprocessMarkdown — extracted from session-preview-tab.tsx
+// ---------------------------------------------------------------------------
+
+/**
+ * Convert fenced ```math blocks to $$...$$ display math for KaTeX.
+ */
+function preprocessMarkdown(md: string): string {
+ return md.replace(/```math\n([\s\S]*?)```/g, (_, p1) => `$$${p1}$$`)
+}
+
+describe("preprocessMarkdown", () => {
+ test("converts fenced math blocks to display math", () => {
+ const input = "text\n```math\nx^2 + y^2 = z^2\n```\nmore text"
+ const result = preprocessMarkdown(input)
+ expect(result).toBe("text\n$$x^2 + y^2 = z^2\n$$\nmore text")
+ })
+
+ test("handles multiple math blocks", () => {
+ const input = "```math\na\n```\nmiddle\n```math\nb\n```"
+ const result = preprocessMarkdown(input)
+ expect(result).toContain("$$a\n$$")
+ expect(result).toContain("$$b\n$$")
+ expect(result).not.toContain("```math")
+ })
+
+ test("leaves non-math fenced code blocks untouched", () => {
+ const input = "```typescript\nconst x = 1\n```"
+ const result = preprocessMarkdown(input)
+ expect(result).toBe(input)
+ })
+
+ test("handles empty input", () => {
+ expect(preprocessMarkdown("")).toBe("")
+ })
+
+ test("handles input with no math blocks", () => {
+ const input = "# Hello\nWorld\n- list item"
+ expect(preprocessMarkdown(input)).toBe(input)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Preview mode visibility logic
+// ---------------------------------------------------------------------------
+
+describe("Preview segment visibility", () => {
+ function isMarkdownFile(path: string): boolean {
+ return /\.md$/i.test(path)
+ }
+
+ test("shows for .md files", () => {
+ expect(isMarkdownFile("README.md")).toBe(true)
+ expect(isMarkdownFile("docs/guide.md")).toBe(true)
+ })
+
+ test("shows for .MD files (case insensitive)", () => {
+ expect(isMarkdownFile("FILE.MD")).toBe(true)
+ })
+
+ test("hides for non-markdown files", () => {
+ expect(isMarkdownFile("app.ts")).toBe(false)
+ expect(isMarkdownFile("style.css")).toBe(false)
+ expect(isMarkdownFile("data.json")).toBe(false)
+ expect(isMarkdownFile("script.py")).toBe(false)
+ })
+
+ test("hides for files with md in the name but not as extension", () => {
+ expect(isMarkdownFile("markdown-parser.ts")).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Diff style fallback logic
+// ---------------------------------------------------------------------------
+
+describe("Preview mode fallback", () => {
+ type DiffStyle = "unified" | "split" | "preview"
+
+ /**
+ * Resolve the effective diff style: if preview is selected but the file
+ * isn't markdown, fall back to the last non-preview style.
+ */
+ function resolveEffectiveDiffStyle(
+ selected: DiffStyle,
+ isMarkdown: boolean,
+ lastDiffMode: "unified" | "split",
+ ): DiffStyle {
+ if (selected === "preview" && !isMarkdown) {
+ return lastDiffMode
+ }
+ return selected
+ }
+
+ test("returns preview for .md files when preview is selected", () => {
+ expect(resolveEffectiveDiffStyle("preview", true, "split")).toBe("preview")
+ })
+
+ test("falls back to last diff mode for non-md files when preview selected", () => {
+ expect(resolveEffectiveDiffStyle("preview", false, "split")).toBe("split")
+ expect(resolveEffectiveDiffStyle("preview", false, "unified")).toBe("unified")
+ })
+
+ test("returns unified/split regardless of file type", () => {
+ expect(resolveEffectiveDiffStyle("unified", true, "split")).toBe("unified")
+ expect(resolveEffectiveDiffStyle("split", false, "unified")).toBe("split")
+ })
+})
diff --git a/packages/extension/test/overlay_sync.test.ts b/packages/extension/test/overlay_sync.test.ts
new file mode 100644
index 00000000..2ff655e6
--- /dev/null
+++ b/packages/extension/test/overlay_sync.test.ts
@@ -0,0 +1,108 @@
+import { describe, expect, test } from "vitest"
+import { execFileSync } from "node:child_process"
+import { join } from "node:path"
+
+/**
+ * Tests for overlay-sync.mjs — the overlay ↔ fork sync checker.
+ *
+ * These test the script's behavior, not the current sync state (which
+ * depends on whether other PRs have landed on the fork since the last
+ * extraction).
+ */
+
+const SCRIPT = join(__dirname, "..", "..", "app-bundle", "scripts", "overlay-sync.mjs")
+
+describe("overlay-sync script", () => {
+ test("--check runs without crashing and produces structured output", () => {
+ // The script exits 0 (PASS/SKIP) or 1 (DRIFT). Both are valid.
+ // Only a crash (exit > 1 or thrown error) is a test failure.
+ let stdout: string
+ let exitCode: number
+
+ try {
+ stdout = execFileSync("node", [SCRIPT, "--check"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ })
+ exitCode = 0
+ } catch (e: any) {
+ stdout = e.stdout ?? ""
+ exitCode = e.status ?? 1
+ }
+
+ // Should produce recognizable output
+ expect(stdout).toContain("[overlay-sync]")
+ // Exit code 0 = PASS/SKIP, 1 = DRIFT (both acceptable)
+ expect(exitCode).toBeLessThanOrEqual(1)
+ })
+
+ test("--check reports fork path", () => {
+ let stdout: string
+ try {
+ stdout = execFileSync("node", [SCRIPT, "--check"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ })
+ } catch (e: any) {
+ stdout = e.stdout ?? ""
+ }
+
+ // Should resolve and report the fork path (or SKIP if not found)
+ const hasForkPath = stdout.includes("fork:") || stdout.includes("SKIP")
+ expect(hasForkPath).toBe(true)
+ })
+
+ test("--apply runs without crashing", () => {
+ // We don't actually want to mutate files in the test, so we verify
+ // the script at least parses and finds the fork. If there's drift,
+ // it will apply it — that's fine, we want the overlay in sync anyway.
+ let exitCode: number
+ let stdout: string
+ try {
+ stdout = execFileSync("node", [SCRIPT, "--apply"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ })
+ exitCode = 0
+ } catch (e: any) {
+ stdout = e.stdout ?? ""
+ exitCode = e.status ?? 1
+ }
+
+ expect(stdout).toContain("[overlay-sync]")
+ expect(exitCode).toBe(0)
+ })
+
+ test("--check after --apply reports PASS", () => {
+ // After apply, check should pass (all files now match the fork)
+ let stdout: string
+ let exitCode: number
+
+ // First apply
+ try {
+ execFileSync("node", [SCRIPT, "--apply"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ })
+ } catch {
+ // apply may exit non-zero if fork not found
+ }
+
+ // Then check
+ try {
+ stdout = execFileSync("node", [SCRIPT, "--check"], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "pipe"],
+ })
+ exitCode = 0
+ } catch (e: any) {
+ stdout = e.stdout ?? ""
+ exitCode = e.status ?? 1
+ }
+
+ // After apply, everything should be in sync
+ const passed = stdout.includes("PASS") || stdout.includes("SKIP")
+ expect(passed).toBe(true)
+ expect(exitCode).toBe(0)
+ })
+})
diff --git a/packages/extension/test/revert_concurrent.test.ts b/packages/extension/test/revert_concurrent.test.ts
new file mode 100644
index 00000000..6678990d
--- /dev/null
+++ b/packages/extension/test/revert_concurrent.test.ts
@@ -0,0 +1,214 @@
+import { describe, expect, test, vi, beforeEach } from "vitest"
+
+/**
+ * Tests for #770: Revert + concurrent edit handling.
+ *
+ * Tests the revert controller and concurrent-edit detection logic
+ * in isolation from the SolidJS component.
+ */
+
+// ---------------------------------------------------------------------------
+// Revert controller
+// ---------------------------------------------------------------------------
+
+function createRevertController(opts: {
+ getOriginal: () => string
+ getCurrentContent: () => string
+ onSave: (path: string, content: string) => Promise
+ onRevertEditor: () => void
+}) {
+ let hasEdits = false
+
+ return {
+ get hasEdits() {
+ return hasEdits
+ },
+ markEdited() {
+ hasEdits = true
+ },
+ async revert(path: string) {
+ const original = opts.getOriginal()
+ // Write original content to disk
+ await opts.onSave(path, original)
+ // Reset the editor (clears undo history via the CM6 onRevert callback)
+ opts.onRevertEditor()
+ hasEdits = false
+ },
+ reset() {
+ hasEdits = false
+ },
+ }
+}
+
+describe("Revert controller", () => {
+ test("hasEdits starts false", () => {
+ const ctrl = createRevertController({
+ getOriginal: () => "original",
+ getCurrentContent: () => "modified",
+ onSave: async () => {},
+ onRevertEditor: () => {},
+ })
+ expect(ctrl.hasEdits).toBe(false)
+ })
+
+ test("markEdited sets hasEdits to true", () => {
+ const ctrl = createRevertController({
+ getOriginal: () => "original",
+ getCurrentContent: () => "modified",
+ onSave: async () => {},
+ onRevertEditor: () => {},
+ })
+ ctrl.markEdited()
+ expect(ctrl.hasEdits).toBe(true)
+ })
+
+ test("revert writes original content to disk", async () => {
+ const saves: Array<{ path: string; content: string }> = []
+ const ctrl = createRevertController({
+ getOriginal: () => "original content",
+ getCurrentContent: () => "user edits",
+ onSave: async (path, content) => {
+ saves.push({ path, content })
+ },
+ onRevertEditor: () => {},
+ })
+ ctrl.markEdited()
+ await ctrl.revert("test.ts")
+
+ expect(saves).toEqual([{ path: "test.ts", content: "original content" }])
+ })
+
+ test("revert calls onRevertEditor", async () => {
+ const editorReverted = vi.fn()
+ const ctrl = createRevertController({
+ getOriginal: () => "orig",
+ getCurrentContent: () => "mod",
+ onSave: async () => {},
+ onRevertEditor: editorReverted,
+ })
+ ctrl.markEdited()
+ await ctrl.revert("test.ts")
+
+ expect(editorReverted).toHaveBeenCalledTimes(1)
+ })
+
+ test("revert resets hasEdits to false", async () => {
+ const ctrl = createRevertController({
+ getOriginal: () => "orig",
+ getCurrentContent: () => "mod",
+ onSave: async () => {},
+ onRevertEditor: () => {},
+ })
+ ctrl.markEdited()
+ expect(ctrl.hasEdits).toBe(true)
+
+ await ctrl.revert("test.ts")
+ expect(ctrl.hasEdits).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Concurrent edit detection
+// ---------------------------------------------------------------------------
+
+type ConcurrentEditState = "none" | "detected"
+
+function createConcurrentEditDetector() {
+ let state: ConcurrentEditState = "none"
+ let lastDiffVersion: string | null = null
+ let userHasEdits = false
+
+ return {
+ get state() {
+ return state
+ },
+ setUserHasEdits(v: boolean) {
+ userHasEdits = v
+ },
+ /**
+ * Called when a new fileDiff prop arrives.
+ * Returns true if an external change was detected while user has edits.
+ */
+ onDiffUpdate(diffVersion: string): boolean {
+ if (lastDiffVersion === null) {
+ lastDiffVersion = diffVersion
+ return false
+ }
+ if (diffVersion !== lastDiffVersion) {
+ lastDiffVersion = diffVersion
+ if (userHasEdits) {
+ state = "detected"
+ return true
+ }
+ }
+ return false
+ },
+ dismiss() {
+ state = "none"
+ },
+ reload() {
+ state = "none"
+ },
+ }
+}
+
+describe("Concurrent edit detection", () => {
+ test("starts in none state", () => {
+ const det = createConcurrentEditDetector()
+ expect(det.state).toBe("none")
+ })
+
+ test("first diff update sets baseline, no detection", () => {
+ const det = createConcurrentEditDetector()
+ const detected = det.onDiffUpdate("v1")
+ expect(detected).toBe(false)
+ expect(det.state).toBe("none")
+ })
+
+ test("same diff version = no detection", () => {
+ const det = createConcurrentEditDetector()
+ det.onDiffUpdate("v1")
+ det.setUserHasEdits(true)
+ const detected = det.onDiffUpdate("v1")
+ expect(detected).toBe(false)
+ })
+
+ test("new diff version with user edits = detected", () => {
+ const det = createConcurrentEditDetector()
+ det.onDiffUpdate("v1")
+ det.setUserHasEdits(true)
+ const detected = det.onDiffUpdate("v2")
+ expect(detected).toBe(true)
+ expect(det.state).toBe("detected")
+ })
+
+ test("new diff version without user edits = silent update", () => {
+ const det = createConcurrentEditDetector()
+ det.onDiffUpdate("v1")
+ det.setUserHasEdits(false)
+ const detected = det.onDiffUpdate("v2")
+ expect(detected).toBe(false)
+ expect(det.state).toBe("none")
+ })
+
+ test("dismiss clears detected state", () => {
+ const det = createConcurrentEditDetector()
+ det.onDiffUpdate("v1")
+ det.setUserHasEdits(true)
+ det.onDiffUpdate("v2")
+ expect(det.state).toBe("detected")
+
+ det.dismiss()
+ expect(det.state).toBe("none")
+ })
+
+ test("reload clears detected state", () => {
+ const det = createConcurrentEditDetector()
+ det.onDiffUpdate("v1")
+ det.setUserHasEdits(true)
+ det.onDiffUpdate("v2")
+
+ det.reload()
+ expect(det.state).toBe("none")
+ })
+})
diff --git a/packages/extension/test/sidebar_view.test.ts b/packages/extension/test/sidebar_view.test.ts
index 1cbbad36..419c391e 100644
--- a/packages/extension/test/sidebar_view.test.ts
+++ b/packages/extension/test/sidebar_view.test.ts
@@ -1238,6 +1238,19 @@ describe("sidebar webview — context menu", () => {
expect(src).toContain("Open in Terminal");
});
+ it("context menu shows 'Restore' for ghost (deleted) entries instead of normal file ops", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_webview.ts"),
+ "utf8",
+ );
+ // Reads gitStatus from the data attribute to detect ghost entries
+ expect(src).toMatch(/dataset\.gitStatus\s*===\s*"deleted"/);
+ // Shows "Restore" for ghost entries
+ expect(src).toContain("Restore");
+ // The restore op
+ expect(src).toContain('"restore"');
+ });
+
it("sidebar_view.ts CSS includes context-menu styling", () => {
const provider = new SidebarViewProvider(makeExtensionUri());
const view = makeWebviewView();
@@ -1634,6 +1647,15 @@ describe("sidebar webview — drag and drop", () => {
expect(req.op).toBe("move");
expect(req.targetDir).toBe("/lib");
});
+
+ it("FileOpRequest type includes restore op for git-deleted ghost entries", async () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_bridge.ts"),
+ "utf8",
+ );
+ // The op union should include "restore"
+ expect(src).toMatch(/"restore"/);
+ });
});
// ── Git status colors (#673) ─────────────────────────────────────────────────
@@ -1669,6 +1691,20 @@ describe("sidebar — git status colors", () => {
expect(html).toContain("--vscode-gitDecoration-untrackedResourceForeground");
});
+ it("CSS suppresses strikethrough on directory nodes with git-deleted status", async () => {
+ const { SidebarViewProvider } = await import("../src/sidebar_view");
+ const provider = new SidebarViewProvider(makeExtensionUri());
+ const view = makeWebviewView();
+ provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) });
+ const html = view.webview.html;
+
+ // Deleted files get strikethrough
+ expect(html).toContain(".git-deleted");
+ expect(html).toMatch(/\.git-deleted\s*\{[^}]*text-decoration:\s*line-through/);
+ // But directories do NOT — the override rule suppresses it
+ expect(html).toMatch(/\[data-type="directory"\].*\.git-deleted\s*\{[^}]*text-decoration:\s*none/);
+ });
+
it("webview applies git status CSS classes to file labels", () => {
const src = readFileSync(
resolve(__dirname, "..", "src", "sidebar_webview.ts"),
@@ -1679,6 +1715,17 @@ describe("sidebar — git status colors", () => {
expect(src).toMatch(/label\.classList\.add.*git-/);
});
+ it("renderFileNode stores gitStatus as a data attribute for context menu detection", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_webview.ts"),
+ "utf8",
+ );
+ // The file node's DOM element should carry data-git-status when gitStatus is set
+ expect(src).toContain("dataset.gitStatus");
+ // Should be set from entry.gitStatus
+ expect(src).toMatch(/dataset\.gitStatus\s*=\s*entry\.gitStatus/);
+ });
+
it("sidebar_view.ts annotates children with git status from the Git extension", () => {
const src = readFileSync(
resolve(__dirname, "..", "src", "sidebar_view.ts"),
@@ -1692,6 +1739,19 @@ describe("sidebar — git status colors", () => {
// Walks workingTreeChanges and indexChanges
expect(src).toContain("workingTreeChanges");
expect(src).toContain("indexChanges");
+ // Injects ghost entries for deleted files before annotation
+ expect(src).toContain("injectDeletedEntries");
+ });
+
+ it("annotateGitStatus receives the parent directory path so it can inject deleted ghost entries", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_view.ts"),
+ "utf8",
+ );
+ // annotateGitStatus should accept a parentDir parameter
+ expect(src).toMatch(/annotateGitStatus\(entries,\s*\w+/);
+ // The getChildren call site should pass the directory path
+ expect(src).toMatch(/annotateGitStatus\(entries,\s*p\)/);
});
it("propagateGitStatusToDirs gives directories the most notable child status", async () => {
@@ -1702,6 +1762,7 @@ describe("sidebar — git status colors", () => {
{ name: "src", type: "directory" as const, path: "/project/src" },
{ name: "docs", type: "directory" as const, path: "/project/docs" },
{ name: "clean", type: "directory" as const, path: "/project/clean" },
+ { name: "trash", type: "directory" as const, path: "/project/trash" },
{ name: "main.ts", type: "file" as const, path: "/project/main.ts", gitStatus: "modified" as const },
];
@@ -1710,6 +1771,7 @@ describe("sidebar — git status colors", () => {
["/project/src/index.ts", "modified"],
["/project/src/util.ts", "untracked"],
["/project/docs/README.md", "added"],
+ ["/project/trash/old.ts", "deleted"],
]);
const result = propagateGitStatusToDirs(entries, statusMap);
@@ -1720,9 +1782,65 @@ describe("sidebar — git status colors", () => {
expect(result.find(e => e.name === "docs")?.gitStatus).toBe("added");
// clean has no changed children → no git status
expect(result.find(e => e.name === "clean")?.gitStatus).toBeUndefined();
- // Files keep their original status
+ // trash has deleted children → gets "deleted" color (CSS prevents strikethrough on dirs)
+ expect(result.find(e => e.name === "trash")?.gitStatus).toBe("deleted");
+ // Files keep their original status
expect(result.find(e => e.name === "main.ts")?.gitStatus).toBe("modified");
});
+
+ it("injectDeletedEntries adds ghost entries for deleted files missing from the directory listing", async () => {
+ vi.resetModules();
+ const { injectDeletedEntries } = await import("../src/sidebar_view");
+
+ // Existing entries from the filesystem — deleted.ts is NOT here (already removed from disk)
+ const entries = [
+ { name: "main.ts", type: "file" as const, path: "/project/src/main.ts" },
+ { name: "util.ts", type: "file" as const, path: "/project/src/util.ts", gitStatus: "modified" as const },
+ ];
+
+ // Git status map includes a deleted file under the same directory
+ const statusMap = new Map([
+ ["/project/src/deleted.ts", "deleted"],
+ ["/project/src/util.ts", "modified"],
+ ]);
+
+ const result = injectDeletedEntries(entries, statusMap, "/project/src");
+
+ // The ghost entry should appear in the result
+ const ghost = result.find(e => e.name === "deleted.ts");
+ expect(ghost).toBeDefined();
+ expect(ghost!.path).toBe("/project/src/deleted.ts");
+ expect(ghost!.type).toBe("file");
+ expect(ghost!.gitStatus).toBe("deleted");
+
+ // Existing entries should still be present and unchanged
+ expect(result.find(e => e.name === "main.ts")).toBeDefined();
+ expect(result.find(e => e.name === "util.ts")?.gitStatus).toBe("modified");
+
+ // No duplicate: util.ts exists on disk, so it shouldn't be injected again
+ expect(result.filter(e => e.name === "util.ts")).toHaveLength(1);
+ });
+
+ it("injectDeletedEntries does not inject files from subdirectories (only direct children)", async () => {
+ vi.resetModules();
+ const { injectDeletedEntries } = await import("../src/sidebar_view");
+
+ const entries = [
+ { name: "index.ts", type: "file" as const, path: "/project/src/index.ts" },
+ ];
+
+ const statusMap = new Map([
+ ["/project/src/deleted.ts", "deleted"], // direct child — should appear
+ ["/project/src/sub/deep.ts", "deleted"], // nested — should NOT appear
+ ["/project/other/file.ts", "deleted"], // different dir — should NOT appear
+ ]);
+
+ const result = injectDeletedEntries(entries, statusMap, "/project/src");
+
+ expect(result.find(e => e.name === "deleted.ts")).toBeDefined();
+ expect(result.find(e => e.name === "deep.ts")).toBeUndefined();
+ expect(result.find(e => e.name === "file.ts")).toBeUndefined();
+ });
});
// ── Reactive git status (#673 — push git changes to webview) ─────────────────
@@ -1906,6 +2024,19 @@ describe("sidebar — reactive git status", () => {
expect(src).toMatch(/classList\.remove|className.*replace|git-/);
});
+ it("applyGitStatus updates dataset.gitStatus on file nodes so click/context-menu guards stay current", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_webview.ts"),
+ "utf8",
+ );
+ // applyGitStatus should set dataset.gitStatus when a status is present
+ // and delete it when the status is cleared (file restored)
+ const applyFn = src.slice(src.indexOf("function applyGitStatus"), src.indexOf("function applyGitStatus") + 1500);
+ expect(applyFn).toContain("dataset.gitStatus");
+ // Should clear it when no status matches (delete operator or empty assignment)
+ expect(applyFn).toMatch(/delete\s+.*dataset\.gitStatus|dataset\.gitStatus\s*=\s*""/);
+ });
+
it("webview applies git status to root-level project nodes too", () => {
const src = readFileSync(
resolve(__dirname, "..", "src", "sidebar_webview.ts"),
@@ -1918,6 +2049,20 @@ describe("sidebar — reactive git status", () => {
expect(src).toContain("startsWith");
});
+ it("webview re-requests children when git-status has orphaned deleted entries with no DOM node", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_webview.ts"),
+ "utf8",
+ );
+ // The git-status handler should detect orphaned deleted entries
+ // (deleted files in the status map with no matching DOM node)
+ // and re-request children for the parent directory so ghost entries appear.
+ // Look for the re-request pattern in the git-status case:
+ expect(src).toContain("orphaned");
+ // Should invalidate cache and re-request children
+ expect(src).toMatch(/get-children/);
+ });
+
it("host pushes git-status after every roots re-render to prevent color loss", async () => {
vi.resetModules();
@@ -2610,3 +2755,354 @@ describe("executeFileOp — delete with confirmation", () => {
(vs.workspace as any).workspaceFolders = [];
});
});
+
+// ── Restore (git checkout) for ghost entries ─────────────────────────────────
+
+describe("executeFileOp — restore ghost entry", () => {
+ it("executeFileOp restore case runs git checkout HEAD to restore a deleted file", async () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_view.ts"),
+ "utf8",
+ );
+ // Should have a "restore" case in executeFileOp
+ expect(src).toMatch(/case\s*"restore"/);
+ // Should run git checkout HEAD --
+ expect(src).toContain("git");
+ expect(src).toContain("checkout");
+ expect(src).toContain("HEAD");
+ });
+});
+
+// ── Ghost entry click suppression ────────────────────────────────────────────
+
+describe("ghost entry click behavior", () => {
+ it("single-clicking a ghost (deleted) file does not post open-file", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_webview.ts"),
+ "utf8",
+ );
+ // The click handler in renderFileNode should check gitStatus before posting open-file
+ // Find the click handler block near "open-file"
+ const openFileIdx = src.indexOf('"open-file"');
+ expect(openFileIdx).toBeGreaterThan(-1);
+ // There should be a gitStatus guard before the open-file post
+ const blockBefore = src.slice(Math.max(0, openFileIdx - 200), openFileIdx);
+ expect(blockBefore).toContain("gitStatus");
+ });
+
+ it("click guard reads dataset.gitStatus from the DOM at click time, not from a closure", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_webview.ts"),
+ "utf8",
+ );
+ // Find the click handler near "open-file"
+ const openFileIdx = src.indexOf('"open-file"');
+ const blockBefore = src.slice(Math.max(0, openFileIdx - 300), openFileIdx);
+ // Must read from row.dataset.gitStatus (DOM), not entry.gitStatus (closure)
+ expect(blockBefore).toContain("row.dataset.gitStatus");
+ expect(blockBefore).not.toContain("entry.gitStatus");
+ });
+});
+
+// ── Root-reorder drop handler: non-root files must pass through ──────────────
+
+describe("root-reorder drop handler propagation", () => {
+ it("does NOT call stopImmediatePropagation before confirming the source is a root", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_webview.ts"),
+ "utf8",
+ );
+ // Find the setupRootReorderDropTarget function's drop handler
+ const fnStart = src.indexOf("function setupRootReorderDropTarget");
+ expect(fnStart).toBeGreaterThan(-1);
+ const fnBody = src.slice(fnStart, fnStart + 3500);
+
+ // Find the drop listener within this function (skip "dragleave"/"dragover")
+ const dropIdx = fnBody.indexOf('"drop"');
+ expect(dropIdx).toBeGreaterThan(-1);
+ const dropBody = fnBody.slice(dropIdx, dropIdx + 600);
+
+ // stopImmediatePropagation must come AFTER the sourceRoot check,
+ // not before it — otherwise non-root file drops are silently swallowed
+ const stopIdx = dropBody.indexOf("stopImmediatePropagation");
+ const rootCheckIdx = dropBody.indexOf("currentRoots.find");
+ expect(stopIdx).toBeGreaterThan(-1);
+ expect(rootCheckIdx).toBeGreaterThan(-1);
+ expect(rootCheckIdx).toBeLessThan(stopIdx);
+ });
+});
+
+// ── Lane 2 relay allowlist for file-op-notify ────────────────────────────────
+
+describe("chat panel Lane 2 relay", () => {
+ it("allowlists file-op-notify in both relay scripts so sidebar file moves reach the session page", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "chat_panel.ts"),
+ "utf8",
+ );
+ // There are two relay scripts (renderHtml and renderTransitionHtml),
+ // each must include file-op-notify in their allowlist
+ const matches = src.match(/file-op-notify/g);
+ expect(matches).toBeDefined();
+ expect(matches!.length).toBeGreaterThanOrEqual(2);
+ });
+});
+
+// ── File-op-notify: sidebar notifies chat panel after move/rename ─────────────
+
+describe("file-op-notify for Files Changed tracking", () => {
+ it("executeFileOp returns newPath for move operations", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_view.ts"),
+ "utf8",
+ );
+ // Find the move case in executeFileOp
+ const moveCase = src.indexOf('case "move"');
+ expect(moveCase).toBeGreaterThan(-1);
+ const moveBlock = src.slice(moveCase, moveCase + 800);
+ // Must return newPath so the bridge can notify the session page
+ expect(moveBlock).toContain("newPath");
+ });
+
+ it("executeFileOp returns newPath for rename operations", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_view.ts"),
+ "utf8",
+ );
+ // Find the rename case in executeFileOp
+ const renameCase = src.indexOf('case "rename"');
+ expect(renameCase).toBeGreaterThan(-1);
+ const renameBlock = src.slice(renameCase, renameCase + 1000);
+ expect(renameBlock).toContain("newPath");
+ });
+
+ it("sidebar_bridge posts file-op-notify to the chat panel after a successful move/rename", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_bridge.ts"),
+ "utf8",
+ );
+ expect(src).toContain("file-op-notify");
+ expect(src).toContain("oldPath");
+ expect(src).toContain("newPath");
+ });
+
+ it("notifyFileMove sends os.homedir() so the browser iframe can normalize paths", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_view.ts"),
+ "utf8",
+ );
+ const notifyBlock = src.slice(src.indexOf("notifyFileMove"), src.indexOf("notifyFileMove") + 400);
+ expect(notifyBlock).toContain("homedir");
+ });
+});
+
+// ── Drag auto-scroll ─────────────────────────────────────────────────────────
+
+describe("drag auto-scroll", () => {
+ it("all three dragover handlers call handleDragAutoScroll", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_webview.ts"),
+ "utf8",
+ );
+ // Each of the three setup functions should call handleDragAutoScroll in their dragover
+ for (const fn of ["setupDirectoryDropTarget", "setupFileDropTarget", "setupRootReorderDropTarget"]) {
+ const fnStart = src.indexOf(`function ${fn}`);
+ expect(fnStart, `${fn} exists`).toBeGreaterThan(-1);
+ const fnBody = src.slice(fnStart, fnStart + 2500);
+ expect(fnBody, `${fn} calls handleDragAutoScroll`).toContain("handleDragAutoScroll");
+ }
+ });
+
+ it("clearAutoScroll is called on dragend and in clearDropTarget", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_webview.ts"),
+ "utf8",
+ );
+ // dragend listener should clear auto-scroll
+ const dragendIdx = src.indexOf('"dragend"');
+ expect(dragendIdx).toBeGreaterThan(-1);
+ const dragendBlock = src.slice(dragendIdx, dragendIdx + 300);
+ expect(dragendBlock).toContain("clearAutoScroll");
+
+ // clearDropTarget should also clear auto-scroll
+ const clearDropIdx = src.indexOf("function clearDropTarget");
+ expect(clearDropIdx).toBeGreaterThan(-1);
+ const clearDropBlock = src.slice(clearDropIdx, clearDropIdx + 300);
+ expect(clearDropBlock).toContain("clearAutoScroll");
+ });
+});
+
+// ── Sidebar flicker prevention ───────────────────────────────────────────────
+
+describe("sidebar — flicker prevention", () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("filesystem watcher debounces rapid events into a single fs-changed per folder", async () => {
+ vi.useFakeTimers();
+ vi.resetModules();
+
+ // Wire a mock createFileSystemWatcher that captures event handlers
+ const fsCbs: { create: Array<(uri: any) => void>; change: Array<(uri: any) => void>; del: Array<(uri: any) => void> } = {
+ create: [], change: [], del: [],
+ };
+ const vscodeMock = await import("vscode");
+ (vscodeMock.workspace as any).createFileSystemWatcher = () => ({
+ onDidCreate: (cb: (uri: any) => void) => { fsCbs.create.push(cb); return { dispose() {} }; },
+ onDidChange: (cb: (uri: any) => void) => { fsCbs.change.push(cb); return { dispose() {} }; },
+ onDidDelete: (cb: (uri: any) => void) => { fsCbs.del.push(cb); return { dispose() {} }; },
+ dispose() {},
+ });
+ // Set up a workspace folder so getWorkspaceFolder resolves
+ (vscodeMock.workspace as any).workspaceFolders = [
+ { uri: { fsPath: "/project" }, name: "project", index: 0 },
+ ];
+
+ const { SidebarViewProvider } = await import("../src/sidebar_view");
+ const provider = new SidebarViewProvider(makeExtensionUri());
+ const view = makeWebviewView();
+ provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) });
+ (view.webview.postMessage as any).mockClear();
+
+ // Fire 10 rapid file-change events (simulating a git checkout)
+ for (let i = 0; i < 10; i++) {
+ for (const cb of fsCbs.change) cb({ fsPath: `/project/src/file${i}.ts` });
+ }
+
+ // Within the debounce window, no fs-changed should have been sent
+ const callsBefore = (view.webview.postMessage as any).mock.calls
+ .filter((c: any[]) => c[0]?.kind === "fs-changed");
+ expect(callsBefore).toHaveLength(0);
+
+ // Advance past the debounce window
+ vi.advanceTimersByTime(350);
+
+ // Exactly one fs-changed for the /project folder
+ const callsAfter = (view.webview.postMessage as any).mock.calls
+ .filter((c: any[]) => c[0]?.kind === "fs-changed");
+ expect(callsAfter).toHaveLength(1);
+ expect(callsAfter[0][0].folder).toBe("/project");
+
+ // Restore
+ (vscodeMock.workspace as any).workspaceFolders = [];
+ });
+
+ it("filesystem watcher coalesces events from multiple folders", async () => {
+ vi.useFakeTimers();
+ vi.resetModules();
+
+ const fsCbs: { create: Array<(uri: any) => void>; change: Array<(uri: any) => void>; del: Array<(uri: any) => void> } = {
+ create: [], change: [], del: [],
+ };
+ const vscodeMock = await import("vscode");
+ (vscodeMock.workspace as any).createFileSystemWatcher = () => ({
+ onDidCreate: (cb: (uri: any) => void) => { fsCbs.create.push(cb); return { dispose() {} }; },
+ onDidChange: (cb: (uri: any) => void) => { fsCbs.change.push(cb); return { dispose() {} }; },
+ onDidDelete: (cb: (uri: any) => void) => { fsCbs.del.push(cb); return { dispose() {} }; },
+ dispose() {},
+ });
+ (vscodeMock.workspace as any).workspaceFolders = [
+ { uri: { fsPath: "/project-a" }, name: "a", index: 0 },
+ { uri: { fsPath: "/project-b" }, name: "b", index: 1 },
+ ];
+
+ const { SidebarViewProvider } = await import("../src/sidebar_view");
+ const provider = new SidebarViewProvider(makeExtensionUri());
+ const view = makeWebviewView();
+ provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) });
+ (view.webview.postMessage as any).mockClear();
+
+ // Interleave events from two folders
+ for (const cb of fsCbs.change) {
+ cb({ fsPath: "/project-a/file1.ts" });
+ cb({ fsPath: "/project-b/file1.ts" });
+ cb({ fsPath: "/project-a/file2.ts" });
+ }
+
+ vi.advanceTimersByTime(350);
+
+ const fsMsgs = (view.webview.postMessage as any).mock.calls
+ .filter((c: any[]) => c[0]?.kind === "fs-changed");
+ // One message per folder, not per event
+ expect(fsMsgs).toHaveLength(2);
+ const folders = fsMsgs.map((c: any[]) => c[0].folder).sort();
+ expect(folders).toEqual(["/project-a", "/project-b"]);
+
+ (vscodeMock.workspace as any).workspaceFolders = [];
+ });
+
+ it("dispose clears the filesystem debounce timer so it does not fire into a dead webview", async () => {
+ vi.useFakeTimers();
+ vi.resetModules();
+
+ const fsCbs: { change: Array<(uri: any) => void> } = { change: [] };
+ const vscodeMock = await import("vscode");
+ (vscodeMock.workspace as any).createFileSystemWatcher = () => ({
+ onDidCreate: () => ({ dispose() {} }),
+ onDidChange: (cb: (uri: any) => void) => { fsCbs.change.push(cb); return { dispose() {} }; },
+ onDidDelete: () => ({ dispose() {} }),
+ dispose() {},
+ });
+ (vscodeMock.workspace as any).workspaceFolders = [
+ { uri: { fsPath: "/project" }, name: "project", index: 0 },
+ ];
+
+ const { SidebarViewProvider } = await import("../src/sidebar_view");
+ const provider = new SidebarViewProvider(makeExtensionUri());
+ const view = makeWebviewView();
+ provider.resolveWebviewView(view, {}, { isCancellationRequested: false, onCancellationRequested: () => ({ dispose() {} }) });
+ (view.webview.postMessage as any).mockClear();
+
+ // Fire an event, starting the debounce timer
+ for (const cb of fsCbs.change) cb({ fsPath: "/project/file.ts" });
+
+ // Dispose before the timer fires
+ for (const cb of view._disposeCbs) cb();
+
+ // Advance past the debounce window
+ vi.advanceTimersByTime(350);
+
+ // The fs-changed message should NOT have been sent (timer was cleared)
+ const fsMsgs = (view.webview.postMessage as any).mock.calls
+ .filter((c: any[]) => c[0]?.kind === "fs-changed");
+ expect(fsMsgs).toHaveLength(0);
+
+ (vscodeMock.workspace as any).workspaceFolders = [];
+ });
+
+ it("section-order handler skips re-render when order is unchanged", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "sidebar_webview.ts"),
+ "utf8",
+ );
+ // The section-order message handler should compare incoming order with
+ // currentSectionOrder before calling renderRoots, to avoid a redundant
+ // full DOM wipe on sidebar show and section-reorder echo.
+ const handlerStart = src.indexOf('case "section-order"');
+ expect(handlerStart).toBeGreaterThan(-1);
+ const handlerBlock = src.slice(handlerStart, handlerStart + 800);
+ // Must check equality before rendering — look for a comparison guard
+ expect(handlerBlock).toMatch(/currentSectionOrder/);
+ // The guard must come BEFORE renderRoots — find both positions
+ const guardIdx = handlerBlock.indexOf("currentSectionOrder");
+ const renderIdx = handlerBlock.indexOf("renderRoots");
+ expect(renderIdx).toBeGreaterThan(-1);
+ // There must be an early-exit/skip before the renderRoots call
+ expect(handlerBlock).toMatch(/JSON\.stringify|every|===.*break|return/);
+ });
+
+ it("sidebar webview is registered with retainContextWhenHidden", () => {
+ const src = readFileSync(
+ resolve(__dirname, "..", "src", "extension.ts"),
+ "utf8",
+ );
+ // Find the registerWebviewViewProvider call for the sidebar
+ const regIdx = src.indexOf('registerWebviewViewProvider("amicode.workspace"');
+ expect(regIdx).toBeGreaterThan(-1);
+ // The third argument should include retainContextWhenHidden: true
+ const regBlock = src.slice(regIdx, regIdx + 200);
+ expect(regBlock).toContain("retainContextWhenHidden");
+ expect(regBlock).toContain("true");
+ });
+});