Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ public import CmuxNextCloud
import CmuxNextCompat
public import CmuxNextSettings
public import Foundation
import os

/// `cloud.startChat {task, cwd?, harness?, model?, key}`: a new chat's first send with Cloud picked
/// as where it runs (the environment picker's Cloud row, Labs). The App picks or makes a Cloud
Expand Down Expand Up @@ -174,7 +175,27 @@ struct AgentPaneHandOff {
}
}

private static let cloudLog = Logger(subsystem: "com.cmuxterm.app.next", category: "app.cloud")

/// `cloud.startChat`, and a refusal made here, before the App's start ran (the folder check, the
/// gesture, an attachment), logged under `app.cloud` beside the App's own start failures: such a
/// refusal never reaches CloudStartChat, so without this the log said nothing at all.
func cloud(_ start: AgentPaneCloudStart) async -> [String: Any] {
let reply = await cloudChecked(start)
if let code = Self.refusedHere(reply) {
Self.cloudLog.error("cloud start chat refused on this Mac before it started: \(code, privacy: .public)")
}
return reply
}

/// The reply's code when this pane refused the start itself (`transport.*`, `cloud_attachments`,
/// `unsupported`); nil for a success or the App's own failure.
nonisolated static func refusedHere(_ reply: [String: Any]) -> String? {
guard reply["ok"] as? Bool == false, let code = (reply["error"] as? [String: Any])?["code"] as? String else { return nil }
return code.hasPrefix("transport.") || code == "cloud_attachments" || code == "unsupported" ? code : nil
}

private func cloudChecked(_ start: AgentPaneCloudStart) async -> [String: Any] {
// The folder is a path on this Mac: a chat on another machine never names one to copy.
guard let run = model.onCloudStartChat, !model.chatIsElsewhere else { return AgentPaneModel.unsupported("cloud.startChat") }
guard AgentPaneCloudAttachments(start.attachments) != nil else {
Expand Down
6 changes: 6 additions & 0 deletions webviews/src/agent-session/acpmux/CloudStartView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,9 @@ test("row state words are screen-reader only, hidden by the card's own sheet", a
);
expect(html).toContain('<span class="visually-hidden">');
});

