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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +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 |

## Fork のビルド方法 (macOS)

Expand Down
54 changes: 53 additions & 1 deletion apps/desktop/src/main/todo-agent/trpc-router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { todoPromptPresets } from "@superset/local-db";
import { TRPCError } from "@trpc/server";
Expand Down Expand Up @@ -645,6 +645,58 @@ export const createTodoAgentRouter = () => {
return { path: filePath };
}),

/**
* Read an image attachment back from disk so the renderer can
* preview it inline. Restricted to the saveAttachment output
* directory to prevent the renderer from coercing the main
* process into reading arbitrary user files via this channel.
*/
readAttachment: publicProcedure
.input(z.object({ path: z.string().min(1).max(4096) }))
.query(({ input }) => {
const dir = path.resolve(
path.join(app.getPath("userData"), "todo-agent", "attachments"),
);
const resolved = path.resolve(input.path);
const dirPrefix = dir.endsWith(path.sep) ? dir : dir + path.sep;
if (!resolved.startsWith(dirPrefix)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "添付ディレクトリ外のパスは読み取れません",
});
}
let buf: Buffer;
try {
buf = readFileSync(resolved);
} catch (error) {
throw new TRPCError({
code: "NOT_FOUND",
message:
error instanceof Error
? `添付ファイルを読めませんでした: ${error.message}`
: "添付ファイルを読めませんでした",
});
}
const ext = path.extname(resolved).toLowerCase();
const mimeType =
ext === ".png"
? "image/png"
: ext === ".jpg" || ext === ".jpeg"
? "image/jpeg"
: ext === ".gif"
? "image/gif"
: ext === ".webp"
? "image/webp"
: ext === ".svg"
? "image/svg+xml"
: "application/octet-stream";
return {
mimeType,
dataBase64: buf.toString("base64"),
byteLength: buf.byteLength,
};
}),

