diff --git a/packages/app/src/components/session/session-preview-tab.tsx b/packages/app/src/components/session/session-preview-tab.tsx index 2da318a1a..787df2000 100644 --- a/packages/app/src/components/session/session-preview-tab.tsx +++ b/packages/app/src/components/session/session-preview-tab.tsx @@ -57,15 +57,37 @@ export function SessionPreviewTab(props: { const seen = new Set() const entries: PreviewFileEntry[] = [] + // Build a suffix→absolute map from touchedFiles so we can resolve ~/... paths from diffs + const touched = props.touchedFiles?.() ?? [] + const suffixToAbsolute = new Map() + for (const t of touched) { + // For "/Users/jj/.julia/dev/X", try progressively shorter suffixes + const parts = t.file.split("/") + for (let i = 1; i < parts.length; i++) { + suffixToAbsolute.set(parts.slice(i).join("/"), t.file) + } + } + + // Resolve ~/... to absolute using the suffix map + const resolveFile = (file: string): string => { + if (!file.startsWith("~/")) return file + const suffix = file.slice(2) // strip ~/ + const absolute = suffixToAbsolute.get(suffix) + return absolute ?? file + } + const toEntry = (file: string, status: string): PreviewFileEntry | null => { if (!file.endsWith(".md")) return null - if (seen.has(file)) return null - seen.add(file) - const parts = file.split("/") + // Use the resolved absolute path as the canonical key + const resolved = resolveFile(file) + if (seen.has(resolved)) return null + seen.add(resolved) + const parts = resolved.split("/") const basename = parts[parts.length - 1] - const relativePath = file.startsWith("~/") ? file.slice(2) : file + // Show a short relative path: strip common leading segments until we hit a recognizable dir + const relativePath = resolved.replace(/^\/Users\/[^/]+\//, "") return { - path: file, + path: resolved, relativePath, basename, extension: ".md" as const, @@ -74,12 +96,9 @@ export function SessionPreviewTab(props: { } // Primary: all files touched by edit tools in this session - const touched = props.touchedFiles?.() - if (touched) { - for (const t of touched) { - const entry = toEntry(t.file, t.status) - if (entry) entries.push(entry) - } + for (const t of touched) { + const entry = toEntry(t.file, t.status) + if (entry) entries.push(entry) } // Supplement: any diff files not already in touchedFiles diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index c92515857..7f8303b95 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -231,6 +231,8 @@ export const dict = { "prompt.mode.shell.exit": "esc للخروج", "session.child.promptDisabled": "لا يمكن إرسال موجّهات إلى جلسات الوكلاء الفرعيين.", "session.child.backToParent": "العودة إلى الجلسة الرئيسية.", + "session.archived.readOnly": "هذه الجلسة مؤرشفة وللقراءة فقط.", + "session.archived.unarchive": "إلغاء الأرشفة للاستئناف.", "prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية", "prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟", "prompt.example.3": "إصلاح الاختبارات المعطلة", @@ -658,6 +660,7 @@ export const dict = { "common.rename": "إعادة تسمية", "common.reset": "إعادة تعيين", "common.archive": "أرشفة", + "common.unarchive": "إلغاء الأرشفة", "common.delete": "حذف", "common.close": "إغلاق", "common.edit": "تحرير", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 02221d3d6..2531546b7 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -231,6 +231,8 @@ export const dict = { "prompt.mode.shell.exit": "esc para sair", "session.child.promptDisabled": "Não é possível enviar prompts em sessões de subagentes.", "session.child.backToParent": "Voltar à sessão principal.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "Corrigir um TODO no código", "prompt.example.2": "Qual é a stack tecnológica deste projeto?", "prompt.example.3": "Corrigir testes quebrados", @@ -667,6 +669,7 @@ export const dict = { "common.rename": "Renomear", "common.reset": "Redefinir", "common.archive": "Arquivar", + "common.unarchive": "Unarchive", "common.delete": "Excluir", "common.close": "Fechar", "common.edit": "Editar", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index e8ab3999f..70255e867 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -249,6 +249,8 @@ export const dict = { "prompt.mode.shell.exit": "esc za izlaz", "session.child.promptDisabled": "Sesijama podagenta nije moguće slati upite.", "session.child.backToParent": "Nazad na glavnu sesiju.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "Popravi TODO u bazi koda", "prompt.example.2": "Koji je tehnološki stack ovog projekta?", @@ -727,6 +729,7 @@ export const dict = { "common.rename": "Preimenuj", "common.reset": "Resetuj", "common.archive": "Arhiviraj", + "common.unarchive": "Unarchive", "common.delete": "Izbriši", "common.close": "Zatvori", "common.edit": "Uredi", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 4413ef83e..895b3bbe7 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -247,6 +247,8 @@ export const dict = { "prompt.mode.shell.exit": "esc for at afslutte", "session.child.promptDisabled": "Der kan ikke sendes prompts til underagentsessioner.", "session.child.backToParent": "Tilbage til hovedsessionen.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "Ret en TODO i koden", "prompt.example.2": "Hvad er teknologistakken for dette projekt?", @@ -723,6 +725,7 @@ export const dict = { "common.rename": "Omdøb", "common.reset": "Nulstil", "common.archive": "Arkivér", + "common.unarchive": "Unarchive", "common.delete": "Slet", "common.close": "Luk", "common.edit": "Rediger", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 93a6e1a6c..c830787a2 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -236,6 +236,8 @@ export const dict = { "prompt.mode.shell.exit": "esc zum Verlassen", "session.child.promptDisabled": "Unteragenten-Sitzungen können keine Prompts erhalten.", "session.child.backToParent": "Zurück zur Hauptsitzung.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "Ein TODO in der Codebasis beheben", "prompt.example.2": "Was ist der Tech-Stack dieses Projekts?", "prompt.example.3": "Fehlerhafte Tests beheben", @@ -676,6 +678,7 @@ export const dict = { "common.rename": "Umbenennen", "common.reset": "Zurücksetzen", "common.archive": "Archivieren", + "common.unarchive": "Unarchive", "common.delete": "Löschen", "common.close": "Schließen", "common.edit": "Bearbeiten", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index f28ba1520..4c8da2695 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -248,6 +248,8 @@ export const dict = { "prompt.mode.shell.exit": "esc para salir", "session.child.promptDisabled": "No se pueden enviar prompts a las sesiones de subagentes.", "session.child.backToParent": "Volver a la sesión principal.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "Arreglar un TODO en el código", "prompt.example.2": "¿Cuál es el stack tecnológico de este proyecto?", @@ -730,6 +732,7 @@ export const dict = { "common.rename": "Renombrar", "common.reset": "Restablecer", "common.archive": "Archivar", + "common.unarchive": "Unarchive", "common.delete": "Eliminar", "common.close": "Cerrar", "common.edit": "Editar", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 46ea6b22b..fbd039525 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -231,6 +231,8 @@ export const dict = { "prompt.mode.shell.exit": "esc pour quitter", "session.child.promptDisabled": "Les sessions de sous-agents ne peuvent pas recevoir de messages.", "session.child.backToParent": "Revenir à la session principale.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "Corriger un TODO dans la base de code", "prompt.example.2": "Quelle est la pile technique de ce projet ?", "prompt.example.3": "Réparer les tests échoués", @@ -672,6 +674,7 @@ export const dict = { "common.rename": "Renommer", "common.reset": "Réinitialiser", "common.archive": "Archiver", + "common.unarchive": "Unarchive", "common.delete": "Supprimer", "common.close": "Fermer", "common.edit": "Modifier", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index c2f55a823..c83d07c29 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -230,6 +230,8 @@ export const dict = { "prompt.mode.shell.exit": "escで終了", "session.child.promptDisabled": "サブエージェントのセッションにはプロンプトを送信できません。", "session.child.backToParent": "メインセッションに戻る。", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "コードベースのTODOを修正", "prompt.example.2": "このプロジェクトの技術スタックは何ですか?", "prompt.example.3": "壊れたテストを修正", @@ -663,6 +665,7 @@ export const dict = { "common.rename": "名前変更", "common.reset": "リセット", "common.archive": "アーカイブ", + "common.unarchive": "Unarchive", "common.delete": "削除", "common.close": "閉じる", "common.edit": "編集", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 0d367f9bb..452335b40 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -541,6 +541,7 @@ export const dict = { "common.rename": "이름 바꾸기", "common.reset": "초기화", "common.archive": "보관", + "common.unarchive": "Unarchive", "common.delete": "삭제", "common.close": "닫기", "common.edit": "편집", @@ -870,6 +871,8 @@ export const dict = { "common.clear": "지우기", "session.child.promptDisabled": "하위 에이전트 세션에는 프롬프트를 입력할 수 없습니다.", "session.child.backToParent": "메인 세션으로 돌아가기.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "wsl.server.add": "WSL 서버 추가", "wsl.server.addShort": "WSL 추가", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 1155137db..0166636af 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -610,6 +610,7 @@ export const dict = { "common.rename": "Gi nytt navn", "common.reset": "Tilbakestill", "common.archive": "Arkiver", + "common.unarchive": "Unarchive", "common.delete": "Slett", "common.close": "Lukk", "common.edit": "Rediger", @@ -961,6 +962,8 @@ export const dict = { "session.child.promptDisabled": "Underagentsesjoner kan ikke motta forespørsler.", "session.child.backToParent": "Tilbake til hovedsesjonen.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "wsl.server.add": "Legg til WSL-server", "wsl.server.addShort": "Legg til WSL", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 36ad1437b..11a601e34 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -232,6 +232,8 @@ export const dict = { "prompt.mode.shell.exit": "esc aby wyjść", "session.child.promptDisabled": "Nie można wysyłać promptów w sesjach podagentów.", "session.child.backToParent": "Wróć do głównej sesji.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "Napraw TODO w bazie kodu", "prompt.example.2": "Jaki jest stos technologiczny tego projektu?", "prompt.example.3": "Napraw zepsute testy", @@ -667,6 +669,7 @@ export const dict = { "common.rename": "Zmień nazwę", "common.reset": "Resetuj", "common.archive": "Archiwizuj", + "common.unarchive": "Unarchive", "common.delete": "Usuń", "common.close": "Zamknij", "common.edit": "Edytuj", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d1607edfc..c2aa58b58 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -248,6 +248,8 @@ export const dict = { "prompt.mode.shell.exit": "esc для выхода", "session.child.promptDisabled": "В сессиях субагентов нельзя отправлять запросы.", "session.child.backToParent": "Вернуться к основной сессии.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "Исправить TODO в коде", "prompt.example.2": "Какой технологический стек этого проекта?", @@ -727,6 +729,7 @@ export const dict = { "common.rename": "Переименовать", "common.reset": "Сбросить", "common.archive": "Архивировать", + "common.unarchive": "Unarchive", "common.delete": "Удалить", "common.close": "Закрыть", "common.edit": "Редактировать", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 5ca6bb8b6..36cf63889 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -248,6 +248,8 @@ export const dict = { "prompt.mode.shell.exit": "กด esc เพื่อออก", "session.child.promptDisabled": "ไม่สามารถส่งพรอมต์ในเซสชันเอเจนต์ย่อยได้.", "session.child.backToParent": "กลับไปยังเซสชันหลัก.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "แก้ไข TODO ในโค้ดเบส", "prompt.example.2": "เทคโนโลยีของโปรเจกต์นี้คืออะไร?", @@ -720,6 +722,7 @@ export const dict = { "common.rename": "เปลี่ยนชื่อ", "common.reset": "รีเซ็ต", "common.archive": "จัดเก็บ", + "common.unarchive": "Unarchive", "common.delete": "ลบ", "common.close": "ปิด", "common.edit": "แก้ไข", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 56e7a5b3c..a37f871d4 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -253,6 +253,8 @@ export const dict = { "prompt.mode.shell.exit": "çıkmak için esc", "session.child.promptDisabled": "Alt ajan oturumlarına istem gönderilemez.", "session.child.backToParent": "Ana oturuma dön.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "Kod tabanındaki bir TODO'yu düzelt", "prompt.example.2": "Bu projenin teknoloji yığını nedir?", @@ -732,6 +734,7 @@ export const dict = { "common.rename": "Yeniden adlandır", "common.reset": "Sıfırla", "common.archive": "Arşivle", + "common.unarchive": "Unarchive", "common.delete": "Sil", "common.close": "Kapat", "common.edit": "Düzenle", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index afe2045d6..db77bd1aa 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -250,6 +250,8 @@ export const dict = { "prompt.mode.shell.exit": "esc для виходу", "session.child.promptDisabled": "Сесії підагентів не можна надсилати запити.", "session.child.backToParent": "Назад до основної сесії.", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "Виправити TODO у коді", "prompt.example.2": "Який технологічний стек цього проєкту?", @@ -777,6 +779,7 @@ export const dict = { "common.rename": "Перейменувати", "common.reset": "Скинути", "common.archive": "Архівувати", + "common.unarchive": "Unarchive", "common.delete": "Видалити", "common.close": "Закрити", "common.edit": "Редагувати", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index a71f7d25e..6b8aed0e6 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -270,6 +270,8 @@ export const dict = { "prompt.mode.shell.exit": "按 esc 退出", "session.child.promptDisabled": "无法向子智能体会话发送提示词。", "session.child.backToParent": "返回主会话。", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "修复代码库中的一个 TODO", "prompt.example.2": "这个项目的技术栈是什么?", "prompt.example.3": "修复失败的测试", @@ -717,6 +719,7 @@ export const dict = { "common.rename": "重命名", "common.reset": "重置", "common.archive": "归档", + "common.unarchive": "Unarchive", "common.delete": "删除", "common.close": "关闭", "common.edit": "编辑", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index b376dd71d..a933b588e 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -248,6 +248,8 @@ export const dict = { "prompt.mode.shell.exit": "按 esc 退出", "session.child.promptDisabled": "無法在子代理程式工作階段中輸入提示詞。", "session.child.backToParent": "返回主要工作階段。", + "session.archived.readOnly": "This session is archived and read-only.", + "session.archived.unarchive": "Unarchive to resume.", "prompt.example.1": "修復程式碼庫中的一個 TODO", "prompt.example.2": "這個專案的技術堆疊是什麼?", @@ -714,6 +716,7 @@ export const dict = { "common.rename": "重新命名", "common.reset": "重設", "common.archive": "封存", + "common.unarchive": "Unarchive", "common.delete": "刪除", "common.close": "關閉", "common.edit": "編輯", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cd9bd284e..c53217564 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -97,6 +97,7 @@ import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" +import { authTokenFromCredentials } from "@/utils/server" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { postRouteInfo } from "@/utils/amicode-route-info" @@ -740,17 +741,42 @@ export default function Page() { return accumulateDiffs(editParts) }) - // All files touched by edit tools in this session — independent of git state. - // The Preview tab uses this so documents remain visible even after being committed/reverted. + // All files touched by edit tools in this session — fetched from the server + // which scans ALL messages regardless of client-side pagination. + const touchedFilesQuery = createQuery(() => { + const sessionID = params.id + return { + queryKey: ["session-touched-files", sessionID ?? "", sessionDiffVersion()] as const, + enabled: !!sessionID, + placeholderData: [] as Array<{ file: string; status: string }>, + staleTime: 30_000, + queryFn: sessionID + ? async () => { + const server = serverSDK() + const url = new URL(`/session/${sessionID}/touched-files`, server.url) + url.searchParams.set("directory", sdk().directory) + const headers: Record = {} + const httpServer = server.server.http + if (httpServer.password) { + headers.Authorization = `Basic ${authTokenFromCredentials({ username: httpServer.username, password: httpServer.password })}` + } + const res = await fetch(url.toString(), { headers }) + if (!res.ok) return [] as Array<{ file: string; status: string }> + return (await res.json()) as Array<{ file: string; status: string }> + } + : skipToken, + } + }) const touchedFiles = createMemo(() => { + const serverFiles = touchedFilesQuery.data ?? [] + if (serverFiles.length > 0) { + // Keep absolute paths from the server — the file read endpoint handles them directly. + // Do NOT normalize to ~/ (the webview can't reliably resolve ~ back to $HOME). + return serverFiles.map((f) => ({ file: f.file, status: f.status })) + } + // Fallback: derive from loaded tool parts (covers live edits before server catches up) const allMessages = messages() if (!allMessages.length) return [] as Array<{ file: string; status: string }> - const home = typeof globalThis.process !== "undefined" ? globalThis.process.env?.HOME : undefined - const toHomePath = (p: string) => { - if (p.startsWith("~/")) return p - if (home && p.startsWith(home)) return "~" + p.slice(home.length) - return p - } const seen = new Map() for (const msg of allMessages) { const msgParts = sync().data.part[msg.id] @@ -761,10 +787,8 @@ export default function Page() { const meta = part.state.metadata as Record | undefined const filediff = meta?.filediff as { file?: string } | undefined if (filediff?.file) { - const normalized = toHomePath(filediff.file) - if (!seen.has(normalized)) { - // First touch of this file — check if it was created or modified - seen.set(normalized, part.tool === "write" ? "added" : "modified") + if (!seen.has(filediff.file)) { + seen.set(filediff.file, part.tool === "write" ? "added" : "modified") } } } diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index de5ad54da..6c02288bc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -75,6 +75,11 @@ export const PermissionResponsePayload = Schema.Struct({ response: PermissionV1.Reply, }) +export const TouchedFile = Schema.Struct({ + file: Schema.String, + status: Schema.Literals(["added", "modified"]), +}) + export const SessionPaths = { list: root, status: `${root}/status`, @@ -82,6 +87,7 @@ export const SessionPaths = { children: `${root}/:sessionID/children`, todo: `${root}/:sessionID/todo`, diff: `${root}/:sessionID/diff`, + touchedFiles: `${root}/:sessionID/touched-files`, messages: `${root}/:sessionID/message`, message: `${root}/:sessionID/message/:messageID`, create: root, @@ -176,6 +182,18 @@ export const SessionApi = HttpApi.make("session") description: "Get the file changes (diff) that resulted from a specific user message in the session.", }), ), + HttpApiEndpoint.get("touchedFiles", SessionPaths.touchedFiles, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + success: described(Schema.Array(TouchedFile), "All files touched by edit tools in this session"), + error: ApiNotFoundError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.touchedFiles", + summary: "Get touched files", + description: "Get all files that were edited or written by tools in this session, regardless of current git state.", + }), + ), HttpApiEndpoint.get("messages", SessionPaths.messages, { params: { sessionID: SessionID }, query: MessagesQuery, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 4ffe5d922..0fc56ba55 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -103,6 +103,30 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return yield* session.diff(ctx.params.sessionID) }) + const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply_patch"]) + + const touchedFiles = Effect.fn("SessionHttpApi.touchedFiles")(function* (ctx: { + params: { sessionID: SessionID } + }) { + yield* requireSession(ctx.params.sessionID) + const allMessages = yield* SessionError.mapStorageNotFound( + session.messages({ sessionID: ctx.params.sessionID }), + ) + const seen = new Map() + for (const msg of allMessages) { + for (const part of msg.parts) { + if (part.type !== "tool" || !EDIT_TOOLS.has(part.tool)) continue + if (part.state.status !== "completed") continue + const meta = part.state.metadata as Record | undefined + const filediff = meta?.filediff as { file?: string } | undefined + if (filediff?.file && !seen.has(filediff.file)) { + seen.set(filediff.file, part.tool === "write" ? "added" : "modified") + } + } + } + return Array.from(seen, ([file, status]) => ({ file, status })) + }) + const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: { params: { sessionID: SessionID } query: typeof MessagesQuery.Type @@ -417,6 +441,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", .handle("children", children) .handle("todo", todo) .handle("diff", diff) + .handle("touchedFiles", touchedFiles) .handle("messages", messages) .handle("message", message) .handleRaw("create", createRaw) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aa..2536ea182 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -68,6 +68,8 @@ import type { FileReadResponses, FileStatusErrors, FileStatusResponses, + FileWriteErrors, + FileWriteResponses, FindFilesErrors, FindFilesResponses, FindSymbolsErrors, @@ -217,12 +219,15 @@ import type { SessionSummarizeResponses, SessionTodoErrors, SessionTodoResponses, + SessionTouchedFilesErrors, + SessionTouchedFilesResponses, SessionUnrevertErrors, SessionUnrevertResponses, SessionUnshareErrors, SessionUnshareResponses, SessionUpdateErrors, SessionUpdateResponses, + SkillPartInput, SubtaskPartInput, SyncHistoryListErrors, SyncHistoryListResponses, @@ -1920,6 +1925,45 @@ export class File extends HeyApiClient { ...params, }) } + + /** + * Write file + * + * Write content to a file on disk. Used by the Preview tab's raw editor. + */ + public write( + parameters?: { + directory?: string + workspace?: string + path?: string + content?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "path" }, + { in: "body", key: "content" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/file/write", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } } export class Instance extends HeyApiClient { @@ -3698,6 +3742,38 @@ export class Session2 extends HeyApiClient { }) } + /** + * Get touched files + * + * Get all files that were edited or written by tools in this session, regardless of current git state. + */ + public touchedFiles( + parameters: { + sessionID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/touched-files", + ...options, + ...params, + }) + } + /** * Get session messages * @@ -3757,7 +3833,7 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string - parts?: Array + parts?: Array }, options?: Options, ) { @@ -4110,7 +4186,7 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string - parts?: Array + parts?: Array }, options?: Options, ) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 747823883..37ff1cb91 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2743,7 +2743,6 @@ export type PromptInput = { text: string files?: Array agents?: Array - skills?: Array } export type ConflictError = { @@ -4027,7 +4026,6 @@ export type SessionMessageUser = { text: string files?: Array agents?: Array - skills?: Array type: "user" } @@ -8128,6 +8126,39 @@ export type FileStatusResponses = { export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] +export type FileWriteData = { + body?: { + path: string + content: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/file/write" +} + +export type FileWriteErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileWriteError = FileWriteErrors[keyof FileWriteErrors] + +export type FileWriteResponses = { + /** + * Write result + */ + 200: { + ok: boolean + } +} + +export type FileWriteResponse = FileWriteResponses[keyof FileWriteResponses] + export type InstanceDisposeData = { body?: never path?: never @@ -9799,6 +9830,43 @@ export type SessionDiffResponses = { export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] +export type SessionTouchedFilesData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/touched-files" +} + +export type SessionTouchedFilesErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionTouchedFilesError = SessionTouchedFilesErrors[keyof SessionTouchedFilesErrors] + +export type SessionTouchedFilesResponses = { + /** + * All files touched by edit tools in this session + */ + 200: Array<{ + file: string + status: "added" | "modified" + }> +} + +export type SessionTouchedFilesResponse = SessionTouchedFilesResponses[keyof SessionTouchedFilesResponses] + export type SessionMessagesData = { body?: never path: {