From ad38c8af53338d65b3a0b5425dc1f31a9d611bb0 Mon Sep 17 00:00:00 2001 From: Alex Netsch Date: Thu, 17 Jul 2025 14:35:44 +0100 Subject: [PATCH] Add plan mode (#174) * feat: add related tasks functionality to task details panel - Introduced a new context for managing related tasks, including fetching and state management. - Added a new RelatedTasksTab component to display related tasks and their statuses. - Updated TaskDetailsProvider to fetch related tasks based on the selected attempt. - Enhanced TaskDetailsContext to include related tasks state and methods. - Modified TabNavigation to include a new tab for related tasks with a count indicator. - Updated TaskDetailsPanel to render the RelatedTasksTab when selected. - Adjusted API calls to support fetching related tasks and task details. - Updated types to include parent_task_attempt in task-related data structures. - Enhanced UI components to reflect changes in task statuses and interactions. Padding (vibe-kanban 97abacaa) frontend/src/components/tasks/TaskDetails/RelatedTasksTab.tsx Add some padding to make tasks in the list look nice Move get children; Search for latest plan across all processes Jump to task created from plan feat: add latest attempt executor to task status and update TaskCard UI * Use correct naming convention * feat: enhance plan presentation handling in Claude executor and UI * format * Always show create task for planning tasks * Add claude hook to stop after plan creation * Lint --------- Co-authored-by: Louis Knight-Webb --- ...aaf8d3cce788d2494ff283e2fad71df0a05d.json} | 16 +- ...66d3298666a546e964a509538731ece90c9e.json} | 14 +- ...abd77695b05d1dd84ef3102930bc0fe6404f.json} | 14 +- ...200038176dc8c56c49eeaaa65763a1b276eb.json} | 16 +- ...ab167592a20b0d857fe97642dc8a34f3ca170.json | 86 +++++++ ...a4edb4ba7f8b70693f86fc83860f8adda9065.json | 62 +++++ ...205da09c67a18907bbf562b6177f8bc8bc0bc.json | 74 ------ ...c7443d603931c082bd38f13b8f1f127b88711.json | 56 ----- ...0250716170000_add_parent_task_to_tasks.sql | 7 + backend/src/bin/generate_types.rs | 2 + backend/src/executor.rs | 13 +- backend/src/executors/amp.rs | 1 + backend/src/executors/claude.rs | 85 ++++++- backend/src/mcp/task_server.rs | 3 + backend/src/models/task.rs | 76 +++++- backend/src/routes/task_attempts.rs | 210 ++++++++++++++++- backend/src/routes/tasks.rs | 5 + backend/src/services/process_service.rs | 11 + .../context/TaskDetailsContextProvider.tsx | 95 +++++++- .../components/context/taskDetailsContext.ts | 17 ++ frontend/src/components/tasks/TaskCard.tsx | 14 +- .../TaskDetails/DisplayConversationEntry.tsx | 15 +- .../tasks/TaskDetails/RelatedTasksTab.tsx | 216 ++++++++++++++++++ .../tasks/TaskDetails/TabNavigation.tsx | 34 ++- .../src/components/tasks/TaskDetailsPanel.tsx | 14 +- .../components/tasks/TaskDetailsToolbar.tsx | 53 ++++- .../tasks/Toolbar/CurrentAttempt.tsx | 201 ++++++++++++---- .../src/components/ui/markdown-renderer.tsx | 23 +- frontend/src/lib/api.ts | 29 +++ frontend/src/lib/utils.ts | 4 + frontend/src/pages/project-tasks.tsx | 4 + shared/types.ts | 16 +- 32 files changed, 1227 insertions(+), 259 deletions(-) rename backend/.sqlx/{query-36241e9163a8c496f8e6a662bd50cdfe38a57757432de65d75bf6a10ea134530.json => query-00aa2d8701f6b1ed2e84ad00b9b6aaf8d3cce788d2494ff283e2fad71df0a05d.json} (62%) rename backend/.sqlx/{query-73a58d62b2f18489ef5be9f4760f3bee51d6dedd550c3be9bf72e29b2203e63c.json => query-216efabcdaa2a6ea166e4468a6ac66d3298666a546e964a509538731ece90c9e.json} (71%) rename backend/.sqlx/{query-d7f5b59e70cc177ec547b5f2f185713f7a284b3dbc774f61d9d95dd830df34c0.json => query-2188432c66e9010684b6bb670d19abd77695b05d1dd84ef3102930bc0fe6404f.json} (72%) rename backend/.sqlx/{query-9a893c35e2d5fa480396d5f403c3e9263217904dc3dfe43beca9c9541c4be619.json => query-5ae4dea70309b2aa40d41412f70b200038176dc8c56c49eeaaa65763a1b276eb.json} (62%) create mode 100644 backend/.sqlx/query-807dcfd9652232f7908466a513eab167592a20b0d857fe97642dc8a34f3ca170.json create mode 100644 backend/.sqlx/query-8aba98bb4d1701d1686d68371bca4edb4ba7f8b70693f86fc83860f8adda9065.json delete mode 100644 backend/.sqlx/query-9c615a14edb0886be82b9004961205da09c67a18907bbf562b6177f8bc8bc0bc.json delete mode 100644 backend/.sqlx/query-fdb06a7d9050f98d73e743b6522c7443d603931c082bd38f13b8f1f127b88711.json create mode 100644 backend/migrations/20250716170000_add_parent_task_to_tasks.sql create mode 100644 frontend/src/components/tasks/TaskDetails/RelatedTasksTab.tsx diff --git a/backend/.sqlx/query-36241e9163a8c496f8e6a662bd50cdfe38a57757432de65d75bf6a10ea134530.json b/backend/.sqlx/query-00aa2d8701f6b1ed2e84ad00b9b6aaf8d3cce788d2494ff283e2fad71df0a05d.json similarity index 62% rename from backend/.sqlx/query-36241e9163a8c496f8e6a662bd50cdfe38a57757432de65d75bf6a10ea134530.json rename to backend/.sqlx/query-00aa2d8701f6b1ed2e84ad00b9b6aaf8d3cce788d2494ff283e2fad71df0a05d.json index 58c1c2c2..2eb7de4e 100644 --- a/backend/.sqlx/query-36241e9163a8c496f8e6a662bd50cdfe38a57757432de65d75bf6a10ea134530.json +++ b/backend/.sqlx/query-00aa2d8701f6b1ed2e84ad00b9b6aaf8d3cce788d2494ff283e2fad71df0a05d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE tasks \n SET title = $3, description = $4, status = $5 \n WHERE id = $1 AND project_id = $2 \n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "query": "UPDATE tasks \n SET title = $3, description = $4, status = $5, parent_task_attempt = $6 \n WHERE id = $1 AND project_id = $2 \n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { @@ -29,18 +29,23 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "parent_task_attempt: Uuid", "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 6, + "ordinal": 7, "type_info": "Text" } ], "parameters": { - "Right": 5 + "Right": 6 }, "nullable": [ true, @@ -48,9 +53,10 @@ false, true, false, + true, false, false ] }, - "hash": "36241e9163a8c496f8e6a662bd50cdfe38a57757432de65d75bf6a10ea134530" + "hash": "00aa2d8701f6b1ed2e84ad00b9b6aaf8d3cce788d2494ff283e2fad71df0a05d" } diff --git a/backend/.sqlx/query-73a58d62b2f18489ef5be9f4760f3bee51d6dedd550c3be9bf72e29b2203e63c.json b/backend/.sqlx/query-216efabcdaa2a6ea166e4468a6ac66d3298666a546e964a509538731ece90c9e.json similarity index 71% rename from backend/.sqlx/query-73a58d62b2f18489ef5be9f4760f3bee51d6dedd550c3be9bf72e29b2203e63c.json rename to backend/.sqlx/query-216efabcdaa2a6ea166e4468a6ac66d3298666a546e964a509538731ece90c9e.json index 1247e1b0..96f1fad3 100644 --- a/backend/.sqlx/query-73a58d62b2f18489ef5be9f4760f3bee51d6dedd550c3be9bf72e29b2203e63c.json +++ b/backend/.sqlx/query-216efabcdaa2a6ea166e4468a6ac66d3298666a546e964a509538731ece90c9e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE id = $1 AND project_id = $2", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE id = $1 AND project_id = $2", "describe": { "columns": [ { @@ -29,13 +29,18 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "parent_task_attempt: Uuid", "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 6, + "ordinal": 7, "type_info": "Text" } ], @@ -48,9 +53,10 @@ false, true, false, + true, false, false ] }, - "hash": "73a58d62b2f18489ef5be9f4760f3bee51d6dedd550c3be9bf72e29b2203e63c" + "hash": "216efabcdaa2a6ea166e4468a6ac66d3298666a546e964a509538731ece90c9e" } diff --git a/backend/.sqlx/query-d7f5b59e70cc177ec547b5f2f185713f7a284b3dbc774f61d9d95dd830df34c0.json b/backend/.sqlx/query-2188432c66e9010684b6bb670d19abd77695b05d1dd84ef3102930bc0fe6404f.json similarity index 72% rename from backend/.sqlx/query-d7f5b59e70cc177ec547b5f2f185713f7a284b3dbc774f61d9d95dd830df34c0.json rename to backend/.sqlx/query-2188432c66e9010684b6bb670d19abd77695b05d1dd84ef3102930bc0fe6404f.json index b76cb6ed..daae994b 100644 --- a/backend/.sqlx/query-d7f5b59e70cc177ec547b5f2f185713f7a284b3dbc774f61d9d95dd830df34c0.json +++ b/backend/.sqlx/query-2188432c66e9010684b6bb670d19abd77695b05d1dd84ef3102930bc0fe6404f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE id = $1", + "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM tasks \n WHERE id = $1", "describe": { "columns": [ { @@ -29,13 +29,18 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "parent_task_attempt: Uuid", "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 6, + "ordinal": 7, "type_info": "Text" } ], @@ -48,9 +53,10 @@ false, true, false, + true, false, false ] }, - "hash": "d7f5b59e70cc177ec547b5f2f185713f7a284b3dbc774f61d9d95dd830df34c0" + "hash": "2188432c66e9010684b6bb670d19abd77695b05d1dd84ef3102930bc0fe6404f" } diff --git a/backend/.sqlx/query-9a893c35e2d5fa480396d5f403c3e9263217904dc3dfe43beca9c9541c4be619.json b/backend/.sqlx/query-5ae4dea70309b2aa40d41412f70b200038176dc8c56c49eeaaa65763a1b276eb.json similarity index 62% rename from backend/.sqlx/query-9a893c35e2d5fa480396d5f403c3e9263217904dc3dfe43beca9c9541c4be619.json rename to backend/.sqlx/query-5ae4dea70309b2aa40d41412f70b200038176dc8c56c49eeaaa65763a1b276eb.json index 40162a4e..d8b022eb 100644 --- a/backend/.sqlx/query-9a893c35e2d5fa480396d5f403c3e9263217904dc3dfe43beca9c9541c4be619.json +++ b/backend/.sqlx/query-5ae4dea70309b2aa40d41412f70b200038176dc8c56c49eeaaa65763a1b276eb.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO tasks (id, project_id, title, description, status) \n VALUES ($1, $2, $3, $4, $5) \n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", + "query": "INSERT INTO tasks (id, project_id, title, description, status, parent_task_attempt) \n VALUES ($1, $2, $3, $4, $5, $6) \n RETURNING id as \"id!: Uuid\", project_id as \"project_id!: Uuid\", title, description, status as \"status!: TaskStatus\", parent_task_attempt as \"parent_task_attempt: Uuid\", created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"", "describe": { "columns": [ { @@ -29,18 +29,23 @@ "type_info": "Text" }, { - "name": "created_at!: DateTime", + "name": "parent_task_attempt: Uuid", "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 6, "type_info": "Text" }, { "name": "updated_at!: DateTime", - "ordinal": 6, + "ordinal": 7, "type_info": "Text" } ], "parameters": { - "Right": 5 + "Right": 6 }, "nullable": [ true, @@ -48,9 +53,10 @@ false, true, false, + true, false, false ] }, - "hash": "9a893c35e2d5fa480396d5f403c3e9263217904dc3dfe43beca9c9541c4be619" + "hash": "5ae4dea70309b2aa40d41412f70b200038176dc8c56c49eeaaa65763a1b276eb" } diff --git a/backend/.sqlx/query-807dcfd9652232f7908466a513eab167592a20b0d857fe97642dc8a34f3ca170.json b/backend/.sqlx/query-807dcfd9652232f7908466a513eab167592a20b0d857fe97642dc8a34f3ca170.json new file mode 100644 index 00000000..d0375bcb --- /dev/null +++ b/backend/.sqlx/query-807dcfd9652232f7908466a513eab167592a20b0d857fe97642dc8a34f3ca170.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "SELECT \n t.id AS \"id!: Uuid\", \n t.project_id AS \"project_id!: Uuid\", \n t.title, \n t.description, \n t.status AS \"status!: TaskStatus\", \n t.parent_task_attempt AS \"parent_task_attempt: Uuid\", \n t.created_at AS \"created_at!: DateTime\", \n t.updated_at AS \"updated_at!: DateTime\",\n CASE \n WHEN in_progress_attempts.task_id IS NOT NULL THEN true \n ELSE false \n END AS \"has_in_progress_attempt!: i64\",\n CASE \n WHEN merged_attempts.task_id IS NOT NULL THEN true \n ELSE false \n END AS \"has_merged_attempt!\",\n CASE \n WHEN failed_attempts.task_id IS NOT NULL THEN true \n ELSE false \n END AS \"has_failed_attempt!\",\n latest_executor_attempts.executor AS \"latest_attempt_executor\"\n FROM tasks t\n LEFT JOIN (\n SELECT DISTINCT ta.task_id\n FROM task_attempts ta\n JOIN execution_processes ep \n ON ta.id = ep.task_attempt_id\n JOIN (\n -- pick exactly one “latest” activity per process,\n -- tiebreaking so that running‐states are lower priority\n SELECT execution_process_id, status\n FROM (\n SELECT\n execution_process_id,\n status,\n ROW_NUMBER() OVER (\n PARTITION BY execution_process_id\n ORDER BY\n created_at DESC,\n CASE \n WHEN status IN ('setuprunning','executorrunning') THEN 1 \n ELSE 0 \n END\n ) AS rn\n FROM task_attempt_activities\n ) sub\n WHERE rn = 1\n ) latest_act \n ON ep.id = latest_act.execution_process_id\n WHERE latest_act.status IN ('setuprunning','executorrunning')\n ) in_progress_attempts \n ON t.id = in_progress_attempts.task_id\n LEFT JOIN (\n SELECT DISTINCT ta.task_id\n FROM task_attempts ta\n WHERE ta.merge_commit IS NOT NULL\n ) merged_attempts \n ON t.id = merged_attempts.task_id\n LEFT JOIN (\n SELECT DISTINCT latest_attempts.task_id\n FROM (\n -- Get the latest attempt for each task\n SELECT task_id, id as attempt_id, created_at,\n ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at DESC) AS rn\n FROM task_attempts\n WHERE merge_commit IS NULL -- Don't show as failed if already merged\n ) latest_attempts\n JOIN execution_processes ep \n ON latest_attempts.attempt_id = ep.task_attempt_id\n JOIN (\n -- pick exactly one \"latest\" activity per process,\n -- tiebreaking so that running‐states are lower priority\n SELECT execution_process_id, status\n FROM (\n SELECT\n execution_process_id,\n status,\n ROW_NUMBER() OVER (\n PARTITION BY execution_process_id\n ORDER BY\n created_at DESC,\n CASE \n WHEN status IN ('setuprunning','executorrunning') THEN 1 \n ELSE 0 \n END\n ) AS rn\n FROM task_attempt_activities\n ) sub\n WHERE rn = 1\n ) latest_act \n ON ep.id = latest_act.execution_process_id\n WHERE latest_attempts.rn = 1 -- Only consider the latest attempt\n AND latest_act.status IN ('setupfailed','executorfailed')\n ) failed_attempts \n ON t.id = failed_attempts.task_id\n LEFT JOIN (\n SELECT task_id, executor\n FROM (\n SELECT task_id, executor, created_at,\n ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at DESC) AS rn\n FROM task_attempts\n ) latest_attempts\n WHERE rn = 1\n ) latest_executor_attempts \n ON t.id = latest_executor_attempts.task_id\n WHERE t.project_id = $1\n ORDER BY t.created_at DESC;\n ", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "status!: TaskStatus", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "parent_task_attempt: Uuid", + "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "has_in_progress_attempt!: i64", + "ordinal": 8, + "type_info": "Integer" + }, + { + "name": "has_merged_attempt!", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "has_failed_attempt!", + "ordinal": 10, + "type_info": "Integer" + }, + { + "name": "latest_attempt_executor", + "ordinal": 11, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + false, + true, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "807dcfd9652232f7908466a513eab167592a20b0d857fe97642dc8a34f3ca170" +} diff --git a/backend/.sqlx/query-8aba98bb4d1701d1686d68371bca4edb4ba7f8b70693f86fc83860f8adda9065.json b/backend/.sqlx/query-8aba98bb4d1701d1686d68371bca4edb4ba7f8b70693f86fc83860f8adda9065.json new file mode 100644 index 00000000..e36fffa6 --- /dev/null +++ b/backend/.sqlx/query-8aba98bb4d1701d1686d68371bca4edb4ba7f8b70693f86fc83860f8adda9065.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT DISTINCT t.id as \"id!: Uuid\", t.project_id as \"project_id!: Uuid\", t.title, t.description, t.status as \"status!: TaskStatus\", t.parent_task_attempt as \"parent_task_attempt: Uuid\", t.created_at as \"created_at!: DateTime\", t.updated_at as \"updated_at!: DateTime\"\n FROM tasks t\n WHERE (\n -- Find children: tasks that have this attempt as parent\n t.parent_task_attempt = $1 AND t.project_id = $2\n ) OR (\n -- Find parent: task that owns the parent attempt of current task\n EXISTS (\n SELECT 1 FROM tasks current_task \n JOIN task_attempts parent_attempt ON current_task.parent_task_attempt = parent_attempt.id\n WHERE parent_attempt.task_id = t.id \n AND parent_attempt.id = $1 \n AND current_task.project_id = $2\n )\n )\n -- Exclude the current task itself to prevent circular references\n AND t.id != (SELECT task_id FROM task_attempts WHERE id = $1)\n ORDER BY t.created_at DESC", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "project_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "status!: TaskStatus", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "parent_task_attempt: Uuid", + "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "created_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + true, + false, + false, + true, + false, + true, + false, + false + ] + }, + "hash": "8aba98bb4d1701d1686d68371bca4edb4ba7f8b70693f86fc83860f8adda9065" +} diff --git a/backend/.sqlx/query-9c615a14edb0886be82b9004961205da09c67a18907bbf562b6177f8bc8bc0bc.json b/backend/.sqlx/query-9c615a14edb0886be82b9004961205da09c67a18907bbf562b6177f8bc8bc0bc.json deleted file mode 100644 index 63883b07..00000000 --- a/backend/.sqlx/query-9c615a14edb0886be82b9004961205da09c67a18907bbf562b6177f8bc8bc0bc.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT \n t.id AS \"id!: Uuid\", \n t.project_id AS \"project_id!: Uuid\", \n t.title, \n t.description, \n t.status AS \"status!: TaskStatus\", \n t.created_at AS \"created_at!: DateTime\", \n t.updated_at AS \"updated_at!: DateTime\",\n CASE \n WHEN in_progress_attempts.task_id IS NOT NULL THEN true \n ELSE false \n END AS \"has_in_progress_attempt!: i64\",\n CASE \n WHEN merged_attempts.task_id IS NOT NULL THEN true \n ELSE false \n END AS \"has_merged_attempt!\",\n CASE \n WHEN failed_attempts.task_id IS NOT NULL THEN true \n ELSE false \n END AS \"has_failed_attempt!\"\n FROM tasks t\n LEFT JOIN (\n SELECT DISTINCT ta.task_id\n FROM task_attempts ta\n JOIN execution_processes ep \n ON ta.id = ep.task_attempt_id\n JOIN (\n -- pick exactly one “latest” activity per process,\n -- tiebreaking so that running‐states are lower priority\n SELECT execution_process_id, status\n FROM (\n SELECT\n execution_process_id,\n status,\n ROW_NUMBER() OVER (\n PARTITION BY execution_process_id\n ORDER BY\n created_at DESC,\n CASE \n WHEN status IN ('setuprunning','executorrunning') THEN 1 \n ELSE 0 \n END\n ) AS rn\n FROM task_attempt_activities\n ) sub\n WHERE rn = 1\n ) latest_act \n ON ep.id = latest_act.execution_process_id\n WHERE latest_act.status IN ('setuprunning','executorrunning')\n ) in_progress_attempts \n ON t.id = in_progress_attempts.task_id\n LEFT JOIN (\n SELECT DISTINCT ta.task_id\n FROM task_attempts ta\n WHERE ta.merge_commit IS NOT NULL\n ) merged_attempts \n ON t.id = merged_attempts.task_id\n LEFT JOIN (\n SELECT DISTINCT latest_attempts.task_id\n FROM (\n -- Get the latest attempt for each task\n SELECT task_id, id as attempt_id, created_at,\n ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at DESC) AS rn\n FROM task_attempts\n WHERE merge_commit IS NULL -- Don't show as failed if already merged\n ) latest_attempts\n JOIN execution_processes ep \n ON latest_attempts.attempt_id = ep.task_attempt_id\n JOIN (\n -- pick exactly one \"latest\" activity per process,\n -- tiebreaking so that running‐states are lower priority\n SELECT execution_process_id, status\n FROM (\n SELECT\n execution_process_id,\n status,\n ROW_NUMBER() OVER (\n PARTITION BY execution_process_id\n ORDER BY\n created_at DESC,\n CASE \n WHEN status IN ('setuprunning','executorrunning') THEN 1 \n ELSE 0 \n END\n ) AS rn\n FROM task_attempt_activities\n ) sub\n WHERE rn = 1\n ) latest_act \n ON ep.id = latest_act.execution_process_id\n WHERE latest_attempts.rn = 1 -- Only consider the latest attempt\n AND latest_act.status IN ('setupfailed','executorfailed')\n ) failed_attempts \n ON t.id = failed_attempts.task_id\n WHERE t.project_id = $1\n ORDER BY t.created_at DESC;\n ", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Blob" - }, - { - "name": "project_id!: Uuid", - "ordinal": 1, - "type_info": "Blob" - }, - { - "name": "title", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "description", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "status!: TaskStatus", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "created_at!: DateTime", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "has_in_progress_attempt!: i64", - "ordinal": 7, - "type_info": "Integer" - }, - { - "name": "has_merged_attempt!", - "ordinal": 8, - "type_info": "Integer" - }, - { - "name": "has_failed_attempt!", - "ordinal": 9, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - false, - false, - true, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "9c615a14edb0886be82b9004961205da09c67a18907bbf562b6177f8bc8bc0bc" -} diff --git a/backend/.sqlx/query-fdb06a7d9050f98d73e743b6522c7443d603931c082bd38f13b8f1f127b88711.json b/backend/.sqlx/query-fdb06a7d9050f98d73e743b6522c7443d603931c082bd38f13b8f1f127b88711.json deleted file mode 100644 index 44b45c4d..00000000 --- a/backend/.sqlx/query-fdb06a7d9050f98d73e743b6522c7443d603931c082bd38f13b8f1f127b88711.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id as \"id!: Uuid\", project_id as \"project_id?: Uuid\", title, description, template_name, created_at as \"created_at!: DateTime\", updated_at as \"updated_at!: DateTime\"\n FROM task_templates \n WHERE project_id = $1 OR project_id IS NULL\n ORDER BY project_id IS NULL, template_name ASC", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Blob" - }, - { - "name": "project_id?: Uuid", - "ordinal": 1, - "type_info": "Blob" - }, - { - "name": "title", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "description", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "template_name", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "created_at!: DateTime", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 6, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - true, - false, - true, - false, - false, - false - ] - }, - "hash": "fdb06a7d9050f98d73e743b6522c7443d603931c082bd38f13b8f1f127b88711" -} diff --git a/backend/migrations/20250716170000_add_parent_task_to_tasks.sql b/backend/migrations/20250716170000_add_parent_task_to_tasks.sql new file mode 100644 index 00000000..1739f09f --- /dev/null +++ b/backend/migrations/20250716170000_add_parent_task_to_tasks.sql @@ -0,0 +1,7 @@ +PRAGMA foreign_keys = ON; + +-- Add parent_task_attempt column to tasks table +ALTER TABLE tasks ADD COLUMN parent_task_attempt BLOB REFERENCES task_attempts(id); + +-- Create index for parent_task_attempt lookups +CREATE INDEX idx_tasks_parent_task_attempt ON tasks(parent_task_attempt); \ No newline at end of file diff --git a/backend/src/bin/generate_types.rs b/backend/src/bin/generate_types.rs index 6e64fbf3..024d5934 100644 --- a/backend/src/bin/generate_types.rs +++ b/backend/src/bin/generate_types.rs @@ -8,6 +8,7 @@ fn generate_constants() -> String { export const EXECUTOR_TYPES: string[] = [ "echo", "claude", + "claude-plan", "amp", "gemini", "charm-opencode", @@ -26,6 +27,7 @@ export const EDITOR_TYPES: EditorType[] = [ export const EXECUTOR_LABELS: Record = { "echo": "Echo (Test Mode)", "claude": "Claude", + "claude-plan": "Claude Plan", "amp": "Amp", "gemini": "Gemini", "charm-opencode": "Charm Opencode", diff --git a/backend/src/executor.rs b/backend/src/executor.rs index 414616a7..f53cf95b 100644 --- a/backend/src/executor.rs +++ b/backend/src/executor.rs @@ -64,6 +64,7 @@ pub enum ActionType { Search { query: String }, WebFetch { url: String }, TaskCreate { description: String }, + PlanPresentation { plan: String }, Other { description: String }, } @@ -347,6 +348,7 @@ pub enum ExecutorType { pub enum ExecutorConfig { Echo, Claude, + ClaudePlan, Amp, Gemini, #[serde(alias = "setup_script")] @@ -376,6 +378,7 @@ impl FromStr for ExecutorConfig { match s { "echo" => Ok(ExecutorConfig::Echo), "claude" => Ok(ExecutorConfig::Claude), + "claude-plan" => Ok(ExecutorConfig::ClaudePlan), "amp" => Ok(ExecutorConfig::Amp), "gemini" => Ok(ExecutorConfig::Gemini), "charm-opencode" => Ok(ExecutorConfig::CharmOpencode), @@ -393,6 +396,7 @@ impl ExecutorConfig { match self { ExecutorConfig::Echo => Box::new(EchoExecutor), ExecutorConfig::Claude => Box::new(ClaudeExecutor::new()), + ExecutorConfig::ClaudePlan => Box::new(ClaudeExecutor::new_plan_mode()), ExecutorConfig::Amp => Box::new(AmpExecutor), ExecutorConfig::Gemini => Box::new(GeminiExecutor), ExecutorConfig::ClaudeCodeRouter => Box::new(CCRExecutor::new()), @@ -409,7 +413,9 @@ impl ExecutorConfig { ExecutorConfig::CharmOpencode => { dirs::home_dir().map(|home| home.join(".opencode.json")) } - ExecutorConfig::Claude | ExecutorConfig::ClaudeCodeRouter => { + ExecutorConfig::Claude => dirs::home_dir().map(|home| home.join(".claude.json")), + ExecutorConfig::ClaudePlan => dirs::home_dir().map(|home| home.join(".claude.json")), + ExecutorConfig::ClaudeCodeRouter => { dirs::home_dir().map(|home| home.join(".claude.json")) } ExecutorConfig::Amp => { @@ -428,6 +434,7 @@ impl ExecutorConfig { ExecutorConfig::Echo => None, // Echo doesn't support MCP ExecutorConfig::CharmOpencode => Some(vec!["mcpServers"]), ExecutorConfig::Claude => Some(vec!["mcpServers"]), + ExecutorConfig::ClaudePlan => Some(vec!["mcpServers"]), ExecutorConfig::Amp => Some(vec!["amp", "mcpServers"]), // Nested path for Amp ExecutorConfig::Gemini => Some(vec!["mcpServers"]), ExecutorConfig::ClaudeCodeRouter => Some(vec!["mcpServers"]), @@ -449,6 +456,7 @@ impl ExecutorConfig { ExecutorConfig::Echo => "Echo (Test Mode)", ExecutorConfig::CharmOpencode => "Charm Opencode", ExecutorConfig::Claude => "Claude", + ExecutorConfig::ClaudePlan => "Claude Plan", ExecutorConfig::Amp => "Amp", ExecutorConfig::Gemini => "Gemini", ExecutorConfig::ClaudeCodeRouter => "Claude Code Router", @@ -462,6 +470,7 @@ impl std::fmt::Display for ExecutorConfig { let s = match self { ExecutorConfig::Echo => "echo", ExecutorConfig::Claude => "claude", + ExecutorConfig::ClaudePlan => "claude-plan", ExecutorConfig::Amp => "amp", ExecutorConfig::Gemini => "gemini", ExecutorConfig::CharmOpencode => "charm-opencode", @@ -930,7 +939,7 @@ mod tests { .normalize_logs(claude_logs, "/tmp/test-worktree") .unwrap(); - assert_eq!(result.executor_type, "claude"); + assert_eq!(result.executor_type, "Claude"); assert_eq!( result.session_id, Some("499dcce4-04aa-4a3e-9e0c-ea0228fa87c9".to_string()) diff --git a/backend/src/executors/amp.rs b/backend/src/executors/amp.rs index 92b0ada1..b3b5e6b8 100644 --- a/backend/src/executors/amp.rs +++ b/backend/src/executors/amp.rs @@ -345,6 +345,7 @@ impl AmpExecutor { ActionType::CommandRun { command } => format!("`{}`", command), ActionType::Search { query } => format!("`{}`", query), ActionType::WebFetch { url } => format!("`{}`", url), + ActionType::PlanPresentation { plan } => format!("Plan Presentation: `{}`", plan), ActionType::TaskCreate { description } => description.clone(), ActionType::Other { description: _ } => { // For other tools, try to extract key information or fall back to tool name diff --git a/backend/src/executors/claude.rs b/backend/src/executors/claude.rs index ab53bd52..c0803578 100644 --- a/backend/src/executors/claude.rs +++ b/backend/src/executors/claude.rs @@ -2,7 +2,7 @@ use std::path::Path; use async_trait::async_trait; use command_group::{AsyncCommandGroup, AsyncGroupChild}; -use tokio::process::Command; +use tokio::{fs, process::Command}; use uuid::Uuid; use crate::{ @@ -14,6 +14,49 @@ use crate::{ utils::shell::get_shell_command, }; +/// Create Claude settings file with PostToolUse hook for exit_plan_mode +async fn create_claude_settings_file(worktree_path: &str) -> Result<(), ExecutorError> { + let claude_dir = Path::new(worktree_path).join(".claude"); + let settings_file = claude_dir.join("settings.local.json"); + + // Create .claude directory if it doesn't exist + fs::create_dir_all(&claude_dir).await.map_err(|e| { + tracing::warn!("Failed to create .claude directory: {}", e); + ExecutorError::GitError(format!("Failed to create .claude directory: {}", e)) + })?; + + // Create settings content with PreToolUse hook to auto-approve exit_plan_mode + let settings_content = r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "exit_plan_mode", + "hooks": [ + { + "type": "command", + "command": "echo '{\"decision\": \"approve\", \"reason\": \"Auto-approving exit_plan_mode tool\", \"continue\": false, \"stopReason\": \"Plan presented - waiting for user approval before continuing\"}'" + } + ] + } + ] + } +}"#; + + // Write settings file + fs::write(&settings_file, settings_content) + .await + .map_err(|e| { + tracing::warn!("Failed to write Claude settings file: {}", e); + ExecutorError::GitError(format!("Failed to write Claude settings file: {}", e)) + })?; + + tracing::info!( + "Created Claude settings file at: {}", + settings_file.display() + ); + Ok(()) +} + /// An executor that uses Claude CLI to process tasks pub struct ClaudeExecutor { executor_type: String, @@ -35,6 +78,13 @@ impl ClaudeExecutor { } } + pub fn new_plan_mode() -> Self { + Self { + executor_type: "ClaudePlan".to_string(), + command: "npx -y @anthropic-ai/claude-code@latest -p --permission-mode=plan --verbose --output-format=stream-json".to_string() + } + } + /// Create a new ClaudeExecutor with custom settings pub fn with_command(executor_type: String, command: String) -> Self { Self { @@ -63,6 +113,15 @@ impl ClaudeFollowupExecutor { } } + pub fn new_plan_mode(session_id: String, prompt: String) -> Self { + Self { + session_id, + prompt, + executor_type: "ClaudePlan".to_string(), + command_base: "npx -y @anthropic-ai/claude-code@latest -p --permission-mode=plan --verbose --output-format=stream-json".to_string() + } + } + /// Create a new ClaudeFollowupExecutor with custom settings pub fn with_command( session_id: String, @@ -92,6 +151,11 @@ impl Executor for ClaudeExecutor { .await? .ok_or(ExecutorError::TaskNotFound)?; + // Create Claude settings file with PostToolUse hook for plan mode + if self.executor_type == "ClaudePlan" { + create_claude_settings_file(worktree_path).await?; + } + let prompt = if let Some(task_description) = task.description { format!( r#"project_id: {} @@ -338,7 +402,7 @@ Task title: {}"#, Ok(NormalizedConversation { entries, session_id, - executor_type: "claude".to_string(), + executor_type: self.executor_type.clone(), prompt: None, summary: None, }) @@ -428,6 +492,7 @@ impl ClaudeExecutor { ActionType::Search { query } => format!("`{}`", query), ActionType::WebFetch { url } => format!("`{}`", url), ActionType::TaskCreate { description } => description.clone(), + ActionType::PlanPresentation { plan } => plan.clone(), ActionType::Other { description: _ } => { // For other tools, try to extract key information or fall back to tool name match tool_name.to_lowercase().as_str() { @@ -598,6 +663,17 @@ impl ClaudeExecutor { } } } + "exit_plan_mode" => { + if let Some(plan) = input.get("plan").and_then(|p| p.as_str()) { + ActionType::PlanPresentation { + plan: plan.to_string(), + } + } else { + ActionType::Other { + description: "Plan presentation".to_string(), + } + } + } _ => ActionType::Other { description: format!("Tool: {}", tool_name), }, @@ -613,6 +689,11 @@ impl Executor for ClaudeFollowupExecutor { _task_id: Uuid, worktree_path: &str, ) -> Result { + // Create Claude settings file with PostToolUse hook for plan mode + if self.executor_type == "ClaudePlan" { + create_claude_settings_file(worktree_path).await?; + } + // Use shell command for cross-platform compatibility let (shell_cmd, shell_arg) = get_shell_command(); // Pass prompt via stdin instead of command line to avoid shell escaping issues diff --git a/backend/src/mcp/task_server.rs b/backend/src/mcp/task_server.rs index bff338a3..40dbcf24 100644 --- a/backend/src/mcp/task_server.rs +++ b/backend/src/mcp/task_server.rs @@ -269,6 +269,7 @@ impl TaskServer { project_id: project_uuid, title: title.clone(), description: description.clone(), + parent_task_attempt: None, }; match Task::create(&self.pool, &create_task_data, task_id).await { @@ -573,6 +574,7 @@ impl TaskServer { let new_title = title.unwrap_or(current_task.title); let new_description = description.or(current_task.description); let new_status = status_enum.unwrap_or(current_task.status); + let new_parent_task_attempt = current_task.parent_task_attempt; match Task::update( &self.pool, @@ -581,6 +583,7 @@ impl TaskServer { new_title, new_description, new_status, + new_parent_task_attempt, ) .await { diff --git a/backend/src/models/task.rs b/backend/src/models/task.rs index 22024dbf..f17edbd8 100644 --- a/backend/src/models/task.rs +++ b/backend/src/models/task.rs @@ -24,6 +24,7 @@ pub struct Task { pub title: String, pub description: Option, pub status: TaskStatus, + pub parent_task_attempt: Option, // Foreign key to parent TaskAttempt pub created_at: DateTime, pub updated_at: DateTime, } @@ -36,11 +37,13 @@ pub struct TaskWithAttemptStatus { pub title: String, pub description: Option, pub status: TaskStatus, + pub parent_task_attempt: Option, pub created_at: DateTime, pub updated_at: DateTime, pub has_in_progress_attempt: bool, pub has_merged_attempt: bool, pub has_failed_attempt: bool, + pub latest_attempt_executor: Option, } #[derive(Debug, Deserialize, TS)] @@ -49,6 +52,7 @@ pub struct CreateTask { pub project_id: Uuid, pub title: String, pub description: Option, + pub parent_task_attempt: Option, } #[derive(Debug, Deserialize, TS)] @@ -57,6 +61,7 @@ pub struct CreateTaskAndStart { pub project_id: Uuid, pub title: String, pub description: Option, + pub parent_task_attempt: Option, pub executor: Option, } @@ -66,6 +71,7 @@ pub struct UpdateTask { pub title: Option, pub description: Option, pub status: Option, + pub parent_task_attempt: Option, } impl Task { @@ -80,6 +86,7 @@ impl Task { t.title, t.description, t.status AS "status!: TaskStatus", + t.parent_task_attempt AS "parent_task_attempt: Uuid", t.created_at AS "created_at!: DateTime", t.updated_at AS "updated_at!: DateTime", CASE @@ -93,7 +100,8 @@ impl Task { CASE WHEN failed_attempts.task_id IS NOT NULL THEN true ELSE false - END AS "has_failed_attempt!" + END AS "has_failed_attempt!", + latest_executor_attempts.executor AS "latest_attempt_executor" FROM tasks t LEFT JOIN ( SELECT DISTINCT ta.task_id @@ -168,6 +176,16 @@ impl Task { AND latest_act.status IN ('setupfailed','executorfailed') ) failed_attempts ON t.id = failed_attempts.task_id + LEFT JOIN ( + SELECT task_id, executor + FROM ( + SELECT task_id, executor, created_at, + ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY created_at DESC) AS rn + FROM task_attempts + ) latest_attempts + WHERE rn = 1 + ) latest_executor_attempts + ON t.id = latest_executor_attempts.task_id WHERE t.project_id = $1 ORDER BY t.created_at DESC; "#, @@ -184,11 +202,13 @@ impl Task { title: record.title, description: record.description, status: record.status, + parent_task_attempt: record.parent_task_attempt, created_at: record.created_at, updated_at: record.updated_at, has_in_progress_attempt: record.has_in_progress_attempt != 0, has_merged_attempt: record.has_merged_attempt != 0, has_failed_attempt: record.has_failed_attempt != 0, + latest_attempt_executor: record.latest_attempt_executor, }) .collect(); @@ -198,7 +218,7 @@ impl Task { pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE id = $1"#, id @@ -214,7 +234,7 @@ impl Task { ) -> Result, sqlx::Error> { sqlx::query_as!( Task, - r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" + r#"SELECT id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime" FROM tasks WHERE id = $1 AND project_id = $2"#, id, @@ -231,14 +251,15 @@ impl Task { ) -> Result { sqlx::query_as!( Task, - r#"INSERT INTO tasks (id, project_id, title, description, status) - VALUES ($1, $2, $3, $4, $5) - RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + r#"INSERT INTO tasks (id, project_id, title, description, status, parent_task_attempt) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, task_id, data.project_id, data.title, data.description, - TaskStatus::Todo as TaskStatus + TaskStatus::Todo as TaskStatus, + data.parent_task_attempt ) .fetch_one(pool) .await @@ -251,19 +272,21 @@ impl Task { title: String, description: Option, status: TaskStatus, + parent_task_attempt: Option, ) -> Result { let status_value = status as TaskStatus; sqlx::query_as!( Task, r#"UPDATE tasks - SET title = $3, description = $4, status = $5 + SET title = $3, description = $4, status = $5, parent_task_attempt = $6 WHERE id = $1 AND project_id = $2 - RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, + RETURNING id as "id!: Uuid", project_id as "project_id!: Uuid", title, description, status as "status!: TaskStatus", parent_task_attempt as "parent_task_attempt: Uuid", created_at as "created_at!: DateTime", updated_at as "updated_at!: DateTime""#, id, project_id, title, description, - status_value + status_value, + parent_task_attempt ) .fetch_one(pool) .await @@ -312,4 +335,37 @@ impl Task { .await?; Ok(result.is_some()) } + + pub async fn find_related_tasks_by_attempt_id( + pool: &SqlitePool, + attempt_id: Uuid, + project_id: Uuid, + ) -> Result, sqlx::Error> { + // Find both children and parent for this attempt + sqlx::query_as!( + Task, + r#"SELECT DISTINCT t.id as "id!: Uuid", t.project_id as "project_id!: Uuid", t.title, t.description, t.status as "status!: TaskStatus", t.parent_task_attempt as "parent_task_attempt: Uuid", t.created_at as "created_at!: DateTime", t.updated_at as "updated_at!: DateTime" + FROM tasks t + WHERE ( + -- Find children: tasks that have this attempt as parent + t.parent_task_attempt = $1 AND t.project_id = $2 + ) OR ( + -- Find parent: task that owns the parent attempt of current task + EXISTS ( + SELECT 1 FROM tasks current_task + JOIN task_attempts parent_attempt ON current_task.parent_task_attempt = parent_attempt.id + WHERE parent_attempt.task_id = t.id + AND parent_attempt.id = $1 + AND current_task.project_id = $2 + ) + ) + -- Exclude the current task itself to prevent circular references + AND t.id != (SELECT task_id FROM task_attempts WHERE id = $1) + ORDER BY t.created_at DESC"#, + attempt_id, + project_id + ) + .fetch_all(pool) + .await + } } diff --git a/backend/src/routes/task_attempts.rs b/backend/src/routes/task_attempts.rs index f71b1f34..e4a212ca 100644 --- a/backend/src/routes/task_attempts.rs +++ b/backend/src/routes/task_attempts.rs @@ -6,16 +6,19 @@ use axum::{ Json, Router, }; use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; use uuid::Uuid; use crate::{ app_state::AppState, - executor::{ExecutorConfig, NormalizedConversation, NormalizedEntry, NormalizedEntryType}, + executor::{ + ActionType, ExecutorConfig, NormalizedConversation, NormalizedEntry, NormalizedEntryType, + }, models::{ config::Config, execution_process::{ExecutionProcess, ExecutionProcessSummary, ExecutionProcessType}, executor_session::ExecutorSession, - task::Task, + task::{Task, TaskStatus}, task_attempt::{ BranchStatus, CreateFollowUpAttempt, CreatePrParams, CreateTaskAttempt, TaskAttempt, TaskAttemptState, TaskAttemptStatus, WorktreeDiff, @@ -1354,6 +1357,197 @@ pub async fn get_execution_process_normalized_logs( })) } +/// Find plan content with context by searching through multiple processes in the same attempt +async fn find_plan_content_with_context( + pool: &SqlitePool, + attempt_id: Uuid, +) -> Result { + // Get all execution processes for this attempt + let execution_processes = + match ExecutionProcess::find_by_task_attempt_id(pool, attempt_id).await { + Ok(processes) => processes, + Err(e) => { + tracing::error!( + "Failed to fetch execution processes for attempt {}: {}", + attempt_id, + e + ); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // Look for claudeplan processes (most recent first) + for claudeplan_process in execution_processes + .iter() + .rev() + .filter(|p| p.executor_type.as_deref() == Some("claude-plan")) + { + if let Some(stdout) = &claudeplan_process.stdout { + if !stdout.trim().is_empty() { + // Create executor and normalize logs + let executor_config = ExecutorConfig::ClaudePlan; + let executor = executor_config.create_executor(); + + // Use working directory for normalization + let working_dir_path = + match std::fs::canonicalize(&claudeplan_process.working_directory) { + Ok(canonical_path) => canonical_path.to_string_lossy().to_string(), + Err(_) => claudeplan_process.working_directory.clone(), + }; + + // Normalize logs and extract plan content + match executor.normalize_logs(stdout, &working_dir_path) { + Ok(normalized_conversation) => { + // Search for plan content in the normalized conversation + if let Some(plan_content) = normalized_conversation + .entries + .iter() + .rev() + .find_map(|entry| { + if let NormalizedEntryType::ToolUse { + action_type: ActionType::PlanPresentation { plan }, + .. + } = &entry.entry_type + { + Some(plan.clone()) + } else { + None + } + }) + { + return Ok(plan_content); + } + } + Err(_) => { + continue; + } + } + } + } + } + + tracing::error!( + "No claudeplan content found in any process in attempt {}", + attempt_id + ); + Err(StatusCode::NOT_FOUND) +} + +pub async fn approve_plan( + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + State(app_state): State, +) -> Result>, StatusCode> { + // Verify task attempt exists and belongs to the correct task + match TaskAttempt::exists_for_task(&app_state.db_pool, attempt_id, task_id, project_id).await { + Ok(false) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task attempt existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + Ok(true) => {} + } + let current_task = match Task::find_by_id(&app_state.db_pool, task_id).await { + Ok(Some(task)) => task, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to fetch current task: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // Find plan content with context across the task hierarchy + let plan_content = find_plan_content_with_context(&app_state.db_pool, attempt_id).await?; + + use crate::models::task::CreateTask; + let new_task_id = Uuid::new_v4(); + let create_task_data = CreateTask { + project_id, + title: format!("Execute Plan: {}", current_task.title), + description: Some(plan_content), + parent_task_attempt: Some(attempt_id), + }; + + let new_task = match Task::create(&app_state.db_pool, &create_task_data, new_task_id).await { + Ok(task) => task, + Err(e) => { + tracing::error!("Failed to create new task: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + // Mark original task as completed since it now has children + if let Err(e) = + Task::update_status(&app_state.db_pool, task_id, project_id, TaskStatus::Done).await + { + tracing::error!("Failed to update original task status to Done: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } else { + tracing::info!( + "Original task {} marked as Done after plan approval (has children)", + task_id + ); + } + + Ok(ResponseJson(ApiResponse { + success: true, + data: Some(FollowUpResponse { + message: format!("Plan approved and new task created: {}", new_task.title), + actual_attempt_id: new_task_id, // Return the new task ID + created_new_attempt: true, + }), + message: Some("Plan approved and new task created".to_string()), + })) +} + +pub async fn get_task_attempt_details( + Path(attempt_id): Path, + State(app_state): State, +) -> Result>, StatusCode> { + match TaskAttempt::find_by_id(&app_state.db_pool, attempt_id).await { + Ok(Some(attempt)) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(attempt), + message: None, + })), + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to get task attempt {}: {}", attempt_id, e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + +pub async fn get_task_attempt_children( + Path((project_id, task_id, attempt_id)): Path<(Uuid, Uuid, Uuid)>, + State(app_state): State, +) -> Result>>, StatusCode> { + // Verify task exists in the specified project + match Task::find_by_id_and_project_id(&app_state.db_pool, task_id, project_id).await { + Ok(Some(_)) => {} // Task exists, proceed + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(e) => { + tracing::error!("Failed to check task existence: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + + match Task::find_related_tasks_by_attempt_id(&app_state.db_pool, attempt_id, project_id).await { + Ok(related_tasks) => Ok(ResponseJson(ApiResponse { + success: true, + data: Some(related_tasks), + message: None, + })), + Err(e) => { + tracing::error!( + "Failed to fetch children for task attempt {}: {}", + attempt_id, + e + ); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } +} + pub fn task_attempts_router() -> Router { use axum::routing::post; @@ -1427,4 +1621,16 @@ pub fn task_attempts_router() -> Router { "/projects/:project_id/tasks/:task_id/attempts/:attempt_id", get(get_task_attempt_execution_state), ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/approve-plan", + post(approve_plan), + ) + .route( + "/projects/:project_id/tasks/:task_id/attempts/:attempt_id/children", + get(get_task_attempt_children), + ) + .route( + "/attempts/:attempt_id/details", + get(get_task_attempt_details), + ) } diff --git a/backend/src/routes/tasks.rs b/backend/src/routes/tasks.rs index f733675f..7706028e 100644 --- a/backend/src/routes/tasks.rs +++ b/backend/src/routes/tasks.rs @@ -142,6 +142,7 @@ pub async fn create_task_and_start( project_id: payload.project_id, title: payload.title.clone(), description: payload.description.clone(), + parent_task_attempt: payload.parent_task_attempt, }; let task = match Task::create(&app_state.db_pool, &create_task_payload, task_id).await { Ok(task) => task, @@ -236,6 +237,9 @@ pub async fn update_task( let title = payload.title.unwrap_or(existing_task.title); let description = payload.description.or(existing_task.description); let status = payload.status.unwrap_or(existing_task.status); + let parent_task_attempt = payload + .parent_task_attempt + .or(existing_task.parent_task_attempt); match Task::update( &app_state.db_pool, @@ -244,6 +248,7 @@ pub async fn update_task( title, description, status, + parent_task_attempt, ) .await { diff --git a/backend/src/services/process_service.rs b/backend/src/services/process_service.rs index 53163a05..f5bcafd5 100644 --- a/backend/src/services/process_service.rs +++ b/backend/src/services/process_service.rs @@ -636,6 +636,7 @@ impl ProcessService { fn resolve_executor_config(executor_name: &Option) -> crate::executor::ExecutorConfig { match executor_name.as_ref().map(|s| s.as_str()) { Some("claude") => crate::executor::ExecutorConfig::Claude, + Some("claude-plan") => crate::executor::ExecutorConfig::ClaudePlan, Some("claude-code-router") => crate::executor::ExecutorConfig::ClaudeCodeRouter, Some("amp") => crate::executor::ExecutorConfig::Amp, Some("gemini") => crate::executor::ExecutorConfig::Gemini, @@ -792,6 +793,16 @@ impl ProcessService { return Err(TaskAttemptError::TaskNotFound); // No session ID for followup } } + crate::executor::ExecutorConfig::ClaudePlan => { + if let Some(sid) = session_id { + Box::new(ClaudeFollowupExecutor::new_plan_mode( + sid.clone(), + prompt.clone(), + )) + } else { + return Err(TaskAttemptError::TaskNotFound); // No session ID for followup + } + } crate::executor::ExecutorConfig::Amp => { if let Some(tid) = session_id { Box::new(AmpFollowupExecutor { diff --git a/frontend/src/components/context/TaskDetailsContextProvider.tsx b/frontend/src/components/context/TaskDetailsContextProvider.tsx index 82139ac8..3a9550ce 100644 --- a/frontend/src/components/context/TaskDetailsContextProvider.tsx +++ b/frontend/src/components/context/TaskDetailsContextProvider.tsx @@ -12,12 +12,14 @@ import { import type { EditorType, ExecutionProcess, + ExecutionProcessSummary, + Task, TaskAttempt, TaskAttemptState, TaskWithAttemptStatus, WorktreeDiff, } from 'shared/types.ts'; -import { attemptsApi, executionProcessesApi } from '@/lib/api.ts'; +import { attemptsApi, executionProcessesApi, tasksApi } from '@/lib/api.ts'; import { TaskAttemptDataContext, TaskAttemptLoadingContext, @@ -27,6 +29,7 @@ import { TaskDetailsContext, TaskDiffContext, TaskExecutionStateContext, + TaskRelatedTasksContext, TaskSelectedAttemptContext, } from './taskDetailsContext.ts'; import { AttemptData } from '@/lib/types.ts'; @@ -35,8 +38,8 @@ const TaskDetailsProvider: FC<{ task: TaskWithAttemptStatus; projectId: string; children: ReactNode; - activeTab: 'logs' | 'diffs'; - setActiveTab: Dispatch>; + activeTab: 'logs' | 'diffs' | 'related'; + setActiveTab: Dispatch>; setShowEditorDialog: Dispatch>; userSelectedTab: boolean; projectHasDevScript?: boolean; @@ -64,6 +67,13 @@ const TaskDetailsProvider: FC<{ const [diffError, setDiffError] = useState(null); const [isBackgroundRefreshing, setIsBackgroundRefreshing] = useState(false); + // Related tasks state + const [relatedTasks, setRelatedTasks] = useState(null); + const [relatedTasksLoading, setRelatedTasksLoading] = useState(true); + const [relatedTasksError, setRelatedTasksError] = useState( + null + ); + const [executionState, setExecutionState] = useState( null ); @@ -75,6 +85,39 @@ const TaskDetailsProvider: FC<{ }); const diffLoadingRef = useRef(false); + const relatedTasksLoadingRef = useRef(false); + + const fetchRelatedTasks = useCallback(async () => { + if (!projectId || !task?.id || !selectedAttempt?.id) { + setRelatedTasks(null); + setRelatedTasksLoading(false); + return; + } + + // Prevent multiple concurrent requests + if (relatedTasksLoadingRef.current) { + return; + } + + relatedTasksLoadingRef.current = true; + setRelatedTasksLoading(true); + setRelatedTasksError(null); + + try { + const children = await tasksApi.getChildren( + projectId, + task.id, + selectedAttempt.id + ); + setRelatedTasks(children); + } catch (err) { + console.error('Failed to load related tasks:', err); + setRelatedTasksError('Failed to load related tasks'); + } finally { + relatedTasksLoadingRef.current = false; + setRelatedTasksLoading(false); + } + }, [projectId, task?.id, selectedAttempt?.id]); const fetchDiff = useCallback( async (isBackgroundRefresh = false) => { @@ -126,6 +169,21 @@ const TaskDetailsProvider: FC<{ fetchDiff(); }, [fetchDiff]); + useEffect(() => { + if (selectedAttempt && task) { + fetchRelatedTasks(); + } else if (task && !selectedAttempt) { + // If we have a task but no selectedAttempt, wait a bit then clear loading state + // This happens when a task has no attempts yet + const timeout = setTimeout(() => { + setRelatedTasks(null); + setRelatedTasksLoading(false); + }, 1000); // Wait 1 second for attempts to load + + return () => clearTimeout(timeout); + } + }, [selectedAttempt, task, fetchRelatedTasks]); + const fetchExecutionState = useCallback( async (attemptId: string, taskId: string) => { if (!task) return; @@ -217,7 +275,7 @@ const TaskDetailsProvider: FC<{ } } - setAttemptData((prev) => { + setAttemptData((prev: AttemptData) => { const newData = { activities: activitiesResult, processes: processesResult, @@ -247,7 +305,7 @@ const TaskDetailsProvider: FC<{ } return attemptData.processes.some( - (process) => + (process: ExecutionProcessSummary) => (process.process_type === 'codingagent' || process.process_type === 'setupscript') && process.status === 'running' @@ -400,6 +458,27 @@ const TaskDetailsProvider: FC<{ [executionState, fetchExecutionState] ); + const relatedTasksValue = useMemo( + () => ({ + relatedTasks, + setRelatedTasks, + relatedTasksLoading, + setRelatedTasksLoading, + relatedTasksError, + setRelatedTasksError, + fetchRelatedTasks, + totalRelatedCount: + (task?.parent_task_attempt ? 1 : 0) + (relatedTasks?.length || 0), + }), + [ + relatedTasks, + relatedTasksLoading, + relatedTasksError, + fetchRelatedTasks, + task?.parent_task_attempt, + ] + ); + return ( @@ -414,7 +493,11 @@ const TaskDetailsProvider: FC<{ - {children} + + {children} + diff --git a/frontend/src/components/context/taskDetailsContext.ts b/frontend/src/components/context/taskDetailsContext.ts index efa965a0..6400ae53 100644 --- a/frontend/src/components/context/taskDetailsContext.ts +++ b/frontend/src/components/context/taskDetailsContext.ts @@ -1,6 +1,7 @@ import { createContext, Dispatch, SetStateAction } from 'react'; import type { EditorType, + Task, TaskAttempt, TaskAttemptState, TaskWithAttemptStatus, @@ -106,3 +107,19 @@ export const TaskExecutionStateContext = createContext( {} as TaskExecutionStateContextValue ); + +interface TaskRelatedTasksContextValue { + relatedTasks: Task[] | null; + setRelatedTasks: Dispatch>; + relatedTasksLoading: boolean; + setRelatedTasksLoading: Dispatch>; + relatedTasksError: string | null; + setRelatedTasksError: Dispatch>; + fetchRelatedTasks: () => Promise; + totalRelatedCount: number; +} + +export const TaskRelatedTasksContext = + createContext( + {} as TaskRelatedTasksContextValue + ); diff --git a/frontend/src/components/tasks/TaskCard.tsx b/frontend/src/components/tasks/TaskCard.tsx index f2aa5bf4..54bfa62c 100644 --- a/frontend/src/components/tasks/TaskCard.tsx +++ b/frontend/src/components/tasks/TaskCard.tsx @@ -1,5 +1,6 @@ import { KeyboardEvent, useCallback, useEffect, useRef } from 'react'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { DropdownMenu, DropdownMenuContent, @@ -16,6 +17,7 @@ import { XCircle, } from 'lucide-react'; import type { TaskWithAttemptStatus } from 'shared/types'; +import { is_planning_executor_type } from '@/lib/utils'; type Task = TaskWithAttemptStatus; @@ -78,7 +80,17 @@ export function TaskCard({
-

{task.title}

+
+

+ {task.latest_attempt_executor && + is_planning_executor_type(task.latest_attempt_executor) && ( + + PLAN + + )} + {task.title} +

+
{/* In Progress Spinner */} diff --git a/frontend/src/components/tasks/TaskDetails/DisplayConversationEntry.tsx b/frontend/src/components/tasks/TaskDetails/DisplayConversationEntry.tsx index 4138247b..96e7af2c 100644 --- a/frontend/src/components/tasks/TaskDetails/DisplayConversationEntry.tsx +++ b/frontend/src/components/tasks/TaskDetails/DisplayConversationEntry.tsx @@ -78,6 +78,9 @@ const getEntryIcon = (entryType: NormalizedEntryType) => { if (action_type.action === 'task_create') { return ; } + if (action_type.action === 'plan_presentation') { + return ; + } return ; } return ; @@ -109,6 +112,14 @@ const getContentClassName = (entryType: NormalizedEntryType) => { return `${baseClasses} font-mono text-purple-700 dark:text-purple-300 bg-purple-50 dark:bg-purple-950/20 px-2 py-1 rounded`; } + // Special styling for plan presentations + if ( + entryType.type === 'tool_use' && + entryType.action_type.action === 'plan_presentation' + ) { + return `${baseClasses} text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/20 px-3 py-2 rounded-md border-l-4 border-blue-400`; + } + return baseClasses; }; @@ -224,9 +235,11 @@ const createIncrementalDiff = ( // Helper function to determine if content should be rendered as markdown const shouldRenderMarkdown = (entryType: NormalizedEntryType) => { - // Render markdown for assistant messages and tool outputs that contain backticks + // Render markdown for assistant messages, plan presentations, and tool outputs that contain backticks return ( entryType.type === 'assistant_message' || + (entryType.type === 'tool_use' && + entryType.action_type.action === 'plan_presentation') || (entryType.type === 'tool_use' && entryType.tool_name && (entryType.tool_name.toLowerCase() === 'todowrite' || diff --git a/frontend/src/components/tasks/TaskDetails/RelatedTasksTab.tsx b/frontend/src/components/tasks/TaskDetails/RelatedTasksTab.tsx new file mode 100644 index 00000000..3f1fc45a --- /dev/null +++ b/frontend/src/components/tasks/TaskDetails/RelatedTasksTab.tsx @@ -0,0 +1,216 @@ +import { useContext, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + TaskDetailsContext, + TaskRelatedTasksContext, +} from '@/components/context/taskDetailsContext.ts'; +import { attemptsApi, tasksApi } from '@/lib/api.ts'; +import type { Task, TaskAttempt } from 'shared/types.ts'; +import { + AlertCircle, + CheckCircle, + Clock, + XCircle, + ArrowUp, + ArrowDown, +} from 'lucide-react'; + +function RelatedTasksTab() { + const { task, projectId } = useContext(TaskDetailsContext); + const { relatedTasks, relatedTasksLoading, relatedTasksError } = useContext( + TaskRelatedTasksContext + ); + const navigate = useNavigate(); + + // State for parent task details + const [parentTaskDetails, setParentTaskDetails] = useState<{ + task: Task; + attempt: TaskAttempt; + } | null>(null); + const [parentTaskLoading, setParentTaskLoading] = useState(false); + + const handleTaskClick = (relatedTask: any) => { + navigate(`/projects/${projectId}/tasks/${relatedTask.id}`); + }; + + const hasParent = task?.parent_task_attempt; + const children = relatedTasks || []; + + // Fetch parent task details when component mounts + useEffect(() => { + const fetchParentTaskDetails = async () => { + if (!task?.parent_task_attempt) { + setParentTaskDetails(null); + return; + } + + setParentTaskLoading(true); + try { + const attemptData = await attemptsApi.getDetails( + task.parent_task_attempt + ); + const parentTask = await tasksApi.getById( + projectId, + attemptData.task_id + ); + setParentTaskDetails({ + task: parentTask, + attempt: attemptData, + }); + } catch (error) { + console.error('Error fetching parent task details:', error); + setParentTaskDetails(null); + } finally { + setParentTaskLoading(false); + } + }; + + fetchParentTaskDetails(); + }, [task?.parent_task_attempt, projectId]); + + const handleParentClick = async () => { + if (task?.parent_task_attempt) { + try { + const attemptData = await attemptsApi.getDetails( + task.parent_task_attempt + ); + navigate( + `/projects/${projectId}/tasks/${attemptData.task_id}?attempt=${task.parent_task_attempt}` + ); + } catch (error) { + console.error('Error navigating to parent task:', error); + } + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'done': + return ; + case 'inprogress': + return ; + case 'cancelled': + return ; + case 'inreview': + return ; + default: + return ; + } + }; + + if (relatedTasksLoading) { + return ( +
+
+
+ ); + } + + if (relatedTasksError) { + return ( +
+
+ +

{relatedTasksError}

+
+
+ ); + } + + const totalRelatedTasks = (hasParent ? 1 : 0) + children.length; + + if (totalRelatedTasks === 0) { + return ( +
+
+
+

No related tasks found.

+

+ This task doesn't have any parent task or subtasks. +

+
+
+
+ ); + } + + return ( +
+ {/* Parent Task */} + {hasParent && ( +
+

+ + Parent Task +

+ +
+ )} + + {/* Child Tasks */} + {children.length > 0 && ( +
+

+ + Child Tasks ({children.length}) +

+
+ {children.map((childTask) => ( + + ))} +
+
+ )} +
+ ); +} + +export default RelatedTasksTab; diff --git a/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx b/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx index 81778f94..6822cccb 100644 --- a/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx +++ b/frontend/src/components/tasks/TaskDetails/TabNavigation.tsx @@ -1,15 +1,19 @@ -import { GitCompare, MessageSquare } from 'lucide-react'; +import { GitCompare, MessageSquare, Network } from 'lucide-react'; import { useContext } from 'react'; -import { TaskDiffContext } from '@/components/context/taskDetailsContext.ts'; +import { + TaskDiffContext, + TaskRelatedTasksContext, +} from '@/components/context/taskDetailsContext.ts'; type Props = { - activeTab: 'logs' | 'diffs'; - setActiveTab: (tab: 'logs' | 'diffs') => void; + activeTab: 'logs' | 'diffs' | 'related'; + setActiveTab: (tab: 'logs' | 'diffs' | 'related') => void; setUserSelectedTab: (tab: boolean) => void; }; function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) { const { diff } = useContext(TaskDiffContext); + const { totalRelatedCount } = useContext(TaskRelatedTasksContext); return (
@@ -48,6 +52,28 @@ function TabNavigation({ activeTab, setActiveTab, setUserSelectedTab }: Props) { )} +
); diff --git a/frontend/src/components/tasks/TaskDetailsPanel.tsx b/frontend/src/components/tasks/TaskDetailsPanel.tsx index 320fdc84..f22ba0ee 100644 --- a/frontend/src/components/tasks/TaskDetailsPanel.tsx +++ b/frontend/src/components/tasks/TaskDetailsPanel.tsx @@ -9,6 +9,7 @@ import { import type { TaskWithAttemptStatus } from 'shared/types'; import DiffTab from '@/components/tasks/TaskDetails/DiffTab.tsx'; import LogsTab from '@/components/tasks/TaskDetails/LogsTab.tsx'; +import RelatedTasksTab from '@/components/tasks/TaskDetails/RelatedTasksTab.tsx'; import DeleteFileConfirmationDialog from '@/components/tasks/DeleteFileConfirmationDialog.tsx'; import TabNavigation from '@/components/tasks/TaskDetails/TabNavigation.tsx'; import CollapsibleToolbar from '@/components/tasks/TaskDetails/CollapsibleToolbar.tsx'; @@ -36,7 +37,9 @@ export function TaskDetailsPanel({ const [showEditorDialog, setShowEditorDialog] = useState(false); // Tab and collapsible state - const [activeTab, setActiveTab] = useState<'logs' | 'diffs'>('logs'); + const [activeTab, setActiveTab] = useState<'logs' | 'diffs' | 'related'>( + 'logs' + ); const [userSelectedTab, setUserSelectedTab] = useState(false); // Reset to logs tab when task changes @@ -67,6 +70,7 @@ export function TaskDetailsPanel({ <> {!task ? null : ( - {activeTab === 'diffs' ? : } + {activeTab === 'diffs' ? ( + + ) : activeTab === 'related' ? ( + + ) : ( + + )}
diff --git a/frontend/src/components/tasks/TaskDetailsToolbar.tsx b/frontend/src/components/tasks/TaskDetailsToolbar.tsx index e433bd90..89d71e25 100644 --- a/frontend/src/components/tasks/TaskDetailsToolbar.tsx +++ b/frontend/src/components/tasks/TaskDetailsToolbar.tsx @@ -1,4 +1,5 @@ import { useCallback, useContext, useEffect, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import { Play } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useConfig } from '@/components/config-provider'; @@ -28,6 +29,7 @@ function TaskDetailsToolbar() { const { selectedAttempt, setSelectedAttempt } = useContext( TaskSelectedAttemptContext ); + const { isStopping } = useContext(TaskAttemptStoppingContext); const { fetchAttemptData, setAttemptData, isAttemptRunning } = useContext( TaskAttemptDataContext @@ -35,6 +37,7 @@ function TaskDetailsToolbar() { const { fetchExecutionState } = useContext(TaskExecutionStateContext); const [taskAttempts, setTaskAttempts] = useState([]); + const location = useLocation(); const { config } = useConfig(); @@ -125,18 +128,46 @@ function TaskDetailsToolbar() { }); if (result.length > 0) { - const latestAttempt = result.reduce((latest, current) => - new Date(current.created_at) > new Date(latest.created_at) - ? current - : latest - ); + // Check if there's an attempt query parameter + const urlParams = new URLSearchParams(location.search); + const attemptParam = urlParams.get('attempt'); + + let selectedAttemptToUse: TaskAttempt; + + if (attemptParam) { + // Try to find the specific attempt + const specificAttempt = result.find( + (attempt) => attempt.id === attemptParam + ); + if (specificAttempt) { + selectedAttemptToUse = specificAttempt; + } else { + // Fall back to latest if specific attempt not found + selectedAttemptToUse = result.reduce((latest, current) => + new Date(current.created_at) > new Date(latest.created_at) + ? current + : latest + ); + } + } else { + // Use latest attempt if no specific attempt requested + selectedAttemptToUse = result.reduce((latest, current) => + new Date(current.created_at) > new Date(latest.created_at) + ? current + : latest + ); + } + setSelectedAttempt((prev) => { - if (JSON.stringify(prev) === JSON.stringify(latestAttempt)) + if (JSON.stringify(prev) === JSON.stringify(selectedAttemptToUse)) return prev; - return latestAttempt; + return selectedAttemptToUse; }); - fetchAttemptData(latestAttempt.id, latestAttempt.task_id); - fetchExecutionState(latestAttempt.id, latestAttempt.task_id); + fetchAttemptData(selectedAttemptToUse.id, selectedAttemptToUse.task_id); + fetchExecutionState( + selectedAttemptToUse.id, + selectedAttemptToUse.task_id + ); } else { setSelectedAttempt(null); setAttemptData({ @@ -150,7 +181,7 @@ function TaskDetailsToolbar() { } finally { setLoading(false); } - }, [task, projectId, fetchAttemptData, fetchExecutionState]); + }, [task, projectId, fetchAttemptData, fetchExecutionState, location.search]); useEffect(() => { fetchTaskAttempts(); @@ -235,7 +266,7 @@ function TaskDetailsToolbar() { branches={branches} /> ) : ( -
+
No attempts yet
diff --git a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx index e6be20a9..390b774d 100644 --- a/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx +++ b/frontend/src/components/tasks/Toolbar/CurrentAttempt.tsx @@ -9,6 +9,7 @@ import { Settings, StopCircle, } from 'lucide-react'; +import { is_planning_executor_type } from '@/lib/utils'; import { Tooltip, TooltipContent, @@ -31,7 +32,13 @@ import { DialogTitle, } from '@/components/ui/dialog.tsx'; import BranchSelector from '@/components/tasks/BranchSelector.tsx'; -import { attemptsApi, executionProcessesApi } from '@/lib/api.ts'; +import { + attemptsApi, + executionProcessesApi, + makeRequest, + FollowUpResponse, + ApiResponse, +} from '@/lib/api.ts'; import { Dispatch, SetStateAction, @@ -52,10 +59,12 @@ import { TaskAttemptStoppingContext, TaskDetailsContext, TaskExecutionStateContext, + TaskRelatedTasksContext, TaskSelectedAttemptContext, } from '@/components/context/taskDetailsContext.ts'; import { useConfig } from '@/components/config-provider.tsx'; import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts.ts'; +import { useNavigate } from 'react-router-dom'; // Helper function to get the display name for different editor types function getEditorDisplayName(editorType: string): string { @@ -107,11 +116,15 @@ function CurrentAttempt({ useContext(TaskDetailsContext); const { config } = useConfig(); const { setSelectedAttempt } = useContext(TaskSelectedAttemptContext); + const navigate = useNavigate(); const { isStopping, setIsStopping } = useContext(TaskAttemptStoppingContext); const { attemptData, fetchAttemptData, isAttemptRunning } = useContext( TaskAttemptDataContext ); - const { fetchExecutionState } = useContext(TaskExecutionStateContext); + const { relatedTasks } = useContext(TaskRelatedTasksContext); + const { executionState, fetchExecutionState } = useContext( + TaskExecutionStateContext + ); const [isStartingDevServer, setIsStartingDevServer] = useState(false); const [merging, setMerging] = useState(false); @@ -124,6 +137,7 @@ function CurrentAttempt({ const [showRebaseDialog, setShowRebaseDialog] = useState(false); const [selectedRebaseBranch, setSelectedRebaseBranch] = useState(''); const [showStopConfirmation, setShowStopConfirmation] = useState(false); + const [isApprovingPlan, setIsApprovingPlan] = useState(false); const processedDevServerLogs = useMemo(() => { if (!devServerDetails) return 'No output yet...'; @@ -144,6 +158,14 @@ function CurrentAttempt({ ); }, [attemptData.processes]); + // Check if plan approval is needed + const isPlanTask = useMemo(() => { + return !!( + selectedAttempt.executor && + is_planning_executor_type(selectedAttempt.executor) + ); + }, [selectedAttempt.executor]); + const fetchDevServerDetails = useCallback(async () => { if (!runningDevServer || !task || !selectedAttempt) return; @@ -375,6 +397,48 @@ function CurrentAttempt({ setShowCreatePRDialog(true); }; + const handlePlanApproval = async () => { + if (!task || !selectedAttempt || !isPlanTask) return; + + setIsApprovingPlan(true); + try { + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/approve-plan`, + { + method: 'POST', + // No body needed - endpoint only handles approval now + } + ); + + if (response.ok) { + const result: ApiResponse = await response.json(); + if (result.success && result.data) { + console.log('Plan approved successfully:', result.message); + + // If a new task was created, navigate to it + if (result.data.created_new_attempt) { + const newTaskId = result.data.actual_attempt_id; + console.log('Navigating to new task:', newTaskId); + navigate(`/projects/${projectId}/tasks/${newTaskId}`); + } else { + // Otherwise, just refresh the current task data + fetchAttemptData(selectedAttempt.id, selectedAttempt.task_id); + } + } else { + setError(`Failed to approve plan: ${result.message}`); + } + } else { + setError('Failed to approve plan'); + } + } catch (error) { + setError( + `Error approving plan: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } finally { + setIsApprovingPlan(false); + } + }; + // Get display name for selected branch const selectedBranchDisplayName = useMemo(() => { if (!selectedBranch) return 'current'; @@ -432,7 +496,10 @@ function CurrentAttempt({ size="sm" onClick={handleRebaseDialogOpen} disabled={ - rebasing || branchStatusLoading || isAttemptRunning + rebasing || + branchStatusLoading || + isAttemptRunning || + isPlanTask } className="h-4 w-4 p-0 hover:bg-muted" > @@ -455,10 +522,28 @@ function CurrentAttempt({
- Merge Status + {isPlanTask ? 'Plan Status' : 'Merge Status'}
- {selectedAttempt.merge_commit ? ( + {isPlanTask ? ( + // Plan status for planning tasks + relatedTasks && relatedTasks.length > 0 ? ( +
+
+ + Task Created + +
+ ) : ( +
+
+ + Draft + +
+ ) + ) : // Merge status for regular tasks + selectedAttempt.merge_commit ? (
@@ -482,7 +567,7 @@ function CurrentAttempt({
-
+
Worktree Path
- )} - {!branchStatus.merged && ( - <> + {branchStatus.is_behind && + !branchStatus.merged && + !isPlanTask && ( - - + )} + {isPlanTask ? ( + // Plan tasks: show approval button + + ) : ( + // Normal merge and PR buttons for regular tasks + !branchStatus.merged && ( + <> + + + + ) )} )} diff --git a/frontend/src/components/ui/markdown-renderer.tsx b/frontend/src/components/ui/markdown-renderer.tsx index 504e27ba..cec1c39d 100644 --- a/frontend/src/components/ui/markdown-renderer.tsx +++ b/frontend/src/components/ui/markdown-renderer.tsx @@ -28,46 +28,37 @@ function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) { ), p: ({ children, ...props }) => ( -

+

{children}

), h1: ({ children, ...props }) => ( -

+

{children}

), h2: ({ children, ...props }) => ( -

+

{children}

), h3: ({ children, ...props }) => ( -

+

{children}

), ul: ({ children, ...props }) => ( -
    +
      {children}
    ), ol: ({ children, ...props }) => ( -
      +
        {children}
      ), li: ({ children, ...props }) => ( -
    1. +
    2. {children}
    3. ), diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 56024bd0..9645a200 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -47,6 +47,12 @@ export interface ApiResponse { message?: string; } +export interface FollowUpResponse { + message: string; + actual_attempt_id: string; + created_new_attempt: boolean; +} + // Additional interface for file search results export interface FileSearchResult { path: string; @@ -181,6 +187,13 @@ export const tasksApi = { return handleApiResponse(response); }, + getById: async (projectId: string, taskId: string): Promise => { + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${taskId}` + ); + return handleApiResponse(response); + }, + create: async (projectId: string, data: CreateTask): Promise => { const response = await makeRequest(`/api/projects/${projectId}/tasks`, { method: 'POST', @@ -227,6 +240,17 @@ export const tasksApi = { ); return handleApiResponse(response); }, + + getChildren: async ( + projectId: string, + taskId: string, + attemptId: string + ): Promise => { + const response = await makeRequest( + `/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/children` + ); + return handleApiResponse(response); + }, }; // Task Attempts APIs @@ -454,6 +478,11 @@ export const attemptsApi = { ); return handleApiResponse(response); }, + + getDetails: async (attemptId: string): Promise => { + const response = await makeRequest(`/api/attempts/${attemptId}/details`); + return handleApiResponse(response); + }, }; // Execution Process APIs diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 9ad0df42..016122c5 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -4,3 +4,7 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function is_planning_executor_type(executorType: string): boolean { + return executorType === 'claude-plan'; +} diff --git a/frontend/src/pages/project-tasks.tsx b/frontend/src/pages/project-tasks.tsx index 71d862a2..a29c941b 100644 --- a/frontend/src/pages/project-tasks.tsx +++ b/frontend/src/pages/project-tasks.tsx @@ -176,6 +176,7 @@ export function ProjectTasks() { project_id: projectId!, title, description: description || null, + parent_task_attempt: null, }); await fetchTasks(); // Open the newly created task in the details panel @@ -196,6 +197,7 @@ export function ProjectTasks() { project_id: projectId!, title, description: description || null, + parent_task_attempt: null, executor: executor || null, }; const result = await tasksApi.createAndStart(projectId!, payload); @@ -218,6 +220,7 @@ export function ProjectTasks() { title, description: description || null, status, + parent_task_attempt: null, }); await fetchTasks(); setEditingTask(null); @@ -292,6 +295,7 @@ export function ProjectTasks() { title: task.title, description: task.description, status: newStatus, + parent_task_attempt: task.parent_task_attempt, }); } catch (err) { // Revert the optimistic update if the API call failed diff --git a/shared/types.ts b/shared/types.ts index c7f33b2f..01708bff 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -22,7 +22,7 @@ export type SoundConstants = { sound_files: Array, sound_labels: Arra export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, }; -export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "amp" } | { "type": "gemini" } | { "type": "setup-script", script: string, } | { "type": "claude-code-router" } | { "type": "charm-opencode" }; +export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "claude-plan" } | { "type": "amp" } | { "type": "gemini" } | { "type": "setup-script", script: string, } | { "type": "claude-code-router" } | { "type": "charm-opencode" }; export type ExecutorConstants = { executor_types: Array, executor_labels: Array, }; @@ -42,17 +42,17 @@ export type GitBranch = { name: string, is_current: boolean, is_remote: boolean, export type CreateBranch = { name: string, base_branch: string | null, }; -export type CreateTask = { project_id: string, title: string, description: string | null, }; +export type CreateTask = { project_id: string, title: string, description: string | null, parent_task_attempt: string | null, }; -export type CreateTaskAndStart = { project_id: string, title: string, description: string | null, executor: ExecutorConfig | null, }; +export type CreateTaskAndStart = { project_id: string, title: string, description: string | null, parent_task_attempt: string | null, executor: ExecutorConfig | null, }; export type TaskStatus = "todo" | "inprogress" | "inreview" | "done" | "cancelled"; -export type Task = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, created_at: string, updated_at: string, }; +export type Task = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, parent_task_attempt: string | null, created_at: string, updated_at: string, }; -export type TaskWithAttemptStatus = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, created_at: string, updated_at: string, has_in_progress_attempt: boolean, has_merged_attempt: boolean, has_failed_attempt: boolean, }; +export type TaskWithAttemptStatus = { id: string, project_id: string, title: string, description: string | null, status: TaskStatus, parent_task_attempt: string | null, created_at: string, updated_at: string, has_in_progress_attempt: boolean, has_merged_attempt: boolean, has_failed_attempt: boolean, latest_attempt_executor: string | null, }; -export type UpdateTask = { title: string | null, description: string | null, status: TaskStatus | null, }; +export type UpdateTask = { title: string | null, description: string | null, status: TaskStatus | null, parent_task_attempt: string | null, }; export type TaskTemplate = { id: string, project_id: string | null, title: string, description: string | null, template_name: string, created_at: string, updated_at: string, }; @@ -120,12 +120,13 @@ export type NormalizedEntry = { timestamp: string | null, entry_type: Normalized export type NormalizedEntryType = { "type": "user_message" } | { "type": "assistant_message" } | { "type": "tool_use", tool_name: string, action_type: ActionType, } | { "type": "system_message" } | { "type": "error_message" } | { "type": "thinking" }; -export type ActionType = { "action": "file_read", path: string, } | { "action": "file_write", path: string, } | { "action": "command_run", command: string, } | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { "action": "task_create", description: string, } | { "action": "other", description: string, }; +export type ActionType = { "action": "file_read", path: string, } | { "action": "file_write", path: string, } | { "action": "command_run", command: string, } | { "action": "search", query: string, } | { "action": "web_fetch", url: string, } | { "action": "task_create", description: string, } | { "action": "plan_presentation", plan: string, } | { "action": "other", description: string, }; // Generated constants export const EXECUTOR_TYPES: string[] = [ "echo", "claude", + "claude-plan", "amp", "gemini", "charm-opencode", @@ -144,6 +145,7 @@ export const EDITOR_TYPES: EditorType[] = [ export const EXECUTOR_LABELS: Record = { "echo": "Echo (Test Mode)", "claude": "Claude", + "claude-plan": "Claude Plan", "amp": "Amp", "gemini": "Gemini", "charm-opencode": "Charm Opencode",