settings: router({
get: publicProcedure.query(() => getTodoSettings()),
update: publicProcedure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,15 @@ import {
import { MarkdownRenderer } from "renderer/components/MarkdownRenderer";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { ChangesSidebar } from "./ChangesSidebar";
import { AttachmentChips } from "./components/AttachmentChips";
import { AttachmentPreviewDialog } from "./components/AttachmentPreviewDialog";
import { PresetsDialog } from "./PresetsDialog";
import { SchedulesSection } from "./SchedulesSection";
import {
type AttachmentRef,
extractAttachmentRefs,
stripAttachmentRefs,
} from "./utils/attachmentRefs";

async function copyToClipboard(text: string, label = "コピーしました") {
try {
Expand Down Expand Up @@ -868,6 +875,31 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) {
"description" | "goal" | null
>(null);
const [editDraft, setEditDraft] = useState("");
const [previewAttachment, setPreviewAttachment] =
useState<AttachmentRef | null>(null);

const descriptionAttachments = useMemo(
() => extractAttachmentRefs(session.description),
[session.description],
);
const descriptionBody = useMemo(
() =>
descriptionAttachments.length > 0
? stripAttachmentRefs(session.description)
: session.description,
[session.description, descriptionAttachments.length],
);
const goalAttachments = useMemo(
() => extractAttachmentRefs(session.goal ?? ""),
[session.goal],
);
const goalBody = useMemo(
() =>
goalAttachments.length > 0
? stripAttachmentRefs(session.goal ?? "")
: (session.goal ?? ""),
[session.goal, goalAttachments.length],
);

const utils = electronTrpc.useUtils();
const startMut = electronTrpc.todoAgent.start.useMutation();
Expand Down Expand Up @@ -915,6 +947,7 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) {
// next.
setEditingField(null);
setEditDraft("");
setPreviewAttachment(null);
}, [session.id]);

// Force a re-render once per second while the session is still
Expand Down Expand Up @@ -1247,8 +1280,20 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) {
</div>
</div>
) : (
<div className="whitespace-pre-wrap text-xs leading-relaxed">
{session.description}
<div>
{descriptionBody.length > 0 ? (
<div className="whitespace-pre-wrap text-xs leading-relaxed">
{descriptionBody}
</div>
) : descriptionAttachments.length > 0 ? (
<div className="text-xs text-muted-foreground">
(添付のみ)
</div>
) : null}
<AttachmentChips
attachments={descriptionAttachments}
onSelect={setPreviewAttachment}
/>
</div>
)}
</DetailBlock>
Expand Down Expand Up @@ -1299,8 +1344,20 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) {
</div>
</div>
) : session.goal?.trim() ? (
<div className="whitespace-pre-wrap text-xs leading-relaxed">
{session.goal}
<div>
{goalBody.length > 0 ? (
<div className="whitespace-pre-wrap text-xs leading-relaxed">
{goalBody}
</div>
) : goalAttachments.length > 0 ? (
<div className="text-xs text-muted-foreground">
(添付のみ)
</div>
) : null}
<AttachmentChips
attachments={goalAttachments}
onSelect={setPreviewAttachment}
/>
</div>
) : (
<div className="text-xs text-muted-foreground">
Expand Down Expand Up @@ -1448,6 +1505,13 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) {
</p>
</div>
</div>
<AttachmentPreviewDialog
attachment={previewAttachment}
open={previewAttachment != null}
onOpenChange={(o) => {
if (!o) setPreviewAttachment(null);
}}
/>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { HiMiniPaperClip } from "react-icons/hi2";
import type { AttachmentRef } from "../../utils/attachmentRefs";

interface AttachmentChipsProps {
attachments: AttachmentRef[];
onSelect: (attachment: AttachmentRef) => void;
}

/**
* Read-only chip strip used by the SessionDetail panel to surface
* attachments referenced by `![](path)` tokens in description / goal
* text. Mirrors the chip styling used by the composer's
* `ImagePasteTextarea` so the create and read views feel consistent,
* but omits the remove (X) button — read-only context.
*/
export function AttachmentChips({
attachments,
onSelect,
}: AttachmentChipsProps) {
if (attachments.length === 0) return null;
return (
<div className="flex flex-wrap gap-1 mt-1.5">
{attachments.map((a) => (
<button
key={a.path}
type="button"
onClick={() => onSelect(a)}
title={`${a.name} · クリックでプレビュー`}
className="inline-flex items-center gap-1 text-[10px] rounded-md border border-border/60 bg-muted/50 hover:bg-muted px-1.5 py-0.5 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
>
<HiMiniPaperClip className="size-3 text-muted-foreground/80" />
<span className="truncate max-w-[200px]">{a.name}</span>
</button>
))}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AttachmentChips } from "./AttachmentChips";
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Button } from "@superset/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@superset/ui/dialog";
import { toast } from "@superset/ui/sonner";
import { electronTrpc } from "renderer/lib/electron-trpc";
import type { AttachmentRef } from "../../utils/attachmentRefs";

interface AttachmentPreviewDialogProps {
attachment: AttachmentRef | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}

/**
* Nested modal that previews a TODO attachment image. Mounting the
* preview as its own Dialog keeps the parent Agent Manager dialog
* open underneath — Radix routes outside-clicks and Esc to the
* top-most dialog only, which is the requested behavior.
*/
export function AttachmentPreviewDialog({
attachment,
open,
onOpenChange,
}: AttachmentPreviewDialogProps) {
const enabled = open && attachment != null;
const { data, isLoading, error } =
electronTrpc.todoAgent.readAttachment.useQuery(
{ path: attachment?.path ?? "" },
{ enabled, retry: false, staleTime: 60_000 },
);

const copyPath = async () => {
if (!attachment) return;
try {
await navigator.clipboard.writeText(attachment.path);
toast.success("パスをコピーしました");
} catch (err) {
toast.error(err instanceof Error ? err.message : "コピーに失敗しました");
}
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(960px,calc(100vw-4rem))] max-w-[calc(100vw-4rem)] max-h-[90vh] p-0 gap-0 overflow-hidden flex flex-col rounded-xl">
<DialogTitle className="sr-only">
{attachment?.name ?? "添付プレビュー"}
</DialogTitle>
<div className="flex items-center justify-between border-b px-4 h-11 shrink-0 gap-2">
<div className="min-w-0 flex flex-col">
<div className="text-sm font-medium truncate">
{attachment?.name}
</div>
{attachment ? (
<div className="text-[10px] text-muted-foreground truncate">
{attachment.path}
</div>
) : null}
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2 text-[11px]"
onClick={copyPath}
>
パスをコピー
</Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-auto bg-muted/20 flex items-center justify-center p-4">
{!attachment ? null : isLoading ? (
<div className="text-xs text-muted-foreground">読み込み中…</div>
) : error ? (
<div className="text-xs text-destructive">
読み込みに失敗しました: {error.message}
</div>
) : data ? (
<img
src={`data:${data.mimeType};base64,${data.dataBase64}`}
alt={attachment.alt || attachment.name}
className="max-w-full max-h-[80vh] object-contain rounded-md shadow-sm"
/>
) : null}
</div>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AttachmentPreviewDialog } from "./AttachmentPreviewDialog";
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
export interface AttachmentRef {
/** The full markdown match including `![]` and the parens. */
fullMatch: string;
/** The alt text inside `![alt]` (often empty). */
alt: string;
/** Absolute path on disk. */
path: string;
/** Pretty filename to show in the chip (UUID prefix stripped). */
name: string;
}

/**
* Match `![alt](path)` markdown image references whose path lives under
* the desktop app's `todo-agent/attachments/` directory. Both POSIX and
* Windows path separators are accepted so the same regex works for
* existing sessions saved on either platform.
*
* The path inside the parens is captured up to the next `)` so URL-style
* encoded characters survive. Spaces are intentionally rejected — the
* saveAttachment writer sanitizes filenames to `[^\w.-] -> _`, so a
* raw space in the path would mean the reference is unrelated to our
* attachment store and we should leave it alone.
*/
const ATTACHMENT_REF_RE =
/!\[([^\]]*)\]\(([^()\s]*[/\\]todo-agent[/\\]attachments[/\\][^)\s]+)\)/g;

