Răsfoiți Sursa

fix: git_url in /start payload, block option radio cards, design-task plan review (approve + RFC feedback), terminal entry types (shell-reset/failed/query), failed-task handling, branch prefill on create form

clanker 1 lună în urmă
părinte
comite
0b1675e78b

+ 11 - 3
src/Api/SaddleApi.vala

@@ -24,9 +24,10 @@ namespace ClankerSaddle.Api {
      * `data` shapes store `session_commands` rows), `comment`, `block`
      * (question/rfc/review with optional `options` and markdown `body`;
      * returns the new `block_id`), `subtask`, `plan`, `progress`,
-     * `review`, `done` (completes the task) and `status`
-     * (working|blocked). Conversation-level types also append a typed
-     * entry to the session's terminal log.
+     * `review`, `done` (completes the task), `failed` (fails the task
+     * with the explanation) and `status` (working|blocked).
+     * Conversation-level types also append a typed entry to the session's
+     * terminal log.
      */
     public class SaddleUpdateEndpoint : Object, Endpoint {
 
@@ -131,6 +132,13 @@ namespace ClankerSaddle.Api {
                         get_string_or(data, "working_branch", null), "done");
                     break;
 
+                case "failed":
+                    string failure = get_string_or(data, "summary",
+                        get_string_or(data, "text", ""));
+                    yield tasks.fail_task(task_id, failure.length > 0 ? failure : null);
+                    yield tasks.log_session_entry(task_id, "failed", "saddle failure", failure);
+                    break;
+
                 case "status":
                     string state = get_string_or(data, "state", "");
                     if (state == "working" || state == "blocked") {

+ 4 - 3
src/Api/TerminalSse.vala

@@ -15,9 +15,10 @@ namespace ClankerSaddle.Api {
      *
      * Each event carries `id: <entry id>` (so EventSource reconnects
      * resume from the last seen entry) and a `data` payload of
-     * `{"id", "command", "output", "type"}` — the entry types the terminal
-     * page renders with per-type styling (command, comment, block,
-     * user-comment, progress, review, plan).
+     * `{"id", "command", "output", "type", "input"?}` — the entry types
+     * the terminal page renders with per-type styling (command, comment,
+     * block, user-comment, progress, review, plan, shell-reset, failed,
+     * query).
      */
     public class TerminalSseEndpoint : SseEndpoint {
 

+ 35 - 19
src/Services/SaddleService.vala

@@ -253,7 +253,8 @@ namespace ClankerSaddle.Services {
          * not yet handed over (a resumed workspace picks its pending
          * answers up from the assignment itself). Project checkouts are
          * addressed by the `SADDLE_PROJECTS_ROOT` convention
-         * (`root/name`), the LLM endpoint by the {@link LlmEndpoint}
+         * (`root/task-<task id>/name`) and cloned from each project's
+         * `git_url`; the LLM endpoint by the {@link LlmEndpoint}
          * environment. Returns the parsed JSON response body.
          */
         public async Json.Object push_task_to_saddle(int64 saddle_id, TaskEntity task, int64 session_id,
@@ -318,15 +319,15 @@ namespace ClankerSaddle.Services {
 
             payload.set_member_name("co_developed");
             payload.begin_array();
-            payload.add_value(project_node(((!)project)));
+            payload.add_value(project_node(((!)project), task.id));
             foreach (var linked in co_developed) {
-                payload.add_value(project_node(linked));
+                payload.add_value(project_node(linked, task.id));
             }
             payload.end_array();
             payload.set_member_name("reference");
             payload.begin_array();
             foreach (var linked in references) {
-                payload.add_value(project_node(linked));
+                payload.add_value(project_node(linked, task.id));
             }
             payload.end_array();
 
@@ -434,7 +435,11 @@ namespace ClankerSaddle.Services {
                         null, "done");
                     return true;
                 case "failed":
-                    yield tasks.fail_task(task_id, null);
+                    string explanation = string_member(status, "message");
+                    if (explanation.length == 0) {
+                        explanation = string_member(status, "summary");
+                    }
+                    yield tasks.fail_task(task_id, explanation.length > 0 ? explanation : null);
                     return true;
                 case "blocked":
                     yield ensure_block(tasks, task_id, status);
@@ -524,7 +529,7 @@ namespace ClankerSaddle.Services {
                 }
                 rows.add(new PendingAnswer() {
                     block_id = block.id,
-                    answer = ((!)block).answer ?? ""
+                    answer = raw_answer_for_saddle(((!)block).answer ?? "")
                 });
             }
             return rows.to_immutable_buffer();
@@ -738,9 +743,11 @@ namespace ClankerSaddle.Services {
          * `saddle comment` lines become bot comments, `saddle subtask`
          * lines create child tasks, `saddle plan`/`saddle
          * progress-update`/`saddle review` update the plan, append
-         * progress and raise the review banner, and the `saddle done`
-         * entry completes the task with the summary the agent gave.
-         * Returns true once a done event finished the task.
+         * progress and raise the review banner, the `saddle done` entry
+         * completes the task with the summary the agent gave, and the
+         * `saddle failure` entry fails it with the explanation the agent
+         * piped in. Returns true once a done or failed event finished
+         * the task.
          */
         private async bool apply_entry_events(int64 task_id, Vector<Json.Object> entries,
                 TaskService tasks) throws Error {
@@ -788,6 +795,11 @@ namespace ClankerSaddle.Services {
                         yield tasks.complete_task(task_id,
                             strip_quotes(strip_verb(command, "saddle done")), null, "done");
                         return true;
+                    case "failed":
+                        string explanation = output.length > 0 ? output
+                            : strip_quotes(strip_verb(command, "saddle failure"));
+                        yield tasks.fail_task(task_id, explanation);
+                        return true;
                     default:
                         break;
                 }
@@ -1003,19 +1015,24 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * A `{"name", "path", "branch"}` project reference in the saddle
-         * daemon's wire shape: the checkout lives at
-         * `SADDLE_PROJECTS_ROOT/name` inside the saddle's container.
+         * A `{"name", "git_url", "path", "branch"}` project reference in
+         * the saddle daemon's wire shape: `git_url` is the clone source,
+         * and the checkout lives at
+         * `SADDLE_PROJECTS_ROOT/task-<task_id>/<name>` inside the
+         * saddle's container — one workspace per task, so concurrent
+         * tasks never share a checkout.
          */
-        private static Json.Node project_node(Project project) {
+        private static Json.Node project_node(Project project, int64 task_id) {
             var root_var = (Environment.get_variable("SADDLE_PROJECTS_ROOT") ?? "").strip();
             string projects_root = root_var.length > 0 ? root_var : "/workspace";
             var builder = new Json.Builder();
             builder.begin_object();
             builder.set_member_name("name");
             builder.add_string_value(project.name);
+            builder.set_member_name("git_url");
+            builder.add_string_value(project.git_url);
             builder.set_member_name("path");
-            builder.add_string_value(@"$(projects_root)/$(project.name)");
+            builder.add_string_value(@"$(projects_root)/task-$(task_id)/$(project.name)");
             builder.set_member_name("branch");
             builder.add_string_value(project.default_branch);
             builder.end_object();
@@ -1033,11 +1050,10 @@ namespace ClankerSaddle.Services {
 
         /**
          * The answer as the saddle daemon expects it over /answer (or in a
-         * resume payload): the web UI stores agent-facing text ("Human
-         * selected: …", "Human answered (other): …"), but the daemon
-         * applies its own BlockRequest formatting — delivering the
-         * prefixed string verbatim would wrap it a second time. RFC/plain
-         * replies are stored verbatim and pass through unchanged.
+         * resume payload): verbatim. The daemon applies its own
+         * BlockRequest formatting, so this only strips the "Human
+         * selected: …"/"Human answered (other): …" prefixes still stored
+         * by answers recorded before the daemon took over formatting.
          */
         private static string raw_answer_for_saddle(string answer) {
             if (answer.has_prefix("Human selected: ")) {

+ 4 - 2
src/Services/TaskService.vala

@@ -499,8 +499,9 @@ namespace ClankerSaddle.Services {
 
         /**
          * Records a saddle-reported failure: status 'failed' with the
-         * daemon's message as summary, the open session closed with
-         * outcome 'failed' and its saddle freed.
+         * daemon's message as summary, the explanation stored again as a
+         * bot comment (so the thread carries it), the open session
+         * closed with outcome 'failed' and its saddle freed.
          */
         public async void fail_task(int64 task_id, string? summary) throws Error {
             var task = yield db.query<TaskEntity>()
@@ -515,6 +516,7 @@ namespace ClankerSaddle.Services {
             ((!)task).completed_at = new DateTime.now_local();
             if (summary != null && summary.length > 0) {
                 ((!)task).summary = summary;
+                yield add_comment(task_id, "bot", summary);
             } else if (((!)task).summary == null || ((!)task).summary.length == 0) {
                 ((!)task).summary = "Saddle reported a failure";
             }

+ 6 - 5
src/actions/TaskActions.vala

@@ -199,9 +199,10 @@ namespace ClankerSaddle {
     /**
      * Records the human's answer to a block; the task and block ids come
      * from the sealed {@link BlockAnswerPrivate} blob. Question blocks
-     * submit either a chosen `option` or the free-text `other` field
-     * (formatted the way the agent reads them); rfc/review blocks and the
-     * plain answer form submit the `answer` textarea.
+     * submit either a chosen `option` or the free-text `other` field.
+     * Answers are stored verbatim — the saddle daemon applies its own
+     * "Human selected: …" formatting, so a stored prefix would wrap the
+     * answer a second time.
      */
     public class AnswerBlockAction : TypedStatumAction<BlockAnswerPrivate> {
 
@@ -226,9 +227,9 @@ namespace ClankerSaddle {
             string other = Forms.field_raw(request, "other");
             string answer = Forms.field_raw(request, "answer");
             if (option == "__other__") {
-                answer = other.strip().length > 0 ? @"Human answered (other): $(other.strip())" : answer;
+                answer = other.strip().length > 0 ? other.strip() : answer;
             } else if (option.length > 0) {
-                answer = @"Human selected: $(option)";
+                answer = option;
             }
             if (answer.length == 0) {
                 return directives().notify("error", "An answer is required").navigate(@"/tasks/$(task_id)");

+ 10 - 1
src/entrypoints/TaskDetailEntrypoint.vala

@@ -24,7 +24,13 @@ namespace ClankerSaddle {
         public string when_display { get; set; default = ""; }
     }
 
-    /** A block (question, RFC or review) with its answer state and answer action. */
+    /**
+     * A block (question, RFC or review) with its answer state and answer
+     * action. {@link is_plan_review} marks the design-task plan review
+     * block (`saddle plan` on a design task auto-blocks as
+     * type=rfc/question "Plan submitted for review"), which renders the
+     * approve/feedback flow instead of the plain reply form.
+     */
     public class BlockView : Object {
         public int64 id { get; set; }
         public string question { get; set; default = ""; }
@@ -33,6 +39,7 @@ namespace ClankerSaddle {
         public string body { get; set; default = ""; }
         public string[] options { get; set; }
         public bool answered { get; set; }
+        public bool is_plan_review { get; set; }
         public string created_display { get; set; default = ""; }
         public string answered_display { get; set; default = ""; }
         public Statum.Model.ActionDto? answer_action { get; set; }
@@ -188,6 +195,8 @@ namespace ClankerSaddle {
                     body = block.body ?? "",
                     options = parse_options(block.options),
                     answered = block.answer != null,
+                    is_plan_review = block.block_type == BLOCK_RFC
+                        && block.question == "Plan submitted for review",
                     created_display = format_time(block.created_at),
                     answered_display = format_time(block.answered_at),
                     answer_action = actions.author_private<AnswerBlockAction, BlockAnswerPrivate>(block_private)

+ 11 - 2
src/entrypoints/TasksEntrypoint.vala

@@ -22,10 +22,15 @@ namespace ClankerSaddle {
         public bool ready { get; set; }
     }
 
-    /** A project option in the queue page's filter and create forms. */
+    /**
+     * A project option in the queue page's filter and create forms,
+     * carrying the default branch so the create form can prefill
+     * `branch_from` as the project selection changes.
+     */
     public class TaskProjectOptionView : Object {
         public int64 id { get; set; }
         public string name { get; set; default = ""; }
+        public string default_branch { get; set; default = ""; }
     }
 
     /** Public data of the `tasks` slot. */
@@ -119,7 +124,11 @@ namespace ClankerSaddle {
 
             var options = new Vector<TaskProjectOptionView>();
             foreach (var project in projects) {
-                options.add(new TaskProjectOptionView() { id = project.id, name = project.name });
+                options.add(new TaskProjectOptionView() {
+                    id = project.id,
+                    name = project.name,
+                    default_branch = project.default_branch
+                });
             }
 
             var pub = new TasksPublic() {

+ 58 - 0
src/pages/app.css

@@ -171,6 +171,8 @@ summary { cursor: pointer; font-weight: 600; }
 .badge-plan { background: #f3e8ff; color: var(--plan); }
 .badge-question { background: #ddf4ff; color: #0969da; }
 .badge-rfc { background: #f3e8ff; color: var(--plan); }
+.badge-query { background: #ddf4ff; color: #0969da; }
+.badge-shell-reset { background: var(--line-soft); color: var(--ink-muted); }
 
 .muted { color: var(--ink-muted); }
 .error { color: var(--danger); }
@@ -244,6 +246,58 @@ summary { cursor: pointer; font-weight: 600; }
 .block-card .options label { display: flex; gap: var(--grid-1); align-items: baseline; font-weight: normal; }
 .block-card textarea { min-height: 4rem; }
 
+/* Clickable option cards: the whole label selects its radio, and a
+   checked card highlights (evergreen :has). */
+.options .option-card {
+    align-items: center;
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+    cursor: pointer;
+    padding: var(--grid-1) var(--grid-2);
+}
+
+.options .option-card:hover { border-color: var(--accent); }
+
+.options .option-card:has(input:checked) {
+    background: #ddf4ff;
+    border-color: #0969da;
+}
+
+/* ---- Design-task plan review ---- */
+
+.plan-review .actions { display: flex; gap: var(--grid-1); flex-wrap: wrap; margin: var(--grid-1) 0; }
+.plan-review .rfc-body {
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+    margin: var(--grid-1) 0;
+    padding: var(--grid-2);
+}
+
+.plan-comments { list-style: none; margin: var(--grid-1) 0; padding: 0; }
+
+.plan-comments li {
+    border: 1px solid var(--line);
+    border-left: 3px solid var(--accent);
+    border-radius: var(--radius);
+    margin: var(--grid-1) 0;
+    padding: var(--grid-1) var(--grid-2);
+}
+
+.plan-comments blockquote { margin: 0 0 var(--grid-1); }
+
+.plan-popover {
+    background: var(--surface);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
+    padding: var(--grid-2);
+    position: absolute;
+    width: 22rem;
+    z-index: 40;
+}
+
+.plan-popover .actions { display: flex; gap: var(--grid-1); margin-top: var(--grid-1); }
+
 /* ---- Review banner ---- */
 
 .review-banner {
@@ -356,6 +410,10 @@ summary { cursor: pointer; font-weight: 600; }
 .term-entry.type-review .term-output, .term-entry.type-review .term-command { color: #e3b341; }
 .term-entry.type-plan { border-color: #a371f7; }
 .term-entry.type-plan .term-output, .term-entry.type-plan .term-command { color: #bc8cff; }
+.term-entry.type-shell-reset { border-color: #30363d; opacity: 0.85; }
+.term-entry.type-shell-reset .term-head { color: #8b949e; }
+.term-entry.type-failed { border-color: var(--danger); }
+.term-entry.type-failed .term-output, .term-entry.type-failed .term-command { color: #ff7b72; }
 
 /* ---- Text-selection comment popover ---- */
 

+ 220 - 29
src/pages/task_detail.html

@@ -3,35 +3,207 @@
 <head>
     <pstm-uri>/tasks/{task_id}</pstm-uri>
     <pstm-template>main</pstm-template>
-    <script>
-        // Live updates: the page subscribes to the global pulse (see the
-        // entrypoint); whenever task or saddle state changes anywhere in
-        // the app, the sealed refresh action rehydrates the task slot —
-        // no fixed-interval polling. RFC bodies get the shared
-        // text-highlight -> comment popover (see main.html).
-        if (window.statum) {
-            (function () {
-                'use strict';
-                statum.addStateListener('pulse', function () {
-                    var task = statum.read('task');
-                    if (task && task.refresh) {
-                        statum.act(task.refresh).catch(function () { });
+<script>
+    // Live updates: the page subscribes to the global pulse (see the
+    // entrypoint); whenever task or saddle state changes anywhere in
+    // the app, the sealed refresh action rehydrates the task slot —
+    // no fixed-interval polling. RFC bodies get the shared
+    // text-highlight -> comment popover (see main.html). Design-task
+    // plan reviews collect inline comments on the plan and submit them
+    // as the block answer.
+    if (window.statum) {
+        (function () {
+            'use strict';
+            statum.addStateListener('pulse', function () {
+                var task = statum.read('task');
+                if (task && task.refresh) {
+                    statum.act(task.refresh).catch(function () { });
+                }
+            });
+            if (window.initQuoteComments) {
+                window.initQuoteComments({
+                    matches: function (node) {
+                        return node.closest && node.closest('.rfc-body') != null
+                            && node.closest('[data-plan-body]') == null;
+                    },
+                    action: function () {
+                        var task = statum.read('task');
+                        return task && task.comment;
                     }
                 });
-                if (window.initQuoteComments) {
-                    window.initQuoteComments({
-                        matches: function (node) {
-                            return node.closest && node.closest('.rfc-body') != null;
-                        },
-                        action: function () {
-                            var task = statum.read('task');
-                            return task && task.comment;
+            }
+
+            // Plan-review feedback: collected comments live in these maps
+            // (keyed by block id) so statum re-renders never lose them;
+            // the DOM list is rebuilt after every render cycle.
+            var feedbackByBlock = {};
+            var modeByBlock = {};
+            var planPopover = null;
+
+            statum.afterEvaluate(syncPlanFeedback);
+
+            document.addEventListener('click', function (event) {
+                if (!event.target.closest) return;
+                var toggle = event.target.closest('[data-plan-toggle]');
+                if (toggle) {
+                    hidePlanPopover();
+                    var review = toggle.closest('[data-plan-review]');
+                    var id = review && review.getAttribute('data-block-id');
+                    if (id) modeByBlock[id] = !modeByBlock[id];
+                    syncPlanFeedback();
+                    return;
+                }
+                var submit = event.target.closest('[data-plan-submit]');
+                if (submit) {
+                    submitPlanFeedback(submit.closest('[data-plan-review]'));
+                    return;
+                }
+                var remove = event.target.closest('[data-plan-remove]');
+                if (remove) {
+                    var entry = remove.closest('[data-plan-comment-id]');
+                    var blockId = remove.closest('[data-plan-review]').getAttribute('data-block-id');
+                    var list = feedbackByBlock[blockId] || [];
+                    var index = parseInt(entry.getAttribute('data-plan-comment-id'), 10);
+                    if (!isNaN(index) && list[index]) list.splice(index, 1);
+                    feedbackByBlock[blockId] = list;
+                    syncPlanFeedback();
+                }
+            });
+
+            document.addEventListener('mouseup', function (event) {
+                if (!event.target.closest) return;
+                if (planPopover && planPopover.contains(event.target)) return;
+                var body = event.target.closest('[data-plan-body]');
+                if (!body) {
+                    hidePlanPopover();
+                    return;
+                }
+                var review = body.closest('[data-plan-review]');
+                if (!review || review.getAttribute('data-plan-open') !== '1') return;
+                var selection = window.getSelection();
+                var text = selection ? String(selection.toString()).trim() : '';
+                if (text.length === 0) {
+                    hidePlanPopover();
+                    return;
+                }
+                showPlanPopover(event.pageX, event.pageY, text, review);
+            });
+
+            document.addEventListener('mousedown', function (event) {
+                if (planPopover && event.target.closest && event.target.closest('.plan-popover')) return;
+                hidePlanPopover();
+            });
+
+            function showPlanPopover(x, y, quote, review) {
+                hidePlanPopover();
+                planPopover = document.createElement('div');
+                planPopover.className = 'plan-popover';
+                planPopover.style.left = Math.min(x, window.innerWidth - 140) + 'px';
+                planPopover.style.top = y + 'px';
+
+                var quoted = document.createElement('blockquote');
+                quoted.textContent = quote;
+                planPopover.appendChild(quoted);
+
+                var body = document.createElement('textarea');
+                body.placeholder = 'Your comment on the selected plan text';
+                planPopover.appendChild(body);
+
+                var actions = document.createElement('div');
+                actions.className = 'actions';
+                var add = document.createElement('button');
+                add.type = 'button';
+                add.className = 'primary';
+                add.textContent = 'Add comment';
+                add.addEventListener('click', function () {
+                    if (body.value.trim().length === 0) return;
+                    var id = review.getAttribute('data-block-id');
+                    var list = feedbackByBlock[id] || [];
+                    list.push({ quote: quote, body: body.value.trim() });
+                    feedbackByBlock[id] = list;
+                    hidePlanPopover();
+                    syncPlanFeedback();
+                });
+                var cancel = document.createElement('button');
+                cancel.type = 'button';
+                cancel.textContent = 'Cancel';
+                cancel.addEventListener('click', hidePlanPopover);
+                actions.appendChild(add);
+                actions.appendChild(cancel);
+                planPopover.appendChild(actions);
+
+                document.body.appendChild(planPopover);
+                body.focus();
+            }
+
+            function hidePlanPopover() {
+                if (planPopover) {
+                    planPopover.remove();
+                    planPopover = null;
+                }
+            }
+
+            function submitPlanFeedback(review) {
+                if (!review) return;
+                var id = review.getAttribute('data-block-id');
+                var list = feedbackByBlock[id] || [];
+                if (list.length === 0) {
+                    if (window.showToast) window.showToast('Highlight some plan text and add a comment first', 'error');
+                    return;
+                }
+                var task = statum.read('task');
+                var block = null;
+                (task && task.blocks || []).forEach(function (candidate) {
+                    if (String(candidate.id) === String(id)) block = candidate;
+                });
+                if (!block || !block.answer_action) return;
+                var answer = list.map(function (item) {
+                    return item.quote
+                        ? '> ' + item.quote.replace(/\n/g, '\n> ') + '\n\n' + item.body
+                        : item.body;
+                }).join('\n\n');
+                feedbackByBlock[id] = [];
+                modeByBlock[id] = false;
+                statum.act(block.answer_action, { answer: answer }).catch(function () { });
+            }
+
+            function syncPlanFeedback() {
+                document.querySelectorAll('[data-plan-review]').forEach(function (review) {
+                    var id = review.getAttribute('data-block-id');
+                    var open = modeByBlock[id] ? '1' : '0';
+                    review.setAttribute('data-plan-open', open);
+                    var feedback = review.querySelector('[data-plan-feedback]');
+                    if (feedback) {
+                        if (modeByBlock[id]) feedback.removeAttribute('hidden');
+                        else feedback.setAttribute('hidden', '');
+                    }
+                    var list = review.querySelector('[data-plan-comments]');
+                    if (!list) return;
+                    while (list.firstChild) list.removeChild(list.firstChild);
+                    (feedbackByBlock[id] || []).forEach(function (item, index) {
+                        var entry = document.createElement('li');
+                        entry.setAttribute('data-plan-comment-id', String(index));
+                        if (item.quote) {
+                            var quoted = document.createElement('blockquote');
+                            quoted.textContent = item.quote;
+                            entry.appendChild(quoted);
                         }
+                        var body = document.createElement('div');
+                        body.textContent = item.body;
+                        entry.appendChild(body);
+                        var remove = document.createElement('button');
+                        remove.type = 'button';
+                        remove.className = 'inline';
+                        remove.setAttribute('data-plan-remove', '');
+                        remove.textContent = 'Remove';
+                        entry.appendChild(remove);
+                        list.appendChild(entry);
                     });
-                }
-            })();
-        }
-    </script>
+                });
+            }
+        })();
+    }
+</script>
 </head>
 <body>
     <section>
@@ -158,6 +330,24 @@
                     </div>
                 </div>
 
+                <div stm-if="!block.answered && block.is_plan_review" class="plan-review"
+                     data-plan-review stm-attribute.data-block-id="block.id">
+                    <div class="md rfc-body" stm-markdown="task.plan" data-plan-body>plan</div>
+                    <div class="actions">
+                        <form stm-action="block.answer_action" class="inline">
+                            <input type="hidden" name="answer" value="Plan approved. Proceed with implementation.">
+                            <button type="submit" class="approve">Approve plan</button>
+                        </form>
+                        <button type="button" data-plan-toggle>Give feedback</button>
+                    </div>
+                    <div data-plan-feedback hidden>
+                        <p class="muted">Highlight plan text to attach a comment to it, then submit everything
+                            as feedback — the agent revises the plan and asks again.</p>
+                        <ul class="plan-comments" data-plan-comments></ul>
+                        <button type="button" class="primary" data-plan-submit>Submit feedback</button>
+                    </div>
+                </div>
+
                 <p><span stm-attribute.class="'badge badge-' + block.block_type" stm-text="block.block_type">type</span>
                     <strong stm-text="block.question">question</strong>
                     <span class="muted" stm-text="block.created_display">when</span></p>
@@ -172,12 +362,13 @@
 
                 <form stm-if="!block.answered && block.block_type == 'question'" stm-action="block.answer_action">
                     <div class="options">
-                        <label stm-for-option-in="block.options" stm-key="option">
-                            <input type="radio" name="option" stm-attribute.value="option">
+                        <label stm-for-option-in="block.options" stm-key="option" class="option-card">
+                            <input type="radio" stm-attribute.name="'option-' + block.id"
+                                   stm-attribute.value="option">
                             <span stm-text="option">option</span>
                         </label>
                         <label>
-                            <input type="radio" name="option" value="__other__">
+                            <input type="radio" stm-attribute.name="'option-' + block.id" value="__other__">
                             <span>Other:</span>
                             <textarea name="other" rows="2"></textarea>
                         </label>
@@ -185,7 +376,7 @@
                     <button type="submit" class="primary">Answer</button>
                 </form>
 
-                <form stm-if="!block.answered && block.block_type != 'question' && block.block_type != 'review'"
+                <form stm-if="!block.answered && block.block_type != 'question' && block.block_type != 'review' && !block.is_plan_review"
                       stm-action="block.answer_action" class="inline">
                     <textarea name="answer" rows="3" placeholder="Your reply (delivered to the agent)"></textarea>
                     <button type="submit" class="primary">Reply</button>

+ 10 - 5
src/pages/task_terminal.html

@@ -19,7 +19,10 @@
                     'user-comment': 'user-comment',
                     'progress': 'progress',
                     'review': 'review',
-                    'plan': 'plan'
+                    'plan': 'plan',
+                    'shell-reset': 'Shell was reset',
+                    'failed': 'failure',
+                    'query': 'query'
                 };
 
                 var connected = false;
@@ -78,10 +81,12 @@
                     }
                     card.appendChild(head);
 
-                    var command = document.createElement('div');
-                    command.className = 'term-command';
-                    command.textContent = entry.command || '';
-                    card.appendChild(command);
+                    if (entry.command && entry.command.length > 0) {
+                        var command = document.createElement('div');
+                        command.className = 'term-command';
+                        command.textContent = entry.command;
+                        card.appendChild(command);
+                    }
 
                     if (entry.input && entry.input.length > 0) {
                         var input = document.createElement('pre');

+ 45 - 4
src/pages/tasks.html

@@ -3,6 +3,43 @@
 <head>
     <pstm-uri>/tasks</pstm-uri>
     <pstm-template>main</pstm-template>
+    <script>
+        // Create-form branch prefill: `branch_from` follows the chosen
+        // project's default branch until the user types their own value
+        // (the touched flag keeps hands-off edits authoritative). Events
+        // are delegated and the sync re-runs after every statum render,
+        // because re-renders replace the form's nodes.
+        if (window.statum) {
+            (function () {
+                'use strict';
+
+                function chosenDefaultBranch() {
+                    var select = document.getElementById('create-project');
+                    if (!select) return null;
+                    var option = select.options[select.selectedIndex];
+                    var branch = option && option.getAttribute('data-default-branch');
+                    return branch && branch.length > 0 ? branch : null;
+                }
+
+                function syncBranchFrom() {
+                    var input = document.getElementById('create-branch-from');
+                    if (!input || input.dataset.touched) return;
+                    var branch = chosenDefaultBranch();
+                    if (branch) input.value = branch;
+                }
+
+                document.addEventListener('change', function (event) {
+                    if (event.target.id === 'create-project') syncBranchFrom();
+                });
+                document.addEventListener('input', function (event) {
+                    if (event.target.id === 'create-branch-from') {
+                        event.target.dataset.touched = '1';
+                    }
+                });
+                statum.addStateListener('tasks', syncBranchFrom);
+            })();
+        }
+    </script>
 </head>
 <body>
     <section>
@@ -56,8 +93,10 @@
                 <summary><strong>New task</strong></summary>
                 <form stm-action="tasks.create">
                     <div class="form-row"><label>Project</label>
-                        <select name="project_id" required>
-                            <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id" stm-text="project.name">project</option>
+                        <select name="project_id" id="create-project" required>
+                            <option stm-for-project-in="tasks.projects" stm-attribute.value="project.id"
+                                    stm-attribute.data-default-branch="project.default_branch"
+                                    stm-text="project.name">project</option>
                         </select></div>
                     <div class="form-row"><label>Title</label>
                         <input type="text" name="title" required placeholder="Implement the flange module"></div>
@@ -68,9 +107,11 @@
                     <div class="form-row"><label>Priority (1–9)</label>
                         <input type="number" name="priority" min="1" max="9" value="5"></div>
                     <div class="form-row"><label>Task branch (optional)</label>
-                        <input type="text" name="task_branch" placeholder="Inherited from the parent task when blank"></div>
+                        <input type="text" name="task_branch" placeholder="auto from title"></div>
                     <div class="form-row"><label>Branch from (optional)</label>
-                        <input type="text" name="branch_from" placeholder="Prefilled from the project default"></div>
+                        <input type="text" name="branch_from" id="create-branch-from"
+                               placeholder="project default branch"></div>
+                    <p class="muted">Branch fields are inherited by subtasks unless overridden.</p>
                     <div class="form-row"><label>Design task</label>
                         <input type="checkbox" name="is_design_task"></div>
                     <button type="submit" class="primary">Create task</button>