test("a start this Mac refused before it reached Cloud marks no step failed", () => {
const failed = { key: "k", task: "hey", state: "failed" as const, folder: "paint" };
expect(startupChecklist(failed)[0]?.status).toBe("error");
expect(startupChecklist({ ...failed, refusedHere: true }).map((row) => row.status)).not.toContain("error");
});
3 changes: 2 additions & 1 deletion webviews/src/agent-session/acpmux/CloudStartView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,9 @@ export function startupChecklist(
language: string = paneLanguage(),
): ChecklistStep[] {
const oldStep = start.step ? CLOUD_START_STEPS.indexOf(start.step) : -1;
// A start this Mac refused before it reached Cloud ran no step: none is marked failed.
const failedStep =
start.state === "failed"
start.state === "failed" && !start.refusedHere
? oldStep === 0
? "machine"
: oldStep === 1
Expand Down
1 change: 1 addition & 0 deletions webviews/src/agent-session/acpmux/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -251354,6 +251354,7 @@
"chiefWeb.notSent": {"comment":"Chief web: a message was rejected and can be retried","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Message not sent"}},"de":{"stringUnit":{"state":"translated","value":"Nachricht nicht gesendet"}},"fr":{"stringUnit":{"state":"translated","value":"Message non envoyé"}},"ar":{"stringUnit":{"state":"translated","value":"لم تُرسل الرسالة"}},"es":{"stringUnit":{"state":"translated","value":"Mensaje no enviado"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"訊息未傳送"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"消息未发送"}},"ko":{"stringUnit":{"state":"translated","value":"메시지를 보내지 못했습니다"}},"ja":{"stringUnit":{"state":"translated","value":"メッセージは送信されませんでした"}},"bs":{"stringUnit":{"state":"translated","value":"Poruka nije poslana"}},"da":{"stringUnit":{"state":"translated","value":"Beskeden blev ikke sendt"}},"it":{"stringUnit":{"state":"translated","value":"Messaggio non inviato"}},"km":{"stringUnit":{"state":"translated","value":"សារមិនត្រូវបានផ្ញើទេ"}},"nb":{"stringUnit":{"state":"translated","value":"Meldingen ble ikke sendt"}},"pl":{"stringUnit":{"state":"translated","value":"Wiadomość nie została wysłana"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Mensagem não enviada"}},"ru":{"stringUnit":{"state":"translated","value":"Сообщение не отправлено"}},"th":{"stringUnit":{"state":"translated","value":"ไม่ได้ส่งข้อความ"}},"tr":{"stringUnit":{"state":"translated","value":"Mesaj gönderilmedi"}},"uk":{"stringUnit":{"state":"translated","value":"Повідомлення не надіслано"}},"vi":{"stringUnit":{"state":"translated","value":"Tin nhắn chưa được gửi"}}}},
"context.cacheWarm":{"comment":"Agent pane composer: the context ring hover card's prompt cache line while the cache is warm; {left} is the time until it expires (3:12, or 52:10 for an hour's cache)","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"needs_review","value":"ذاكرة التخزين المؤقت دافئة · متبقٍ {left}"}},"bs":{"stringUnit":{"state":"needs_review","value":"Keš topao · još {left}"}},"da":{"stringUnit":{"state":"needs_review","value":"Cache varm · {left} tilbage"}},"de":{"stringUnit":{"state":"needs_review","value":"Cache warm · noch {left}"}},"en":{"stringUnit":{"state":"translated","value":"Cache warm · {left} left"}},"es":{"stringUnit":{"state":"needs_review","value":"Caché activa · quedan {left}"}},"fr":{"stringUnit":{"state":"needs_review","value":"Cache actif · {left} restantes"}},"it":{"stringUnit":{"state":"needs_review","value":"Cache attiva · {left} rimanenti"}},"ja":{"stringUnit":{"state":"needs_review","value":"キャッシュ有効 · 残り {left}"}},"km":{"stringUnit":{"state":"needs_review","value":"ឃ្លាំងសម្ងាត់នៅក្តៅ · នៅសល់ {left}"}},"ko":{"stringUnit":{"state":"needs_review","value":"캐시 유지 중 · {left} 남음"}},"nb":{"stringUnit":{"state":"needs_review","value":"Hurtigbuffer varm · {left} igjen"}},"pl":{"stringUnit":{"state":"needs_review","value":"Pamięć podręczna aktywna · zostało {left}"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Cache ativo · faltam {left}"}},"ru":{"stringUnit":{"state":"needs_review","value":"Кэш активен · осталось {left}"}},"th":{"stringUnit":{"state":"needs_review","value":"แคชยังอุ่น · เหลือ {left}"}},"tr":{"stringUnit":{"state":"needs_review","value":"Önbellek sıcak · {left} kaldı"}},"uk":{"stringUnit":{"state":"needs_review","value":"Кеш активний · залишилось {left}"}},"vi":{"stringUnit":{"state":"needs_review","value":"Bộ nhớ đệm còn ấm · còn {left}"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"缓存有效 · 剩余 {left}"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"快取有效 · 剩餘 {left}"}}}},
"context.cacheExpired":{"comment":"Agent pane composer: the context ring hover card's prompt cache line once the cache has expired (the next turn reads the whole context again, uncached)","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"needs_review","value":"انتهت صلاحية ذاكرة التخزين المؤقت"}},"bs":{"stringUnit":{"state":"needs_review","value":"Keš je istekao"}},"da":{"stringUnit":{"state":"needs_review","value":"Cache udløbet"}},"de":{"stringUnit":{"state":"needs_review","value":"Cache abgelaufen"}},"en":{"stringUnit":{"state":"translated","value":"Cache expired"}},"es":{"stringUnit":{"state":"needs_review","value":"Caché caducada"}},"fr":{"stringUnit":{"state":"needs_review","value":"Cache expiré"}},"it":{"stringUnit":{"state":"needs_review","value":"Cache scaduta"}},"ja":{"stringUnit":{"state":"needs_review","value":"キャッシュ期限切れ"}},"km":{"stringUnit":{"state":"needs_review","value":"ឃ្លាំងសម្ងាត់បានផុតកំណត់"}},"ko":{"stringUnit":{"state":"needs_review","value":"캐시 만료됨"}},"nb":{"stringUnit":{"state":"needs_review","value":"Hurtigbuffer utløpt"}},"pl":{"stringUnit":{"state":"needs_review","value":"Pamięć podręczna wygasła"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Cache expirado"}},"ru":{"stringUnit":{"state":"needs_review","value":"Кэш истёк"}},"th":{"stringUnit":{"state":"needs_review","value":"แคชหมดอายุแล้ว"}},"tr":{"stringUnit":{"state":"needs_review","value":"Önbelleğin süresi doldu"}},"uk":{"stringUnit":{"state":"needs_review","value":"Кеш закінчився"}},"vi":{"stringUnit":{"state":"needs_review","value":"Bộ nhớ đệm đã hết hạn"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"缓存已过期"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"快取已過期"}}}},
"cloudStartup.folderNotOpen": {"comment":"Agent pane: Cloud startup checklist. A Cloud start refused on this Mac because its folder is not one of the tab's folders.","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This folder isn't open in this tab; pick it to send it to Cloud."}},"ar":{"stringUnit":{"state":"translated","value":"هذا المجلد غير مفتوح في علامة التبويب هذه؛ اختره لإرساله إلى Cloud."}},"bs":{"stringUnit":{"state":"translated","value":"Ovaj folder nije otvoren u ovoj kartici; odaberite ga da ga pošaljete u Cloud."}},"da":{"stringUnit":{"state":"translated","value":"Denne mappe er ikke åben i denne fane; vælg den for at sende den til Cloud."}},"de":{"stringUnit":{"state":"translated","value":"Dieser Ordner ist in diesem Tab nicht geöffnet; wähle ihn aus, um ihn an Cloud zu senden."}},"es":{"stringUnit":{"state":"translated","value":"Esta carpeta no está abierta en esta pestaña; elígela para enviarla a Cloud."}},"fr":{"stringUnit":{"state":"translated","value":"Ce dossier n'est pas ouvert dans cet onglet ; choisissez-le pour l'envoyer vers Cloud."}},"it":{"stringUnit":{"state":"translated","value":"Questa cartella non è aperta in questa scheda; sceglila per inviarla a Cloud."}},"ja":{"stringUnit":{"state":"translated","value":"このフォルダはこのタブで開かれていません。Cloud に送るにはフォルダを選んでください。"}},"km":{"stringUnit":{"state":"translated","value":"ថតនេះមិនបានបើកនៅក្នុងផ្ទាំងនេះទេ។ ជ្រើសរើសវាដើម្បីផ្ញើវាទៅ Cloud។"}},"ko":{"stringUnit":{"state":"translated","value":"이 폴더는 이 탭에서 열려 있지 않습니다. Cloud로 보내려면 폴더를 선택하세요."}},"nb":{"stringUnit":{"state":"translated","value":"Denne mappen er ikke åpen i denne fanen; velg den for å sende den til Cloud."}},"pl":{"stringUnit":{"state":"translated","value":"Ten folder nie jest otwarty w tej karcie; wybierz go, aby wysłać go do Cloud."}},"pt-BR":{"stringUnit":{"state":"translated","value":"Esta pasta não está aberta nesta aba; escolha-a para enviá-la ao Cloud."}},"ru":{"stringUnit":{"state":"translated","value":"Эта папка не открыта в этой вкладке; выберите её, чтобы отправить в Cloud."}},"th":{"stringUnit":{"state":"translated","value":"โฟลเดอร์นี้ไม่ได้เปิดอยู่ในแท็บนี้ เลือกโฟลเดอร์เพื่อส่งไปยัง Cloud"}},"tr":{"stringUnit":{"state":"translated","value":"Bu klasör bu sekmede açık değil; Cloud'a göndermek için klasörü seçin."}},"uk":{"stringUnit":{"state":"translated","value":"Ця папка не відкрита в цій вкладці; виберіть її, щоб надіслати в Cloud."}},"vi":{"stringUnit":{"state":"translated","value":"Thư mục này chưa được mở trong tab này; hãy chọn nó để gửi lên Cloud."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"此文件夹未在此标签页中打开;请选择它以发送到 Cloud。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"此資料夾未在此分頁中開啟;請選擇它以傳送到 Cloud。"}}}},
"cloudStartup.harnessStarting": {"comment":"Agent pane: Cloud startup checklist","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Starting {harness}"}},"ar":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"bs":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"da":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"de":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"es":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"fr":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"it":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"ja":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"km":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"ko":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"nb":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"pl":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"ru":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"th":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"tr":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"uk":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"vi":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"Starting {harness}"}}}}
},
"version": "1.0"
Expand Down
19 changes: 18 additions & 1 deletion webviews/src/agent-session/acpmux/cloudStartProgress.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// l10n-allow-file: test fixture prompts and host words, not shipped UI.
import { expect, test } from "bun:test";
import { cloudStartBegun, cloudStartEnded, cloudStartPushed } from "./cloudStartProgress";
import { cloudRefusalText, cloudStartBegun, cloudStartEnded, cloudStartPushed } from "./cloudStartProgress";

