浏览代码

List UX: newest-first tables, New task expander above the queue

Ordering sweep for list-page consistency: every table now leads
with the most recent row.

- TaskService.list_tasks and list_standalone_tasks order by
  created_at DESC then id DESC. Priority stays a visible column
  but is no longer a sort key; get_ready_tasks keeps the
  dispatcher's priority-first contract.
- TaskService.list_tasks_for_goal, list_comments, list_blocks and
  the sessions in get_task flipped to newest-first (id DESC).
  newest_block now returns the first row of the newest-first list.
- GoalService.list_links flipped to newest-first; goal blocks and
  comments flow newest-first through the goal detail entrypoint.
- TaskDetailEntrypoint, GoalDetailEntrypoint and
  TaskTerminalEntrypoint walk the rows straight through (the old
  reversal passes are gone); the terminal's default session is an
  explicit max so it no longer depends on row order.
- src/pages/tasks.html: the New task expander now sits above the
  tasks table, matching every other list page.
- test/bundles/pages.py: new regression checks - the tasks table
  lists newest first, the New task expander renders above the
  table, and the task comment thread leads with the newest remark.

Known unrelated: web-config.json is gitignored and generated per
workspace (the suite harness reads it at repo root); a fresh
checkout needs one before the suite can boot the app.
clanker 1 月之前
父节点
当前提交
73dcec9314

+ 6 - 4
src/Services/GoalService.vala

@@ -231,11 +231,13 @@ namespace ClankerSaddle.Services {
             }
         }
 