/** Strip the `<uuid>-` prefix that `saveAttachment` adds. */
function prettyAttachmentName(filename: string): string {
const m =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-(.+)$/i.exec(
filename,
);
return m?.[1] ?? filename;
}

function basename(p: string): string {
const idx = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
return idx >= 0 ? p.slice(idx + 1) : p;
}

/**
* Pull every attachment reference out of a description/goal text. Order
* is preserved so the chips line up with the order they appear in the
* source text. Duplicates of the exact same path are collapsed to a
* single chip.
*/
export function extractAttachmentRefs(text: string): AttachmentRef[] {
if (!text) return [];
const seen = new Set<string>();
const out: AttachmentRef[] = [];
for (const m of text.matchAll(ATTACHMENT_REF_RE)) {
const fullMatch = m[0];
const alt = m[1] ?? "";
const p = m[2];
if (!p || seen.has(p)) continue;
seen.add(p);
out.push({
fullMatch,
alt,
path: p,
name: prettyAttachmentName(basename(p)),
});
}
return out;
}

/**
* Return the body text with attachment markdown references removed so
* the user is not staring at long file paths inline. Adjacent blank
* lines created by the removal are collapsed.
*/
export function stripAttachmentRefs(text: string): string {
if (!text) return text;
const stripped = text.replace(ATTACHMENT_REF_RE, "");
return stripped.replace(/\n{3,}/g, "\n\n").trim();
}
Loading
Loading