+
IN
-
-
+
OUT
-
+
{toolResult ? (
{toolResult.text}
) : (
-
- 実行中…
-
+
実行中…
)}
+ {hasChildren && (
+
+
+
+ {children.map((child) => (
+
+ ))}
+
+
+ )}
);
@@ -1669,32 +1817,196 @@ function extractSecondaryInfo(_toolName: string, text: string): string | null {
return text.slice(0, 80);
}
+/**
+ * Lightweight, dependency-free shimmering text. A pure-CSS animated
+ * linear-gradient clipped to the text serves as the "currently running"
+ * affordance for tool names and the OUT-pending label. The actual
+ * animation lives in `globals.css` under `.animate-shine` — this
+ * component is just a small wrapper so callers don't have to remember
+ * the class name.
+ */
+function ShinyText({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface ToolPalette {
+ icon: LucideIcon;
+ iconBg: string;
+ iconColor: string;
+ name: string;
+ accent: string;
+}
+
+/**
+ * Map Claude Code tool names to a small accent palette. The defaults are
+ * intentionally low-saturation so a flood of tool calls in the stream
+ * doesn't turn into a rainbow. Unknown tools fall through to the
+ * generic wrench icon. Keep keys here aligned with the actual tool
+ * names Claude Code emits in the NDJSON stream.
+ */
+function getToolPalette(toolName: string): ToolPalette {
+ const fallback: ToolPalette = {
+ icon: Wrench,
+ iconBg: "bg-muted",
+ iconColor: "text-muted-foreground",
+ name: "text-foreground",
+ accent: "hover:bg-accent/20",
+ };
+ const palettes: Record
= {
+ Agent: {
+ icon: Bot,
+ iconBg: "bg-violet-500/15",
+ iconColor: "text-violet-400",
+ name: "text-violet-300",
+ accent: "hover:bg-violet-500/10",
+ },
+ Task: {
+ icon: Bot,
+ iconBg: "bg-violet-500/15",
+ iconColor: "text-violet-400",
+ name: "text-violet-300",
+ accent: "hover:bg-violet-500/10",
+ },
+ Bash: {
+ icon: SquareTerminal,
+ iconBg: "bg-emerald-500/15",
+ iconColor: "text-emerald-400",
+ name: "text-emerald-300",
+ accent: "hover:bg-emerald-500/10",
+ },
+ Read: {
+ icon: FileText,
+ iconBg: "bg-sky-500/15",
+ iconColor: "text-sky-400",
+ name: "text-sky-300",
+ accent: "hover:bg-sky-500/10",
+ },
+ Edit: {
+ icon: FileEdit,
+ iconBg: "bg-amber-500/15",
+ iconColor: "text-amber-400",
+ name: "text-amber-300",
+ accent: "hover:bg-amber-500/10",
+ },
+ MultiEdit: {
+ icon: FilePen,
+ iconBg: "bg-amber-500/15",
+ iconColor: "text-amber-400",
+ name: "text-amber-300",
+ accent: "hover:bg-amber-500/10",
+ },
+ Write: {
+ icon: FilePlus,
+ iconBg: "bg-orange-500/15",
+ iconColor: "text-orange-400",
+ name: "text-orange-300",
+ accent: "hover:bg-orange-500/10",
+ },
+ Grep: {
+ icon: Search,
+ iconBg: "bg-indigo-500/15",
+ iconColor: "text-indigo-400",
+ name: "text-indigo-300",
+ accent: "hover:bg-indigo-500/10",
+ },
+ Glob: {
+ icon: FolderSearch,
+ iconBg: "bg-indigo-500/15",
+ iconColor: "text-indigo-400",
+ name: "text-indigo-300",
+ accent: "hover:bg-indigo-500/10",
+ },
+ WebFetch: {
+ icon: Globe,
+ iconBg: "bg-cyan-500/15",
+ iconColor: "text-cyan-400",
+ name: "text-cyan-300",
+ accent: "hover:bg-cyan-500/10",
+ },
+ WebSearch: {
+ icon: Globe,
+ iconBg: "bg-cyan-500/15",
+ iconColor: "text-cyan-400",
+ name: "text-cyan-300",
+ accent: "hover:bg-cyan-500/10",
+ },
+ TodoWrite: {
+ icon: CheckSquare,
+ iconBg: "bg-pink-500/15",
+ iconColor: "text-pink-400",
+ name: "text-pink-300",
+ accent: "hover:bg-pink-500/10",
+ },
+ NotebookEdit: {
+ icon: FilePen,
+ iconBg: "bg-amber-500/15",
+ iconColor: "text-amber-400",
+ name: "text-amber-300",
+ accent: "hover:bg-amber-500/10",
+ },
+ SlashCommand: {
+ icon: Sparkles,
+ iconBg: "bg-fuchsia-500/15",
+ iconColor: "text-fuchsia-400",
+ name: "text-fuchsia-300",
+ accent: "hover:bg-fuchsia-500/10",
+ },
+ ExitPlanMode: {
+ icon: ListTree,
+ iconBg: "bg-teal-500/15",
+ iconColor: "text-teal-400",
+ name: "text-teal-300",
+ accent: "hover:bg-teal-500/10",
+ },
+ ToolSearch: {
+ icon: Cog,
+ iconBg: "bg-slate-500/15",
+ iconColor: "text-slate-400",
+ name: "text-slate-300",
+ accent: "hover:bg-slate-500/10",
+ },
+ };
+ return palettes[toolName] ?? fallback;
+}
+
function MessageRow({ event }: { event: TodoStreamEvent }) {
if (event.kind === "assistant_text") {
return (
-
+
);
}
if (event.kind === "result") {
return (
-
+
);
}
if (event.kind === "error") {
return (
-
+
{event.text}
);
}
if (event.kind === "system_init") {
return (
-
-
{event.label}
+
+
+ {event.label}
+
{event.text}
);
diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css
index e6785d1973d..f64f0d4f739 100644
--- a/apps/desktop/src/renderer/globals.css
+++ b/apps/desktop/src/renderer/globals.css
@@ -522,4 +522,39 @@
.animate-clone-indeterminate {
animation: clone-indeterminate 1.4s ease-in-out infinite;
}
+
+ /*
+ * Pure-CSS shimmer used by
. A two-stop gradient built from
+ * `currentColor` and a theme-aware `--shine-peak` slides horizontally
+ * across the text. `-webkit-text-fill-color: transparent` keeps the
+ * `color` declaration intact so `currentColor` in the gradient still
+ * reflects whatever text color the surrounding class set.
+ */
+ @keyframes shine {
+ 0% {
+ background-position: 150% center;
+ }
+ 100% {
+ background-position: -50% center;
+ }
+ }
+ .animate-shine {
+ --shine-peak: rgba(255, 255, 255, 0.92);
+ background-image: linear-gradient(
+ 110deg,
+ currentColor 0%,
+ currentColor 35%,
+ var(--shine-peak) 50%,
+ currentColor 65%,
+ currentColor 100%
+ );
+ background-size: 200% auto;
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ animation: shine 2.4s linear infinite;
+ }
+ :root.light .animate-shine {
+ --shine-peak: rgba(0, 0, 0, 0.55);
+ }
}
From 5f9cd98a3f4c605ecb848e5f45a0bc702e47e268 Mon Sep 17 00:00:00 2001
From: MocA-Love <64681295+MocA-Love@users.noreply.github.com>
Date: Fri, 17 Apr 2026 05:55:01 +0900
Subject: [PATCH 16/18] =?UTF-8?q?fix(desktop):=20TodoButton=20=E3=83=90?=
=?UTF-8?q?=E3=83=83=E3=82=B8=E3=81=AB=20waiting=20=E3=82=B9=E3=83=86?=
=?UTF-8?q?=E3=83=BC=E3=82=BF=E3=82=B9=E3=82=92=E8=BF=BD=E5=8A=A0=20(#219)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #219 で追加された `waiting` ステータス (ScheduleWakeup で一時停止中)
が本 PR の新インジケーターで拾えていないため、queued バケットに追加する。
scheduler が waitingUntil 経過後に自動で queued に戻す挙動から、slot を
占有している扱いとして queued と同じバッジで集計する。
---
.../renderer/features/todo-agent/TodoButton/TodoButton.tsx | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoButton/TodoButton.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoButton/TodoButton.tsx
index 039369baf2c..d337a7fa70c 100644
--- a/apps/desktop/src/renderer/features/todo-agent/TodoButton/TodoButton.tsx
+++ b/apps/desktop/src/renderer/features/todo-agent/TodoButton/TodoButton.tsx
@@ -87,6 +87,10 @@ export const TodoButton = memo(function TodoButton({
acc.running += 1;
break;
case "queued":
+ case "waiting":
+ // `waiting` は ScheduleWakeup で一時停止中のセッション。
+ // scheduler が waitingUntil 経過後に自動で queued に戻すため、
+ // slot を占有している扱いとして queued と同じバッジで集計する。
acc.queued += 1;
break;
case "failed":
From 7015bb851ffba056c59ffe1d0b9f87a7783f9f5c Mon Sep 17 00:00:00 2001
From: MocA-Love <64681295+MocA-Love@users.noreply.github.com>
Date: Fri, 17 Apr 2026 06:36:52 +0900
Subject: [PATCH 17/18] =?UTF-8?q?fix(desktop):=20Codex=20=E3=83=AC?=
=?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E5=AF=BE=E5=BF=9C=20=E2=80=94=20res?=
=?UTF-8?q?tart=20=E5=8F=96=E3=82=8A=E3=81=93=E3=81=BC=E3=81=97=20/=20wait?=
=?UTF-8?q?ing=20=E7=B7=A8=E9=9B=86=E4=B8=8D=E6=95=B4=E5=90=88=20/=20tool?=
=?UTF-8?q?=5Fresult=20=E9=87=8D=E8=A4=87=E6=8F=8F=E7=94=BB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- supervisor.start: 直前 abort で active から脱落途中の場合、
以前の早期 return で再開リクエストが捨てられ session が
preparing で固着する問題を修正。abort 済みなら queue に積み、
drain で再起動する。
- TodoManager canEditFields: waiting (ScheduleWakeup 待機) を
除外。バックエンド updateFields が waiting を許可せず保存時
に PRECONDITION_FAILED で確定失敗する不整合を解消。
- buildStreamTree: イベントが順序逆転して tool_result が先に
到着した場合、standalone とツリー両方に出てしまう重複を排除。
事前パスで tool_use の id 集合も収集し、同じ id を持つ
tool_result は standalone レンダリングをスキップする。
---
.../desktop/src/main/todo-agent/supervisor.ts | 15 ++++++++---
.../todo-agent/TodoManager/TodoManager.tsx | 25 ++++++++++++++++---
2 files changed, 34 insertions(+), 6 deletions(-)
diff --git a/apps/desktop/src/main/todo-agent/supervisor.ts b/apps/desktop/src/main/todo-agent/supervisor.ts
index 5f9abae32b8..3272f923259 100644
--- a/apps/desktop/src/main/todo-agent/supervisor.ts
+++ b/apps/desktop/src/main/todo-agent/supervisor.ts
@@ -88,10 +88,19 @@ class TodoSupervisor {
}
async start(sessionId: string): Promise {
- // Already executing or queued — no-op so repeated `start` calls
- // from the UI do not create duplicate work.
- if (this.active.has(sessionId)) return;
+ // Already pending another launch — coalesce repeat clicks.
if (this.queue.includes(sessionId)) return;
+ // If a previous run is still active AND has not been aborted,
+ // ignore — repeated start clicks should not duplicate work.
+ const active = this.active.get(sessionId);
+ if (active && !active.abortController.signal.aborted) return;
+ // Either no active run, or the active run has already been
+ // aborted and is just tearing down (typical right after abort:
+ // the trpc-router flips status to `preparing` and calls us, but
+ // `runSession`'s finally has not yet removed the entry from
+ // `active`). Queue the restart so drain() picks it up the
+ // moment the slot frees — returning early here would silently
+ // drop the request and leave the session stuck in `preparing`.
this.queue.push(sessionId);
this.drain();
}
diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx
index fefadda938a..8ce448d2ab0 100644
--- a/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx
+++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx
@@ -1124,7 +1124,13 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) {
// `preparing` is still editable: the supervisor has not spawned
// Claude yet, and prepareArtifacts rewrites goal.md before Claude
// reads it, so an edit during preparing still takes effect.
- const canEditFields = canStart || session.status === "preparing";
+ // `waiting` (ScheduleWakeup-paused) is intentionally excluded —
+ // the backend `updateFields` mutation does not allow that status,
+ // so saving would deterministically fail with PRECONDITION_FAILED.
+ // Users who want to edit a waiting session abort it first.
+ const canEditFields =
+ (canStart && session.status !== "waiting") ||
+ session.status === "preparing";
// Bail out of any in-flight edit the moment the session starts
// actually running — e.g. a queued session whose turn arrived
@@ -1683,11 +1689,20 @@ type StreamItem =
function buildStreamTree(events: TodoStreamEvent[]): StreamItem[] {
const toolNodeById = new Map>();
const resultByUseId = new Map();
+ const knownToolUseIds = new Set();
const allItems: StreamItem[] = [];
// Index tool_results with ids up front so we can attach them to
// their tool_use even if the events were appended out of order.
+ // We also collect the set of tool_use ids that exist anywhere in
+ // the stream so a tool_result encountered BEFORE its matching
+ // tool_use can be skipped — otherwise it gets rendered as a
+ // standalone message AND later attached to the tool card via
+ // resultByUseId, producing duplicate output for the same result.
for (const ev of events) {
+ if (ev.kind === "tool_use" && ev.toolUseId) {
+ knownToolUseIds.add(ev.toolUseId);
+ }
if (ev.kind === "tool_result" && ev.toolUseId) {
resultByUseId.set(ev.toolUseId, ev);
}
@@ -1716,8 +1731,12 @@ function buildStreamTree(events: TodoStreamEvent[]): StreamItem[] {
continue;
}
if (ev.kind === "tool_result") {
- // Modern path: already attached via resultByUseId.
- if (ev.toolUseId && toolNodeById.has(ev.toolUseId)) continue;
+ // Modern path: a matching tool_use exists in the stream
+ // somewhere and will (or already did) attach this result
+ // via resultByUseId. Skip the standalone render either
+ // way to avoid duplication when events arrive out of
+ // order (tool_result before tool_use).
+ if (ev.toolUseId && knownToolUseIds.has(ev.toolUseId)) continue;
// Legacy fallback: attach to the most recent dangling
// tool_use without a toolUseId (same positional heuristic
// the old impl used). Keeps replay of pre-upgrade sessions
From dcbf77456101e99c4b9fda3cafb44052ee77fc09 Mon Sep 17 00:00:00 2001
From: MocA-Love <64681295+MocA-Love@users.noreply.github.com>
Date: Fri, 17 Apr 2026 06:51:22 +0900
Subject: [PATCH 18/18] =?UTF-8?q?fix(desktop):=20CodeRabbit=20=E3=83=AC?=
=?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E5=AF=BE=E5=BF=9C=20=E2=80=94=205?=
=?UTF-8?q?=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- scheduler.ts: supervisor.start は同期 wrap で実際には reject しない
ため、'.catch で failed に倒す' という docstring が嘘だった。docstring
を実態(不慮の throw を console に出すだけの保護的 catch)に合わせ、
status 書き換え経路は drain 側の責務に一本化。
- supervisor.ts (extractScheduledWakeup): 範囲外の delaySeconds を
silent clamp していたが、Claude が想定した再開時刻と実際の再開が
ずれて挙動が読めなくなる。コメント通り malformed 扱いで wait に
遷移させない(done の通常終了に倒す)よう修正。
- globals.css (.animate-shine): Stylelint の declaration-empty-line-before
と value-keyword-case を解消(currentColor → currentcolor、宣言前空行)。
- CommentBody.tsx: rehype-sanitize の defaultSchema が className を
剥がして remark-github-blockquote-alert の markdown-alert スタイル
が当たらない問題を修正。markdown-alert / markdown-alert-title /
octicon クラスのみ明示許可するカスタムスキーマを渡す。
- README.md: #229 の追加日 2026-04-17 → 2026-04-16(他行と同じ
PR 作成日 UTC 基準)に揃える。
---
README.md | 2 +-
apps/desktop/src/main/todo-agent/scheduler.ts | 29 +++++++------------
.../desktop/src/main/todo-agent/supervisor.ts | 11 ++++---
apps/desktop/src/renderer/globals.css | 9 +++---
.../components/CommentBody/CommentBody.tsx | 28 ++++++++++++++++--
5 files changed, 49 insertions(+), 30 deletions(-)
diff --git a/README.md b/README.md
index 0f2c2d69787..31567fb4aaa 100644
--- a/README.md
+++ b/README.md
@@ -90,7 +90,7 @@ Works with any CLI agent. Built for local worktree-based development.
| **内部ブラウザの File System Access API 拒否回避** | 内部ブラウザで react-dropzone 系サイトを開くと `FileSystemFileHandle.getFile()` が NotAllowedError で落ちる問題を修正。`persist:superset` セッションに preload を追加し `DataTransferItem.getAsFileSystemHandle()` を null 返却に差し替えて legacy D&D パスへフォールバック | [#207](https://github.com/MocA-Love/superset/pull/207) | 2026-04-16 |
| **PR コメント返信** | Review タブのコメント右上に Reply ボタンを追加。ダイアログから直接返信を投稿できる。レビュースレッドへの返信と通常 PR コメントの両方に対応 | [#206](https://github.com/MocA-Love/superset/pull/206) | 2026-04-16 |
| **TODO Agent スケジュール実行** | 毎日デプロイ / 毎時 lint のような定型 TODO を UI ビルダー (毎時/毎日/毎週/毎月/cron) で登録可能。アプリ起動中に時刻が来ると TODO セッションが自動作成され発火トーストを表示。前回未完了時は skip / queue 選択可 | [#211](https://github.com/MocA-Love/superset/pull/211) | 2026-04-16 |
-| **TODO 詳細の添付画像 chip 化+プレビュー** | TODO 作成時に「やってほしいこと」「ゴール」へ貼り付けた画像を、タスク詳細画面でクリップマーク + ファイル名の chip として表示。クリックでネスト Dialog の画像プレビューを開ける(AgentManager は閉じない)。`todo-agent/attachments/` 配下のみを許可するパス検証付き `readAttachment` tRPC を追加 | [#229](https://github.com/MocA-Love/superset/pull/229) | 2026-04-17 |
+| **TODO 詳細の添付画像 chip 化+プレビュー** | TODO 作成時に「やってほしいこと」「ゴール」へ貼り付けた画像を、タスク詳細画面でクリップマーク + ファイル名の chip として表示。クリックでネスト Dialog の画像プレビューを開ける(AgentManager は閉じない)。`todo-agent/attachments/` 配下のみを許可するパス検証付き `readAttachment` tRPC を追加 | [#229](https://github.com/MocA-Love/superset/pull/229) | 2026-04-16 |
## Fork のビルド方法 (macOS)
diff --git a/apps/desktop/src/main/todo-agent/scheduler.ts b/apps/desktop/src/main/todo-agent/scheduler.ts
index 9db8a55aeab..e817ed81b42 100644
--- a/apps/desktop/src/main/todo-agent/scheduler.ts
+++ b/apps/desktop/src/main/todo-agent/scheduler.ts
@@ -208,11 +208,15 @@ class TodoScheduler {
* hand them back to the supervisor. The status flip is gated on the
* row still being `waiting` at claim time so a race with the user
* clicking Abort (which writes `aborted`) between `listWaitingDue`
- * and the update cannot resurrect an abort into a fresh run. If
- * `supervisor.start` rejects after we've claimed, we mark the
- * session `failed` with a clear reason — the alternative (leaving
- * it stuck at `queued` with no timer) would silently strand a
- * `ScheduleWakeup` session forever.
+ * and the update cannot resurrect an abort into a fresh run.
+ *
+ * `supervisor.start` is currently a synchronous queue+drain wrapper
+ * that does not throw, so the trailing `.catch` here is purely a
+ * defensive log path: if a future change to `start` introduces
+ * validation throws, the rejection still surfaces in the console
+ * instead of becoming an unhandled rejection. Run-time failures
+ * inside `runSession` are owned by the supervisor's own drain
+ * pipeline and are not the scheduler's responsibility.
*/
private resumeDueWaitingSessions(): void {
const sessionStore = getTodoSessionStore();
@@ -224,23 +228,10 @@ class TodoScheduler {
const claimed = sessionStore.claimWaitingForResume(session.id);
if (!claimed) continue;
void supervisor.start(session.id).catch((err) => {
- const message = err instanceof Error ? err.message : String(err);
console.warn(
- `[todo-scheduler] resume waiting failed for ${session.id}:`,
+ `[todo-scheduler] supervisor.start unexpectedly rejected for ${session.id}:`,
err,
);
- // Only mark `failed` if the row is still the row we claimed —
- // the user may have aborted or deleted the session between
- // claim and the rejection, and we must not overwrite that.
- const current = sessionStore.get(session.id);
- if (!current || current.status !== "queued") return;
- sessionStore.update(session.id, {
- status: "failed",
- phase: "failed",
- verdictPassed: false,
- verdictReason: `ScheduleWakeup 再開時に supervisor.start が失敗しました: ${message}`,
- completedAt: Date.now(),
- });
});
}
}
diff --git a/apps/desktop/src/main/todo-agent/supervisor.ts b/apps/desktop/src/main/todo-agent/supervisor.ts
index 3272f923259..e5b69f5a6e3 100644
--- a/apps/desktop/src/main/todo-agent/supervisor.ts
+++ b/apps/desktop/src/main/todo-agent/supervisor.ts
@@ -1361,12 +1361,15 @@ function extractScheduledWakeup(
? (inp.delaySeconds as number)
: null;
if (delaySeconds == null || !Number.isFinite(delaySeconds)) continue;
- // Clamp to match the ScheduleWakeup contract [60, 3600]s — a stray
- // value outside this range is a malformed call, not a wait request.
- const clamped = Math.max(60, Math.min(3600, Math.floor(delaySeconds)));
+ // ScheduleWakeup の契約値は [60, 3600]s。範囲外は malformed と
+ // して扱い wait には遷移させない。silently clamp すると Claude
+ // が想定する再開タイミングと実際の再開がずれて挙動が読めなく
+ // なるため、その時点で done の通常終了に倒す方が安全。
+ const seconds = Math.floor(delaySeconds);
+ if (seconds < 60 || seconds > 3600) continue;
const reason =
typeof inp.reason === "string" ? (inp.reason as string) : null;
- return { delayMs: clamped * 1000, reason };
+ return { delayMs: seconds * 1000, reason };
}
return null;
}
diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css
index f64f0d4f739..c046f3a49a4 100644
--- a/apps/desktop/src/renderer/globals.css
+++ b/apps/desktop/src/renderer/globals.css
@@ -540,13 +540,14 @@
}
.animate-shine {
--shine-peak: rgba(255, 255, 255, 0.92);
+
background-image: linear-gradient(
110deg,
- currentColor 0%,
- currentColor 35%,
+ currentcolor 0%,
+ currentcolor 35%,
var(--shine-peak) 50%,
- currentColor 65%,
- currentColor 100%
+ currentcolor 65%,
+ currentcolor 100%
);
background-size: 200% auto;
-webkit-background-clip: text;
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/CommentBody/CommentBody.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/CommentBody/CommentBody.tsx
index 34ab2e8be8b..b9ac4a4b467 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/CommentBody/CommentBody.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/CommentBody/CommentBody.tsx
@@ -1,7 +1,7 @@
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
-import rehypeSanitize from "rehype-sanitize";
+import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import { remarkAlert } from "remark-github-blockquote-alert";
import { CodeBlock } from "renderer/components/MarkdownRenderer/components/CodeBlock";
@@ -12,6 +12,30 @@ interface CommentBodyProps {
onOpenUrl?: (url: string, e: React.MouseEvent) => void;
}
+// rehype-sanitize の defaultSchema は className 属性を厳しく制限しており、
+// remark-github-blockquote-alert が生成する `markdown-alert` / `markdown-alert-*`
+// クラスは何もしないと剥がされて CSS(globals.css の .markdown-alert ルール)
+// が当たらず、アラートが通常テキストとして表示されてしまう。markdown-alert
+// 系のクラス名のみ明示的に許可するスキーマを使う。
+const sanitizeSchema = {
+ ...defaultSchema,
+ attributes: {
+ ...defaultSchema.attributes,
+ div: [
+ ...((defaultSchema.attributes?.div as unknown[]) ?? []),
+ ["className", /^markdown-alert(?:$|\s|-)/],
+ ],
+ p: [
+ ...((defaultSchema.attributes?.p as unknown[]) ?? []),
+ ["className", /^markdown-alert-title(?:$|\s)/],
+ ],
+ svg: [
+ ...((defaultSchema.attributes?.svg as unknown[]) ?? []),
+ ["className", /^octicon(?:$|\s|-)/],
+ ],
+ },
+};
+
export const CommentBody = memo(function CommentBody({
body,
onOpenUrl,
@@ -19,7 +43,7 @@ export const CommentBody = memo(function CommentBody({
return (
href ? (