-        /** The goal's links, oldest first. */
+        /** The goal's links, newest first (the list-page consistency
+         * sweep). The RULE/effective-context consumers only iterate,
+         * so the flip is invisible to them. */
         public async ImmutableLot<GoalLink> list_links(int64 goal_id) throws Error {
             return yield db.query<GoalLink>()
                 .where(expr("goal_id == $0", elem<int64?>(goal_id)))
-                .order_by(expr("id"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
@@ -699,7 +701,7 @@ namespace ClankerSaddle.Services {
 
         /**
          * The goal's comments (plan 15 C2): the goal-run task's
-         * `task_comments` rows, oldest first — the ONE thread.
+         * `task_comments` rows, newest first — the ONE thread.
          * Empty while the goal has no run task.
          */
         public async ImmutableLot<TaskComment> list_comments(int64 goal_id) throws Error {
@@ -712,7 +714,7 @@ namespace ClankerSaddle.Services {
 
         /**
          * A goal's blocks (plan 15 C1): the goal-run task's
-         * `task_blocks` rows, oldest first — the run task is the
+         * `task_blocks` rows, newest first — the run task is the
          * single home of the goal agent's questions, plans and
          * completion reports. Empty while the goal has no run task.
          */

+ 29 - 22
src/Services/TaskService.vala

@@ -252,7 +252,7 @@ namespace ClankerSaddle.Services {
                 progress = yield list_progress(id),
                 sessions = yield db.query<Session>()
                     .where(expr("task_id == $0", elem<int64?>(id)))
-                    .order_by(expr("id"))
+                    .order_by_desc(expr("id"))
                     .materialise_async()
             };
         }
@@ -333,9 +333,12 @@ namespace ClankerSaddle.Services {
         }
 
         /**
-         * Lists tasks, optionally filtered by status and/or project, ordered
-         * by priority (1 first) then creation time — filtered and sorted
-         * in the query, one materialisation.
+         * Lists tasks, optionally filtered by status and/or project,
+         * newest first (creation descending, id as the same-second
+         * tiebreak) — the list-page consistency sweep: every table
+         * leads with the most recent row. Priority stays a visible
+         * column, no longer a sort key; the dispatcher's candidate
+         * order ({@link get_ready_tasks}) keeps priority-first.
          */
         public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
             var query = db.query<TaskEntity>();
@@ -346,24 +349,24 @@ namespace ClankerSaddle.Services {
                 query.where(expr("project_id == $0", elem<int64?>(project_id)));
             }
             return yield query
-                .order_by(expr("priority"))
-                .order_by(expr("created_at"))
+                .order_by_desc(expr("created_at"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
         /**
          * Every standalone human-workable task row (kind task, no
-         * goal), any status, ordered like {@link list_tasks} — the
-         * auto-enqueue-after dropdowns' option list, queried for
-         * exactly the rows they show instead of every task in the
-         * table.
+         * goal), any status, ordered like {@link list_tasks}
+         * (newest first) — the auto-enqueue-after dropdowns' option
+         * list, queried for exactly the rows they show instead of
+         * every task in the table.
          */
         public async ImmutableLot<TaskEntity> list_standalone_tasks() throws Error {
             return yield db.query<TaskEntity>()
                 .where(expr("kind == $0 && goal_id == $1",
                     elem(TASK_KIND_TASK), elem<int64?>(0)))
-                .order_by(expr("priority"))
-                .order_by(expr("created_at"))
+                .order_by_desc(expr("created_at"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
@@ -519,11 +522,11 @@ namespace ClankerSaddle.Services {
             return task;
         }
 
-        /** Every task under `goal_id` (both kinds), oldest first. */
+        /** Every task under `goal_id` (both kinds), newest first. */
         public async ImmutableLot<TaskEntity> list_tasks_for_goal(int64 goal_id) throws Error {
             return yield db.query<TaskEntity>()
                 .where(expr("goal_id == $0", elem<int64?>(goal_id)))
-                .order_by(expr("id"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
@@ -1542,29 +1545,33 @@ namespace ClankerSaddle.Services {
             return rows.to_immutable_buffer();
         }
 
-        /** Comments on a task, oldest first. */
+        /** Comments on a task, newest first — the thread leads with
+         * the latest remark (the list-page consistency sweep). */
         public async ImmutableLot<TaskComment> list_comments(int64 task_id) throws Error {
             return yield db.query<TaskComment>()
                 .where(expr("task_id == $0", elem<int64?>(task_id)))
-                .order_by(expr("id"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
-        /** Blocks on a task, oldest first. */
+        /** Blocks on a task, newest first — the blocking sections
+         * lead with the most recent question, RFC or review (the
+         * list-page consistency sweep). */
         public async ImmutableLot<TaskBlock> list_blocks(int64 task_id) throws Error {
             return yield db.query<TaskBlock>()
                 .where(expr("task_id == $0", elem<int64?>(task_id)))
-                .order_by(expr("id"))
+                .order_by_desc(expr("id"))
                 .materialise_async();
         }
 
-        /** The task's newest block, or null when it has none. */
+        /** The task's newest block, or null when it has none — the
+         * FIRST row (list_blocks is newest-first since the ordering
+         * sweep; the walker keeps the semantics explicit). */
         public async TaskBlock? newest_block(int64 task_id) throws Error {
-            TaskBlock? newest = null;
             foreach (var block in yield list_blocks(task_id)) {
-                newest = block;
+                return block;
             }
-            return newest;
+            return null;
         }
 
         /**

+ 6 - 3
src/entrypoints/GoalDetailEntrypoint.vala

@@ -607,15 +607,18 @@ namespace ClankerSaddle {
                 if (pending_plan) {
                     plan_pending_open = true;
                 }
+                // Newest-first: list_blocks leads with the latest row,
+                // so appending preserves that order in the view.
                 view.blocks.add(block_view);
             }
             pub.completion_pending = completion_pending;
             // Plan 15 C4: the current (unanswered) question/RFC blocks
             // render in full; the historic answered ones fold behind
             // the answered-blocks expander (the task page's pattern).
-            var block_array = view.blocks.to_array();
-            for (var index = block_array.length - 1; index >= 0; index--) {
-                var block_view = (GoalBlockView) block_array[index];
+            // The rows already arrive newest-first ({@link
+            // TaskService.list_blocks} since the ordering sweep), so a
+            // straight walk keeps both groups newest-first.
+            foreach (var block_view in view.blocks) {
                 if (block_view.answered) {
                     view.blocks_answered.add(block_view);
                 } else if (block_view.is_plan_review) {

+ 9 - 8
src/entrypoints/TaskDetailEntrypoint.vala

@@ -310,9 +310,11 @@ namespace ClankerSaddle {
                 });
             }
 
-            // Newest-first (plan 10 phase 3): the blocking sections
-            // lead with the most recent question, RFC or review.
-            var blocks_ascending = new Vector<BlockView>();
+            // Newest-first (the ordering sweep): list_blocks itself
+            // returns newest-first now, so the rows flow straight
+            // through — the blocking sections lead with the most
+            // recent question, RFC or review.
+            var blocks_newest = new Vector<BlockView>();
             foreach (var block in ((!)detail).blocks) {
                 var block_private = new BlockAnswerPrivate() {
                     task_id = task.id,
@@ -340,17 +342,16 @@ namespace ClankerSaddle {
                 // (the review summary), so the block's own body preview —
                 // the same text — stays hidden until the block is answered.
                 view.show_body = view.block_type != BLOCK_REVIEW || view.answered;
-                blocks_ascending.add(view);
+                blocks_newest.add(view);
             }
             var blocks = new Vector<BlockView>();
             var open_blocks = new Vector<BlockView>();
             var answered_blocks = new Vector<BlockView>();
-            var block_array = blocks_ascending.to_array();
-            for (var index = block_array.length - 1; index >= 0; index--) {
-                var view = block_array[index];
+            foreach (var view in blocks_newest) {
                 blocks.add(view);
                 // Plan 11 task D: the current (unanswered) blocks render
-                // in full; the historical ones fold behind an expander.
+                // in full; the historical ones fold behind an expander —
+                // both groups keep the newest-first order.
                 if (view.answered) {
                     answered_blocks.add(view);
                 } else {

+ 11 - 7
src/entrypoints/TaskTerminalEntrypoint.vala

@@ -106,8 +106,13 @@ namespace ClankerSaddle {
             }
             bool found = false;
             int64 latest = 0;
+            // Sessions arrive newest-first ({@link
+            // TaskService.get_task} since the ordering sweep); the
+            // explicit max keeps the default independent of order.
             foreach (var session in sessions) {
-                latest = session.id;
+                if (session.id > latest) {
+                    latest = session.id;
+                }
                 if (session.id == selected) {
                     found = true;
                 }
@@ -138,12 +143,11 @@ namespace ClankerSaddle {
                 }
             }
             string opening_notice = yield opening_brief_notice(((!)detail).task);
-            var ordered = new Vector<ClankerSaddle.Data.Session>();
-            foreach (var session in sessions) {
-                ordered.add(session);
-            }
-            var session_array = ordered.to_array();
-            for (int i = session_array.length - 1; i >= 0; i--) {
+            // Newest-first straight through: the sessions arrive
+            // newest-first ({@link TaskService.get_task}) and the
+            // dropdown keeps that order — no reversal anymore.
+            var session_array = sessions.to_array();
+            for (int i = 0; i < session_array.length; i++) {
                 var session = session_array[i];
                 bool resumed = session.resume_notice != null
                     && ((!)session.resume_notice).length > 0;

+ 31 - 30
src/pages/tasks.html

@@ -88,36 +88,6 @@
             <span class="muted">— filters apply instantly</span>
         </form>
 
-        <table>
-            <thead>
-                <tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Goal</th>
-                    <th>Priority</th><th>Project</th><th>Capabilities</th></tr>
-            </thead>
-            <tbody>
-                <tr stm-for-task-in="tasks.tasks" stm-if="window.taskRowVisible(task)">
-                    <td stm-text="task.id">id</td>
-                    <td>
-                        <a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
-                        <span class="badge badge-yeehaw" stm-if="task.has_yeehaw"
-                              title="The agent celebrated this task (saddle yeehaw)">🤠</span>
-                    </td>
-                    <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
-                    <td><span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span></td>
-                    <td stm-if="task.goal_id > 0">
-                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a>
-                        <span class="muted" stm-if="task.creator == 'goal'">(agent)</span>
-                    </td>
-                    <td stm-if="task.goal_id == 0" class="muted">—</td>
-                    <td stm-text="task.priority">5</td>
-                    <td stm-text="task.project_name">project</td>
-                    <td><span stm-if="task.capabilities.length > 0"
-                              stm-text="task.capabilities.join(', ')">caps</span>
-                        <span class="muted" stm-if="task.capabilities.length == 0">—</span></td>
-                </tr>
-                <tr stm-if="tasks.tasks.length == 0"><td colspan="8" class="muted">No tasks.</td></tr>
-            </tbody>
-        </table>
-
         <details class="history-expander">
             <summary><strong>New task</strong></summary>
             <form stm-action="tasks.create">
@@ -169,6 +139,37 @@
                 <button type="submit" class="primary">Create task</button>
             </form>
         </details>
+
+        <table>
+            <thead>
+                <tr><th>#</th><th>Title</th><th>Status</th><th>Mode</th><th>Goal</th>
+                    <th>Priority</th><th>Project</th><th>Capabilities</th></tr>
+            </thead>
+            <tbody>
+                <tr stm-for-task-in="tasks.tasks" stm-if="window.taskRowVisible(task)">
+                    <td stm-text="task.id">id</td>
+                    <td>
+                        <a stm-attribute.href="'/tasks/' + task.id" stm-text="task.title">title</a>
+                        <span class="badge badge-yeehaw" stm-if="task.has_yeehaw"
+                              title="The agent celebrated this task (saddle yeehaw)">🤠</span>
+                    </td>
+                    <td><span stm-attribute.class="'badge badge-' + task.status" stm-text="task.display">status</span></td>
+                    <td><span stm-attribute.class="'badge badge-mode-' + task.mode" stm-text="task.mode">mode</span></td>
+                    <td stm-if="task.goal_id > 0">
+                        <a stm-attribute.href="'/goals/' + task.goal_id" stm-text="task.goal_title">goal</a>
+                        <span class="muted" stm-if="task.creator == 'goal'">(agent)</span>
+                    </td>
+                    <td stm-if="task.goal_id == 0" class="muted">—</td>
+                    <td stm-text="task.priority">5</td>
+                    <td stm-text="task.project_name">project</td>
+                    <td><span stm-if="task.capabilities.length > 0"
+                              stm-text="task.capabilities.join(', ')">caps</span>
+                        <span class="muted" stm-if="task.capabilities.length == 0">—</span></td>
+                </tr>
+                <tr stm-if="tasks.tasks.length == 0"><td colspan="8" class="muted">No tasks.</td></tr>
+            </tbody>
+        </table>
+
     </div>
 </body>
 </html>

+ 39 - 0
test/bundles/pages.py

@@ -807,3 +807,42 @@ def run(ctx):
     check("item.kind == 'comment' || item.kind == 'goal-comment'"
           " || item.kind == 'goal-system' ? 'footnote'" in home_html,
           "the dashboard timeline renders comment rows as footnotes")
+
+    print("== list ordering: newest first everywhere ==")
+    # The ordering sweep: every table leads with the most recent row.
+    # Fixtures land old-then-new; the first rendered data row must be
+    # the newest task on the queue, and the create expander sits above
+    # the table (verified on the compiled page markup).
+    conn = db()
+    conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
+                 " values (1, 'Order probe old', 'queued', 5, strftime('%s','now') - 100)")
+    conn.execute("insert into tasks (project_id, title, status, priority, created_at)"
+                 " values (1, 'Order probe new', 'queued', 5, strftime('%s','now'))")
+    conn.commit()
+    conn.close()
+    rows = [t for t in slot_public(auth, "/tasks", "tasks")["tasks"]
+            if t["title"].startswith("Order probe")]
+    check(len(rows) == 2 and rows[0]["title"] == "Order probe new",
+          "the tasks table lists newest first (created desc, newest row leads)")
+    status, queue_page = http("GET", "/tasks", timeout=15)
+    check(status == 200
+          and queue_page.index("New task") < queue_page.index("<table"),
+          "the New task expander sits above the tasks table")
+    # Comments/blocks threads: newest first since the sweep. The
+    # yeehaw task above carries session comments; assert the detail's
+    # comment list leads with the most recently added one.
+    conn = db()
+    conn.execute(
+        "insert into task_comments (task_id, author, body, created_at)"
+        " values (?, 'agent', 'Order probe old remark',"
+        " strftime('%s','now') - 100)", (keeper,))
+    conn.execute(
+        "insert into task_comments (task_id, author, body, created_at)"
+        " values (?, 'agent', 'Order probe new remark',"
+        " strftime('%s','now'))", (keeper,))
+    conn.commit()
+    conn.close()
+    bodies = [c["body"] for c in task_state(auth, keeper)["comments"]
+              if c["body"].startswith("Order probe")]
+    check(len(bodies) == 2 and bodies[0] == "Order probe new remark",
+          "the task comment thread leads with the newest remark")