diff --git a/README.md b/README.md index 8c308ca76ab..31567fb4aaa 100644 --- a/README.md +++ b/README.md @@ -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-16 | ## Fork のビルド方法 (macOS) diff --git a/apps/desktop/src/main/todo-agent/git-status.ts b/apps/desktop/src/main/todo-agent/git-status.ts index fb3cef7e805..6b24b0b505f 100644 --- a/apps/desktop/src/main/todo-agent/git-status.ts +++ b/apps/desktop/src/main/todo-agent/git-status.ts @@ -20,6 +20,23 @@ async function gitOut(args: string[], cwd: string): Promise { } } +/** + * Does `sha` resolve to a commit object in `cwd`'s git dir? Used to + * distinguish "no new commits" from "startHeadSha was orphaned by a + * reset/rebase" — otherwise both look identical in the sidebar. + */ +async function gitRevExists(sha: string, cwd: string): Promise { + try { + await execGitWithShellPath( + ["rev-parse", "--verify", "--quiet", `${sha}^{commit}`], + { cwd }, + ); + return true; + } catch { + return false; + } +} + export async function getCurrentHeadSha(cwd: string): Promise { const out = (await gitOut(["rev-parse", "HEAD"], cwd)).trim(); return out || null; @@ -42,12 +59,34 @@ export interface SessionGitFile { code: string; } +export interface SessionGitChangedFile { + path: string; + /** First letter of git's name-status code: A / M / D / R / C / T */ + code: string; +} + export interface SessionGitSnapshot { branch: string | null; startHeadSha: string | null; currentHeadSha: string | null; commits: SessionGitCommit[]; workingTree: SessionGitFile[]; + /** + * Files whose contents differ between `startHeadSha` and HEAD + * (two-dot `git diff`). Populated regardless of whether HEAD is a + * descendant of startHeadSha, so branch switches / rebases still + * surface the cumulative session delta instead of silently + * rendering an empty sidebar. + */ + sessionFiles: SessionGitChangedFile[]; + /** + * True when `startHeadSha` is set but its commit object is no + * longer reachable (e.g. the branch was reset and the object was + * pruned, or a different repo was swapped in under the worktree). + * The UI uses this to show an explanatory message rather than a + * silently empty panel. + */ + startHeadUnreachable: boolean; ahead: number; behind: number; } @@ -68,32 +107,53 @@ export async function getSessionGitSnapshot(params: { const branch = branchOut.trim() || null; const currentHeadSha = currentOut.trim() || null; - // Commits produced since the session started. If start and current - // are the same (no new commits yet) this returns an empty list. + // Commits produced since the session started. Scoped to the range + // `startHeadSha..HEAD`; when HEAD is not a descendant of + // startHeadSha (branch switch / reset / rebase), this can validly + // return an empty list, and we surface cumulative file-level + // changes via `sessionFiles` below so the sidebar isn't empty. let commits: SessionGitCommit[] = []; + let sessionFiles: SessionGitChangedFile[] = []; + let startHeadUnreachable = false; if (startHeadSha && currentHeadSha && startHeadSha !== currentHeadSha) { - const logOut = await gitOut( - [ - "log", - `${startHeadSha}..${currentHeadSha}`, - `--format=${COMMIT_FORMAT}`, - ], - cwd, - ); - commits = logOut - .split("\n") - .filter((l) => l.length > 0) - .map((line) => { - const [sha, shortSha, subject, authorName, authorDate] = - line.split(COMMIT_DELIM); - return { - sha: sha ?? "", - shortSha: shortSha ?? "", - subject: subject ?? "", - authorName: authorName ?? "", - authorDate: authorDate ?? "", - }; - }); + const reachable = await gitRevExists(startHeadSha, cwd); + if (!reachable) { + startHeadUnreachable = true; + } else { + const logOut = await gitOut( + [ + "log", + `${startHeadSha}..${currentHeadSha}`, + `--format=${COMMIT_FORMAT}`, + ], + cwd, + ); + commits = logOut + .split("\n") + .filter((l) => l.length > 0) + .map((line) => { + const [sha, shortSha, subject, authorName, authorDate] = + line.split(COMMIT_DELIM); + return { + sha: sha ?? "", + shortSha: shortSha ?? "", + subject: subject ?? "", + authorName: authorName ?? "", + authorDate: authorDate ?? "", + }; + }); + + // `git diff --name-status -z A B` compares the two commits + // directly (two-dot in diff has no range semantics), so it + // works even when A and B are on divergent histories. This + // is what lets the sidebar show the real session delta + // when commits are zero but files were touched. + const diffOut = await gitOut( + ["diff", "--name-status", "-z", startHeadSha, currentHeadSha], + cwd, + ); + sessionFiles = parseNameStatusNul(diffOut); + } } // Working tree state via porcelain v1 for stable parsing. @@ -152,11 +212,45 @@ export async function getSessionGitSnapshot(params: { currentHeadSha, commits, workingTree, + sessionFiles, + startHeadUnreachable, ahead, behind, }; } +/** + * Parse `git diff --name-status -z` output. + * + * Standard entries are `\0\0`; rename/copy entries are + * `\0\0\0` — we keep only the new path + * and collapse the code to its first letter so the UI can render a + * single badge per file. + */ +function parseNameStatusNul(raw: string): SessionGitChangedFile[] { + const files: SessionGitChangedFile[] = []; + const parts = raw.split("\0"); + let i = 0; + while (i < parts.length) { + const token = parts[i]; + if (!token) { + i += 1; + continue; + } + const letter = token[0] ?? ""; + if (letter === "R" || letter === "C") { + const newPath = parts[i + 2]; + if (newPath) files.push({ path: newPath, code: letter }); + i += 3; + continue; + } + const p = parts[i + 1]; + if (p) files.push({ path: p, code: letter || token }); + i += 2; + } + return files; +} + export type SessionDiffScope = "session" | "staged" | "unstaged" | "commit"; export async function getSessionFileDiff(params: { diff --git a/apps/desktop/src/main/todo-agent/scheduler.ts b/apps/desktop/src/main/todo-agent/scheduler.ts index f8b9c2a40ef..e817ed81b42 100644 --- a/apps/desktop/src/main/todo-agent/scheduler.ts +++ b/apps/desktop/src/main/todo-agent/scheduler.ts @@ -106,7 +106,12 @@ function isSessionActive(session: SelectTodoSession | undefined): boolean { session.status === "preparing" || session.status === "running" || session.status === "verifying" || - session.status === "paused" + session.status === "paused" || + // `waiting` means the worker called `ScheduleWakeup` to pause + // itself and will be resumed by the scheduler tick. Count it as + // active so the overlap guard and the concurrency display do not + // treat a self-parked session as finished. + session.status === "waiting" ); } @@ -172,6 +177,11 @@ class TodoScheduler { this.inFlight = true; try { const store = getTodoScheduleStore(); + // Wake self-paced (`ScheduleWakeup`) sessions whose deadline + // has passed before we process new schedule fires. Doing this + // first means a schedule firing into an already-waiting + // session sees the updated status and respects overlap mode. + this.resumeDueWaitingSessions(); // Snapshot "due" using tick start time, but compute each // schedule's firedAt from the actual moment fire() runs. // Otherwise a slow fire leaves the next schedule in the loop @@ -193,6 +203,39 @@ class TodoScheduler { } } + /** + * Scan for `waiting` sessions whose `waitingUntil` has elapsed and + * 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. + * + * `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(); + const due = sessionStore.listWaitingDue(Date.now()); + if (due.length === 0) return; + const supervisor = getTodoSupervisor(); + for (const session of due) { + if (this.isStopped) return; + const claimed = sessionStore.claimWaitingForResume(session.id); + if (!claimed) continue; + void supervisor.start(session.id).catch((err) => { + console.warn( + `[todo-scheduler] supervisor.start unexpectedly rejected for ${session.id}:`, + err, + ); + }); + } + } + private async fire( schedule: SelectTodoSchedule, firedAt: number, diff --git a/apps/desktop/src/main/todo-agent/session-store.ts b/apps/desktop/src/main/todo-agent/session-store.ts index 9ef3aa55166..6362ec30aba 100644 --- a/apps/desktop/src/main/todo-agent/session-store.ts +++ b/apps/desktop/src/main/todo-agent/session-store.ts @@ -9,7 +9,7 @@ import { workspaces, worktrees, } from "@superset/local-db"; -import { and, desc, eq, inArray, isNull, not } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, lte, not } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import type { TodoSessionListEntry, @@ -279,6 +279,8 @@ class TodoSessionStore { verdictReason: null, verdictFailingTest: null, artifactPath: template.artifactPath, + waitingUntil: null, + waitingReason: null, startedAt: null, completedAt: null, }); @@ -301,6 +303,51 @@ class TodoSessionStore { .all(); } + /** + * Sessions parked in `waiting` whose `waitingUntil` deadline has + * passed. Drives the scheduler tick that resumes `ScheduleWakeup`- + * paused sessions once their delay elapses. + */ + listWaitingDue(nowMs: number): SelectTodoSession[] { + return localDb + .select() + .from(todoSessions) + .where( + and( + eq(todoSessions.status, "waiting"), + lte(todoSessions.waitingUntil, nowMs), + ), + ) + .all(); + } + + /** + * Atomically flip a row from `waiting` → `queued` and clear the + * parking fields. Returns the updated row (so callers can tell they + * won the claim) or undefined when the session has since moved to a + * different status — typically because the user clicked Abort while + * the scheduler tick was already in flight. Used as the race guard + * before the scheduler hands a session back to the supervisor. + */ + claimWaitingForResume(sessionId: string): SelectTodoSession | undefined { + const updated = localDb + .update(todoSessions) + .set({ + status: "queued", + phase: "queued", + waitingUntil: null, + waitingReason: null, + updatedAt: Date.now(), + }) + .where( + and(eq(todoSessions.id, sessionId), eq(todoSessions.status, "waiting")), + ) + .returning() + .get(); + if (updated) this.emit(updated); + return updated; + } + /** * Cross-workspace list used by the Agent-Manager-style view. Joins in * workspace + project names so the manager can group and label rows diff --git a/apps/desktop/src/main/todo-agent/supervisor.ts b/apps/desktop/src/main/todo-agent/supervisor.ts index e3111c9c9e4..e5b69f5a6e3 100644 --- a/apps/desktop/src/main/todo-agent/supervisor.ts +++ b/apps/desktop/src/main/todo-agent/supervisor.ts @@ -5,6 +5,7 @@ import path from "node:path"; import type { SelectTodoSession } from "@superset/local-db"; import { getCurrentHeadSha } from "./git-status"; import { getTodoSessionStore, resolveWorktreePath } from "./session-store"; +import { getTodoSettings } from "./settings"; import type { TodoStreamEventKind } from "./types"; import { TODO_ARTIFACT_SUBDIR } from "./types"; @@ -46,7 +47,13 @@ interface ActiveRun { } class TodoSupervisor { - private active: ActiveRun | undefined; + /** + * Currently executing sessions keyed by sessionId. The size of this map + * is compared against `maxConcurrentTasks` in `drain()` to decide whether + * the next pending session can start. Keyed storage (as opposed to a + * single slot) lets `abort()` target a specific run without scanning. + */ + private readonly active = new Map(); private readonly queue: string[] = []; /** @@ -81,12 +88,47 @@ class TodoSupervisor { } async start(sessionId: string): Promise { - if (this.active) { - if (!this.queue.includes(sessionId)) this.queue.push(sessionId); - return; - } - await this.runSession(sessionId); - while (this.queue.length > 0) { + // 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(); + } + + /** + * Called by the settings mutation after `maxConcurrentTasks` changes. + * When the user raises the concurrency cap we need to pull the next + * pending sessions from the queue immediately — otherwise they sit + * idle until the currently active session completes, which is the + * exact symptom reported in issue #220. Lowering the cap is handled + * passively (new starts are blocked until capacity frees up; already + * running sessions keep running). + */ + handleSettingsChanged(): void { + this.drain(); + } + + /** + * Launch as many queued sessions as `maxConcurrentTasks` permits. + * Synchronous: each launch kicks off `runSession` as a fire-and- + * forget Promise whose `finally` loops back into `drain()` so the + * next slot fills as soon as a session finishes. The settings value + * is re-read on every call so live setting updates take effect + * without restart. + */ + private drain(): void { + const capacity = getTodoSettings().maxConcurrentTasks; + while (this.active.size < capacity && this.queue.length > 0) { const next = this.queue.shift(); if (!next) continue; // A session can be aborted / deleted / rerun while still @@ -103,7 +145,20 @@ class TodoSupervisor { ) { continue; } - await this.runSession(next); + // `runSession` sets `this.active[sessionId]` synchronously + // before its first `await`, so by the time control returns + // here the slot count reflects the new run and the while + // loop's capacity check stays accurate. + void this.runSession(next) + .catch((err) => { + console.warn( + `[todo-supervisor] runSession crashed for ${next}:`, + err, + ); + }) + .finally(() => { + this.drain(); + }); } } @@ -116,8 +171,9 @@ class TodoSupervisor { if (queueIdx !== -1) { this.queue.splice(queueIdx, 1); } - if (this.active?.sessionId === sessionId) { - this.active.abortController.abort(); + const activeRun = this.active.get(sessionId); + if (activeRun) { + activeRun.abortController.abort(); // Kill the whole process group, not just the direct child. // `claude -p` spawns its own children (the Node-side agent // loop, MCP servers, tool helpers). A plain `child.kill()` @@ -127,7 +183,7 @@ class TodoSupervisor { // actually stop. We `spawn` with `detached: true` so the // child becomes a session leader; here we signal the // negative PID to reach every descendant. - const child = this.active.currentChild; + const child = activeRun.currentChild; if (child?.pid) { const pid = child.pid; killProcessTree(pid, "SIGINT"); @@ -211,7 +267,7 @@ class TodoSupervisor { startedAt: Date.now(), currentChild: null, }; - this.active = run; + this.active.set(sessionId, run); try { appendSetupEvent( @@ -224,12 +280,16 @@ class TodoSupervisor { // sidebar can show exactly what this session produced via // `git log ..HEAD` — user commits made before // the session are excluded from attribution. + // + // On resume (follow-up intervention), keep the ORIGINAL + // starting point. Overwriting it on every run moved the + // goalpost forward and hid earlier commits from the sidebar. if (worktreePath) { appendSetupEvent(sessionId, "worktree", worktreePath); } - const startHeadSha = worktreePath - ? await getCurrentHeadSha(worktreePath) - : null; + const startHeadSha = + session0.startHeadSha ?? + (worktreePath ? await getCurrentHeadSha(worktreePath) : null); if (startHeadSha) { appendSetupEvent( sessionId, @@ -295,6 +355,11 @@ class TodoSupervisor { : null, iteration: 0, startHeadSha, + // Clear any prior ScheduleWakeup parking fields — we are + // actively running again, whether this run was kicked off + // by the scheduler waking us up or by a manual resume. + waitingUntil: null, + waitingReason: null, }); if (!worktreePath) { @@ -453,6 +518,43 @@ class TodoSupervisor { }); continue; } + // Claude parked itself via `ScheduleWakeup` — park the + // session in `waiting` instead of declaring it done, and + // let the scheduler tick wake it back up when the deadline + // passes. `completedAt` stays null so cleanup / retention + // never deletes a paused session, and the scheduler keeps + // counting it against the concurrency budget. + if (turnResult.scheduledWakeup) { + const waitingUntil = + Date.now() + turnResult.scheduledWakeup.delayMs; + store.update(sessionId, { + status: "waiting", + phase: "waiting", + verdictPassed: null, + verdictReason: null, + finalAssistantText: lastAssistantText, + claudeSessionId, + totalCostUsd: aggregatedCostUsd || null, + totalNumTurns: aggregatedNumTurns || null, + waitingUntil, + waitingReason: turnResult.scheduledWakeup.reason, + completedAt: null, + }); + appendRawEvent( + sessionId, + iteration, + "system_init", + "waiting", + `ScheduleWakeup を検知。${Math.round( + turnResult.scheduledWakeup.delayMs / 1000, + )}秒後に再開します${ + turnResult.scheduledWakeup.reason + ? ` (${turnResult.scheduledWakeup.reason})` + : "" + }`, + ); + return; + } store.update(sessionId, { status: "done", phase: "done", @@ -550,7 +652,7 @@ class TodoSupervisor { }); } } finally { - this.active = undefined; + this.active.delete(sessionId); } } @@ -572,6 +674,13 @@ class TodoSupervisor { /** True when the turn was interrupted because the user queued * a mid-turn intervention, NOT because of an external abort. */ interrupted: boolean; + /** + * Latest `ScheduleWakeup` (or equivalent self-pacing) call observed + * during the turn. Null when Claude never asked to wait. The + * supervisor uses this to park the session in the `waiting` + * status instead of treating `child exit` as completion. + */ + scheduledWakeup: { delayMs: number; reason: string | null } | null; }> { return new Promise((resolve) => { const args = [ @@ -624,6 +733,7 @@ class TodoSupervisor { ? `claude を起動できませんでした: ${error.message}` : "claude を起動できませんでした", interrupted: false, + scheduledWakeup: null, }); return; } @@ -639,6 +749,10 @@ class TodoSupervisor { let stderrBuffer = ""; let settled = false; let interruptedForIntervention = false; + let scheduledWakeup: { + delayMs: number; + reason: string | null; + } | null = null; const onAbort = () => { if (child.pid) { @@ -699,6 +813,7 @@ class TodoSupervisor { numTurns, error: interruptedForIntervention ? null : errorText, interrupted: interruptedForIntervention, + scheduledWakeup, }); }; @@ -742,6 +857,9 @@ class TodoSupervisor { if (parsed.numTurns != null) { numTurns = parsed.numTurns; } + if (parsed.scheduledWakeup) { + scheduledWakeup = parsed.scheduledWakeup; + } if (parsed.event) { getTodoSessionStore().appendStreamEvents(params.sessionId, [ { @@ -751,6 +869,8 @@ class TodoSupervisor { kind: parsed.event.kind, label: parsed.event.label, text: parsed.event.text, + toolUseId: parsed.event.toolUseId, + parentToolUseId: parsed.event.parentToolUseId, }, ]); } @@ -1008,6 +1128,18 @@ interface ClassifiedEvent { kind: TodoStreamEventKind; label: string; text: string; + /** + * For `tool_use` events this is the tool_use block id. + * For `tool_result` events this is the `tool_use_id` the result + * targets. Undefined for non-tool events. + */ + toolUseId?: string; + /** + * Set when the NDJSON record has a top-level `parent_tool_use_id`, + * i.e. the message was emitted from inside a subagent (Agent/Task + * tool) context. + */ + parentToolUseId?: string; } interface ClassifiedLine { @@ -1016,6 +1148,14 @@ interface ClassifiedLine { costUsd: number | null; numTurns: number | null; event: ClassifiedEvent | null; + /** + * Non-null when this line carried a Claude self-pacing call — currently + * `ScheduleWakeup`, the /loop dynamic-mode primitive. The supervisor + * propagates this out of the turn so a subsequent `child exit` event + * parks the session in the `waiting` status instead of flipping it to + * `done` and losing it from the concurrency count. + */ + scheduledWakeup: { delayMs: number; reason: string | null } | null; } /** @@ -1031,12 +1171,20 @@ function classifyStreamJson(payload: unknown): ClassifiedLine { costUsd: null, numTurns: null, event: null, + scheduledWakeup: null, }; if (typeof payload !== "object" || payload === null) return empty; const rec = payload as Record; const type = typeof rec.type === "string" ? (rec.type as string) : ""; const sessionId = typeof rec.session_id === "string" ? (rec.session_id as string) : null; + // Claude Code sets `parent_tool_use_id` on the top-level NDJSON + // record whenever the message was emitted inside a subagent + // context (i.e. the main session invoked the Task/Agent tool). + const parentToolUseId = + typeof rec.parent_tool_use_id === "string" + ? (rec.parent_tool_use_id as string) + : undefined; if (type === "system" && rec.subtype === "init") { return { @@ -1051,35 +1199,58 @@ function classifyStreamJson(payload: unknown): ClassifiedLine { } if (type === "assistant") { + // Extract text, tool_use, and scheduled wakeup up front so a + // message that carries both "here's what I'm doing" text AND a + // `ScheduleWakeup` tool_use in the same content array still + // propagates the wakeup. The previous early-return on text + // silently dropped ScheduleWakeup in the mixed case, which made + // the supervisor mark the session as `done` the moment the + // child exited instead of parking it in `waiting`. const text = extractAssistantText(rec.message); + const tool = extractToolUseSummary(rec.message); + const wakeup = extractScheduledWakeup(rec.message); if (text) { return { ...empty, sessionId, - event: { kind: "assistant_text", label: "Claude", text }, + event: { + kind: "assistant_text", + label: "Claude", + text, + parentToolUseId, + }, + scheduledWakeup: wakeup, }; } - const tool = extractToolUseSummary(rec.message); if (tool) { return { ...empty, sessionId, - event: { kind: "tool_use", label: tool.label, text: tool.text }, + event: { + kind: "tool_use", + label: tool.label, + text: tool.text, + toolUseId: tool.id, + parentToolUseId, + }, + scheduledWakeup: wakeup, }; } return empty; } if (type === "user") { - const text = extractToolResultText(rec.message); - if (text) { + const result = extractToolResultDetails(rec.message); + if (result) { return { ...empty, sessionId, event: { kind: "tool_result", label: "tool result", - text: truncate(text, 400), + text: truncate(result.text, 400), + toolUseId: result.toolUseId, + parentToolUseId, }, }; } @@ -1105,6 +1276,7 @@ function classifyStreamJson(payload: unknown): ClassifiedLine { label: "result", text: resultText ?? "(空の結果)", }, + scheduledWakeup: null, }; } @@ -1144,7 +1316,7 @@ function extractAssistantText(message: unknown): string | null { function extractToolUseSummary( message: unknown, -): { label: string; text: string } | null { +): { label: string; text: string; id: string | undefined } | null { if (typeof message !== "object" || message === null) return null; const content = (message as { content?: unknown }).content; if (!Array.isArray(content)) return null; @@ -1153,22 +1325,70 @@ function extractToolUseSummary( const rec = part as Record; if (rec.type !== "tool_use") continue; const name = typeof rec.name === "string" ? (rec.name as string) : "tool"; + const id = typeof rec.id === "string" ? (rec.id as string) : undefined; const input = rec.input; const inputSummary = summarizeToolInput(name, input); - return { label: name, text: inputSummary }; + return { label: name, text: inputSummary, id }; } return null; } -function extractToolResultText(message: unknown): string | null { +/** + * Look for a `ScheduleWakeup` tool_use in the assistant message. This is + * the /loop-mode primitive Claude uses to park itself until a deadline + * (e.g. "re-check CI in 5 minutes"). In headless mode Claude still + * surfaces the same tool_use in the stream and then exits, so detecting + * the call here is what lets the supervisor distinguish "work really + * finished" from "work paused itself" — without this, the session flips + * to `done` on child exit and disappears from the concurrency count. + */ +function extractScheduledWakeup( + message: unknown, +): { delayMs: number; reason: string | null } | null { + if (typeof message !== "object" || message === null) return null; + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) return null; + for (const part of content) { + if (typeof part !== "object" || part === null) continue; + const rec = part as Record; + if (rec.type !== "tool_use") continue; + if (rec.name !== "ScheduleWakeup") continue; + const input = rec.input; + if (typeof input !== "object" || input === null) continue; + const inp = input as Record; + const delaySeconds = + typeof inp.delaySeconds === "number" + ? (inp.delaySeconds as number) + : null; + if (delaySeconds == null || !Number.isFinite(delaySeconds)) continue; + // 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: seconds * 1000, reason }; + } + return null; +} + +function extractToolResultDetails( + message: unknown, +): { text: string; toolUseId: string | undefined } | null { if (typeof message !== "object" || message === null) return null; const content = (message as { content?: unknown }).content; if (!Array.isArray(content)) return null; const parts: string[] = []; + let toolUseId: string | undefined; for (const part of content) { if (typeof part !== "object" || part === null) continue; const rec = part as Record; if (rec.type === "tool_result") { + if (!toolUseId && typeof rec.tool_use_id === "string") { + toolUseId = rec.tool_use_id as string; + } const inner = rec.content; if (typeof inner === "string") { parts.push(inner); @@ -1184,7 +1404,8 @@ function extractToolResultText(message: unknown): string | null { } } const joined = parts.join("\n").trim(); - return joined.length > 0 ? joined : null; + if (joined.length === 0) return null; + return { text: joined, toolUseId }; } function summarizeToolInput(name: string, input: unknown): string { diff --git a/apps/desktop/src/main/todo-agent/trpc-router.ts b/apps/desktop/src/main/todo-agent/trpc-router.ts index 05e679a8929..8b66b88480f 100644 --- a/apps/desktop/src/main/todo-agent/trpc-router.ts +++ b/apps/desktop/src/main/todo-agent/trpc-router.ts @@ -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"; @@ -175,7 +175,11 @@ export const createTodoAgentRouter = () => { session.status !== "queued" && session.status !== "failed" && session.status !== "aborted" && - session.status !== "escalated" + session.status !== "escalated" && + // Allow manual "wake now" on a ScheduleWakeup-paused + // session — the user should not have to wait out the + // delay if they already have the context they wanted. + session.status !== "waiting" ) { throw new TRPCError({ code: "PRECONDITION_FAILED", @@ -185,6 +189,10 @@ export const createTodoAgentRouter = () => { store.update(input.sessionId, { status: "preparing", phase: "preparing", + // Clear the ScheduleWakeup parking fields so the row + // reflects an active run rather than a pending wake. + waitingUntil: null, + waitingReason: null, }); // Fire-and-forget: the supervisor drives the rest of the loop. void getTodoSupervisor().start(input.sessionId); @@ -220,11 +228,12 @@ export const createTodoAgentRouter = () => { /** * Edit the user-authored fields (description / goal) of a TODO - * session that has not started yet. Allowed only in pre-start - * states (queued / failed / aborted / escalated) so a running - * worker's prompt can never mutate under its feet. When the - * description / goal changes we rewrite the session's goal.md - * so the next run picks up the edit. + * session. Allowed in queued / preparing / failed / aborted / + * escalated. `preparing` is safe because the supervisor has + * not spawned Claude yet and `prepareArtifacts` will rewrite + * goal.md before it is read. Refused once the session is + * running / verifying so the worker's prompt never mutates + * under its feet. */ updateFields: publicProcedure .input( @@ -251,6 +260,7 @@ export const createTodoAgentRouter = () => { } if ( session.status !== "queued" && + session.status !== "preparing" && session.status !== "failed" && session.status !== "aborted" && session.status !== "escalated" @@ -258,7 +268,7 @@ export const createTodoAgentRouter = () => { throw new TRPCError({ code: "PRECONDITION_FAILED", message: - "実行中またはキュー済みでないセッションは編集できません。中断してから再度お試しください。", + "実行中のセッションは編集できません。中断してから再度お試しください。", }); } const patch: { @@ -385,6 +395,8 @@ export const createTodoAgentRouter = () => { verdictReason: null, verdictFailingTest: null, artifactPath, + waitingUntil: null, + waitingReason: null, startedAt: null, completedAt: null, }); @@ -645,11 +657,71 @@ 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 .input(todoSettingsUpdateSchema) - .mutation(({ input }) => updateTodoSettings(input)), + .mutation(({ input }) => { + const next = updateTodoSettings(input); + // Nudge the supervisor so a raised `maxConcurrentTasks` + // immediately releases queued sessions. Without this, a + // bump from 1 → N leaves already-pending tasks waiting + // until the currently running session finishes. + getTodoSupervisor().handleSettingsChanged(); + return next; + }), }), schedule: router({ diff --git a/apps/desktop/src/main/todo-agent/types.ts b/apps/desktop/src/main/todo-agent/types.ts index b095f2377bf..cce6473b15e 100644 --- a/apps/desktop/src/main/todo-agent/types.ts +++ b/apps/desktop/src/main/todo-agent/types.ts @@ -132,7 +132,8 @@ export type TodoSessionPhase = | "failed" | "escalated" | "aborted" - | "paused"; + | "paused" + | "waiting"; export const TODO_ARTIFACT_SUBDIR = ".superset/todo"; @@ -172,6 +173,23 @@ export interface TodoStreamEvent { text: string; /** Optional raw payload for the "raw" / debug kind. */ raw?: unknown; + /** + * The Anthropic tool-use block id this event corresponds to. + * - For `tool_use` events: the id of the tool_use content block. + * - For `tool_result` events: the `tool_use_id` the result answers. + * Lets the UI pair tool_use ↔ tool_result by id instead of position, + * which is robust to concurrent / out-of-order SDK emissions. + */ + toolUseId?: string; + /** + * Set on messages emitted from inside a subagent's context (i.e. when + * the main session invoked the `Task`/`Agent` tool). Its value is the + * tool_use id of the parent Agent tool call. The UI uses this to nest + * sub-tool activity under the parent Agent card, matching the VSCode + * Claude Code extension's presentation. + * See: https://docs.claude.com/en/docs/agent-sdk/ (Subagents) + */ + parentToolUseId?: string; } export interface TodoStreamUpdate { 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 4f74e5a5474..d337a7fa70c 100644 --- a/apps/desktop/src/renderer/features/todo-agent/TodoButton/TodoButton.tsx +++ b/apps/desktop/src/renderer/features/todo-agent/TodoButton/TodoButton.tsx @@ -1,6 +1,6 @@ import { Button } from "@superset/ui/button"; import { cn } from "@superset/ui/utils"; -import { memo, useCallback, useState } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import { HiMiniListBullet } from "react-icons/hi2"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { TodoManager } from "../TodoManager"; @@ -12,6 +12,46 @@ interface TodoButtonProps { worktreePath?: string | null; } +type StatusCategory = "running" | "queued" | "failed" | "paused"; + +interface StatusBadgeConfig { + label: string; + dot: string; + badge: string; + pulse?: boolean; +} + +const STATUS_BADGE_ORDER: StatusCategory[] = [ + "running", + "queued", + "failed", + "paused", +]; + +const STATUS_BADGE_META: Record = { + running: { + label: "実行中", + dot: "bg-amber-500", + badge: "bg-amber-500/15 text-amber-600 dark:text-amber-400", + pulse: true, + }, + queued: { + label: "待機中", + dot: "bg-primary", + badge: "bg-primary/15 text-primary", + }, + failed: { + label: "失敗/要確認", + dot: "bg-rose-500", + badge: "bg-rose-500/15 text-rose-600 dark:text-rose-400", + }, + paused: { + label: "一時停止", + dot: "bg-muted-foreground/60", + badge: "bg-muted text-muted-foreground", + }, +}; + /** * Entry point for the fork-local TODO autonomous agent feature. Sits * immediately left of the WorkspaceRunButton in PresetsBar. @@ -32,16 +72,51 @@ export const TodoButton = memo(function TodoButton({ { refetchInterval: 3000 }, ); - const runningCount = (allSessions ?? []).filter( - (s) => - s.status === "preparing" || - s.status === "running" || - s.status === "verifying", - ).length; - const queuedCount = (allSessions ?? []).filter( - (s) => s.status === "queued", - ).length; - const activeCount = runningCount + queuedCount; + const counts = useMemo(() => { + const acc: Record = { + running: 0, + queued: 0, + failed: 0, + paused: 0, + }; + for (const s of allSessions ?? []) { + switch (s.status) { + case "preparing": + case "running": + case "verifying": + acc.running += 1; + break; + case "queued": + case "waiting": + // `waiting` は ScheduleWakeup で一時停止中のセッション。 + // scheduler が waitingUntil 経過後に自動で queued に戻すため、 + // slot を占有している扱いとして queued と同じバッジで集計する。 + acc.queued += 1; + break; + case "failed": + case "escalated": + acc.failed += 1; + break; + case "paused": + acc.paused += 1; + break; + default: + break; + } + } + return acc; + }, [allSessions]); + + const activeCount = + counts.running + counts.queued + counts.failed + counts.paused; + + const tooltip = useMemo(() => { + const parts = STATUS_BADGE_ORDER.filter((key) => counts[key] > 0).map( + (key) => `${STATUS_BADGE_META[key].label}: ${counts[key]}`, + ); + if (parts.length === 0) return "自律 TODO Agent Manager を開く"; + return `自律 TODO Agent Manager を開く (${parts.join(" / ")})`; + }, [counts]); const handleRequestNewTodo = useCallback(() => { setModalOpen(true); @@ -55,25 +130,47 @@ export const TodoButton = memo(function TodoButton({ variant="ghost" className={cn( "h-7 gap-1 px-2 text-xs", - activeCount > 0 && "text-primary", + counts.running > 0 && "text-primary", )} onClick={() => setManagerOpen(true)} - title="自律 TODO Agent Manager を開く" + title={tooltip} > TODO - {runningCount > 0 && ( - - - - - - {runningCount} - - )} - {queuedCount > 0 && ( - - +{queuedCount} + {activeCount > 0 && ( + + {STATUS_BADGE_ORDER.map((key) => { + const count = counts[key]; + if (count <= 0) return null; + const meta = STATUS_BADGE_META[key]; + return ( + + + {meta.pulse && ( + + )} + + + {count} + + ); + })} )} diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/ChangesSidebar/ChangesSidebar.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/ChangesSidebar/ChangesSidebar.tsx index 38ac9a5099d..d82a96bbcf0 100644 --- a/apps/desktop/src/renderer/features/todo-agent/TodoManager/ChangesSidebar/ChangesSidebar.tsx +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/ChangesSidebar/ChangesSidebar.tsx @@ -33,6 +33,7 @@ export function ChangesSidebar({ sessionId, active }: ChangesSidebarProps) { const [selected, setSelected] = useState(null); const [commitsOpen, setCommitsOpen] = useState(true); const [workingTreeOpen, setWorkingTreeOpen] = useState(true); + const [sessionFilesOpen, setSessionFilesOpen] = useState(true); const snapshot = electronTrpc.todoAgent.gitSnapshot.useQuery( { sessionId }, @@ -70,6 +71,8 @@ export function ChangesSidebar({ sessionId, active }: ChangesSidebarProps) { const data = snapshot.data; const commits = data?.commits ?? []; const workingTree = data?.workingTree ?? []; + const sessionFiles = data?.sessionFiles ?? []; + const startHeadUnreachable = data?.startHeadUnreachable ?? false; const stagedCount = useMemo( () => workingTree.filter((f) => f.stage === "staged").length, @@ -145,6 +148,82 @@ export function ChangesSidebar({ sessionId, active }: ChangesSidebarProps) { )} + {startHeadUnreachable && ( +
+ 開始時 HEAD + のコミットが見つかりません。ブランチがリセットされたか、 + オブジェクトが失われている可能性があります。 +
+ )} + + {/* Cumulative session delta (startHeadSha ↔ HEAD), shown + even when no new commits exist so branch switches / + rebases don't leave the sidebar looking empty. */} +
+ + {sessionFilesOpen && ( +
+ {!data?.startHeadSha ? ( +

+ 開始時 HEAD が未記録のため、差分を算出できません。 +

+ ) : sessionFiles.length === 0 ? ( +

+ 開始時からの差分はありません。 +

+ ) : ( + sessionFiles.map((file) => { + const key = `session:${file.path}`; + // Deletions ARE the diff at session scope — + // `git diff ..HEAD -- ` still emits + // a valid deletion patch, so keep every entry + // clickable. The working-tree section below + // rightly disables `D`, because there the file + // is already gone from the worktree. + return ( + + ); + }) + )} +
+ )} +
+ {/* Commits since session start */}
+
+ setFilter(e.target.value)} + placeholder="絞り込み(名前 / タイトル / プロジェクト)" + className="h-8 text-xs rounded-md" + /> +
- {(schedules?.length ?? 0) === 0 && ( + {(schedules?.length ?? 0) === 0 ? (

まだスケジュールはありません。「新規」ボタンから作成してください。
@@ -75,20 +104,25 @@ export function SchedulesSection() { スケジュールはアプリ起動中のみ発火します。

+ ) : filteredSchedules.length === 0 ? ( +

+ 条件に一致するスケジュールがありません。 +

+ ) : ( + filteredSchedules.map((schedule) => ( + openEdit(schedule)} + /> + )) )} - {(schedules ?? []).map((schedule) => ( - openEdit(schedule)} - /> - ))}
diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleEditorDialog/ScheduleEditorDialog.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleEditorDialog/ScheduleEditorDialog.tsx index fbd6fcb72dd..b6e59ea7d32 100644 --- a/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleEditorDialog/ScheduleEditorDialog.tsx +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/SchedulesSection/components/ScheduleEditorDialog/ScheduleEditorDialog.tsx @@ -218,7 +218,7 @@ export function ScheduleEditorDialog({ return ( - + {initial ? "スケジュールを編集" : "新しいスケジュール"} 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 b807cb53a15..8ce448d2ab0 100644 --- a/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/TodoManager.tsx @@ -14,12 +14,30 @@ import { ScrollArea } from "@superset/ui/scroll-area"; import { toast } from "@superset/ui/sonner"; import { Textarea } from "@superset/ui/textarea"; import { cn } from "@superset/ui/utils"; +import { + Bot, + CheckSquare, + Cog, + FileEdit, + FilePen, + FilePlus, + FileText, + FolderSearch, + Globe, + ListTree, + type LucideIcon, + Search, + Sparkles, + SquareTerminal, + Wrench, +} from "lucide-react"; import type { TodoSessionListEntry, TodoStreamEvent, } from "main/todo-agent/types"; import { type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, useCallback, useEffect, useMemo, @@ -49,8 +67,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 { @@ -490,6 +515,22 @@ export function TodoManager({ ) : ( <> +
+ + {(sessions?.length ?? 0) > 0 + ? `${sessions?.length} 件のタスク` + : "タスクなし"} + + +
{(sessions?.length ?? 0) === 0 - ? "まだ TODO セッションはありません。右上の『新しい TODO』から作成してください。" + ? "まだ TODO セッションはありません。『新規』から作成してください。" : "条件に一致するセッションがありません。"}

)} @@ -566,16 +607,7 @@ export function TodoManager({
- {composerOpen ? ( - { - setComposerOpen(false); - setSelectedId(id); - }} - onCancel={() => setComposerOpen(false)} - /> - ) : selected ? ( + {selected ? ( setSelectedId(null)} @@ -615,11 +647,35 @@ export function TodoManager({ )}
+ {/* + Rendered inside DialogContent (same pattern as + ScheduleEditorDialog in SchedulesSection) so the + settings dialog stacks on top of the Manager without + causing the outer Dialog to close. See Issue #217. + */} + + + + 新しい TODO + {composerOpen && ( + { + setComposerOpen(false); + setSelectedId(id); + }} + onCancel={() => setComposerOpen(false)} + /> + )} + +
-
); } @@ -650,7 +706,8 @@ function SessionRow({ const isActive = session.status === "preparing" || session.status === "running" || - session.status === "verifying"; + session.status === "verifying" || + session.status === "waiting"; const invalidate = useCallback(async () => { await utils.todoAgent.listAll.invalidate(); @@ -847,7 +904,9 @@ function StatusDot({ status }: { status: string }) { ? "bg-rose-500" : status === "aborted" ? "bg-muted-foreground/50" - : "bg-muted-foreground/40"; + : status === "waiting" + ? "bg-sky-500 animate-pulse" + : "bg-muted-foreground/40"; return ; } @@ -868,6 +927,31 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) { "description" | "goal" | null >(null); const [editDraft, setEditDraft] = useState(""); + const [previewAttachment, setPreviewAttachment] = + useState(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(); @@ -881,13 +965,19 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) { session.status === "queued" || session.status === "preparing" || session.status === "running" || - session.status === "verifying"; + session.status === "verifying" || + // `waiting` is a ScheduleWakeup-paused session the scheduler will + // resume automatically — treat it as active UX-wise so intervene + // / abort remain reachable during the pause. + session.status === "waiting"; const canStart = session.status === "queued" || session.status === "failed" || session.status === "aborted" || - session.status === "escalated"; + session.status === "escalated" || + // Manual "wake now" overrides the remaining ScheduleWakeup delay. + session.status === "waiting"; const isRunning = session.status === "preparing" || session.status === "running" || @@ -915,6 +1005,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 @@ -1030,7 +1121,31 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) { } }, [invalidate, rerunMut, session.id]); - const canEditFields = canStart && !isRunning; + // `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. + // `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 + // mid-edit — so the user doesn't hit Save only to get rejected by + // the backend guard. Only cancel on running/verifying; terminal + // states stay editable (rerun path). + useEffect(() => { + if (!editingField) return; + if (session.status !== "running" && session.status !== "verifying") return; + setEditingField(null); + setEditDraft(""); + toast.warning( + "タスクの実行が開始されたため編集を中止しました。中断してから再度編集してください。", + ); + }, [editingField, session.status]); const startEditField = useCallback( (field: "description" | "goal") => { @@ -1247,8 +1362,20 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) { ) : ( -
- {session.description} +
+ {descriptionBody.length > 0 ? ( +
+ {descriptionBody} +
+ ) : descriptionAttachments.length > 0 ? ( +
+ (添付のみ) +
+ ) : null} +
)} @@ -1299,8 +1426,20 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) {
) : session.goal?.trim() ? ( -
- {session.goal} +
+ {goalBody.length > 0 ? ( +
+ {goalBody} +
+ ) : goalAttachments.length > 0 ? ( +
+ (添付のみ) +
+ ) : null} +
) : (
@@ -1448,6 +1587,13 @@ function SessionDetail({ session, onDeleted }: SessionDetailProps) {

+ { + if (!o) setPreviewAttachment(null); + }} + /> ); } @@ -1502,10 +1648,18 @@ function formatDuration(startMs: number | null, endMs: number | null): string { } /** - * Pair consecutive tool_use → tool_result events into a single card - * (matching VSCode Claude Code extension's IN / OUT grid layout). - * Non-tool events stay as singles. Unpaired tool_use (still streaming) - * appears as a card with empty OUT row. + * Tree node for the live stream UI. + * + * `tool` nodes pair a `tool_use` with its `tool_result` (matched by + * `toolUseId`, NOT by positional proximity) and may contain `children` + * — sub-agent activity that Claude Code emits with `parent_tool_use_id` + * pointing at the parent Agent/Task tool call. This matches the VSCode + * Claude Code extension's presentation: a Task tool folds all of its + * subagent's tool calls underneath itself. + * + * `message` nodes are anything non-tool (assistant text, result, error, + * system_init, raw). They can also appear as `children` of a tool when + * they were emitted inside a subagent context. */ type StreamItem = | { type: "message"; id: string; event: TodoStreamEvent } @@ -1514,33 +1668,107 @@ type StreamItem = id: string; toolUse: TodoStreamEvent; toolResult: TodoStreamEvent | null; + children: StreamItem[]; }; -function pairStreamEvents(events: TodoStreamEvent[]): StreamItem[] { - const items: StreamItem[] = []; - for (let i = 0; i < events.length; i++) { - const ev = events[i]; +/** + * Build the render tree from the flat event buffer. + * + * Step 1: Pair tool_use ↔ tool_result by `toolUseId` (not position). + * Unpaired `tool_result` events fall back to a standalone + * message row so we never silently drop data. Legacy events + * in `stream.jsonl` from before this field existed are paired + * positionally (the original heuristic) to keep replay of + * historical sessions intact. + * Step 2: Nest items under their `parentToolUseId` when it points at a + * known tool node. Items whose parent is unknown stay at the + * top level — that preserves visibility during a mid-session + * restart where we replayed the jsonl without the Agent frame + * that spawned them. + */ +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); + } + } + + // Most-recent tool_use node that lacks a toolUseId and is still + // awaiting its result. Used only for legacy positional pairing. + let pendingLegacyTool: Extract | null = null; + + for (const ev of events) { if (!ev) continue; if (ev.kind === "tool_use") { - const next = events[i + 1]; - if (next?.kind === "tool_result") { - items.push({ - type: "tool", - id: ev.id, - toolUse: ev, - toolResult: next, - }); - i++; - } else { - items.push({ type: "tool", id: ev.id, toolUse: ev, toolResult: null }); + const matchedResult = ev.toolUseId + ? (resultByUseId.get(ev.toolUseId) ?? null) + : null; + const node: Extract = { + type: "tool", + id: ev.id, + toolUse: ev, + toolResult: matchedResult, + children: [], + }; + if (ev.toolUseId) toolNodeById.set(ev.toolUseId, node); + allItems.push(node); + pendingLegacyTool = !ev.toolUseId && !matchedResult ? node : null; + continue; + } + if (ev.kind === "tool_result") { + // 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 losing pairs. + if (!ev.toolUseId && pendingLegacyTool) { + pendingLegacyTool.toolResult = ev; + pendingLegacyTool = null; + continue; + } + allItems.push({ type: "message", id: ev.id, event: ev }); + continue; + } + allItems.push({ type: "message", id: ev.id, event: ev }); + } + + // Nest items under their parent Agent/Task tool node. + const roots: StreamItem[] = []; + for (const item of allItems) { + const parentId = + item.type === "tool" + ? item.toolUse.parentToolUseId + : item.event.parentToolUseId; + if (parentId) { + const parent = toolNodeById.get(parentId); + if (parent) { + parent.children.push(item); + continue; } - } else if (ev.kind === "tool_result") { - items.push({ type: "message", id: ev.id, event: ev }); - } else { - items.push({ type: "message", id: ev.id, event: ev }); } + roots.push(item); } - return items; + return roots; } function StreamView({ events }: { events: TodoStreamEvent[] }) { @@ -1563,13 +1791,13 @@ function StreamView({ events }: { events: TodoStreamEvent[] }) { el.scrollTop = el.scrollHeight; }, [events.length]); - const items = useMemo(() => pairStreamEvents(events), [events]); + const items = useMemo(() => buildStreamTree(events), [events]); return (
{events.length === 0 ? (
@@ -1578,83 +1806,144 @@ function StreamView({ events }: { events: TodoStreamEvent[] }) {
) : (
- {items.map((item) => - item.type === "tool" ? ( - - ) : ( - - ), - )} + {items.map((item) => ( + + ))}
)}
); } +function StreamNode({ item }: { item: StreamItem }) { + if (item.type === "tool") { + return ; + } + return ; +} + /** - * VSCode Claude Code extension faithful reproduction: uses `
` so - * the tool call folds by default, showing only a 2-line summary (bold tool - * name + monospace secondary info). Expanded body shows an IN/OUT grid. - * This matches the extension's `.Ze/._e/.or/.D/.rr/.ir/.lo/.tr` CSS - * classes we reverse-engineered from webview/index.css. + * Styling intent: + * - Tool name gets a distinct subtle color tied to the tool kind (Bash, + * Read, Edit, Task/Agent, …) so scanning the stream is fast. + * - When the tool is still running (no tool_result yet) the name shimmers + * with a pure-CSS `ShinyText` so the user sees it as "live". + * - Expanding the card reveals IN / OUT panes plus — for Agent/Task calls + * — the nested subagent activity tree. Matches the VSCode extension. */ function ToolCallCard({ item, }: { item: Extract; }) { - const { toolUse, toolResult } = item; + const { toolUse, toolResult, children } = item; const toolName = toolUse.label; const secondary = extractSecondaryInfo(toolName, toolUse.text); const hasResult = toolResult != null; + const isRunning = !hasResult; + const palette = getToolPalette(toolName); + const Icon = palette.icon; + const hasChildren = children.length > 0; + // Controlled `open` so React does not clobber the user's toggles on + // re-render (streaming events cause frequent re-renders). Initial + // value auto-expands Agent/Task cards that already have children so + // the user can see the subagent's nested activity without clicking. + const [open, setOpen] = useState(hasChildren); + // Auto-open the card the first time a child arrives in this tool + // (i.e. the subagent just started doing something). The user can + // still close it back down; we only nudge on the 0 → 1 transition. + const prevHadChildren = useRef(hasChildren); + useEffect(() => { + if (!prevHadChildren.current && hasChildren) setOpen(true); + prevHadChildren.current = hasChildren; + }, [hasChildren]); return ( -
- - +
setOpen(e.currentTarget.open)} + > + + - {toolName} + + + + {isRunning ? ( + + {toolName} + + ) : ( + + {toolName} + + )} {secondary && ( - + {secondary} )} - {!hasResult && ( - - … + {hasChildren && ( + + {children.length} )} + {isRunning && ( + + )} -
+
-
+
IN
-
+
 								{toolUse.text}
 							
-
+
OUT
-
+
{toolResult ? (
 									{toolResult.text}
 								
) : ( - - 実行中… - + 実行中… )}
+ {hasChildren && ( +
+
+
+ {children.map((child) => ( + + ))} +
+
+ )}
); @@ -1669,32 +1958,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}
); @@ -1778,9 +2231,28 @@ function statusLabel(session: TodoSession): string { // the retry loop has actually advanced const showIter = !!session.verifyCommand && session.iteration > 1; const iter = showIter ? ` · iter ${session.iteration}` : ""; + if (session.status === "waiting" && session.waitingUntil) { + return `waiting${iter} · ${formatWaitingRemaining(session.waitingUntil)}`; + } return `${session.status}${iter}`; } +/** + * Human-friendly "N秒後" / "N分後" hint for a ScheduleWakeup-paused + * session. Called from the session row label, so it is cheap and has + * no side effects beyond reading the timestamp. + */ +function formatWaitingRemaining(waitingUntil: number): string { + const remainingMs = waitingUntil - Date.now(); + if (remainingMs <= 0) return "wake soon"; + const remainingSec = Math.round(remainingMs / 1000); + if (remainingSec < 60) return `${remainingSec}秒後`; + const remainingMin = Math.round(remainingSec / 60); + if (remainingMin < 60) return `${remainingMin}分後`; + const remainingHr = Math.round(remainingMin / 60); + return `${remainingHr}時間後`; +} + interface SessionGroup { key: string; label: string; diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentChips/AttachmentChips.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentChips/AttachmentChips.tsx new file mode 100644 index 00000000000..8a74259b37f --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentChips/AttachmentChips.tsx @@ -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 ( +
+ {attachments.map((a) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentChips/index.ts b/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentChips/index.ts new file mode 100644 index 00000000000..b835d680d10 --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentChips/index.ts @@ -0,0 +1 @@ +export { AttachmentChips } from "./AttachmentChips"; diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentPreviewDialog/AttachmentPreviewDialog.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentPreviewDialog/AttachmentPreviewDialog.tsx new file mode 100644 index 00000000000..0972a6b3a08 --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentPreviewDialog/AttachmentPreviewDialog.tsx @@ -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 ( + + + + {attachment?.name ?? "添付プレビュー"} + +
+
+
+ {attachment?.name} +
+ {attachment ? ( +
+ {attachment.path} +
+ ) : null} +
+
+ +
+
+
+ {!attachment ? null : isLoading ? ( +
読み込み中…
+ ) : error ? ( +
+ 読み込みに失敗しました: {error.message} +
+ ) : data ? ( + {attachment.alt + ) : null} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentPreviewDialog/index.ts b/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentPreviewDialog/index.ts new file mode 100644 index 00000000000..513278ded5a --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/components/AttachmentPreviewDialog/index.ts @@ -0,0 +1 @@ +export { AttachmentPreviewDialog } from "./AttachmentPreviewDialog"; diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/utils/attachmentRefs/attachmentRefs.ts b/apps/desktop/src/renderer/features/todo-agent/TodoManager/utils/attachmentRefs/attachmentRefs.ts new file mode 100644 index 00000000000..cd7712a7f95 --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/utils/attachmentRefs/attachmentRefs.ts @@ -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 `-` 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(); + 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(); +} diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoManager/utils/attachmentRefs/index.ts b/apps/desktop/src/renderer/features/todo-agent/TodoManager/utils/attachmentRefs/index.ts new file mode 100644 index 00000000000..883ff858eb5 --- /dev/null +++ b/apps/desktop/src/renderer/features/todo-agent/TodoManager/utils/attachmentRefs/index.ts @@ -0,0 +1,5 @@ +export { + type AttachmentRef, + extractAttachmentRefs, + stripAttachmentRefs, +} from "./attachmentRefs"; diff --git a/apps/desktop/src/renderer/globals.css b/apps/desktop/src/renderer/globals.css index e6785d1973d..c046f3a49a4 100644 --- a/apps/desktop/src/renderer/globals.css +++ b/apps/desktop/src/renderer/globals.css @@ -522,4 +522,40 @@ .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); + } } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx index 6d70353b74b..d579ae60e1e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/ReviewPanel.tsx @@ -19,7 +19,7 @@ import { Skeleton } from "@superset/ui/skeleton"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { LuArrowUpRight, LuCheck, @@ -33,18 +33,13 @@ import { LuX, } from "react-icons/lu"; import { VscChevronRight } from "react-icons/vsc"; -import ReactMarkdown from "react-markdown"; -import rehypeRaw from "rehype-raw"; -import rehypeSanitize from "rehype-sanitize"; -import remarkGfm from "remark-gfm"; -import { remarkAlert } from "remark-github-blockquote-alert"; -import { CodeBlock } from "renderer/components/MarkdownRenderer/components/CodeBlock"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { showGitConfirmDialog } from "renderer/lib/git/gitConfirmDialog"; import { PRIcon } from "renderer/screens/main/components/PRIcon"; import { useWorkspaceId } from "renderer/screens/main/components/WorkspaceView/WorkspaceIdContext"; import { useTabsStore } from "renderer/stores/tabs"; import { CheckSteps } from "./components/CheckSteps"; +import { CommentBody } from "./components/CommentBody"; import { ReplyDialog } from "./components/ReplyDialog"; import { ALL_COMMENTS_COPY_ACTION_KEY, @@ -60,45 +55,8 @@ import { resolveCheckDestinationUrl, reviewDecisionConfig, splitPullRequestComments, - stripHtmlComments, } from "./utils"; -const CommentBody = memo(function CommentBody({ - body, - onOpenUrl, -}: { - body: string; - onOpenUrl: (url: string, e: React.MouseEvent) => void; -}) { - return ( - - href ? ( - onOpenUrl(href, e)} - > - {children} - - ) : ( - {children} - ), - code: ({ className, children, node }) => ( - - {children} - - ), - }} - > - {stripHtmlComments(body)} - - ); -}); - function buildIdentitySummary(items: string[]): string { if (items.length === 0) { return "None"; @@ -1464,6 +1422,7 @@ export function ReviewPanel({ onOpenChange={handleReplyDialogOpenChange} onSubmit={handleSubmitReply} isSubmitting={isReplySubmitting} + onOpenUrl={handleOpenUrl} />
); 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 new file mode 100644 index 00000000000..b9ac4a4b467 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/CommentBody/CommentBody.tsx @@ -0,0 +1,70 @@ +import { memo } from "react"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +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"; +import { stripHtmlComments } from "../../utils"; + +interface CommentBodyProps { + body: string; + 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, +}: CommentBodyProps) { + return ( + + href ? ( + onOpenUrl?.(href, e)} + > + {children} + + ) : ( + {children} + ), + code: ({ className, children, node }) => ( + + {children} + + ), + }} + > + {stripHtmlComments(body)} + + ); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/CommentBody/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/CommentBody/index.ts new file mode 100644 index 00000000000..191d4356a14 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/CommentBody/index.ts @@ -0,0 +1 @@ +export { CommentBody } from "./CommentBody"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/ReplyDialog/ReplyDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/ReplyDialog/ReplyDialog.tsx index 2144ee6c6b9..e3e9876d4a1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/ReplyDialog/ReplyDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ReviewPanel/components/ReplyDialog/ReplyDialog.tsx @@ -12,7 +12,8 @@ import { import { Textarea } from "@superset/ui/textarea"; import { useEffect, useRef, useState } from "react"; import { LuLoaderCircle } from "react-icons/lu"; -import { getCommentAvatarFallback, getCommentPreviewText } from "../../utils"; +import { getCommentAvatarFallback } from "../../utils"; +import { CommentBody } from "../CommentBody"; interface ReplyDialogProps { comment: PullRequestComment | null; @@ -20,6 +21,7 @@ interface ReplyDialogProps { onOpenChange: (open: boolean) => void; onSubmit: (body: string) => Promise | void; isSubmitting: boolean; + onOpenUrl?: (url: string, e: React.MouseEvent) => void; } export function ReplyDialog({ @@ -28,6 +30,7 @@ export function ReplyDialog({ onOpenChange, onSubmit, isSubmitting, + onOpenUrl, }: ReplyDialogProps) { const [body, setBody] = useState(""); const inFlightRef = useRef(false); @@ -87,7 +90,7 @@ export function ReplyDialog({ -
+
{comment.avatarUrl ? ( @@ -110,9 +113,9 @@ export function ReplyDialog({ ) : null}
-

- {getCommentPreviewText(comment.body)} -

+
+ +
diff --git a/packages/local-db/drizzle/0057_todo_session_waiting.sql b/packages/local-db/drizzle/0057_todo_session_waiting.sql new file mode 100644 index 00000000000..1b91bb5972c --- /dev/null +++ b/packages/local-db/drizzle/0057_todo_session_waiting.sql @@ -0,0 +1,2 @@ +ALTER TABLE `todo_sessions` ADD `waiting_until` integer;--> statement-breakpoint +ALTER TABLE `todo_sessions` ADD `waiting_reason` text; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0057_snapshot.json b/packages/local-db/drizzle/meta/0057_snapshot.json new file mode 100644 index 00000000000..ca03752010e --- /dev/null +++ b/packages/local-db/drizzle/meta/0057_snapshot.json @@ -0,0 +1,2173 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8306e7ba-5f4d-4168-a0e8-0e3f732f3cd3", + "prevId": "7fb6bb1c-1573-409b-ab09-670bfff160a7", + "tables": { + "browser_history": { + "name": "browser_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_visited_at": { + "name": "last_visited_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visit_count": { + "name": "visit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "browser_history_url_unique": { + "name": "browser_history_url_unique", + "columns": [ + "url" + ], + "isUnique": true + }, + "browser_history_url_idx": { + "name": "browser_history_url_idx", + "columns": [ + "url" + ], + "isUnique": false + }, + "browser_history_last_visited_at_idx": { + "name": "browser_history_last_visited_at_idx", + "columns": [ + "last_visited_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_site_permissions": { + "name": "browser_site_permissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ask'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_site_permissions_origin_idx": { + "name": "browser_site_permissions_origin_idx", + "columns": [ + "origin" + ], + "isUnique": false + }, + "browser_site_permissions_origin_kind_unique": { + "name": "browser_site_permissions_origin_kind_unique", + "columns": [ + "origin", + "kind" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_base_branch": { + "name": "workspace_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_owner": { + "name": "github_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_image": { + "name": "hide_image", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_project_id": { + "name": "neon_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_app": { + "name": "default_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_preset_overrides": { + "name": "agent_preset_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_custom_definitions": { + "name": "agent_custom_definitions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persist_terminal": { + "name": "persist_terminal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "auto_apply_default_preset": { + "name": "auto_apply_default_preset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_sounds_muted": { + "name": "notification_sounds_muted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_volume": { + "name": "notification_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prevent_agent_sleep": { + "name": "prevent_agent_sleep", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delete_local_branch": { + "name": "delete_local_branch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_open_mode": { + "name": "file_open_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_sidebar_open_view_width": { + "name": "right_sidebar_open_view_width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_presets_bar": { + "name": "show_presets_bar", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_compact_terminal_add_button": { + "name": "use_compact_terminal_add_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_family": { + "name": "terminal_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_size": { + "name": "terminal_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_family": { + "name": "editor_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_size": { + "name": "editor_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_resource_monitor": { + "name": "show_resource_monitor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_links_in_app": { + "name": "open_links_in_app", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_editor": { + "name": "default_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "indent_rainbow_enabled": { + "name": "indent_rainbow_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "indent_rainbow_colors": { + "name": "indent_rainbow_colors", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trailing_spaces_enabled": { + "name": "trailing_spaces_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trailing_spaces_color": { + "name": "trailing_spaces_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_graph_enabled": { + "name": "reference_graph_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expose_host_service_via_relay": { + "name": "expose_host_service_via_relay", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_smart_commit": { + "name": "enable_smart_commit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "smart_commit_changes": { + "name": "smart_commit_changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_stash": { + "name": "auto_stash", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_sort_order": { + "name": "branch_sort_order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_default_branch": { + "name": "pin_default_branch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "post_commit_command": { + "name": "post_commit_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sections": { + "name": "workspace_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_collapsed": { + "name": "is_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_sections_project_id_idx": { + "name": "workspace_sections_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sections_project_id_projects_id_fk": { + "name": "workspace_sections_project_id_projects_id_fk", + "tableFrom": "workspace_sections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_unnamed": { + "name": "is_unnamed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "deleting_at": { + "name": "deleting_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_base": { + "name": "port_base", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + }, + "workspaces_section_id_idx": { + "name": "workspaces_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_section_id_workspace_sections_id_fk": { + "name": "workspaces_section_id_workspace_sections_id_fk", + "tableFrom": "workspaces", + "tableTo": "workspace_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_superset": { + "name": "created_by_superset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todo_prompt_presets": { + "name": "todo_prompt_presets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "todo_prompt_presets_name_idx": { + "name": "todo_prompt_presets_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "todo_prompt_presets_updated_at_idx": { + "name": "todo_prompt_presets_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "todo_prompt_presets_kind_idx": { + "name": "todo_prompt_presets_kind_idx", + "columns": [ + "kind" + ], + "isUnique": false + }, + "todo_prompt_presets_workspace_idx": { + "name": "todo_prompt_presets_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todo_schedules": { + "name": "todo_schedules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minute": { + "name": "minute", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hour": { + "name": "hour", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weekday": { + "name": "weekday", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthday": { + "name": "monthday", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_expr": { + "name": "cron_expr", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verify_command": { + "name": "verify_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_iterations": { + "name": "max_iterations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "max_wall_clock_sec": { + "name": "max_wall_clock_sec", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1800 + }, + "custom_system_prompt": { + "name": "custom_system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overlap_mode": { + "name": "overlap_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'skip'" + }, + "auto_sync_before_fire": { + "name": "auto_sync_before_fire", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_session_id": { + "name": "last_run_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "todo_schedules_project_idx": { + "name": "todo_schedules_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "todo_schedules_workspace_idx": { + "name": "todo_schedules_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "todo_schedules_enabled_next_run_idx": { + "name": "todo_schedules_enabled_next_run_idx", + "columns": [ + "enabled", + "next_run_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "todo_schedules_project_id_projects_id_fk": { + "name": "todo_schedules_project_id_projects_id_fk", + "tableFrom": "todo_schedules", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "todo_schedules_workspace_id_workspaces_id_fk": { + "name": "todo_schedules_workspace_id_workspaces_id_fk", + "tableFrom": "todo_schedules", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todo_sessions": { + "name": "todo_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verify_command": { + "name": "verify_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_iterations": { + "name": "max_iterations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "max_wall_clock_sec": { + "name": "max_wall_clock_sec", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1800 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attached_pane_id": { + "name": "attached_pane_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attached_tab_id": { + "name": "attached_tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_session_id": { + "name": "claude_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_assistant_text": { + "name": "final_assistant_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_cost_usd": { + "name": "total_cost_usd", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_num_turns": { + "name": "total_num_turns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_intervention": { + "name": "pending_intervention", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_head_sha": { + "name": "start_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_system_prompt": { + "name": "custom_system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict_passed": { + "name": "verdict_passed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict_reason": { + "name": "verdict_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict_failing_test": { + "name": "verdict_failing_test", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_path": { + "name": "artifact_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "waiting_until": { + "name": "waiting_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "waiting_reason": { + "name": "waiting_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "todo_sessions_workspace_idx": { + "name": "todo_sessions_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "todo_sessions_status_idx": { + "name": "todo_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "todo_sessions_created_at_idx": { + "name": "todo_sessions_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "todo_sessions_project_id_projects_id_fk": { + "name": "todo_sessions_project_id_projects_id_fk", + "tableFrom": "todo_sessions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "todo_sessions_workspace_id_workspaces_id_fk": { + "name": "todo_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "todo_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index b61a851385c..1088a31e3f8 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -400,6 +400,13 @@ "when": 1776356076189, "tag": "0056_add_todo_schedules", "breakpoints": true + }, + { + "idx": 57, + "version": "6", + "when": 1776362216006, + "tag": "0057_todo_session_waiting", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/local-db/src/schema/todo-sessions.ts b/packages/local-db/src/schema/todo-sessions.ts index 3e3b70032de..0815a8c3874 100644 --- a/packages/local-db/src/schema/todo-sessions.ts +++ b/packages/local-db/src/schema/todo-sessions.ts @@ -92,6 +92,15 @@ export const todoSessions = sqliteTable( artifactPath: text("artifact_path").notNull(), + // Populated when the session is in the `waiting` state — i.e. the + // underlying Claude Code worker called `ScheduleWakeup` (or another + // self-pacing primitive) to pause itself until a specific wall-clock + // time. The scheduler tick flips the session back into the run queue + // once this timestamp has passed. Null whenever the session is not + // waiting. Paired with `waitingReason` for the rationale Claude gave. + waitingUntil: integer("waiting_until"), + waitingReason: text("waiting_reason"), + createdAt: integer("created_at") .notNull() .$defaultFn(() => Date.now()), @@ -121,6 +130,7 @@ export const todoSessionStatusValues = [ "escalated", "aborted", "paused", + "waiting", ] as const; export type TodoSessionStatus = (typeof todoSessionStatusValues)[number];