diff --git a/apps/desktop/src/main/renderer-recovery.test.ts b/apps/desktop/src/main/renderer-recovery.test.ts index afacaaeb4d..9704b163a9 100644 --- a/apps/desktop/src/main/renderer-recovery.test.ts +++ b/apps/desktop/src/main/renderer-recovery.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { installRendererRecoveryHandlers } from "./renderer-recovery"; +import { createElectronReloadPrompt, installRendererRecoveryHandlers } from "./renderer-recovery"; type Handler = (...args: unknown[]) => void; @@ -109,4 +109,30 @@ describe("installRendererRecoveryHandlers", () => { expect(showReloadPrompt).not.toHaveBeenCalled(); expect(fixture.reload).not.toHaveBeenCalled(); }); + + it("shows actionable recovery guidance before diagnostic details", async () => { + let detail = ""; + const showMessageBox = vi.fn( + async (options: { title: string; message: string; detail: string }) => { + detail = options.detail; + return { response: 1 }; + }, + ); + const showReloadPrompt = createElectronReloadPrompt(showMessageBox); + + await showReloadPrompt({ kind: "unresponsive", context: {} }); + + expect(showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Multica needs to reload", + message: "The desktop window has been stuck for a few seconds.", + detail: expect.stringContaining( + "Click Reload to refresh this window and keep using Multica.", + ), + }), + ); + expect(detail).toContain("what you were doing right before this message appeared"); + expect(detail).toContain("Activity Monitor sample"); + expect(detail).toContain("Diagnostic details:\nkind: unresponsive\ncontext: {}"); + }); }); diff --git a/apps/desktop/src/main/renderer-recovery.ts b/apps/desktop/src/main/renderer-recovery.ts index e6df0cfb0f..4e26d052e4 100644 --- a/apps/desktop/src/main/renderer-recovery.ts +++ b/apps/desktop/src/main/renderer-recovery.ts @@ -109,18 +109,30 @@ function isRecoverableRendererExit(details: unknown) { function rendererRecoveryMessage(kind: ReloadPromptPayload["kind"]) { switch (kind) { case "render-process-gone": - return "The desktop renderer process stopped responding or crashed."; + return "The desktop window stopped unexpectedly."; case "preload-error": - return "The desktop preload script failed before the app could start."; + return "The desktop window could not finish starting."; case "unresponsive": - return "The desktop window is not responding."; + return "The desktop window has been stuck for a few seconds."; } } function rendererRecoveryDetail(payload: ReloadPromptPayload) { + const guidance = [ + "Click Reload to refresh this window and keep using Multica.", + "If this keeps happening, please tell us what you were doing right before this message appeared and whether Reload recovered the window.", + ]; + + if (payload.kind === "unresponsive") { + guidance.push( + "For macOS reports, an Activity Monitor sample of the Multica Helper (Renderer) process helps us find what blocked the app.", + ); + } + return [ - "Reloading is the safest recovery path for this window.", + ...guidance, "", + "Diagnostic details:", `kind: ${payload.kind}`, `context: ${JSON.stringify(payload.context)}`, ].join("\n"); @@ -132,4 +144,4 @@ function defaultDevLog(tag: string, ...args: unknown[]) { function formatError(error: unknown) { return error instanceof Error ? (error.stack ?? error.message) : String(error); -} \ No newline at end of file +} diff --git a/apps/docs/content/docs/cli.ja.mdx b/apps/docs/content/docs/cli.ja.mdx index 83f35fdf30..0e753e96fe 100644 --- a/apps/docs/content/docs/cli.ja.mdx +++ b/apps/docs/content/docs/cli.ja.mdx @@ -79,6 +79,19 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき | `multica skill import ...` | GitHub、ClawHub、またはローカルマシンからスキルをインポート | | `multica skill files ...` | ネスト: スキルのファイルを管理 | +### スキルインポートの競合 + +`multica skill import --url ` の既定値は `--on-conflict fail` です。同じ名前のスキルがすでに存在する場合、コマンドは構造化された `conflict` 結果で終了し、ワークスペースは変更されません。 + +既存スキルの作成者で、スキル ID とエージェントの紐付けを維持したまま内容を置き換える場合は `--on-conflict overwrite` を使います。既存スキルを残してコピーを取り込む場合は `--on-conflict rename` を使うと、`-2` のような接尾辞が自動で付きます。同名の項目を単に飛ばす場合は `--on-conflict skip` を使います。 + +```bash +multica skill import --url https://skills.sh/acme/repo/review-helper +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip +``` + ## スクワッド | コマンド | 用途 | diff --git a/apps/docs/content/docs/cli.ko.mdx b/apps/docs/content/docs/cli.ko.mdx index 6f32b59887..24b255f5a6 100644 --- a/apps/docs/content/docs/cli.ko.mdx +++ b/apps/docs/content/docs/cli.ko.mdx @@ -79,6 +79,19 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹 | `multica skill import ...` | GitHub, ClawHub, 또는 로컬 기기에서 스킬 가져오기 | | `multica skill files ...` | 중첩: 스킬의 파일 관리 | +### 스킬 가져오기 충돌 + +`multica skill import --url `의 기본값은 `--on-conflict fail`입니다. 같은 이름의 스킬이 이미 있으면 명령은 구조화된 `conflict` 결과로 종료되며 워크스페이스를 변경하지 않습니다. + +기존 스킬을 만든 사용자이고, 스킬 ID와 에이전트 연결은 유지한 채 내용을 바꾸려면 `--on-conflict overwrite`를 사용하세요. 기존 스킬을 그대로 두고 복사본을 가져오려면 `--on-conflict rename`을 사용하면 `-2` 같은 접미사가 자동으로 붙습니다. 같은 이름의 항목을 그냥 건너뛰려면 `--on-conflict skip`을 사용하세요. + +```bash +multica skill import --url https://skills.sh/acme/repo/review-helper +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip +``` + ## 스쿼드 | 명령어 | 용도 | diff --git a/apps/docs/content/docs/cli.mdx b/apps/docs/content/docs/cli.mdx index 2536fb7773..f51db7ddf2 100644 --- a/apps/docs/content/docs/cli.mdx +++ b/apps/docs/content/docs/cli.mdx @@ -79,6 +79,25 @@ For the difference between token types, see [Authentication and tokens](/auth-to | `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine | | `multica skill files ...` | Nested: manage a skill's files | +### Skill import conflicts + +`multica skill import --url ` defaults to `--on-conflict fail`. If a skill +with the same name already exists, the command exits with a structured +`conflict` result and does not change the workspace. + +Use `--on-conflict overwrite` when you created the existing skill and want to +replace its content while preserving its ID and agent bindings. Use +`--on-conflict rename` to import a copy with an automatic suffix such as `-2`. +Use `--on-conflict skip` to leave the existing skill untouched and report +`skipped`. + +```bash +multica skill import --url https://skills.sh/acme/repo/review-helper +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip +``` + ## Squads | Command | Purpose | diff --git a/apps/docs/content/docs/cli.zh.mdx b/apps/docs/content/docs/cli.zh.mdx index 1f8b0e6607..0b5555057c 100644 --- a/apps/docs/content/docs/cli.zh.mdx +++ b/apps/docs/content/docs/cli.zh.mdx @@ -79,6 +79,19 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。 | `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill | | `multica skill files ...` | 嵌套:管理 Skill 的文件 | +### Skill 导入冲突 + +`multica skill import --url ` 默认等同于 `--on-conflict fail`。如果工作区里已经有同名 Skill,命令会返回结构化 `conflict` 结果并退出,不会修改工作区。 + +如果你是已有 Skill 的 creator,并且想用新导入内容覆盖它,同时保留原 Skill 的 ID 和 agent 绑定,用 `--on-conflict overwrite`。如果想保留已有 Skill、另存一份,用 `--on-conflict rename`,系统会自动加 `-2` 这类后缀。如果只是批量导入时遇到同名项就跳过,用 `--on-conflict skip`。 + +```bash +multica skill import --url https://skills.sh/acme/repo/review-helper +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename +multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip +``` + ## 小队 | 命令 | 用途 | diff --git a/apps/mobile/app/(app)/[workspace]/issue/[id]/runs.tsx b/apps/mobile/app/(app)/[workspace]/issue/[id]/runs.tsx index e6e6d4251a..5cbcd58aa5 100644 --- a/apps/mobile/app/(app)/[workspace]/issue/[id]/runs.tsx +++ b/apps/mobile/app/(app)/[workspace]/issue/[id]/runs.tsx @@ -1,7 +1,7 @@ /** * Agent Runs sheet — presented as a formSheet by the parent Stack. Two * sections: Active (queued/dispatched/running, created_at desc) and Past - * (failed → cancelled → completed, completed_at desc within each). Empty + * (completed_at desc, status rank as tiebreaker). Empty * sections hide entirely. * * Both entry points (the in-card AgentActivityRow and the Stack-header @@ -58,9 +58,9 @@ export default function IssueRunsRoute() { t.status === "cancelled", ); return filtered.sort((a, b) => { - const ord = PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status]; - if (ord !== 0) return ord; - return (b.completed_at ?? "").localeCompare(a.completed_at ?? ""); + const timeDiff = (b.completed_at ?? "").localeCompare(a.completed_at ?? ""); + if (timeDiff !== 0) return timeDiff; + return PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status]; }); }, [allTasks]); diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index 541b3f8e0f..8e2695ee2b 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -292,45 +292,72 @@ export function createEnDict(allowSignup: boolean): LandingDict { fixes: "Bug Fixes", }, entries: [ + { + version: "0.3.21", + date: "2026-06-12", + title: "CodeBuddy Runtime", + changes: [], + features: [ + "CodeBuddy can now run local Multica agents, with its available model and effort choices shown automatically", + "Quick-created Issues now keep uploaded files attached from the first draft through the final Issue", + ], + improvements: [ + "Skill import conflicts are clearer: locked skills show a person's name instead of an internal ID, and a single overwrite now completes in one click", + "Desktop recovery prompts now explain what happened first and give clearer details to include when reporting a stuck window", + "Views that sort or filter people by signup time can now load faster", + ], + fixes: [ + "Chat now keeps messages and drafts in sync when sending, stopping, or recovering from a failed send", + "Lark account binding now works reliably for users who are already signed in, and sign-in returns to the binding page", + "Local agent runs no longer announce that work has started before the task folder is ready", + ], + }, { version: "0.3.20", - date: "2026-06-10", - title: "Safer Comment Triggers and More Reliable Attachments", + date: "2026-06-11", + title: "Skill Imports, Cleaner Run History, and Resilient Agents", changes: [], features: [ - "Comment boxes now show which agents or squads will start work before you send, with controls to avoid accidental runs", - "Run transcripts now include timestamps, making agent progress and handoffs easier to review", - "Autopilot detail pages now show who created each autopilot", - "Claude Fable 5 is now available in Multica's supported model and pricing list", + "Skill imports now let you choose what happens when a skill already exists: stop, replace it, save a renamed copy, or skip it", + "Import results now clearly show which skills were added, updated, skipped, blocked by a conflict, or could not be imported", ], improvements: [ - "Comment trigger indicators are quieter, clearer, and less likely to crowd long agent names", - "Desktop now disables daemon start and stop controls when the daemon is managed outside Multica, such as in WSL2", + "Execution logs now show the newest past runs first on web and mobile, so recent progress is easier to scan", + "Changelog content was cleaned up so the latest release notes stay grouped under the right release", ], fixes: [ - "Inline images and files in Issue descriptions now stay visible across web and desktop after reloads", - "Each Issue discussion thread now keeps only one resolved answer at a time, so replacing the conclusion is consistent for everyone", - "Issue pages refresh their data after realtime reconnects, avoiding stale timelines after a connection drop", - "Agent task initiator history now works more reliably for older task records", - "Sticky Issue comments keep a cleaner visual edge while scrolling", + "Issue thread replies now stay in the order they arrived, even when a slower agent reply lands later", + "Agents can recover when a saved session disappears, starting fresh instead of failing again on every mention", + "Reviving an Issue from a new workspace folder now starts a fresh session instead of retrying one that only existed in the old folder", ], }, { version: "0.3.19", - date: "2026-06-09", - title: "More Reliable Agents, Attachments, and Issue Threads", + date: "2026-06-10", + title: "Safer Comment Triggers, Reliable Agents, and Attachments", changes: [], features: [ + "Comment boxes now show which agents or squads will start work before you send, with controls to avoid accidental runs", + "Run transcripts now include timestamps, making agent progress and handoffs easier to review", + "Autopilot detail pages now show who created each autopilot", + "Claude Fable 5 is now available in Multica's supported model and pricing list", "Issue conversations can now resolve a specific reply, making long threads easier to close while keeping the final answer visible", "Lark and Feishu conversations now show a typing reaction while Multica is preparing a reply, then clear it before the answer is sent", "Agent runs now know who started each task, making handoffs, audit trails, and privacy-aware behavior more accurate", "OpenClaw users can point Multica at a custom app location and data folder from their local configuration", ], improvements: [ + "Comment trigger indicators are quieter, clearer, and less likely to crowd long agent names", + "Desktop now disables daemon start and stop controls when the daemon is managed outside Multica, such as in WSL2", "The active agent indicator in an Issue header is easier to read, with motion only while work is running and clearer queued wording otherwise", "The CLI now gives clearer guidance around common errors, sign-in problems, and project setup values", ], fixes: [ + "Inline images and files in Issue descriptions now stay visible across web and desktop after reloads", + "Each Issue discussion thread now keeps only one resolved answer at a time, so replacing the conclusion is consistent for everyone", + "Issue pages refresh their data after realtime reconnects, avoiding stale timelines after a connection drop", + "Agent task initiator history now works more reliably for older task records", + "Sticky Issue comments keep a cleaner visual edge while scrolling", "Newly posted attachments now use stable private download links, so images and files stay visible after temporary upload links expire", "Autopilot runs started from newly created Issues now fail cleanly when the assigned task cannot complete, instead of staying stuck", "Inbox deep links now scroll inside the Issue timeline without pushing the desktop window out of place", diff --git a/apps/web/features/landing/i18n/ja.ts b/apps/web/features/landing/i18n/ja.ts index 4eab8b9c9e..8e01dbee64 100644 --- a/apps/web/features/landing/i18n/ja.ts +++ b/apps/web/features/landing/i18n/ja.ts @@ -268,45 +268,72 @@ export function createJaDict(allowSignup: boolean): LandingDict { fixes: "バグ修正", }, entries: [ + { + version: "0.3.21", + date: "2026-06-12", + title: "CodeBuddy Runtime", + changes: [], + features: [ + "CodeBuddy でローカルの Multica エージェントを動かせるようになり、利用できるモデルと実行の強さが自動で表示されます。", + "クイック作成した Issue では、下書きでアップロードしたファイルが最終的な Issue まで保持されます。", + ], + improvements: [ + "スキル取り込みの競合が分かりやすくなり、ロックされたスキルには内部 ID ではなくメンバー名が表示され、単体の上書きも 1 クリックで完了します。", + "デスクトップの復旧案内は、まず何が起きたかを説明し、固まったウィンドウを報告するときに含める情報も分かりやすくなりました。", + "登録日時でメンバーを並べ替えたり絞り込んだりする画面が、より速く読み込まれるようになりました。", + ], + fixes: [ + "チャットの送信、停止、送信失敗からの復旧時に、メッセージと下書きがより安定して同期されます。", + "Lark アカウント連携は、すでにサインイン済みのユーザーでも安定して完了し、サインイン後も連携ページに戻ります。", + "ローカルエージェントの実行は、タスクフォルダの準備が終わる前に開始済みとして表示されなくなりました。", + ], + }, { version: "0.3.20", - date: "2026-06-10", - title: "より安全なコメントトリガーと安定した添付ファイル", + date: "2026-06-11", + title: "スキルのインポート、実行履歴、より安定したエージェント", changes: [], features: [ - "コメント入力欄では、送信前にどのエージェントやスクワッドが動き始めるかを確認でき、誤って実行することを避けられます。", - "実行記録に時刻が表示されるようになり、エージェントの進捗や引き継ぎを振り返りやすくなりました。", - "オートパイロット詳細ページで、誰が作成したかを確認できるようになりました。", - "Claude Fable 5 が Multica の対応モデルと料金一覧に加わりました。", + "スキルのインポート時に同じスキルがすでにある場合、停止、置き換え、別名で保存、スキップを選べるようになりました。", + "インポート結果では、追加、更新、スキップ、競合、失敗したスキルがわかりやすく表示されます。", ], improvements: [ - "コメントトリガーの表示はより控えめで読みやすく、長いエージェント名でも混み合いにくくなりました。", - "WSL2 など Multica の外でデーモンが管理されている場合、デスクトップは開始と停止の操作を無効にします。", + "Web とモバイルの実行履歴は新しい過去実行を先に表示するため、最近の進捗を追いやすくなりました。", + "変更履歴の内容を整理し、最新のリリースノートが正しいバージョンにまとまるようにしました。", ], fixes: [ - "イシュー説明内の画像とファイルは、Web とデスクトップのどちらでも再読み込み後に表示され続けます。", - "各イシュー会話スレッドは解決済みの回答を 1 つだけ保持するため、結論を置き換えたときの表示が全員でそろいます。", - "リアルタイム接続が復帰したあと、イシュー画面はデータを更新し、古いタイムラインが残りにくくなりました。", - "エージェントタスクの開始者履歴が、古いタスク記録でもより信頼できるようになりました。", - "スクロール中の固定イシューコメントの境界がよりきれいに表示されます。", + "イシューの返信は到着した順番のまま表示され、遅れて届いたエージェント返信が途中に割り込まなくなりました。", + "保存済みセッションが失われた場合でも、エージェントは新しく開始して復旧でき、以後のメンションで失敗し続けません。", + "新しい作業フォルダーからイシューを再開すると、古いフォルダーにだけ存在したセッションではなく新しいセッションで始まります。", ], }, { version: "0.3.19", - date: "2026-06-09", - title: "より安定したエージェント、添付ファイル、イシューの会話", + date: "2026-06-10", + title: "より安全なコメントトリガー、安定したエージェントと添付ファイル", changes: [], features: [ + "コメント入力欄では、送信前にどのエージェントやスクワッドが動き始めるかを確認でき、誤って実行することを避けられます。", + "実行記録に時刻が表示されるようになり、エージェントの進捗や引き継ぎを振り返りやすくなりました。", + "オートパイロット詳細ページで、誰が作成したかを確認できるようになりました。", + "Claude Fable 5 が Multica の対応モデルと料金一覧に加わりました。", "イシューの会話で特定の返信を解決として残せるようになり、長いスレッドを閉じても結論を確認しやすくなりました。", "Lark と Feishu の会話では、Multica が返信を準備している間に入力中のリアクションを表示し、返信前に自動で消します。", "エージェント実行は、誰がそのタスクを始めたかを把握できるようになり、引き継ぎ、監査、プライバシーに配慮した動作がより正確になります。", "OpenClaw ユーザーは、ローカル設定から独自のアプリ場所とデータフォルダーを指定できます。", ], improvements: [ + "コメントトリガーの表示はより控えめで読みやすく、長いエージェント名でも混み合いにくくなりました。", + "WSL2 など Multica の外でデーモンが管理されている場合、デスクトップは開始と停止の操作を無効にします。", "イシュー上部のアクティブなエージェント表示は、実行中だけ動き、待機中は待機状態を明確に示すため、読み取りやすくなりました。", "CLI は、よくあるエラー、サインインの問題、プロジェクト設定の値について、よりわかりやすく案内します。", ], fixes: [ + "イシュー説明内の画像とファイルは、Web とデスクトップのどちらでも再読み込み後に表示され続けます。", + "各イシュー会話スレッドは解決済みの回答を 1 つだけ保持するため、結論を置き換えたときの表示が全員でそろいます。", + "リアルタイム接続が復帰したあと、イシュー画面はデータを更新し、古いタイムラインが残りにくくなりました。", + "エージェントタスクの開始者履歴が、古いタスク記録でもより信頼できるようになりました。", + "スクロール中の固定イシューコメントの境界がよりきれいに表示されます。", "新しく投稿された添付ファイルは安定した非公開ダウンロードリンクを使うため、一時的なアップロードリンクが期限切れになっても画像やファイルを表示できます。", "新規イシューから始まったオートパイロット実行は、割り当てられたタスクが完了できない場合に正しく失敗し、進行中のまま残りません。", "受信箱からコメントリンクを開いたとき、デスクトップ画面全体ではなくイシューのタイムラインだけがスクロールします。", diff --git a/apps/web/features/landing/i18n/ko.ts b/apps/web/features/landing/i18n/ko.ts index ec6bb1e7eb..e417d0a531 100644 --- a/apps/web/features/landing/i18n/ko.ts +++ b/apps/web/features/landing/i18n/ko.ts @@ -267,45 +267,72 @@ export function createKoDict(allowSignup: boolean): LandingDict { fixes: "버그 수정", }, entries: [ + { + version: "0.3.21", + date: "2026-06-12", + title: "CodeBuddy Runtime", + changes: [], + features: [ + "CodeBuddy로 로컬 Multica 에이전트를 실행할 수 있으며, 사용할 수 있는 모델과 실행 강도 선택지가 자동으로 표시됩니다.", + "빠르게 만든 Issue에서도 초안에서 올린 파일이 최종 Issue까지 함께 유지됩니다.", + ], + improvements: [ + "스킬 가져오기 충돌이 더 이해하기 쉬워졌습니다. 잠긴 스킬은 내부 ID 대신 멤버 이름을 보여주고, 단일 덮어쓰기도 한 번의 클릭으로 끝납니다.", + "데스크톱 복구 안내가 먼저 무슨 일이 있었는지 설명하고, 멈춘 창을 신고할 때 포함할 정보를 더 명확하게 보여줍니다.", + "가입 시간으로 멤버를 정렬하거나 필터링하는 화면이 더 빠르게 로드될 수 있습니다.", + ], + fixes: [ + "채팅을 보내거나 중지하거나 전송 실패에서 복구할 때 메시지와 초안이 더 안정적으로 동기화됩니다.", + "Lark 계정 연결은 이미 로그인한 사용자에게도 안정적으로 완료되며, 로그인 후에도 연결 페이지로 돌아옵니다.", + "로컬 에이전트 실행은 작업 폴더가 준비되기 전에 시작된 것으로 표시되지 않습니다.", + ], + }, { version: "0.3.20", - date: "2026-06-10", - title: "더 안전한 댓글 트리거와 더 안정적인 첨부 파일", + date: "2026-06-11", + title: "스킬 가져오기, 실행 기록, 더 안정적인 에이전트", changes: [], features: [ - "댓글 입력창에서 보내기 전에 어떤 에이전트나 스쿼드가 작업을 시작할지 확인하고, 실수로 실행되는 일을 줄일 수 있습니다.", - "실행 기록에 시간이 표시되어 에이전트 진행 상황과 인계를 더 쉽게 검토할 수 있습니다.", - "오토파일럿 상세 페이지에서 누가 만들었는지 확인할 수 있습니다.", - "Claude Fable 5가 Multica의 지원 모델과 가격 목록에 추가되었습니다.", + "스킬을 가져올 때 같은 스킬이 이미 있으면 중단, 교체, 이름을 바꿔 저장, 건너뛰기 중에서 선택할 수 있습니다.", + "가져오기 결과에서 추가, 업데이트, 건너뜀, 충돌, 실패한 스킬을 더 명확하게 확인할 수 있습니다.", ], improvements: [ - "댓글 트리거 표시가 더 조용하고 명확해졌으며, 긴 에이전트 이름도 덜 비좁게 보입니다.", - "WSL2처럼 Multica 밖에서 데몬을 관리하는 경우 데스크톱은 시작과 중지 조작을 비활성화합니다.", + "웹과 모바일 실행 기록은 최신 과거 실행을 먼저 보여 주어 최근 진행 상황을 더 쉽게 확인할 수 있습니다.", + "변경 로그 콘텐츠를 정리해 최신 릴리스 노트가 올바른 버전에 묶이도록 했습니다.", ], fixes: [ - "이슈 설명의 이미지와 파일은 웹과 데스크톱에서 다시 열어도 계속 표시됩니다.", - "각 이슈 대화 스레드는 해결 답변을 하나만 유지해 결론을 바꿀 때 모두에게 일관되게 보입니다.", - "실시간 연결이 복구된 뒤 이슈 화면이 데이터를 새로고침해 오래된 타임라인이 남지 않습니다.", - "에이전트 작업을 시작한 사람의 기록이 오래된 작업에서도 더 안정적으로 유지됩니다.", - "스크롤 중 고정된 이슈 댓글의 가장자리가 더 깔끔하게 보입니다.", + "이슈 스레드 답글은 도착한 순서대로 표시되어, 늦게 도착한 에이전트 답글이 중간에 끼어들지 않습니다.", + "저장된 세션이 사라져도 에이전트가 새로 시작해 복구할 수 있어, 이후 멘션마다 계속 실패하지 않습니다.", + "새 작업 폴더에서 이슈를 다시 시작할 때 이전 폴더에만 있던 세션을 재시도하지 않고 새 세션으로 시작합니다.", ], }, { version: "0.3.19", - date: "2026-06-09", - title: "더 안정적인 에이전트, 첨부 파일, 이슈 대화", + date: "2026-06-10", + title: "더 안전한 댓글 트리거, 안정적인 에이전트와 첨부 파일", changes: [], features: [ + "댓글 입력창에서 보내기 전에 어떤 에이전트나 스쿼드가 작업을 시작할지 확인하고, 실수로 실행되는 일을 줄일 수 있습니다.", + "실행 기록에 시간이 표시되어 에이전트 진행 상황과 인계를 더 쉽게 검토할 수 있습니다.", + "오토파일럿 상세 페이지에서 누가 만들었는지 확인할 수 있습니다.", + "Claude Fable 5가 Multica의 지원 모델과 가격 목록에 추가되었습니다.", "이슈 대화에서 특정 답글을 해결 답변으로 남길 수 있어, 긴 스레드를 접어도 결론을 더 쉽게 확인할 수 있습니다.", "Lark와 Feishu 대화는 Multica가 답변을 준비하는 동안 입력 중 반응을 표시하고, 답변을 보내기 전에 자동으로 지웁니다.", "에이전트 실행은 각 작업을 누가 시작했는지 알 수 있어 인계, 감사, 개인정보를 고려한 동작이 더 정확해집니다.", "OpenClaw 사용자는 로컬 설정에서 사용자 지정 앱 위치와 데이터 폴더를 지정할 수 있습니다.", ], improvements: [ + "댓글 트리거 표시가 더 조용하고 명확해졌으며, 긴 에이전트 이름도 덜 비좁게 보입니다.", + "WSL2처럼 Multica 밖에서 데몬을 관리하는 경우 데스크톱은 시작과 중지 조작을 비활성화합니다.", "이슈 헤더의 활성 에이전트 표시가 더 읽기 쉬워졌으며, 실제 실행 중일 때만 움직이고 대기 중일 때는 대기 상태를 명확히 보여 줍니다.", "CLI는 흔한 오류, 로그인 문제, 프로젝트 설정 값에 대해 더 명확하게 안내합니다.", ], fixes: [ + "이슈 설명의 이미지와 파일은 웹과 데스크톱에서 다시 열어도 계속 표시됩니다.", + "각 이슈 대화 스레드는 해결 답변을 하나만 유지해 결론을 바꿀 때 모두에게 일관되게 보입니다.", + "실시간 연결이 복구된 뒤 이슈 화면이 데이터를 새로고침해 오래된 타임라인이 남지 않습니다.", + "에이전트 작업을 시작한 사람의 기록이 오래된 작업에서도 더 안정적으로 유지됩니다.", + "스크롤 중 고정된 이슈 댓글의 가장자리가 더 깔끔하게 보입니다.", "새로 올린 첨부 파일은 안정적인 비공개 다운로드 링크를 사용해 임시 업로드 링크가 만료된 뒤에도 이미지와 파일이 계속 표시됩니다.", "새 이슈에서 시작된 오토파일럿 실행은 배정된 작업이 완료되지 못하면 올바르게 실패 처리되어 진행 중에 멈춰 있지 않습니다.", "받은함에서 댓글 링크를 열 때 데스크톱 화면 전체가 밀리지 않고 이슈 타임라인 안에서만 스크롤됩니다.", diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 70975423bf..c54c03ed38 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -292,45 +292,72 @@ export function createZhDict(allowSignup: boolean): LandingDict { fixes: "问题修复", }, entries: [ + { + version: "0.3.21", + date: "2026-06-12", + title: "CodeBuddy Runtime", + changes: [], + features: [ + "CodeBuddy 现在可以驱动本地 Multica 智能体,并会自动显示可用的模型和投入强度选项", + "快速创建 Issue 时上传的文件现在会从草稿一直带到最终创建的 Issue 里", + ], + improvements: [ + "技能导入冲突更容易理解:锁定的技能会显示成员名称,不再显示内部 ID;单个覆盖也可以一键完成", + "桌面端恢复提示会先说明发生了什么,并给出更清楚的窗口卡住反馈信息", + "按注册时间排序或筛选成员的页面现在加载更快", + ], + fixes: [ + "聊天在发送、停止或发送失败恢复时,会更稳定地同步消息和草稿", + "Lark 账号绑定现在对已登录用户也能稳定完成,登录后也会回到绑定页面", + "本地智能体运行不会再在任务文件夹准备好之前就显示已经开始", + ], + }, { version: "0.3.20", - date: "2026-06-10", - title: "更安全的评论触发和更稳定的附件", + date: "2026-06-11", + title: "技能导入、运行记录和更稳定的智能体", changes: [], features: [ - "评论输入框现在会在发送前显示哪些智能体或小队会开始工作,也可以避免误触发运行", - "智能体运行记录现在会显示时间点,回看进度和交接信息更清楚", - "自动任务详情页现在会显示创建人", - "Claude Fable 5 现在已加入 Multica 支持的模型和价格列表", + "导入技能时,如果同名技能已存在,现在可以选择停止、替换、另存为新名称或跳过", + "导入结果会清楚显示哪些技能已新增、已更新、已跳过、发生冲突或导入失败", ], improvements: [ - "评论触发提示更安静、更清楚,遇到较长的智能体名称时也不容易拥挤", - "桌面端在守护进程由 Multica 之外的环境管理时,会禁用启动和停止控制,例如 WSL2 场景", + "网页端和移动端的执行记录现在会优先显示最新的历史运行,更容易看清最近进展", + "更新日志内容已整理,最新发布内容会归在正确的版本下", ], fixes: [ - "Issue 描述里的图片和文件在网页端和桌面端重新打开后都会保持可见", - "每个 Issue 讨论线程现在只会保留一个解决结论,替换结论时所有人看到的状态更一致", - "实时连接断开并恢复后,Issue 页面会刷新数据,避免时间线停留在旧状态", - "智能体任务的发起人历史在较早任务记录上也会更可靠", - "滚动时置顶的 Issue 评论边缘显示更干净", + "Issue 讨论里的回复现在会按到达顺序显示,即使较慢的智能体回复稍后才出现,也不会插到前面", + "当已保存的会话失效时,智能体可以自动重新开始,不会在后续每次提及时反复失败", + "从新的工作目录重新唤起 Issue 时,现在会开始新会话,不会继续尝试只存在于旧目录里的会话", ], }, { version: "0.3.19", - date: "2026-06-09", - title: "身份上下文优化、附件稳定性和 Issue 讨论升级", + date: "2026-06-10", + title: "更安全的评论触发、更稳定的智能体和附件", changes: [], features: [ + "评论输入框现在会在发送前显示哪些智能体或小队会开始工作,也可以避免误触发运行", + "智能体运行记录现在会显示时间点,回看进度和交接信息更清楚", + "自动任务详情页现在会显示创建人", + "Claude Fable 5 现在已加入 Multica 支持的模型和价格列表", "Issue 讨论可以把某一条回复设为解决结论,长讨论收起后也能直接看到最终答案", "在 Lark 和飞书里和 Multica 对话时,会显示等待中的输入状态,回复发出后自动清除", "每次智能体任务都会带上真实发起人信息,交接、审计和权限判断更准确", "OpenClaw 可以从本地配置中读取自定义程序位置和数据目录", ], improvements: [ + "评论触发提示更安静、更清楚,遇到较长的智能体名称时也不容易拥挤", + "桌面端在守护进程由 Multica 之外的环境管理时,会禁用启动和停止控制,例如 WSL2 场景", "Issue 顶部的智能体状态更容易区分:运行中才显示动效,等待中会明确显示排队状态", "命令行会直接说明常见错误、登录问题和项目配置问题的处理方式", ], fixes: [ + "Issue 描述里的图片和文件在网页端和桌面端重新打开后都会保持可见", + "每个 Issue 讨论线程现在只会保留一个解决结论,替换结论时所有人看到的状态更一致", + "实时连接断开并恢复后,Issue 页面会刷新数据,避免时间线停留在旧状态", + "智能体任务的发起人历史在较早任务记录上也会更可靠", + "滚动时置顶的 Issue 评论边缘显示更干净", "新上传的附件会使用稳定的私有下载链接,临时上传链接过期后图片和文件仍能正常显示", "自动任务通过新建 Issue 启动后,如果对应的智能体任务失败,会同步标记为失败,不会一直卡在进行中", "从收件箱打开评论链接时,只会滚动 Issue 时间线,不会把桌面窗口内容顶出可见区域", diff --git a/packages/core/api/client.test.ts b/packages/core/api/client.test.ts index 563653a61a..f5cfd0a899 100644 --- a/packages/core/api/client.test.ts +++ b/packages/core/api/client.test.ts @@ -487,6 +487,109 @@ describe("ApiClient", () => { }); }); + describe("cancelTaskById response parsing", () => { + const taskResponse = { + id: "task-1", + agent_id: "agent-1", + runtime_id: "runtime-1", + issue_id: "", + status: "cancelled", + priority: 0, + dispatched_at: null, + started_at: null, + completed_at: "2026-06-12T06:40:00Z", + result: null, + error: null, + created_at: "2026-06-12T06:39:00Z", + }; + + it("parses the cancelled chat message payload", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + ...taskResponse, + cancelled_chat_message: { + chat_session_id: "session-1", + message_id: "message-1", + content: "restore me", + restore_to_input: true, + }, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const client = new ApiClient("https://api.example.test"); + const result = await client.cancelTaskById("task-1"); + + expect(fetchMock.mock.calls[0]).toMatchObject([ + "https://api.example.test/api/tasks/task-1/cancel", + { method: "POST" }, + ]); + expect(result.cancelled_chat_message).toEqual({ + chat_session_id: "session-1", + message_id: "message-1", + content: "restore me", + restore_to_input: true, + }); + }); + + it("treats a null cancelled chat message as absent", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ + ...taskResponse, + cancelled_chat_message: null, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const client = new ApiClient("https://api.example.test"); + const result = await client.cancelTaskById("task-1"); + + expect(result.id).toBe("task-1"); + expect(result.cancelled_chat_message).toBeUndefined(); + }); + + it.each([ + ["a missing task id", { ...taskResponse, id: undefined }], + [ + "a malformed cancelled chat message", + { + ...taskResponse, + cancelled_chat_message: { + chat_session_id: "session-1", + message_id: "message-1", + content: "restore me", + restore_to_input: "true", + }, + }, + ], + ["a null body", null], + ])("falls back for %s", async (_label, body) => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + const client = new ApiClient("https://api.example.test"); + const result = await client.cancelTaskById("task-1"); + + expect(result.id).toBe(""); + expect(result.cancelled_chat_message).toBeUndefined(); + }); + }); + describe("chat attachment wiring", () => { it("uploadFile includes chat_session_id in the FormData body", async () => { const fetchMock = vi.fn().mockResolvedValue( diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 0d4885c523..7be5696a04 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -66,6 +66,7 @@ import type { ChatPendingTask, PendingChatTasksResponse, SendChatMessageResponse, + CancelTaskResponse, Project, CreateProjectRequest, UpdateProjectRequest, @@ -135,6 +136,7 @@ import { AgentTemplateSchema, AgentTemplateSummaryListSchema, AttachmentResponseSchema, + CancelTaskResponseSchema, ChildIssuesResponseSchema, CommentsListSchema, CommentTriggerPreviewSchema, @@ -193,6 +195,7 @@ import { EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE, EMPTY_BILLING_CHECKOUT_SESSION_STATUS, EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE, + EMPTY_CANCEL_TASK_RESPONSE, OctoInstallationSchema, EMPTY_OCTO_INSTALLATION, ListOctoInstallationsResponseSchema, @@ -571,6 +574,7 @@ export class ApiClient { prompt: string; project_id?: string | null; parent_issue_id?: string | null; + attachment_ids?: string[]; }): Promise<{ task_id: string }> { return this.fetch("/api/issues/quick-create", { method: "POST", @@ -1692,8 +1696,11 @@ export class ApiClient { await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" }); } - async cancelTaskById(taskId: string): Promise { - await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" }); + async cancelTaskById(taskId: string): Promise { + const raw = await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" }); + return parseWithFallback(raw, CancelTaskResponseSchema, EMPTY_CANCEL_TASK_RESPONSE, { + endpoint: "POST /api/tasks/{taskId}/cancel", + }); } async listAttachments(issueId: string): Promise { diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 9568cac02a..3f4acad17c 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -10,6 +10,7 @@ import type { BillingPriceTier, BillingTopupsPage, BillingTransactionsPage, + CancelTaskResponse, CreateAgentFromTemplateResponse, CreateBillingCheckoutSessionResponse, CreateBillingPortalSessionResponse, @@ -423,6 +424,67 @@ const RuntimeUsageByHourSchema = z.object({ export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema); +// --------------------------------------------------------------------------- +// Task cancellation (`POST /api/tasks/:id/cancel`) +// +// This response is consumed directly by chat recovery. The embedded task +// object stays loose so daemon/runtime fields can drift, but the optional +// `cancelled_chat_message` payload must be well-formed before the UI deletes +// a message from cache or restores text into the input. +// --------------------------------------------------------------------------- + +const AgentTaskResponseSchema = z.object({ + id: z.string(), + agent_id: z.string().default(""), + runtime_id: z.string().default(""), + issue_id: z.string().default(""), + status: z.string().default("cancelled"), + priority: z.number().default(0), + dispatched_at: z.string().nullable().default(null), + started_at: z.string().nullable().default(null), + completed_at: z.string().nullable().default(null), + result: z.unknown().default(null), + error: z.string().nullable().default(null), + failure_reason: z.string().optional(), + created_at: z.string().default(""), + chat_session_id: z.string().optional(), + autopilot_run_id: z.string().optional(), + parent_task_id: z.string().optional(), + attempt: z.number().optional(), + trigger_comment_id: z.string().optional(), + trigger_summary: z.string().optional(), + kind: z.string().optional(), + work_dir: z.string().optional(), + relative_work_dir: z.string().optional(), +}).loose(); + +const CancelledChatMessageSchema = z.object({ + chat_session_id: z.string(), + message_id: z.string(), + content: z.string(), + restore_to_input: z.boolean().default(false), +}).loose(); + +export const CancelTaskResponseSchema = AgentTaskResponseSchema.extend({ + cancelled_chat_message: CancelledChatMessageSchema.nullish() + .transform((value) => value ?? undefined), +}).loose(); + +export const EMPTY_CANCEL_TASK_RESPONSE: CancelTaskResponse = { + id: "", + agent_id: "", + runtime_id: "", + issue_id: "", + status: "cancelled", + priority: 0, + dispatched_at: null, + started_at: null, + completed_at: null, + result: null, + error: null, + created_at: "", +}; + // --------------------------------------------------------------------------- // Agent template catalog — `/api/agent-templates*` and the // create-from-template response. The desktop app's create-agent picker diff --git a/packages/core/realtime/use-realtime-sync.test.ts b/packages/core/realtime/use-realtime-sync.test.ts index a1ede2d4ef..94e118972c 100644 --- a/packages/core/realtime/use-realtime-sync.test.ts +++ b/packages/core/realtime/use-realtime-sync.test.ts @@ -19,6 +19,7 @@ import { applyChatDoneToCache, applyWorkspaceUpdatedToCache, handleInboxNew, + invalidateChatMessageQueries, resolveInboxSourceSlug, } from "./use-realtime-sync"; @@ -134,6 +135,18 @@ describe("applyChatDoneToCache", () => { }); }); +describe("invalidateChatMessageQueries", () => { + it("invalidates both legacy and paged chat message caches", () => { + const qc = createQueryClient(); + const invalidate = vi.spyOn(qc, "invalidateQueries"); + + invalidateChatMessageQueries(qc, sessionId); + + expect(invalidate).toHaveBeenCalledWith({ queryKey: chatKeys.messages(sessionId) }); + expect(invalidate).toHaveBeenCalledWith({ queryKey: chatKeys.messagesPage(sessionId) }); + }); +}); + describe("applyWorkspaceUpdatedToCache", () => { const wsId = "ws-1"; diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index aa0c2b0378..68e678964d 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -90,6 +90,14 @@ const chatWsLogger = createLogger("chat.ws"); const logger = createLogger("realtime-sync"); +export function invalidateChatMessageQueries( + qc: QueryClient, + sessionId: string, +) { + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); +} + export function applyChatDoneToCache( qc: QueryClient, payload: ChatDonePayload, @@ -126,8 +134,7 @@ export function applyChatDoneToCache( qc.setQueryData(chatKeys.pendingTask(sessionId), {}); // Authoritative refetch reconciles redaction / migrations / clients // that took the fallback branch above. - qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); - qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); + invalidateChatMessageQueries(qc, sessionId); qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) }); } @@ -840,7 +847,7 @@ export function useRealtimeSync( const unsubChatMessage = ws.on("chat:message", (p) => { const payload = p as { chat_session_id: string }; chatWsLogger.info("chat:message (global)", { chat_session_id: payload.chat_session_id }); - qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) }); + invalidateChatMessageQueries(qc, payload.chat_session_id); qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) }); invalidatePendingAggregate(); }); @@ -954,6 +961,9 @@ export function useRealtimeSync( // 2. another tab / admin / system cancels — this is the only path that // drops the pending pill in those cases. Without it the pill spins // forever in the second-tab scenario. + // CancelTask also persists a best-effort assistant snapshot when the + // stopped chat task had already streamed transcript rows, so refresh the + // message page along with clearing pending. const unsubTaskCancelled = ws.on("task:cancelled", (p) => { const payload = p as TaskCancelledPayload; if (!payload.chat_session_id) return; @@ -962,6 +972,7 @@ export function useRealtimeSync( chat_session_id: payload.chat_session_id, }); qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {}); + invalidateChatMessageQueries(qc, payload.chat_session_id); invalidatePendingAggregate(); }); @@ -995,7 +1006,7 @@ export function useRealtimeSync( // this branch only flipped pending — the comment "No new message" // was true then, but FailTask now persists a row. qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {}); - qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) }); + invalidateChatMessageQueries(qc, payload.chat_session_id); qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) }); invalidatePendingAggregate(); }); diff --git a/packages/core/runtimes/cli-version.test.ts b/packages/core/runtimes/cli-version.test.ts index ea05fba86f..08e7eb4678 100644 --- a/packages/core/runtimes/cli-version.test.ts +++ b/packages/core/runtimes/cli-version.test.ts @@ -3,11 +3,12 @@ import { checkQuickCreateCliVersion } from "./cli-version"; describe("checkQuickCreateCliVersion", () => { it("returns ok for a tagged release at or above the minimum", () => { - expect(checkQuickCreateCliVersion("v0.2.20").state).toBe("ok"); + expect(checkQuickCreateCliVersion("v0.2.21").state).toBe("ok"); expect(checkQuickCreateCliVersion("0.3.1").state).toBe("ok"); }); it("returns too_old for a tagged release below the minimum", () => { + expect(checkQuickCreateCliVersion("v0.2.20").state).toBe("too_old"); expect(checkQuickCreateCliVersion("v0.2.15").state).toBe("too_old"); }); diff --git a/packages/core/runtimes/cli-version.ts b/packages/core/runtimes/cli-version.ts index e7c8e0891e..6bce638406 100644 --- a/packages/core/runtimes/cli-version.ts +++ b/packages/core/runtimes/cli-version.ts @@ -2,15 +2,15 @@ * Frontend mirror of the server's MinQuickCreateCLIVersion gate. The * agent-create flow (Quick Create modal) requires the daemon's bundled * multica CLI to be at least this version — older daemons either - * double-create issues on partial CLI failures or mishandle pasted - * screenshot URLs (see PR #1851 / MUL-1496). + * double-create issues on partial CLI failures, drop quick-create attachment + * bindings, or mishandle pasted screenshot URLs (see PR #1851 / MUL-1496). * * Both the frontend pre-validation in the modal and the server's * `/api/issues/quick-create` handler enforce this; the server is the * authoritative trust boundary, the frontend just lets us tell the user * "your daemon needs an upgrade" before they hit submit. */ -export const MIN_QUICK_CREATE_CLI_VERSION = "0.2.20"; +export const MIN_QUICK_CREATE_CLI_VERSION = "0.2.21"; export type CliVersionState = "ok" | "too_old" | "missing"; diff --git a/packages/core/runtimes/local-skills.ts b/packages/core/runtimes/local-skills.ts index 20f7fe0b36..f0c711f842 100644 --- a/packages/core/runtimes/local-skills.ts +++ b/packages/core/runtimes/local-skills.ts @@ -67,6 +67,16 @@ export async function resolveRuntimeLocalSkillImport( current = await api.getImportLocalSkillResult(runtimeId, initial.id); } + if (current.status === "conflict") { + if (!current.conflict) { + throw new Error("runtime local skill import conflict missing details"); + } + return { + status: "conflict", + conflict: current.conflict, + }; + } + if (current.status === "failed" || current.status === "timeout") { throw new Error(current.error || "runtime local skill import failed"); } @@ -74,7 +84,10 @@ export async function resolveRuntimeLocalSkillImport( throw new Error("runtime local skill import did not return a skill"); } - return { skill: current.skill }; + return { + status: current.action === "overwrite" ? "updated" : "created", + skill: current.skill, + }; } export function runtimeLocalSkillsOptions(runtimeId: string | null | undefined) { diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index c015b46854..8e43d60237 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -617,9 +617,18 @@ export type RuntimeLocalSkillStatus = | "pending" | "running" | "completed" + | "conflict" | "failed" | "timeout"; +export type RuntimeLocalSkillImportAction = "overwrite"; + +export interface RuntimeLocalSkillImportConflict { + existing_skill_id: string; + existing_created_by?: string; + can_overwrite: boolean; +} + export interface RuntimeLocalSkillSummary { key: string; name: string; @@ -644,6 +653,9 @@ export interface CreateRuntimeLocalSkillImportRequest { skill_key: string; name?: string; description?: string; + action?: RuntimeLocalSkillImportAction; + target_skill_id?: string; + supports_conflict?: boolean; } export interface RuntimeLocalSkillImportRequest { @@ -652,8 +664,12 @@ export interface RuntimeLocalSkillImportRequest { skill_key: string; name?: string; description?: string; + action?: RuntimeLocalSkillImportAction; + target_skill_id?: string; + supports_conflict?: boolean; status: RuntimeLocalSkillStatus; skill?: Skill; + conflict?: RuntimeLocalSkillImportConflict; error?: string; created_at: string; updated_at: string; @@ -665,5 +681,7 @@ export interface RuntimeLocalSkillsResult { } export interface RuntimeLocalSkillImportResult { - skill: Skill; + status: "created" | "updated" | "conflict"; + skill?: Skill; + conflict?: RuntimeLocalSkillImportConflict; } diff --git a/packages/core/types/chat.ts b/packages/core/types/chat.ts index 762b01a6ab..7b19710c1e 100644 --- a/packages/core/types/chat.ts +++ b/packages/core/types/chat.ts @@ -1,3 +1,5 @@ +import type { AgentTask } from "./agent"; + export interface ChatSession { id: string; workspace_id: string; @@ -79,6 +81,17 @@ export interface SendChatMessageResponse { created_at: string; } +export interface CancelledChatMessage { + chat_session_id: string; + message_id: string; + content: string; + restore_to_input: boolean; +} + +export interface CancelTaskResponse extends AgentTask { + cancelled_chat_message?: CancelledChatMessage; +} + /** * Response from GET /api/chat/sessions/{id}/pending-task. * All fields are absent when the session has no in-flight task. diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index aa726264e8..0dd45db84a 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -44,6 +44,8 @@ export type { RuntimeModelListStatus, RuntimeModelsResult, RuntimeLocalSkillStatus, + RuntimeLocalSkillImportAction, + RuntimeLocalSkillImportConflict, RuntimeLocalSkillSummary, RuntimeLocalSkillListRequest, CreateRuntimeLocalSkillImportRequest, @@ -66,7 +68,17 @@ export type * from "./events"; export type * from "./api"; export type { Attachment } from "./attachment"; export { attachmentDownloadPath, attachmentIdFromDownloadURL, contentReferencesAttachment } from "./attachment-url"; -export type { ChatSession, ChatMessage, ChatMessagesPage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat"; +export type { + ChatSession, + ChatMessage, + ChatMessagesPage, + ChatPendingTask, + PendingChatTaskItem, + PendingChatTasksResponse, + SendChatMessageResponse, + CancelledChatMessage, + CancelTaskResponse, +} from "./chat"; export type { StorageAdapter } from "./storage"; export type { Project, diff --git a/packages/ui/components/common/reaction-bar.tsx b/packages/ui/components/common/reaction-bar.tsx index 12a05c852a..1b57305c67 100644 --- a/packages/ui/components/common/reaction-bar.tsx +++ b/packages/ui/components/common/reaction-bar.tsx @@ -40,7 +40,6 @@ interface ReactionBarProps { onToggle: (emoji: string) => void; getActorName: (type: string, id: string) => string; className?: string; - hideAddButton?: boolean; } function ReactionBar({ @@ -49,7 +48,6 @@ function ReactionBar({ onToggle, getActorName, className, - hideAddButton, }: ReactionBarProps) { const grouped = groupReactions(reactions, currentUserId); @@ -78,7 +76,7 @@ function ReactionBar({ ))} - {!hideAddButton && } + ); } diff --git a/packages/views/chat/components/chat-input.test.tsx b/packages/views/chat/components/chat-input.test.tsx index 8a0ed33c12..ee123e815e 100644 --- a/packages/views/chat/components/chat-input.test.tsx +++ b/packages/views/chat/components/chat-input.test.tsx @@ -1,5 +1,5 @@ import { forwardRef, useRef, useImperativeHandle } from "react"; -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; import { act, render, screen, fireEvent, waitFor } from "@testing-library/react"; import { I18nProvider } from "@multica/core/i18n/react"; import type { UploadResult } from "@multica/core/hooks/use-file-upload"; @@ -130,6 +130,24 @@ vi.mock("@multica/core/chat", () => { }); import { ChatInput } from "./chat-input"; +import { useChatStore } from "@multica/core/chat"; + +beforeEach(() => { + dropHandlers.onDrop = null; + editorProps.last = null; + const state = useChatStore.getState() as unknown as { + activeSessionId: string | null; + selectedAgentId: string; + inputDrafts: Record; + setInputDraft: ReturnType; + clearInputDraft: ReturnType; + }; + state.activeSessionId = null; + state.selectedAgentId = "agent-1"; + state.inputDrafts = {}; + state.setInputDraft.mockClear(); + state.clearInputDraft.mockClear(); +}); function renderInput(props: Partial> = {}) { const onSend = props.onSend ?? vi.fn(); @@ -313,3 +331,105 @@ describe("ChatInput attachment wiring", () => { expect(buttons.length).toBe(1); }); }); + +describe("ChatInput async send", () => { + it("restores a cancelled empty run draft into the editor", async () => { + const onRestoreDraftConsumed = vi.fn(); + renderInput({ + restoreDraftRequest: { + id: "msg-restored", + content: "bring this back", + }, + onRestoreDraftConsumed, + }); + + await waitFor(() => { + expect(useChatStore.getState().setInputDraft).toHaveBeenCalledWith( + "__draft_new__:agent-1", + "bring this back", + ); + expect(editorProps.last?.defaultValue).toBe("bring this back"); + expect(onRestoreDraftConsumed).toHaveBeenCalledTimes(1); + }); + }); + + it("consumes a restore request even when an existing draft blocks restore", async () => { + const state = useChatStore.getState() as unknown as { + inputDrafts: Record; + setInputDraft: ReturnType; + }; + state.inputDrafts["__draft_new__:agent-1"] = "already typing"; + const onRestoreDraftConsumed = vi.fn(); + + renderInput({ + restoreDraftRequest: { + id: "msg-restored", + content: "bring this back", + }, + onRestoreDraftConsumed, + }); + + await waitFor(() => { + expect(onRestoreDraftConsumed).toHaveBeenCalledTimes(1); + }); + expect(state.setInputDraft).not.toHaveBeenCalledWith( + "__draft_new__:agent-1", + "bring this back", + ); + }); + + it("keeps the draft while send is pending and clears after acceptance", async () => { + let resolveSend: (accepted: boolean) => void; + const sendPromise = new Promise((res) => { + resolveSend = res; + }); + const onSend = vi.fn(() => sendPromise); + renderInput({ onSend }); + + fireEvent.change(screen.getByTestId("editor"), { target: { value: "slow network" } }); + + let sendButton: HTMLElement; + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + sendButton = buttons[buttons.length - 1]!; + expect(sendButton).not.toBeDisabled(); + }); + + fireEvent.click(sendButton!); + + expect(onSend).toHaveBeenCalledWith("slow network", undefined); + expect(useChatStore.getState().clearInputDraft).not.toHaveBeenCalled(); + await waitFor(() => expect(sendButton!).toBeDisabled()); + + await act(async () => { + resolveSend!(true); + await sendPromise; + }); + + await waitFor(() => { + expect(useChatStore.getState().clearInputDraft).toHaveBeenCalledWith("__draft_new__:agent-1"); + }); + }); + + it("keeps the draft when send is rejected by the owner", async () => { + const onSend = vi.fn(async () => false); + renderInput({ onSend }); + + fireEvent.change(screen.getByTestId("editor"), { target: { value: "retry me" } }); + + let sendButton: HTMLElement; + await waitFor(() => { + const buttons = screen.getAllByRole("button"); + sendButton = buttons[buttons.length - 1]!; + expect(sendButton).not.toBeDisabled(); + }); + + await act(async () => { + fireEvent.click(sendButton!); + await Promise.resolve(); + }); + + expect(onSend).toHaveBeenCalledWith("retry me", undefined); + expect(useChatStore.getState().clearInputDraft).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/views/chat/components/chat-input.tsx b/packages/views/chat/components/chat-input.tsx index 38d2e7efb1..6c8ebd4a46 100644 --- a/packages/views/chat/components/chat-input.tsx +++ b/packages/views/chat/components/chat-input.tsx @@ -1,7 +1,7 @@ "use client"; import type { ReactNode } from "react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "@multica/ui/lib/utils"; import { ContentEditor, @@ -21,7 +21,9 @@ import { useT } from "../../i18n"; const logger = createLogger("chat.ui"); interface ChatInputProps { - onSend: (content: string, attachmentIds?: string[]) => void; + onSend: (content: string, attachmentIds?: string[]) => void | boolean | Promise; + restoreDraftRequest?: { id: string; content: string } | null; + onRestoreDraftConsumed?: () => void; /** Receives a File and returns the attachment row (with id + CDN link). * The wrapper owner (ChatWindow) lazy-creates a chat_session if needed * and forwards `chatSessionId` to the upload — chat-input only cares @@ -46,6 +48,8 @@ interface ChatInputProps { export function ChatInput({ onSend, + restoreDraftRequest, + onRestoreDraftConsumed, onUploadFile, onStop, isRunning, @@ -67,9 +71,10 @@ export function ChatInput({ // mid-compose gives each agent its own draft. This is a STORAGE key, not // a React identity. // - // `editorKey` — React `key` on the ContentEditor. Used ONLY to force a + // `editorKey` — React `key` on the ContentEditor. Used to force a // remount when the user explicitly switches agent (so Tiptap's - // Placeholder, which only reads on mount, refreshes to "Tell {agent}…"). + // Placeholder, which only reads on mount, refreshes to "Tell {agent}…") + // or when a cancelled empty run restores a draft from the server. // Crucially this does NOT include `activeSessionId`: when the user // uploads a file in a brand-new chat, `handleUploadFile` first awaits // `ensureSession` which lazily creates the session and flips @@ -82,12 +87,19 @@ export function ChatInput({ // first-upload-creates-session work the same as second-upload. const draftKey = activeSessionId ?? `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`; - const editorKey = selectedAgentId ?? "no-agent"; // Select a primitive — empty-string fallback keeps referential stability. const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? ""); const setInputDraft = useChatStore((s) => s.setInputDraft); const clearInputDraft = useChatStore((s) => s.clearInputDraft); const [isEmpty, setIsEmpty] = useState(!inputDraft.trim()); + const [isSubmitting, setIsSubmitting] = useState(false); + const [editorRestore, setEditorRestore] = useState<{ + id: string; + content: string; + draftKey: string; + } | null>(null); + const activeRestore = editorRestore?.draftKey === draftKey ? editorRestore : null; + const editorKey = `${selectedAgentId ?? "no-agent"}:${activeRestore?.id ?? "base"}`; // Number of in-flight uploads. We track this explicitly (rather than // peeking at the editor on every render) so the SubmitButton visibly // disables the instant an upload starts and re-enables the instant it @@ -110,6 +122,26 @@ export function ChatInput({ // attachment never binds to the chat message. const uploadMapRef = useRef>(new Map()); + useEffect(() => { + if (!restoreDraftRequest) return; + if (inputDraft.trim()) { + logger.info("input.restore skipped: draft already has content", { + draftKey, + restoreId: restoreDraftRequest.id, + }); + onRestoreDraftConsumed?.(); + return; + } + setInputDraft(draftKey, restoreDraftRequest.content); + setIsEmpty(!restoreDraftRequest.content.trim()); + setEditorRestore({ + id: restoreDraftRequest.id, + content: restoreDraftRequest.content, + draftKey, + }); + onRestoreDraftConsumed?.(); + }, [draftKey, inputDraft, onRestoreDraftConsumed, restoreDraftRequest, setInputDraft]); + const handleUpload = useCallback( async (file: File): Promise => { if (!onUploadFile) return null; @@ -135,12 +167,13 @@ export function ChatInput({ onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)), }); - const handleSend = () => { + const handleSend = async () => { const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim(); - if (!content || isRunning || disabled || noAgent) { + if (!content || isRunning || isSubmitting || disabled || noAgent) { logger.debug("input.send skipped", { emptyContent: !content, isRunning, + isSubmitting, disabled, noAgent, }); @@ -172,7 +205,17 @@ export function ChatInput({ draftKey: keyAtSend, attachmentCount: activeIds.length, }); - onSend(content, activeIds.length > 0 ? activeIds : undefined); + setIsSubmitting(true); + let accepted: void | boolean; + try { + accepted = await onSend(content, activeIds.length > 0 ? activeIds : undefined); + } catch (err) { + logger.warn("input.send failed", err); + setIsSubmitting(false); + return; + } + setIsSubmitting(false); + if (accepted === false) return; editorRef.current?.clearContent(); // Drop focus so the caret doesn't keep blinking under the StatusPill / // streaming reply that's about to take over the user's attention. The @@ -228,7 +271,7 @@ export function ChatInput({ // intentionally does not depend on activeSessionId. key={editorKey} ref={editorRef} - defaultValue={inputDraft} + defaultValue={activeRestore?.content ?? inputDraft} placeholder={placeholder} onUpdate={(md) => { setIsEmpty(!md.trim()); @@ -264,7 +307,7 @@ export function ChatInput({ )} 0} + disabled={isEmpty || isSubmitting || !!disabled || !!noAgent || pendingUploads > 0} running={isRunning} onStop={onStop} tooltip={`${t(($) => $.input.send_tooltip)} · ${formatShortcut(modKey, enterKey)}`} diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx index 764f88c9e9..703c8d0acc 100644 --- a/packages/views/chat/components/chat-window.tsx +++ b/packages/views/chat/components/chat-window.tsx @@ -12,6 +12,7 @@ import { PopoverContent, PopoverTrigger, } from "@multica/ui/components/ui/popover"; +import { toast } from "sonner"; import { useWorkspaceId } from "@multica/core/hooks"; import { useAuthStore } from "@multica/core/auth"; import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries"; @@ -35,6 +36,7 @@ import { pendingChatTaskOptions, pendingChatTasksOptions, chatKeys, + isTaskMessageTaskId, } from "@multica/core/chat/queries"; import { useCreateChatSession, @@ -75,6 +77,106 @@ function seedChatMessagesPageCache( ); } +function appendChatMessageToLatestPageCache( + qc: ReturnType, + sessionId: string, + message: ChatMessage, +) { + qc.setQueryData>( + chatKeys.messagesPage(sessionId), + (old) => { + if (!old) { + return { + pages: [{ + messages: [message], + limit: 50, + has_more: false, + next_cursor: null, + }], + pageParams: [null], + }; + } + if (old.pages.some((page) => page.messages.some((m) => m.id === message.id))) { + return old; + } + return { + ...old, + pages: old.pages.map((page, index) => + index === 0 ? { ...page, messages: [...page.messages, message] } : page, + ), + }; + }, + ); +} + +function removeChatMessageFromPageCache( + qc: ReturnType, + sessionId: string, + messageId: string, +) { + qc.setQueryData | undefined>( + chatKeys.messagesPage(sessionId), + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + messages: page.messages.filter((m) => m.id !== messageId), + })), + }; + }, + ); +} + +function removeChatMessageFromCaches( + qc: ReturnType, + sessionId: string, + messageId: string, +) { + qc.setQueryData( + chatKeys.messages(sessionId), + (old) => old?.filter((m) => m.id !== messageId) ?? old, + ); + removeChatMessageFromPageCache(qc, sessionId, messageId); +} + +function replaceOptimisticChatMessageId( + qc: ReturnType, + sessionId: string, + optimisticId: string, + messageId: string, + taskId: string, +) { + const replace = (messages: ChatMessage[] | undefined) => { + if (!messages) return messages; + if (messages.some((m) => m.id === messageId)) { + return messages.filter((m) => m.id !== optimisticId); + } + return messages.map((m) => + m.id === optimisticId ? { ...m, id: messageId, task_id: taskId } : m, + ); + }; + + qc.setQueryData( + chatKeys.messages(sessionId), + replace, + ); + qc.setQueryData | undefined>( + chatKeys.messagesPage(sessionId), + (old) => { + if (!old) return old; + return { + ...old, + pages: old.pages.map((page) => ({ + ...page, + messages: replace(page.messages) ?? page.messages, + })), + }; + }, + ); +} + export function ChatWindow() { const { t } = useT("chat"); const wsId = useWorkspaceId(); @@ -123,6 +225,14 @@ export function ChatWindow() { pendingChatTaskOptions(activeSessionId ?? ""), ); const pendingTaskId = pendingTask?.task_id ?? null; + const stopRequestedBeforeTaskRef = useRef(false); + const [restoreDraftRequest, setRestoreDraftRequest] = useState<{ + id: string; + content: string; + } | null>(null); + const handleRestoreDraftConsumed = useCallback(() => { + setRestoreDraftRequest(null); + }, []); // Legacy archived sessions (the old soft-archive feature was removed but // pre-existing rows with status='archived' may still exist) are excluded @@ -277,11 +387,58 @@ export function ChatWindow() { [ensureSession, uploadWithToast, qc, setActiveSession], ); + const cancelChatTask = useCallback( + async ( + taskId: string, + sessionId: string, + options: { restoreDraftToInput: boolean; source: string }, + ) => { + apiLogger.info("cancelTask.start", { + taskId, + sessionId, + source: options.source, + }); + qc.setQueryData(chatKeys.pendingTask(sessionId), {}); + + try { + const result = await api.cancelTaskById(taskId); + const restored = result.cancelled_chat_message; + if (restored?.restore_to_input) { + removeChatMessageFromCaches(qc, restored.chat_session_id, restored.message_id); + if (options.restoreDraftToInput && restored.chat_session_id === sessionId) { + setRestoreDraftRequest({ + id: restored.message_id, + content: restored.content, + }); + } + } + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); + apiLogger.info("cancelTask.success", { + taskId, + sessionId, + restoredToInput: !!restored?.restore_to_input && options.restoreDraftToInput, + }); + return result; + } catch (err) { + apiLogger.warn("cancelTask.error (task may have already finished)", { + taskId, + sessionId, + err, + }); + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); + return null; + } + }, + [qc], + ); + const handleSend = useCallback( - async (content: string, attachmentIds?: string[]) => { + async (content: string, attachmentIds?: string[]): Promise => { if (!activeAgent) { apiLogger.warn("sendChatMessage skipped: no active agent"); - return; + return false; } const finalContent = content; @@ -296,10 +453,17 @@ export function ChatWindow() { attachmentCount: attachmentIds?.length ?? 0, }); - const sessionId = await ensureSession(finalContent); + let sessionId: string | null = null; + try { + sessionId = await ensureSession(finalContent); + } catch (err) { + apiLogger.error("sendChatMessage.ensureSession.error", err); + toast.error(t(($) => $.input.send_failed_toast)); + return false; + } if (!sessionId) { apiLogger.warn("sendChatMessage aborted: ensureSession returned null"); - return; + return false; } // Optimistic burst — everything that gives the user "I sent a message @@ -322,7 +486,7 @@ export function ChatWindow() { // "new-chat first-message" white flash. Priming the cache first means // the very first read after activeSessionId flips hits data // synchronously and ChatMessageList mounts directly. - seedChatMessagesPageCache(qc, sessionId, [optimistic]); + appendChatMessageToLatestPageCache(qc, sessionId, optimistic); qc.setQueryData( chatKeys.messages(sessionId), (old) => (old ? [...old, optimistic] : [optimistic]), @@ -342,12 +506,23 @@ export function ChatWindow() { setActiveSession(sessionId); apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id }); - const result = await api.sendChatMessage(sessionId, finalContent, attachmentIds); + let result; + try { + result = await api.sendChatMessage(sessionId, finalContent, attachmentIds); + } catch (err) { + apiLogger.error("sendChatMessage.error.rollback", { sessionId, optimisticId: optimistic.id, err }); + stopRequestedBeforeTaskRef.current = false; + removeChatMessageFromCaches(qc, sessionId, optimistic.id); + qc.setQueryData(chatKeys.pendingTask(sessionId), {}); + toast.error(t(($) => $.input.send_failed_toast)); + return false; + } apiLogger.info("sendChatMessage.success", { sessionId, messageId: result.message_id, taskId: result.task_id, }); + replaceOptimisticChatMessageId(qc, sessionId, optimistic.id, result.message_id, result.task_id); // Replace the temporary task_id with the server's real one (so the WS // task: handlers can match against it) and snap the anchor to the // server's created_at — keeping the elapsed-seconds reading stable. @@ -356,15 +531,26 @@ export function ChatWindow() { status: "queued", created_at: result.created_at, }); + if (stopRequestedBeforeTaskRef.current) { + stopRequestedBeforeTaskRef.current = false; + await cancelChatTask(result.task_id, sessionId, { + restoreDraftToInput: true, + source: "deferred-send", + }); + return false; + } qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); + return true; }, [ activeSessionId, activeAgent, ensureSession, + cancelChatTask, qc, setActiveSession, + t, ], ); @@ -373,27 +559,19 @@ export function ChatWindow() { apiLogger.debug("cancelTask skipped: no pending task"); return; } - // Optimistic clear — pill disappears + input unlocks the moment the - // user clicks Stop, instead of after the HTTP roundtrip. WS - // task:cancelled will confirm later (no-op if cache is already empty); - // if the cancel POST fails because the task already finished, the - // assistant message arrives via task:completed → chat:done and renders - // normally. Either way the UI is in sync with reality without latency. - apiLogger.info("cancelTask.start", { taskId: pendingTaskId, sessionId: activeSessionId }); - qc.setQueryData(chatKeys.pendingTask(activeSessionId), {}); - qc.invalidateQueries({ queryKey: chatKeys.messages(activeSessionId) }); - qc.invalidateQueries({ queryKey: chatKeys.messagesPage(activeSessionId) }); - // Fire-and-forget — UI is already in its post-cancel state. We log the - // outcome but never block on it. - api.cancelTaskById(pendingTaskId).then( - () => apiLogger.info("cancelTask.success", { taskId: pendingTaskId }), - (err) => - apiLogger.warn("cancelTask.error (task may have already finished)", { - taskId: pendingTaskId, - err, - }), - ); - }, [pendingTaskId, activeSessionId, qc]); + if (!isTaskMessageTaskId(pendingTaskId)) { + stopRequestedBeforeTaskRef.current = true; + apiLogger.info("cancelTask.deferred until server task id", { + taskId: pendingTaskId, + sessionId: activeSessionId, + }); + return; + } + void cancelChatTask(pendingTaskId, activeSessionId, { + restoreDraftToInput: true, + source: "active-input", + }); + }, [pendingTaskId, activeSessionId, cancelChatTask]); const handleSelectAgent = useCallback( (agent: Agent) => { @@ -590,6 +768,8 @@ export function ChatWindow() { * when there's no agent (the EmptyState above carries the CTA). */} apiLogger.info("cancelTask.success (history row)", { taskId: task.task_id, sessionId: session.id }), + (result) => { + const restored = result.cancelled_chat_message; + if (restored?.restore_to_input) { + removeChatMessageFromCaches(queryClient, restored.chat_session_id, restored.message_id); + } + apiLogger.info("cancelTask.success (history row)", { taskId: task.task_id, sessionId: session.id }); + }, (err) => apiLogger.warn("cancelTask.error (history row; task may have already finished)", { taskId: task.task_id, diff --git a/packages/views/issues/components/comment-card.tsx b/packages/views/issues/components/comment-card.tsx index 8ddbea5783..b7f36b8639 100644 --- a/packages/views/issues/components/comment-card.tsx +++ b/packages/views/issues/components/comment-card.tsx @@ -26,7 +26,6 @@ import { import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@multica/ui/components/ui/collapsible"; import { ActorAvatar } from "../../common/actor-avatar"; import { ReactionBar } from "@multica/ui/components/common/reaction-bar"; -import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-picker"; import { cn } from "@multica/ui/lib/utils"; import { copyText } from "@multica/ui/lib/clipboard"; import { useActorName } from "@multica/core/workspace/hooks"; @@ -400,8 +399,6 @@ function CommentRow({ const [confirmDelete, setConfirmDelete] = useState(false); const reactions = entry.reactions ?? []; - const contentText = entry.content ?? ""; - const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8; return (
@@ -437,10 +434,6 @@ function CommentRow({ )}
- onToggleReaction(entry.id, emoji)} - align="end" - /> onToggleReaction(entry.id, emoji)} getActorName={getActorName} - hideAddButton={!isLongContent} className="mt-1.5 pl-12 pr-4" /> @@ -612,8 +604,6 @@ function CommentCardImpl({ const replyCount = allNestedReplies.length; const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80); const reactions = entry.reactions ?? []; - const contentText = entry.content ?? ""; - const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8; const isHighlighted = highlightedCommentId === entry.id; @@ -709,10 +699,6 @@ function CommentCardImpl({ {open && (
- onToggleReaction(entry.id, emoji)} - align="end" - /> onToggleReaction(entry.id, emoji)} getActorName={getActorName} - hideAddButton={!isLongContent} className="mt-1.5 pl-10" /> diff --git a/packages/views/issues/components/execution-log-section.tsx b/packages/views/issues/components/execution-log-section.tsx index 91f21e5541..513c1770bf 100644 --- a/packages/views/issues/components/execution-log-section.tsx +++ b/packages/views/issues/components/execution-log-section.tsx @@ -48,9 +48,9 @@ interface ExecutionLogSectionProps { issueId: string; } -// Past-runs sort priority: failed first (needs attention), then -// cancelled (procedural noise), then completed (the boring 'done' -// case sinks to the bottom). Within each group, newest first. +// Past-runs sort priority: newest first by timestamp. When two runs +// share the same timestamp, failed ranks above cancelled, which ranks +// above completed. const PAST_STATUS_RANK: Record = { failed: 0, cancelled: 1, @@ -96,17 +96,15 @@ export function ExecutionLogSection({ issueId }: ExecutionLogSectionProps) { t.status === "failed" || t.status === "cancelled", ); - // Stable sort: failed first, cancelled second, completed last. - // Within group: newest completed_at first (fall back to created_at - // for malformed rows missing completed_at). return past.toSorted((a, b) => { - const rankDiff = - (PAST_STATUS_RANK[a.status] ?? 99) - - (PAST_STATUS_RANK[b.status] ?? 99); - if (rankDiff !== 0) return rankDiff; const at = a.completed_at ?? a.created_at; const bt = b.completed_at ?? b.created_at; - return new Date(bt).getTime() - new Date(at).getTime(); + const timeDiff = new Date(bt).getTime() - new Date(at).getTime(); + if (timeDiff !== 0) return timeDiff; + return ( + (PAST_STATUS_RANK[a.status] ?? 99) - + (PAST_STATUS_RANK[b.status] ?? 99) + ); }); }, [tasks]); diff --git a/packages/views/issues/components/thread-utils.test.ts b/packages/views/issues/components/thread-utils.test.ts new file mode 100644 index 0000000000..2c1f9c76ce --- /dev/null +++ b/packages/views/issues/components/thread-utils.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { TimelineEntry } from "@multica/core/types"; +import { collectThreadReplies } from "./thread-utils"; + +function comment(id: string, createdAt: string, parentId: string | null): TimelineEntry { + return { + type: "comment", + id, + actor_type: "member", + actor_id: "user-1", + content: id, + parent_id: parentId, + created_at: createdAt, + updated_at: createdAt, + comment_type: "comment", + } as TimelineEntry; +} + +function bucketByParent(entries: TimelineEntry[]): Map { + const map = new Map(); + for (const e of entries) { + if (!e.parent_id) continue; + const list = map.get(e.parent_id) ?? []; + list.push(e); + map.set(e.parent_id, list); + } + return map; +} + +describe("collectThreadReplies", () => { + it("orders a late nested reply after earlier sibling replies (#3691)", () => { + // R1 (50m ago) triggered a slow agent; R2 (30m) and R3 (10m) arrived while + // it ran; D (3m ago) is the agent's reply, forced to nest under R1. A + // depth-first walk yields R1-D-R2-R3; the thread must read R1-R2-R3-D. + const r1 = comment("r1", "2026-06-11T10:00:00Z", "root"); + const r2 = comment("r2", "2026-06-11T10:20:00Z", "root"); + const r3 = comment("r3", "2026-06-11T10:40:00Z", "root"); + const d = comment("d", "2026-06-11T10:47:00Z", "r1"); + + const out = collectThreadReplies("root", bucketByParent([r1, r2, r3, d])); + + expect(out.map((e) => e.id)).toEqual(["r1", "r2", "r3", "d"]); + }); + + it("still returns every descendant across nesting levels", () => { + const r1 = comment("r1", "2026-06-11T10:00:00Z", "root"); + const d1 = comment("d1", "2026-06-11T10:05:00Z", "r1"); + const d2 = comment("d2", "2026-06-11T10:10:00Z", "d1"); + + const out = collectThreadReplies("root", bucketByParent([r1, d1, d2])); + + expect(out.map((e) => e.id)).toEqual(["r1", "d1", "d2"]); + }); + + it("breaks created_at ties by id so the order is deterministic", () => { + const b = comment("b", "2026-06-11T10:00:00Z", "root"); + const a = comment("a", "2026-06-11T10:00:00Z", "b"); + + const out = collectThreadReplies("root", bucketByParent([b, a])); + + expect(out.map((e) => e.id)).toEqual(["a", "b"]); + }); +}); diff --git a/packages/views/issues/components/thread-utils.ts b/packages/views/issues/components/thread-utils.ts index ab821e8b81..a47074fb91 100644 --- a/packages/views/issues/components/thread-utils.ts +++ b/packages/views/issues/components/thread-utils.ts @@ -1,11 +1,19 @@ import type { TimelineEntry } from "@multica/core/types"; +import { sortTimelineEntriesAsc } from "@multica/core/issues/timeline-sort"; /** * Walks the parent_id graph rooted at `rootId` and returns every descendant in - * traversal order. Shared between CommentCard (which renders the expanded - * thread) and ResolvedThreadBar (which displays the collapsed count + author - * list) so the two views stay in sync — direct-children-only counts diverge - * once nested replies exist (see Emacs review on PR #2300). + * CHRONOLOGICAL order (created_at ASC, id tie-break). Shared between + * CommentCard (which renders the expanded thread) and ResolvedThreadBar + * (which displays the collapsed count + author list) so the two views stay in + * sync — direct-children-only counts diverge once nested replies exist (see + * Emacs review on PR #2300). + * + * Chronological, not depth-first: agent replies are forced to nest under the + * comment that triggered them, so a depth-first walk lets a slow agent's late + * reply render BEFORE earlier sibling replies (#3691). The server's --thread + * output the agent reads is already chronological (ListThreadCommentsForIssue + * in comment.sql); this keeps the UI on the same order. */ export function collectThreadReplies( rootId: string, @@ -20,7 +28,7 @@ export function collectThreadReplies( } }; walk(rootId); - return out; + return sortTimelineEntriesAsc(out); } /** diff --git a/packages/views/lark/bind-page.test.tsx b/packages/views/lark/bind-page.test.tsx new file mode 100644 index 0000000000..7352d00f03 --- /dev/null +++ b/packages/views/lark/bind-page.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { I18nProvider } from "@multica/core/i18n/react"; +import enCommon from "../locales/en/common.json"; + +const TEST_RESOURCES = { en: { common: enCommon } }; + +// --------------------------------------------------------------------------- +// Hoisted mutable auth state — lets individual tests set different scenarios +// --------------------------------------------------------------------------- +const mockAuthState = vi.hoisted(() => ({ + user: null as { id: string; email: string } | null, + isLoading: false, +})); + +const mockNavigatePush = vi.hoisted(() => vi.fn()); +const mockRedeemToken = vi.hoisted(() => vi.fn()); + +vi.mock("@multica/core/auth", () => { + const useAuthStore = Object.assign( + (sel?: (s: typeof mockAuthState) => unknown) => + sel ? sel(mockAuthState) : mockAuthState, + { getState: () => mockAuthState }, + ); + return { useAuthStore }; +}); + +vi.mock("../navigation", () => ({ + useNavigation: () => ({ push: mockNavigatePush }), +})); + +vi.mock("@multica/core/api", () => ({ + api: { redeemLarkBindingToken: mockRedeemToken }, +})); + +import { LarkBindPage } from "./bind-page"; + +function I18nWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function renderPage(token: string | null) { + return render(, { wrapper: I18nWrapper }); +} + +describe("LarkBindPage", () => { + beforeEach(() => { + mockAuthState.user = null; + mockAuthState.isLoading = false; + mockNavigatePush.mockReset(); + mockRedeemToken.mockReset(); + }); + + it("shows redeeming text while auth is still loading (not needs-auth)", () => { + mockAuthState.isLoading = true; + mockAuthState.user = null; + renderPage("tok123"); + expect(screen.getByText(/redeeming binding token/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /sign in/i })).toBeNull(); + }); + + it("shows needs-auth UI when auth finishes loading and user is null", () => { + mockAuthState.isLoading = false; + mockAuthState.user = null; + renderPage("tok123"); + expect( + screen.getByRole("button", { name: /sign in/i }), + ).toBeInTheDocument(); + }); + + it("starts redemption immediately when user is already logged in", async () => { + mockAuthState.isLoading = false; + mockAuthState.user = { id: "u1", email: "u@example.com" }; + mockRedeemToken.mockResolvedValue({ + workspace_id: "ws1", + installation_id: "inst1", + }); + renderPage("tok123"); + await waitFor(() => { + expect(mockRedeemToken).toHaveBeenCalledWith("tok123"); + }); + }); + + it("shows success state after successful redemption", async () => { + mockAuthState.isLoading = false; + mockAuthState.user = { id: "u1", email: "u@example.com" }; + mockRedeemToken.mockResolvedValue({ + workspace_id: "ws1", + installation_id: "inst1", + }); + renderPage("tok123"); + await waitFor(() => { + expect(screen.getByText(/you're bound/i)).toBeInTheDocument(); + }); + }); + + it("sign-in button navigates with ?next= parameter (not ?redirect=)", () => { + mockAuthState.isLoading = false; + mockAuthState.user = null; + renderPage("mytoken"); + fireEvent.click(screen.getByRole("button", { name: /sign in/i })); + expect(mockNavigatePush).toHaveBeenCalledTimes(1); + const url: string = mockNavigatePush.mock.calls[0]?.[0] as string; + expect(url).toContain("?next="); + expect(url).not.toContain("?redirect="); + expect(url).toContain(encodeURIComponent("mytoken")); + }); + + it("shows missing token error when token is null", () => { + renderPage(null); + expect( + screen.getByText(/missing its binding token/i), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/views/lark/bind-page.tsx b/packages/views/lark/bind-page.tsx index d1e9e4973e..8c13223618 100644 --- a/packages/views/lark/bind-page.tsx +++ b/packages/views/lark/bind-page.tsx @@ -29,6 +29,7 @@ type RedeemState = export function LarkBindPage({ token }: { token: string | null }) { const { t } = useT("common"); const user = useAuthStore((s) => s.user); + const isAuthLoading = useAuthStore((s) => s.isLoading); const navigation = useNavigation(); const [state, setState] = useState({ kind: "idle" }); @@ -37,11 +38,12 @@ export function LarkBindPage({ token }: { token: string | null }) { setState({ kind: "error", reason: "missing_token" }); return; } + if (isAuthLoading) return; if (!user) { setState({ kind: "needs-auth" }); return; } - if (state.kind !== "idle") return; + if (state.kind !== "idle" && state.kind !== "needs-auth") return; setState({ kind: "redeeming" }); (async () => { try { @@ -58,7 +60,7 @@ export function LarkBindPage({ token }: { token: string | null }) { }); } })(); - }, [token, user, state.kind]); + }, [token, user, isAuthLoading, state.kind]); return (
@@ -76,7 +78,7 @@ export function LarkBindPage({ token }: { token: string | null }) { size="sm" onClick={() => navigation.push( - `/login?redirect=${encodeURIComponent( + `/login?next=${encodeURIComponent( `/lark/bind?token=${encodeURIComponent(token ?? "")}`, )}`, ) diff --git a/packages/views/locales/en/chat.json b/packages/views/locales/en/chat.json index 77ec1da7da..d5aa77689c 100644 --- a/packages/views/locales/en/chat.json +++ b/packages/views/locales/en/chat.json @@ -11,7 +11,8 @@ "placeholder_named": "Message {{name}}…", "placeholder_default": "Start a message…", "send_tooltip": "Send", - "stop_tooltip": "Stop" + "stop_tooltip": "Stop", + "send_failed_toast": "Failed to send message" }, "message_list": { "show_details": "Show details", diff --git a/packages/views/locales/en/skills.json b/packages/views/locales/en/skills.json index de6a32b0b2..6287901417 100644 --- a/packages/views/locales/en/skills.json +++ b/packages/views/locales/en/skills.json @@ -231,9 +231,27 @@ "bulk_complete_hint": "Import complete.", "bulk_cancelled_hint": "Import cancelled.", "bulk_done_button": "Done", - "bulk_summary_imported": "Imported", + "bulk_summary_created": "Created", + "bulk_summary_updated": "Updated", + "bulk_summary_conflicts": "Conflicts", "bulk_summary_skipped": "Skipped", - "bulk_summary_failed": "Failed" + "bulk_summary_failed": "Failed", + "conflict_single_title": "A skill with this name already exists", + "conflict_bulk_title": "{{count}} skills need a decision", + "conflict_hint": "Choose whether to overwrite the existing workspace skill, import with a new name, or skip it.", + "conflict_locked_creator": "This skill was created by {{creator}}. Only the creator can overwrite it from local import; edit it from the Skill detail page if you need to change it.", + "conflict_locked": "Only the creator can overwrite this skill from local import; edit it from the Skill detail page if you need to change it.", + "conflict_overwrite": "Overwrite", + "conflict_rename": "Rename", + "conflict_skip": "Skip", + "conflict_cancel": "Cancel", + "conflict_overwrite_all": "Overwrite all", + "conflict_skip_all": "Skip all", + "conflict_rename_label": "New skill name", + "conflict_footer": "{{count}} conflict decisions pending.", + "conflict_apply_button": "Apply decisions", + "conflict_missing_resolution": "Choose how to resolve this conflict.", + "conflict_name_still_exists": "That name already exists. Choose another name or skip." }, "file_tree": { "no_files": "No files" diff --git a/packages/views/locales/ja/chat.json b/packages/views/locales/ja/chat.json index b297912c76..2a8319be4d 100644 --- a/packages/views/locales/ja/chat.json +++ b/packages/views/locales/ja/chat.json @@ -10,7 +10,8 @@ "placeholder_named": "{{name}} にメッセージ…", "placeholder_default": "メッセージを入力…", "send_tooltip": "送信", - "stop_tooltip": "停止" + "stop_tooltip": "停止", + "send_failed_toast": "メッセージを送信できませんでした" }, "message_list": { "show_details": "詳細を表示", diff --git a/packages/views/locales/ja/skills.json b/packages/views/locales/ja/skills.json index 8ac5001276..ae72a940a7 100644 --- a/packages/views/locales/ja/skills.json +++ b/packages/views/locales/ja/skills.json @@ -228,9 +228,27 @@ "bulk_complete_hint": "インポートが完了しました。", "bulk_cancelled_hint": "インポートをキャンセルしました。", "bulk_done_button": "完了", - "bulk_summary_imported": "インポート済み", + "bulk_summary_created": "作成済み", + "bulk_summary_updated": "更新済み", + "bulk_summary_conflicts": "競合", "bulk_summary_skipped": "スキップ", - "bulk_summary_failed": "失敗" + "bulk_summary_failed": "失敗", + "conflict_single_title": "同じ名前のスキルがすでに存在します", + "conflict_bulk_title": "{{count}} 個のスキルに判断が必要です", + "conflict_hint": "既存のワークスペーススキルを上書きするか、新しい名前でインポートするか、スキップするかを選択してください。", + "conflict_locked_creator": "このスキルは {{creator}} が作成しました。ローカルインポートで上書きできるのは作成者だけです。変更が必要な場合は、スキル詳細ページで編集してください。", + "conflict_locked": "ローカルインポートでこのスキルを上書きできるのは作成者だけです。変更が必要な場合は、スキル詳細ページで編集してください。", + "conflict_overwrite": "上書き", + "conflict_rename": "名前を変更", + "conflict_skip": "スキップ", + "conflict_cancel": "キャンセル", + "conflict_overwrite_all": "すべて上書き", + "conflict_skip_all": "すべてスキップ", + "conflict_rename_label": "新しいスキル名", + "conflict_footer": "{{count}} 件の競合判断が残っています。", + "conflict_apply_button": "決定を適用", + "conflict_missing_resolution": "この競合の処理方法を選択してください。", + "conflict_name_still_exists": "その名前はすでに存在します。別の名前を選択するか、スキップしてください。" }, "file_tree": { "no_files": "ファイルなし" diff --git a/packages/views/locales/ko/chat.json b/packages/views/locales/ko/chat.json index f84be61209..3a8bde7c96 100644 --- a/packages/views/locales/ko/chat.json +++ b/packages/views/locales/ko/chat.json @@ -11,7 +11,8 @@ "placeholder_named": "{{name}}에게 메시지 보내기…", "placeholder_default": "메시지 입력…", "send_tooltip": "보내기", - "stop_tooltip": "중지" + "stop_tooltip": "중지", + "send_failed_toast": "메시지를 보내지 못했습니다" }, "message_list": { "show_details": "세부 정보 보기", diff --git a/packages/views/locales/ko/skills.json b/packages/views/locales/ko/skills.json index dce5b2cf56..562bcdf6f1 100644 --- a/packages/views/locales/ko/skills.json +++ b/packages/views/locales/ko/skills.json @@ -231,9 +231,27 @@ "bulk_complete_hint": "가져오기가 완료되었습니다.", "bulk_cancelled_hint": "가져오기가 취소되었습니다.", "bulk_done_button": "완료", - "bulk_summary_imported": "가져옴", + "bulk_summary_created": "생성됨", + "bulk_summary_updated": "업데이트됨", + "bulk_summary_conflicts": "충돌", "bulk_summary_skipped": "건너뜀", - "bulk_summary_failed": "실패" + "bulk_summary_failed": "실패", + "conflict_single_title": "이 이름의 스킬이 이미 있습니다", + "conflict_bulk_title": "{{count}}개의 스킬에 결정이 필요합니다", + "conflict_hint": "기존 워크스페이스 스킬을 덮어쓸지, 새 이름으로 가져올지, 건너뛸지 선택하세요.", + "conflict_locked_creator": "이 스킬은 {{creator}}님이 만들었습니다. 로컬 가져오기에서 덮어쓰기는 생성자만 할 수 있습니다. 변경이 필요하면 스킬 상세 페이지에서 편집하세요.", + "conflict_locked": "로컬 가져오기에서 이 스킬은 생성자만 덮어쓸 수 있습니다. 변경이 필요하면 스킬 상세 페이지에서 편집하세요.", + "conflict_overwrite": "덮어쓰기", + "conflict_rename": "이름 변경", + "conflict_skip": "건너뛰기", + "conflict_cancel": "취소", + "conflict_overwrite_all": "모두 덮어쓰기", + "conflict_skip_all": "모두 건너뛰기", + "conflict_rename_label": "새 스킬 이름", + "conflict_footer": "{{count}}개의 충돌 결정이 남아 있습니다.", + "conflict_apply_button": "결정 적용", + "conflict_missing_resolution": "이 충돌을 처리할 방법을 선택하세요.", + "conflict_name_still_exists": "해당 이름이 이미 있습니다. 다른 이름을 선택하거나 건너뛰세요." }, "file_tree": { "no_files": "파일 없음" diff --git a/packages/views/locales/zh-Hans/chat.json b/packages/views/locales/zh-Hans/chat.json index 0abfbf3d46..5eb786ff76 100644 --- a/packages/views/locales/zh-Hans/chat.json +++ b/packages/views/locales/zh-Hans/chat.json @@ -10,7 +10,8 @@ "placeholder_named": "给 {{name}} 发消息…", "placeholder_default": "输入消息…", "send_tooltip": "发送", - "stop_tooltip": "停止" + "stop_tooltip": "停止", + "send_failed_toast": "发送消息失败" }, "message_list": { "show_details": "查看详情", diff --git a/packages/views/locales/zh-Hans/skills.json b/packages/views/locales/zh-Hans/skills.json index 7beacc57be..484cb4be64 100644 --- a/packages/views/locales/zh-Hans/skills.json +++ b/packages/views/locales/zh-Hans/skills.json @@ -243,9 +243,27 @@ "bulk_complete_hint": "导入完成。", "bulk_cancelled_hint": "导入已取消。", "bulk_done_button": "完成", - "bulk_summary_imported": "已导入", + "bulk_summary_created": "已创建", + "bulk_summary_updated": "已更新", + "bulk_summary_conflicts": "冲突", "bulk_summary_skipped": "已跳过", - "bulk_summary_failed": "失败" + "bulk_summary_failed": "失败", + "conflict_single_title": "工作区里已存在同名 skill", + "conflict_bulk_title": "{{count}} 个 skill 需要处理冲突", + "conflict_hint": "选择覆盖现有工作区 skill、改名导入,或跳过。", + "conflict_locked_creator": "该 skill 由 {{creator}} 创建,只能由创建者通过本地导入覆盖;如需修改请到 Skill 详情页编辑。", + "conflict_locked": "该 skill 只能由创建者通过本地导入覆盖;如需修改请到 Skill 详情页编辑。", + "conflict_overwrite": "覆盖", + "conflict_rename": "改名", + "conflict_skip": "跳过", + "conflict_cancel": "取消", + "conflict_overwrite_all": "全部覆盖", + "conflict_skip_all": "全部跳过", + "conflict_rename_label": "新的 skill 名称", + "conflict_footer": "还有 {{count}} 个冲突决策待处理。", + "conflict_apply_button": "应用决策", + "conflict_missing_resolution": "请选择如何处理这个冲突。", + "conflict_name_still_exists": "这个名称仍然已存在。请换一个名称或跳过。" }, "file_tree": { "no_files": "无文件" diff --git a/packages/views/modals/quick-create-issue.test.tsx b/packages/views/modals/quick-create-issue.test.tsx index ba2c70b58c..1ab7e2d770 100644 --- a/packages/views/modals/quick-create-issue.test.tsx +++ b/packages/views/modals/quick-create-issue.test.tsx @@ -1,6 +1,6 @@ import { forwardRef, useImperativeHandle, useRef, useState, type ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; const mockQuickCreateIssue = vi.hoisted(() => vi.fn()); @@ -11,6 +11,7 @@ const mockClearPrompt = vi.hoisted(() => vi.fn()); const mockSetKeepOpen = vi.hoisted(() => vi.fn()); const mockSetLastMode = vi.hoisted(() => vi.fn()); const mockToastSuccess = vi.hoisted(() => vi.fn()); +const mockUploadWithToast = vi.hoisted(() => vi.fn()); const mockQuickCreateStore = { lastActorType: null as "agent" | "squad" | null, @@ -116,7 +117,7 @@ vi.mock("@multica/core/runtimes", () => ({ })); vi.mock("@multica/core/hooks/use-file-upload", () => ({ - useFileUpload: () => ({ uploadWithToast: vi.fn(), uploading: false }), + useFileUpload: () => ({ uploadWithToast: mockUploadWithToast, uploading: false }), })); vi.mock("../issues/components/pickers/assignee-picker", () => ({ @@ -141,7 +142,7 @@ vi.mock("../common/pill-button", () => ({ })); vi.mock("../editor", () => { - const ContentEditor = forwardRef(({ defaultValue, onUpdate, onSubmit, placeholder }: any, ref: any) => { + const ContentEditor = forwardRef(({ defaultValue, onUpdate, onSubmit, onUploadFile, placeholder }: any, ref: any) => { const valueRef = useRef(defaultValue || ""); const [value, setValue] = useState(defaultValue || ""); @@ -156,20 +157,28 @@ vi.mock("../editor", () => { })); return ( -