const begun = () => cloudStartBegun("k-1", "fix the build", "/Users/me/src/paint/");

Expand Down Expand Up @@ -138,3 +138,20 @@ test("forwards the environment id and setup ran or cached state", () => {
});
expect(cached?.setup).toEqual({ state: "done", status: "ready" });
});

test("a start this Mac's pane refused says why by its code, never the generic connection refusal", () => {
const generic = "The app refused the agent connection.";
const outside = cloudStartEnded(begun(), "k-1", { ok: false, code: "transport.path_outside_roots", message: generic });
expect(outside).toEqual({
...begun(),
state: "failed",
message: "This folder isn't open in this tab; pick it to send it to Cloud.",
refusedHere: true,
});
const gesture = cloudStartEnded(begun(), "k-1", { ok: false, code: "transport.gesture_required", message: generic });
expect(gesture?.message).toBe(cloudRefusalText("transport.gesture_required"));
expect(gesture?.message).not.toBe(generic);
expect(gesture?.refusedHere).toBe(true);
// The App's own failure keeps the host's words and its checklist step.
expect(cloudStartEnded(begun(), "k-1", { ok: false, code: "cloud_start_failed", message: "No machine" })?.refusedHere).toBeUndefined();
});
20 changes: 18 additions & 2 deletions webviews/src/agent-session/acpmux/cloudStartProgress.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { CloudStartAnswer } from "./cloudRun";
import { translate } from "./i18n";
import { errorMessage } from "./transportErrors";
import { sliceWhole } from "../../ui/wholeChars";

/// A chat starting in Cloud, as the chat shows it (CloudStartView; cloud work mode, leo-workspace
Expand Down Expand Up @@ -56,6 +58,9 @@ export type CloudStartProgress = {
setup?: CloudStartupSetup;
/// True after the harness has started. The checklist stays available but folds to one quiet line.
initialized?: boolean;
/// This Mac's pane refused the start before it reached Cloud (a `transport.*` code: a folder that
/// is not the tab's own, a Send without a gesture): no step ran, so the checklist marks none failed.
refusedHere?: boolean;
};

export type CloudStartupOperation = {
Expand Down Expand Up @@ -329,6 +334,17 @@ export function cloudStartEnded(
): CloudStartProgress | undefined {
if (current?.key !== key) return current;
if (answer.ok) return undefined;
const message = line(answer.message, 240);
return { ...current, state: "failed", ...(message ? { message } : {}) };
// The pane's own refusal (AgentPaneCloudStart: the folder check, the gesture) is worded by its
// code, never the host's generic "The app refused the agent connection." (cloudRefusalText).
const refusedHere = answer.code.startsWith("transport.");
const message = refusedHere ? cloudRefusalText(answer.code) : line(answer.message, 240);
return { ...current, state: "failed", ...(message ? { message } : {}), ...(refusedHere ? { refusedHere } : {}) };
}

/// The words for a Cloud start this Mac's pane refused: a folder that is not one of the tab's own
/// asks the person to pick it (a pick grants it, `workspace.useFolder`); any other `transport.*`
/// code reads as the pane's refusal text (transportErrors.ts).
export function cloudRefusalText(code: string): string {
if (code === "transport.path_outside_roots") return translate("cloudStartup.folderNotOpen");
return errorMessage({ code });
}
Loading