Explorar el Código

Add the advisor chat UI: /chats pages, actions, dashboard hero (goal 27)

The chat half of the advisor (goal 27, task 2): the pages and statum
actions over the backend core landed in the previous commit.

- /chats (ChatsEntrypoint + chats.html): the signed-in user's advisor
  chats, newest first, each row linking the chat with a status chip
  (idle / thinking / awaiting answer), created and updated stamps and a
  sealed, stm-confirm delete action. Chats are private per user - the
  list is always filtered by the viewer's user_id. Empty state points
  at the dashboard composer. A Chats link joins the Work nav.
- /chats/{id} (ChatDetailEntrypoint + ChatDetailEntrypoint's
  ChatState.build + chat_detail.html): the transcript as user bubbles
  right / assistant bubbles left (markdown via stm-markdown), tool
  calls as collapsed details showing the name, pretty-printed
  arguments and result, error rows in an error style, system rows
  muted. A parked question renders its option buttons plus an Other
  free-text box; pending suggestion cards render the payload fields
  (title, mandate/brief, mode, priority, branch, project) with
  Approve / Approve + enqueue / Decline buttons wired to the sealed
  DecideAdvisorSuggestionAction (handler owned by the approvals task).
  The composer disables while a turn runs (a thinking indicator
  pulses); a not-found state renders for unknown or other users' ids.
  The page subscribes to the global pulse and re-runs its sealed
  RefreshAdvisorChatAction per bump - the dashboard's live-refresh
  pattern - so background-turn appends stream in without a reload.
- Actions (AdvisorActions.vala): StartAdvisorChatAction (hero: titles
  the chat from the message's first line clamped ~80 chars, records
  the row, kicks the background turn, navigates to the chat),
  SendAdvisorMessageAction (sealed chat id; a message runs a turn, an
  answer field resumes a parked question - the question card and the
  composer share the one sealed DTO; answers reply with the rebuilt
  slot), AnswerAdvisorQuestionAction (the API/external answer path),
  DeleteAdvisorChatAction (sealed; cascade + navigate), the refresh
  action, and DecideAdvisorSuggestionAction raising not-available
  until the approvals task fills its handler.
- Dashboard hero: an Ask the advisor composer at the very top of
  home.html, predominant and disabled with a notice when no endpoint
  resolves (DashboardState.advisor_configured).
- CSS: hero, chat lanes and bubbles, tool details, question and
  suggestion cards, composer, chat status chips - the app's existing
  tokens and 8px grid.
- test/bundles/chatui.py: 44 checks driving the whole arc against the
  scripted LLM endpoint - pages render, hero create/clamp/navigate,
  tool round and parked question in the slot, answer resume, composer
  follow-up, refresh action, suggestion card, per-user privacy (empty
  list, not-found detail, no delete action), delete cascade, and the
  unconfigured hero (a hand-spawned app with both LLM env vars empty,
  since start_app always pins the SADDLE_LLM_ fallback).

Full meson test: 1401 checks passed, 0 failed across all bundles.
saddle-Clanker-Saddle hace 4 semanas
padre
commit
a11b9c5056

+ 17 - 0
meson.build

@@ -128,6 +128,18 @@ discoveries_page = custom_target('discoveries-page',
     command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'DiscoveriesPage', '--ns', 'ClankerSaddle', '@INPUT@'],
     depend_files: template_deps
 )
+chats_page = custom_target('chats-page',
+    input: 'src/pages/chats.html',
+    output: 'ChatsPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'ChatsPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
+chat_detail_page = custom_target('chat-detail-page',
+    input: 'src/pages/chat_detail.html',
+    output: 'ChatDetailPage.vala',
+    command: [statum_mkpstm, '-o', '@OUTPUT@', '-n', 'ChatDetailPage', '--ns', 'ClankerSaddle', '@INPUT@'],
+    depend_files: template_deps
+)
 users_page = custom_target('users-page',
     input: 'src/pages/users.html',
     output: 'UsersPage.vala',
@@ -227,6 +239,7 @@ app_sources = files(
     'src/actions/SaddleActions.vala',
     'src/actions/RuleActions.vala',
     'src/actions/DiscoveryActions.vala',
+    'src/actions/AdvisorActions.vala',
     'src/actions/StackActions.vala',
     'src/actions/NoteActions.vala',
     'src/actions/CapabilityActions.vala',
@@ -243,6 +256,8 @@ app_sources = files(
     'src/entrypoints/SaddlesEntrypoint.vala',
     'src/entrypoints/RulesEntrypoint.vala',
     'src/entrypoints/DiscoveriesEntrypoint.vala',
+    'src/entrypoints/ChatsEntrypoint.vala',
+    'src/entrypoints/ChatDetailEntrypoint.vala',
     'src/entrypoints/StacksEntrypoint.vala',
     'src/entrypoints/UsersEntrypoint.vala',
     'src/entrypoints/ProfileEntrypoint.vala',
@@ -267,6 +282,8 @@ app_generated = [
     saddles_page,
     rules_page,
     discoveries_page,
+    chats_page,
+    chat_detail_page,
     stacks_page,
     stack_detail_page,
     users_page,

+ 351 - 0
src/actions/AdvisorActions.vala

@@ -0,0 +1,351 @@
+using Inversion;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /** Sealed chat id carried by the advisor chat actions. */
+    public class AdvisorChatActionPrivate : Object {
+        public int64 chat_id { get; set; }
+    }
+
+    /**
+     * Sealed suggestion + chat ids carried by the suggestion decision
+     * action (goal 27 task 3 owns its handler).
+     */
+    public class AdvisorSuggestionActionPrivate : Object {
+        public int64 suggestion_id { get; set; }
+        public int64 chat_id { get; set; }
+    }
+
+    /**
+     * Starts a new advisor chat (goal 27) from the dashboard's "Ask
+     * the advisor" composer: creates the chat titled from the first
+     * line of the message (clamped ~80 chars), records the message as
+     * the opening user row and kicks the background
+     * {@link AdvisorService} turn. The POST returns before the turn
+     * finishes — the chat page's pulse-driven refresh streams the
+     * appends in — and the browser navigates to /chats/{id}.
+     */
+    public class StartAdvisorChatAction : StatumAction {
+
+        private ChatService chat_service = inject<ChatService>();
+        private AdvisorService advisor_service = inject<AdvisorService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var me = SpryAuth.current(request);
+            if (me == null) {
+                return directives().navigate("/");
+            }
+            int64 viewer_id = ((!)me).user_id;
+
+            string message = Forms.field_raw(request, "message");
+            if (message.strip().length == 0) {
+                return directives().notify("error",
+                    "Type a question for the advisor first").navigate("/");
+            }
+
+            var chat = yield chat_service.create_chat(viewer_id,
+                chat_title(message));
+            yield chat_service.record_user_message(chat.id, message.strip());
+            // The turn runs in the service's own scope; this request
+            // finishes (and navigates) immediately.
+            advisor_service.run_turn.begin(chat.id, viewer_id);
+            return directives().navigate(@"/chats/$(chat.id)");
+        }
+
+        /**
+         * The chat's title: the message's first non-empty line,
+         * clamped to ~80 characters on a word boundary.
+         */
+        private static string chat_title(string message) {
+            string first = "";
+            foreach (string line in message.strip().split("\n")) {
+                if (line.strip().length > 0) {
+                    first = line.strip();
+                    break;
+                }
+            }
+            if (first.length <= 80) {
+                return first;
+            }
+            string cut = first.substring(0, 80);
+            int last_space = cut.last_index_of(" ");
+            if (last_space > 40) {
+                cut = cut.substring(0, last_space);
+            }
+            return cut.strip() + "…";
+        }
+    }
+
+    /**
+     * Appends the composer's message to a chat and kicks the next
+     * background advisor turn, answering with the rebuilt `chat` slot
+     * (the user's own row appears at once; the turn's appends stream
+     * in through the pulse refresh). The chat id is sealed.
+     */
+    public class SendAdvisorMessageAction : TypedStatumAction<AdvisorChatActionPrivate> {
+
+        private ChatService chat_service = inject<ChatService>();
+        private AdvisorService advisor_service = inject<AdvisorService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type {
+            get { return typeof(AdvisorChatActionPrivate); }
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/chats");
+            }
+            var me = SpryAuth.current(request);
+            if (me == null) {
+                return directives().navigate("/chats");
+            }
+            int64 viewer_id = ((!)me).user_id;
+            int64 chat_id = ((!)ctx).chat_id;
+
+            var chat = yield chat_service.get_chat(chat_id, viewer_id);
+            if (chat == null) {
+                return directives().notify("error", "That chat no longer exists")
+                    .navigate("/chats");
+            }
+
+            string message = Forms.field_raw(request, "message");
+            // The question card answers through this action too (the
+            // page's one sealed send DTO serves the composer, the
+            // option buttons and the "Other" box): an answer resumes
+            // a PARKED question, a message starts a normal turn.
+            string answer = Forms.field_raw(request, "answer");
+            if (answer.strip().length > 0) {
+                bool accepted = false;
+                try {
+                    accepted = yield advisor_service.answer_question(chat_id,
+                        viewer_id, answer);
+                } catch (Error e) {
+                    return directives().notify("error",
+                        @"Could not record the answer: $(e.message)");
+                }
+                if (!accepted) {
+                    return directives().notify("error",
+                        "That question has already been answered");
+                }
+                var answered = yield chat_service.get_chat(chat_id, viewer_id);
+                if (answered == null) {
+                    return directives().navigate("/chats");
+                }
+                var resumed = yield ChatState.build_from(state_service,
+                    chat_service, advisor_service, action_registry, viewer_id,
+                    answered);
+                return directives().set(resumed);
+            }
+            if (message.strip().length == 0) {
+                var empty = yield ChatState.build_from(state_service,
+                    chat_service, advisor_service, action_registry, viewer_id,
+                    chat);
+                return directives().set(empty);
+            }
+
+            yield chat_service.record_user_message(chat_id, message.strip());
+            advisor_service.run_turn.begin(chat_id, viewer_id);
+
+            var updated = yield chat_service.get_chat(chat_id, viewer_id);
+            var state = yield ChatState.build_from(state_service, chat_service,
+                advisor_service, action_registry, viewer_id, updated);
+            return directives().set(state);
+        }
+    }
+
+    /**
+     * Records the human's answer to the chat's pending question (the
+     * question card's option buttons and "Other" box submit here via
+     * the JSON transport) and resumes the turn; answers with the
+     * rebuilt `chat` slot. The chat id is sealed.
+     */
+    public class AnswerAdvisorQuestionAction : TypedStatumAction<AdvisorChatActionPrivate> {
+
+        private ChatService chat_service = inject<ChatService>();
+        private AdvisorService advisor_service = inject<AdvisorService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type {
+            get { return typeof(AdvisorChatActionPrivate); }
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/chats");
+            }
+            var me = SpryAuth.current(request);
+            if (me == null) {
+                return directives().navigate("/chats");
+            }
+            int64 viewer_id = ((!)me).user_id;
+            int64 chat_id = ((!)ctx).chat_id;
+
+            var chat = yield chat_service.get_chat(chat_id, viewer_id);
+            if (chat == null) {
+                return directives().notify("error", "That chat no longer exists")
+                    .navigate("/chats");
+            }
+
+            string answer = Forms.field_raw(request, "answer");
+            // answer_question records the reply and runs the next
+            // turn in its own scope; the answer arrives as this
+            // action's reply.
+            bool accepted = false;
+            try {
+                accepted = yield advisor_service.answer_question(chat_id,
+                    viewer_id, answer);
+            } catch (Error e) {
+                return directives().notify("error",
+                    @"Could not record the answer: $(e.message)");
+            }
+            if (!accepted) {
+                return directives().notify("error",
+                    "That question has already been answered").navigate(
+                    @"/chats/$(chat_id)");
+            }
+
+            var updated = yield chat_service.get_chat(chat_id, viewer_id);
+            if (updated == null) {
+                return directives().navigate("/chats");
+            }
+            var state = yield ChatState.build_from(state_service, chat_service,
+                advisor_service, action_registry, viewer_id, updated);
+            return directives().set(state);
+        }
+    }
+
+    /**
+     * Deletes a chat (its messages and suggestions cascade) and sends
+     * the browser to /chats. The chat id is sealed; deleting someone
+     * else's id is a silent no-op (chats are private per user).
+     */
+    public class DeleteAdvisorChatAction : TypedStatumAction<AdvisorChatActionPrivate> {
+
+        private ChatService chat_service = inject<ChatService>();
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type {
+            get { return typeof(AdvisorChatActionPrivate); }
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var ctx = request_private;
+            if (ctx == null) {
+                return directives().navigate("/chats");
+            }
+            var me = SpryAuth.current(request);
+            if (me == null) {
+                return directives().navigate("/chats");
+            }
+
+            try {
+                yield chat_service.delete_chat(((!)ctx).chat_id,
+                    ((!)me).user_id);
+                return directives().notify("success", "Chat deleted")
+                    .navigate("/chats");
+            } catch (Error e) {
+                return directives().notify("error",
+                    @"Could not delete the chat: $(e.message)")
+                    .navigate("/chats");
+            }
+        }
+    }
+
+    /**
+     * The chat page's pulse-driven refresh (goal 27): rebuilds the
+     * `chat` slot from current rows so a running turn's appends —
+     * tool calls, the assistant reply, a parked question, a
+     * suggestion — stream in without a reload. Navigates nowhere.
+     * Like the dashboard's refresh it is authored unsealed (GET): the
+     * page it serves is already the viewer's own.
+     */
+    public class RefreshAdvisorChatAction : StatumAction {
+
+        private ChatService chat_service = inject<ChatService>();
+        private AdvisorService advisor_service = inject<AdvisorService>();
+        private UserService users = inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var me = SpryAuth.current(request);
+            if (me == null) {
+                return directives().navigate("/chats");
+            }
+            int64 chat_id = Forms.integer(request, "chat_id", 0);
+            if (chat_id <= 0) {
+                return directives().navigate("/chats");
+            }
+            var chat = yield chat_service.get_chat(chat_id, ((!)me).user_id);
+            if (chat == null) {
+                // Deleted mid-turn (or never the viewer's): nothing to
+                // refresh — the page's own navigation takes over.
+                return directives().navigate("/chats");
+            }
+            var state = yield ChatState.build_from(state_service, chat_service,
+                advisor_service, action_registry, ((!)me).user_id, chat);
+            return directives().set(state);
+        }
+    }
+
+    /**
+     * Decides a pending suggestion (goal 27): 'approve' creates the
+     * draft, 'approve_enqueue' creates the draft AND queues it,
+     * 'decline' dismisses it. THE HANDLER IS TASK 3'S (goal 27 task
+     * 176): until it lands this base raises a plain "not yet
+     * available" error so the chat UI can ship wired against the
+     * final action name. The suggestion and chat ids are sealed; the
+     * decision rides the `decision` field.
+     */
+    public class DecideAdvisorSuggestionAction : TypedStatumAction<AdvisorSuggestionActionPrivate> {
+
+        private UserService users = inject<UserService>();
+
+        protected override Type private_type {
+            get { return typeof(AdvisorSuggestionActionPrivate); }
+        }
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+            // Goal 27 task 3 (approvals + completion callback) owns
+            // the body: approve -> draft task/goal, approve_enqueue ->
+            // draft + enqueue, decline -> dismissed row.
+            return directives().notify("error",
+                "Suggestion decisions are not available yet").navigate("/chats");
+        }
+    }
+}

+ 331 - 0
src/entrypoints/ChatDetailEntrypoint.vala

@@ -0,0 +1,331 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /**
+     * One row of a chat's transcript (goal 27). The `kind` is the
+     * stored wire kind — 'user', 'assistant', 'tool', 'error' or
+     * 'system' — and drives the bubble styling. A tool row carries
+     * its call's {@link tool_name}, {@link arguments} and
+     * {@link result} (parsed out of the row's JSON payload); a
+     * question row carries its {@link options} plus answer state.
+     */
+    public class ChatMessageView : Object {
+        public int64 id { get; set; }
+        /** user / assistant / tool / error / system. */
+        public string kind { get; set; default = "user"; }
+        public string content { get; set; default = ""; }
+        public bool is_question { get; set; }
+        public string[] options { get; set; default = {}; }
+        public bool answered { get; set; }
+        public string answer { get; set; default = ""; }
+        public string when_display { get; set; default = ""; }
+        /** Tool rows only ("" elsewhere). */
+        public string tool_name { get; set; default = ""; }
+        /** Tool rows only: the call's raw JSON arguments (pretty when parseable). */
+        public string arguments { get; set; default = ""; }
+        /** Tool rows only: the call's raw JSON result (pretty when parseable). */
+        public string result { get; set; default = ""; }
+    }
+
+    /**
+     * One suggestion card (goal 27): a task or goal the advisor
+     * proposed mid-chat. While `pending` the card offers the decision
+     * buttons; afterwards it states the outcome and links what was
+     * created (task_id/goal_id > 0).
+     */
+    public class ChatSuggestionView : Object {
+        public int64 id { get; set; }
+        /** "task" or "goal". */
+        public string kind { get; set; default = "task"; }
+        public string status { get; set; default = "pending"; }
+        public string title { get; set; default = ""; }
+        /** Task suggestions: the mandate; goal suggestions: the brief. */
+        public string body { get; set; default = ""; }
+        public string mode { get; set; default = ""; }
+        public int64 priority { get; set; }
+        public string branch_name { get; set; default = ""; }
+        public int64 project_id { get; set; }
+        public int64 task_id { get; set; }
+        public int64 goal_id { get; set; }
+        public string created_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? decide { get; set; }
+    }
+
+    /** Public data of the `chat` slot (the /chats/{id} page). */
+    public class ChatPublic : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = "idle"; }
+        public string status_display { get; set; default = "idle"; }
+        public bool advisor_configured { get; set; }
+        public Statum.Model.ActionDto? send { get; set; }
+        public Statum.Model.ActionDto? delete { get; set; }
+        public Statum.Model.ActionDto? refresh { get; set; }
+    }
+
+    /**
+     * Builds the `chat` PAGE slot shared by the chat detail entrypoint
+     * and the advisor actions ({@link SendAdvisorMessageAction} answers
+     * with a rebuilt slot; {@link RefreshAdvisorChatAction} re-runs it
+     * on every StatePulse bump) so all paths hydrate identical state:
+     * the chat row, the transcript (tool/question payloads parsed),
+     * the suggestion cards and their sealed decision action.
+     */
+    public class ChatState : Object {
+
+        /** The human label of a chat's loop status. */
+        public static string status_display(string status) {
+            if (status == "running") {
+                return "thinking";
+            }
+            if (status == "awaiting_answer") {
+                return "awaiting answer";
+            }
+            return "idle";
+        }
+
+        /**
+         * Builds the slot for `chat_id` as owned by `viewer_id`; an
+         * unknown id (or someone else's — chats are private per user)
+         * yields the state with id 0 so the template can render its
+         * not-found notice.
+         */
+        public static async Statum.Slot build(StateService state_service,
+                ChatService chats, AdvisorService advisor,
+                ActionRegistry? actions, int64 viewer_id, int64 chat_id)
+                throws GLib.Error {
+            var chat = yield chats.get_chat(chat_id, viewer_id);
+            return yield build_from(state_service, chats, advisor, actions,
+                viewer_id, chat);
+        }
+
+        /**
+         * The same slot over an ALREADY-LOADED chat row (actions that
+         * just created or verified the chat skip the re-read).
+         */
+        public static async Statum.Slot build_from(StateService state_service,
+                ChatService chats, AdvisorService advisor,
+                ActionRegistry? actions, int64 viewer_id, AdvisorChat? chat)
+                throws GLib.Error {
+            var pub = new ChatPublic() {
+                advisor_configured = advisor.configured
+            };
+            PropertyDictionary props;
+
+            if (chat != null) {
+                pub.id = ((!)chat).id;
+                pub.title = ((!)chat).title;
+                pub.status = ((!)chat).status;
+                pub.status_display = status_display(((!)chat).status);
+                if (actions != null) {
+                    var sealed = new AdvisorChatActionPrivate() {
+                        chat_id = ((!)chat).id
+                    };
+                    pub.send = ((!)actions).author_private<SendAdvisorMessageAction,
+                        AdvisorChatActionPrivate>(sealed);
+                    pub.delete = ((!)actions).author_private<DeleteAdvisorChatAction,
+                        AdvisorChatActionPrivate>(sealed);
+                    pub.refresh = ((!)actions).author<RefreshAdvisorChatAction>("GET");
+                }
+
+                var messages = new Vector<ChatMessageView>();
+                foreach (var row in yield chats.list_messages(((!)chat).id)) {
+                    messages.add(message_view(row));
+                }
+
+                var suggestions = new Vector<ChatSuggestionView>();
+                foreach (var row in yield chats.list_suggestions(((!)chat).id)) {
+                    suggestions.add(suggestion_view(row, actions));
+                }
+
+                // to_properties SNAPSHOTS the object's values: every
+                // scalar must be final before the mapping (the lists
+                // then ride the dictionary directly).
+                props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+                props["messages"] = StateJson.list<ChatMessageView>(
+                    messages.to_immutable_buffer());
+                props["suggestions"] = StateJson.list<ChatSuggestionView>(
+                    suggestions.to_immutable_buffer());
+            } else {
+                props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+                props["messages"] = StateJson.list<ChatMessageView>(
+                    new Vector<ChatMessageView>().to_immutable_buffer());
+                props["suggestions"] = StateJson.list<ChatSuggestionView>(
+                    new Vector<ChatSuggestionView>().to_immutable_buffer());
+            }
+            return state_service.new_slot(Scope.PAGE, new Statum.State() {
+                type_name = "chat",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+        }
+
+        /** Shapes one transcript row (payloads parsed for render). */
+        private static ChatMessageView message_view(AdvisorMessage row) {
+            var view = new ChatMessageView() {
+                id = row.id,
+                kind = row.kind,
+                content = row.content,
+                when_display = format_stamp(row.created_at)
+            };
+            if (row.answer != null) {
+                view.answered = true;
+                view.answer = (!)row.answer;
+            }
+            if (row.payload == null) {
+                return view;
+            }
+            var body = parse_object(((!)row.payload));
+            if (body == null) {
+                return view;
+            }
+            var parsed = (!)body;
+            if (parsed.has_member("question")) {
+                view.is_question = true;
+                var options = new Vector<string>();
+                var options_member = parsed.get_member("options");
+                if (options_member != null
+                        && options_member.get_node_type() == Json.NodeType.ARRAY) {
+                    foreach (var node in options_member.get_array().get_elements()) {
+                        if (node.get_node_type() == Json.NodeType.VALUE
+                                && node.get_string() != null) {
+                            options.add((!)node.get_string());
+                        }
+                    }
+                }
+                view.options = options.to_array();
+                return view;
+            }
+            if (parsed.has_member("name")) {
+                view.tool_name = member_string(parsed, "name");
+                view.arguments = pretty(member_string(parsed, "arguments"));
+                view.result = pretty(member_string(parsed, "result"));
+            }
+            return view;
+        }
+
+        /** Shapes one suggestion row (payload fields + sealed decide). */
+        private static ChatSuggestionView suggestion_view(AdvisorSuggestion row,
+                ActionRegistry? actions) throws GLib.Error {
+            var view = new ChatSuggestionView() {
+                id = row.id,
+                kind = row.kind,
+                status = row.status,
+                task_id = row.task_id,
+                goal_id = row.goal_id,
+                created_display = format_stamp(row.created_at)
+            };
+            var body = parse_object(row.payload);
+            if (body != null) {
+                var parsed = (!)body;
+                view.title = member_string(parsed, "title");
+                view.body = member_string(parsed,
+                    row.kind == "goal" ? "brief" : "mandate");
+                view.mode = member_string(parsed, "mode");
+                view.branch_name = member_string(parsed, "branch_name");
+                if (parsed.has_member("priority")
+                        && parsed.get_member("priority").get_node_type()
+                            == Json.NodeType.VALUE) {
+                    view.priority = parsed.get_int_member("priority");
+                }
+                if (parsed.has_member("project_id")
+                        && parsed.get_member("project_id").get_node_type()
+                            == Json.NodeType.VALUE) {
+                    view.project_id = parsed.get_int_member("project_id");
+                }
+            }
+            if (actions != null && row.status == "pending") {
+                view.decide = ((!)actions)
+                    .author_private<DecideAdvisorSuggestionAction,
+                        AdvisorSuggestionActionPrivate>(
+                        new AdvisorSuggestionActionPrivate() {
+                            suggestion_id = row.id,
+                            chat_id = row.chat_id
+                        });
+            }
+            return view;
+        }
+
+        /** A JSON payload's root object, or null when unparseable. */
+        private static Json.Object? parse_object(string payload) {
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(payload);
+            } catch (Error e) {
+                return null;
+            }
+            var root = parser.get_root();
+            if (root == null || root.get_node_type() != Json.NodeType.OBJECT) {
+                return null;
+            }
+            return ((!)root).get_object();
+        }
+
+        private static string member_string(Json.Object body, string member) {
+            if (!body.has_member(member)) {
+                return "";
+            }
+            var node = body.get_member(member);
+            if (node.get_node_type() == Json.NodeType.VALUE && node.get_string() != null) {
+                return (!)node.get_string();
+            }
+            return "";
+        }
+
+        /** Re-indents JSON text for the collapsed tool-call details. */
+        private static string pretty(string json) {
+            if (json.length == 0) {
+                return "";
+            }
+            var parser = new Json.Parser();
+            try {
+                parser.load_from_data(json);
+            } catch (Error e) {
+                return json;
+            }
+            var root = parser.get_root();
+            if (root == null) {
+                return json;
+            }
+            return Json.to_string(((!)root), true);
+        }
+    }
+
+    /**
+     * Entrypoint for /chats/{id} (goal 27): one advisor chat — the
+     * transcript (user/assistant bubbles, collapsed tool calls,
+     * question cards with option buttons, error and system notices),
+     * the pending suggestion cards, the composer and the delete
+     * action. The page subscribes to the global pulse and re-runs its
+     * sealed refresh action on every bump, so a running turn's
+     * appends stream in without a reload.
+     */
+    public class ChatDetailEntrypoint : StatumEntrypoint {
+
+        private ChatService chat_service = Inversion.inject<ChatService>();
+        private AdvisorService advisor_service = Inversion.inject<AdvisorService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var me = SpryAuth.current(request);
+            int64 viewer_id = me != null ? ((!)me).user_id : 0;
+            int64 chat_id = int64.parse(request.route("chat_id") ?? "0");
+
+            var state = yield ChatState.build(state_service, chat_service,
+                advisor_service, action_registry, viewer_id, chat_id);
+            return directives().set(state).subscribe_global(StatePulse.SLOT_TYPE);
+        }
+    }
+}

+ 84 - 0
src/entrypoints/ChatsEntrypoint.vala

@@ -0,0 +1,84 @@
+using Invercargill;
+using Invercargill.DataStructures;
+using Spry;
+using Spry.Authentication;
+using Statum;
+using ClankerSaddle.Data;
+using ClankerSaddle.Services;
+
+namespace ClankerSaddle {
+
+    /**
+     * One advisor chat row on the /chats list. {@link status_display}
+     * is the human label of the chat's loop status ('idle', 'running'
+     * or 'awaiting_answer'); the badge class mirrors the chip.
+     */
+    public class ChatRowView : Object {
+        public int64 id { get; set; }
+        public string title { get; set; default = ""; }
+        public string status { get; set; default = "idle"; }
+        public string status_display { get; set; default = "idle"; }
+        public string created_display { get; set; default = ""; }
+        public string updated_display { get; set; default = ""; }
+        public Statum.Model.ActionDto? delete { get; set; }
+    }
+
+    /** Public data of the `chats` slot (the /chats list page). */
+    public class ChatsPublic : Object {
+        /** True when an LLM endpoint resolves from the environment. */
+        public bool advisor_configured { get; set; }
+    }
+
+    /**
+     * Entrypoint for /chats (goal 27): the signed-in user's advisor
+     * chats, newest first. Chats are PRIVATE per user — the list is
+     * always filtered by the viewer's user_id, and every row's delete
+     * action is sealed against that same owner.
+     */
+    public class ChatsEntrypoint : StatumEntrypoint {
+
+        private ChatService chat_service = Inversion.inject<ChatService>();
+        private AdvisorService advisor_service = Inversion.inject<AdvisorService>();
+        private UserService users = Inversion.inject<UserService>();
+
+        public override async DirectiveBuilder handle() throws GLib.Error {
+            var guard = yield SpryAuth.require_login(directives(), request, users);
+            if (guard != null) {
+                return (!)guard;
+            }
+
+            var me = SpryAuth.current(request);
+            int64 viewer_id = me != null ? ((!)me).user_id : 0;
+
+            var rows = new Vector<ChatRowView>();
+            foreach (var chat in yield chat_service.list_chats(viewer_id)) {
+                var sealed = new AdvisorChatActionPrivate() {
+                    chat_id = chat.id
+                };
+                rows.add(new ChatRowView() {
+                    id = chat.id,
+                    title = chat.title,
+                    status = chat.status,
+                    status_display = ChatState.status_display(chat.status),
+                    created_display = format_stamp(chat.created_at),
+                    updated_display = format_stamp(chat.updated_at),
+                    delete = action_registry.author_private<DeleteAdvisorChatAction,
+                        AdvisorChatActionPrivate>(sealed)
+                });
+            }
+
+            var pub = new ChatsPublic() {
+                advisor_configured = advisor_service.configured
+            };
+            var props = (PropertyDictionary) GObjectMapping.to_properties(pub);
+            props["chats"] = StateJson.list<ChatRowView>(rows.to_immutable_buffer());
+
+            var slot = state_service.new_slot(Scope.PAGE, new Statum.State() {
+                type_name = "chats",
+                public_data = props,
+                private_data = new PropertyDictionary()
+            });
+            return directives().set(slot).subscribe_global(StatePulse.SLOT_TYPE);
+        }
+    }
+}

+ 22 - 0
src/entrypoints/HomeEntrypoint.vala

@@ -128,6 +128,18 @@ namespace ClankerSaddle {
         public string push_vapid_key { get; set; default = ""; }
         /** The signed-in user holds at least one push subscription. */
         public bool push_subscribed { get; set; }
+        /**
+         * True when an LLM endpoint resolves from the environment —
+         * the dashboard's "Ask the advisor" composer renders disabled
+         * with a notice when false (goal 27).
+         */
+        public bool advisor_configured { get; set; }
+        /**
+         * Starts a new advisor chat from the dashboard's hero
+         * composer (goal 27): creates the chat, records the message
+         * and kicks the background turn.
+         */
+        public Statum.Model.ActionDto? start_advisor { get; set; }
         public Statum.Model.ActionDto? push_subscribe { get; set; }
         public Statum.Model.ActionDto? push_unsubscribe { get; set; }
         /** Queues a REAL test push to this user (the bell's probe). */
@@ -341,7 +353,17 @@ namespace ClankerSaddle {
                 pub.push_unsubscribe = ((!)actions).author<PushUnsubscribeAction>();
                 pub.push_test = ((!)actions).author<PushTestAction>();
                 pub.add_note = ((!)actions).author<AddNoteAction>();
+                // The advisor composer (goal 27): an unsealed action —
+                // the chat it creates belongs to the signed-in user
+                // by construction.
+                pub.start_advisor = ((!)actions).author<StartAdvisorChatAction>();
             }
+            // Mirrors AdvisorService.configured (the same environment
+            // resolution, resolved statically so the shared build
+            // needs no new injected dependency) so the composer can
+            // disable itself with a notice when no endpoint is set.
+            pub.advisor_configured =
+                Services.AdvisorLlmEndpoint.from_environment() != null;
             // Web Push bell state (plan 11 task E; per-browser since
             // the user-global flag lied to second browsers): the VAPID
             // key the browser needs, whether this user subscribed

+ 8 - 0
src/main.vala

@@ -123,6 +123,8 @@ int main(string[] args) {
         statum.add_page<SaddlesPage, SaddlesEntrypoint>();
         statum.add_page<RulesPage, RulesEntrypoint>();
         statum.add_page<DiscoveriesPage, DiscoveriesEntrypoint>();
+        statum.add_page<ChatsPage, ChatsEntrypoint>();
+        statum.add_page<ChatDetailPage, ChatDetailEntrypoint>();
         statum.add_page<StacksPage, StacksEntrypoint>();
         statum.add_page<StackDetailPage, StackDetailEntrypoint>();
         statum.add_page<UsersPage, UsersEntrypoint>();
@@ -183,6 +185,12 @@ int main(string[] args) {
         statum.action<RefreshUnderstandingAction>();
         statum.action<UpdateDiscoveryAction>();
         statum.action<DeleteDiscoveryAction>();
+        statum.action<StartAdvisorChatAction>();
+        statum.action<SendAdvisorMessageAction>();
+        statum.action<AnswerAdvisorQuestionAction>();
+        statum.action<DeleteAdvisorChatAction>();
+        statum.action<RefreshAdvisorChatAction>();
+        statum.action<DecideAdvisorSuggestionAction>();
         statum.action<CreateStackAction>();
         statum.action<UpdateStackAction>();
         statum.action<DeleteStackAction>();

+ 143 - 0
src/pages/app.css

@@ -1330,3 +1330,146 @@ dialog#quote-comment-dialog blockquote {
 @media (max-width: 40rem) {
     .log-header { align-items: flex-start; flex-direction: column; }
 }
+
+/* --- Advisor chats (goal 27) ---------------------------------------- */
+
+/* The dashboard's hero composer: the page's FIRST and predominant
+   control, above every dashboard section. */
+.advisor-hero {
+    border-color: var(--accent);
+    margin-bottom: var(--grid-3);
+}
+.advisor-hero form { margin: 0; }
+.advisor-hero textarea {
+    font-size: 1.05rem;
+    min-height: 3.4rem;
+}
+.advisor-hero .form-row { align-items: flex-end; }
+.advisor-hero .notice { margin-bottom: 0; }
+
+/* The chat page: a column of transcript lanes under the head. */
+.chat-page { display: flex; flex-direction: column; gap: var(--grid-2); }
+.chat-head h2 { margin-bottom: 0.25rem; }
+.chat-head .chat-delete { margin-left: var(--grid-1); }
+
+.chat-thread {
+    background: var(--surface);
+    border: 1px solid var(--line-soft);
+    border-radius: var(--radius);
+    display: flex;
+    flex-direction: column;
+    gap: var(--grid-1);
+    max-height: 60vh;
+    overflow-y: auto;
+    padding: var(--grid-2);
+}
+
+/* Bubbles: the user's rows hug the right, the advisor's the left. */
+.chat-row { display: flex; flex-direction: column; }
+.chat-row-user { align-items: flex-end; }
+.chat-row-assistant,
+.chat-row-system,
+.chat-row-error { align-items: flex-start; }
+
+.chat-bubble {
+    border: 1px solid var(--line-soft);
+    border-radius: var(--radius);
+    max-width: 46rem;
+    padding: var(--grid-1) var(--grid-2);
+}
+.chat-bubble .md,
+.chat-bubble .md p { margin: 0; }
+.chat-bubble .md p + p { margin-top: 0.5rem; }
+.chat-user { background: #ddf4ff; border-color: #b6e3ff; }
+.chat-assistant { background: var(--surface-sunken); }
+.chat-bubble .muted { font-size: 0.75rem; margin: 0.25rem 0 0; }
+
+/* Tool calls sit between the lanes as collapsed details. */
+.chat-tool { width: 100%; }
+.chat-tool details {
+    background: var(--surface-sunken);
+    border: 1px solid var(--line-soft);
+    border-radius: 6px;
+    font-size: 0.85rem;
+    padding: 0.35rem 0.75rem;
+}
+.chat-tool summary { cursor: pointer; }
+.chat-tool .badge { margin-right: 0.5rem; }
+.chat-tool .muted { margin: 0.4rem 0 0.15rem; }
+.chat-tool pre {
+    background: var(--surface);
+    border: 1px solid var(--line-soft);
+    border-radius: 4px;
+    font-size: 0.78rem;
+    margin: 0;
+    max-height: 18rem;
+    overflow: auto;
+    padding: 0.5rem 0.6rem;
+    white-space: pre-wrap;
+    word-break: break-word;
+}
+
+/* Errors stand out; system notices stay quiet. */
+.chat-error { width: 100%; }
+.chat-error p {
+    background: #ffebe9;
+    border: 1px solid #ff818266;
+    border-radius: var(--radius);
+    color: var(--danger);
+    margin: 0;
+    padding: var(--grid-1) var(--grid-2);
+}
+.chat-system { width: 100%; }
+.chat-system p {
+    border-left: 3px solid var(--line);
+    color: var(--ink-muted);
+    margin: 0;
+    padding-left: 0.75rem;
+}
+
+/* A parked question: the bubble plus its answer card. */
+.chat-question { align-items: flex-start; display: flex; flex-direction: column; }
+.chat-question-card { margin-top: var(--grid-1); padding: var(--grid-1) var(--grid-2); }
+.chat-question-card .muted { margin: 0 0 var(--grid-1); }
+.chat-options { display: flex; flex-wrap: wrap; gap: var(--grid-1); margin-bottom: var(--grid-1); }
+.chat-options button { cursor: pointer; }
+
+/* The thinking indicator, while a turn runs. */
+.chat-thinking p {
+    animation: chat-think 1.2s ease-in-out infinite;
+    font-style: italic;
+    margin: 0;
+}
+@keyframes chat-think { 50% { opacity: 0.45; } }
+
+/* Suggestion cards: the decision buttons row. */
+.chat-suggestion-actions {
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--grid-1);
+    margin-top: var(--grid-1);
+}
+
+/* The composer: a textarea beside its send button. */
+.chat-composer-row {
+    display: flex;
+    gap: var(--grid-1);
+    align-items: flex-end;
+}
+.chat-composer-row textarea { flex: 1; resize: vertical; }
+.chat-composer-row button:disabled,
+.chat-composer-row textarea:disabled { opacity: 0.6; }
+
+/* Chat status chips (the list rows and the page head). */
+.badge-chat-idle { background: #dafbe1; color: var(--success); }
+.badge-chat-running { background: #fff8c5; color: var(--warning); }
+.badge-chat-awaiting_answer { background: #ddf4ff; color: var(--accent); }
+.badge-tool { background: #f0f0f0; color: #6e7781; }
+.badge-suggestion-pending { background: #fff8c5; color: var(--warning); }
+.badge-suggestion-approved { background: #dafbe1; color: var(--success); }
+.badge-suggestion-declined { background: var(--line-soft); color: var(--ink-muted); }
+
+@media (max-width: 40rem) {
+    .chat-bubble { max-width: 100%; }
+    .chat-composer-row { flex-direction: column; align-items: stretch; }
+}

+ 262 - 0
src/pages/chat_detail.html

@@ -0,0 +1,262 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/chats/{chat_id}</pstm-uri>
+    <pstm-template>main</pstm-template>
+<script>
+    // Live chat (goal 27): a turn runs in the BACKGROUND - the send
+    // returns immediately and every append (tool call, assistant
+    // reply, parked question, suggestion) bumps the global pulse.
+    // This listener re-runs the sealed refresh action per bump, the
+    // exact pattern the dashboard uses, so the transcript streams in
+    // without a reload. All submissions ride statum.act (the JSON
+    // transport), never a urlencoded form: Astralis mangles '+' in
+    // form bodies and free text is full of them.
+    if (window.statum) {
+        (function () {
+            'use strict';
+
+            function chatState() {
+                return statum.read('chat') || {};
+            }
+
+            statum.addStateListener('pulse', function () {
+                var chat = chatState();
+                if (chat.refresh) {
+                    statum.act(chat.refresh, { chat_id: chat.id }).catch(function () { });
+                }
+            });
+
+            // Keep the transcript pinned to its newest row after the
+            // initial render and every refresh.
+            if (statum.afterEvaluate) {
+                statum.afterEvaluate(function () {
+                    var thread = document.getElementById('chat-thread');
+                    if (thread) {
+                        thread.scrollTop = thread.scrollHeight;
+                    }
+                });
+            }
+
+            function trimmed(text) {
+                return String(text || '').replace(/^\s+|\s+$/g, '');
+            }
+
+            window.sendAdvisorMessage = function () {
+                var chat = chatState();
+                if (!chat.send || chat.status === 'running') return;
+                var box = document.getElementById('chat-composer');
+                if (!box) return;
+                var text = trimmed(box.value);
+                if (!text) return;
+                box.value = '';
+                statum.act(chat.send, { message: text }).catch(function () { });
+            };
+
+            // The question card's "Other" box: the form carries its
+            // own input, so no state lookups are needed.
+            window.submitAdvisorOther = function (form) {
+                var chat = chatState();
+                if (!chat.send || !form || !form.elements.other) return;
+                var text = trimmed(form.elements.other.value);
+                if (!text) return;
+                form.elements.other.value = '';
+                statum.act(chat.send, { answer: text }).catch(function () { });
+            };
+
+            // Option buttons and suggestion decisions go through one
+            // delegated click handler: the cards carry their row ids
+            // as data attributes (hydrated from state), the buttons
+            // carry their payload as text or a data-decision, and the
+            // handler re-reads the CURRENT state row - so a refresh
+            // mid-click can never act on a stale sealed action.
+            function suggestionRow(id) {
+                var chat = chatState();
+                var rows = chat.suggestions || [];
+                for (var index = 0; index < rows.length; index++) {
+                    if (String(rows[index].id) === String(id)) return rows[index];
+                }
+                return null;
+            }
+
+            document.addEventListener('click', function (event) {
+                if (!event.target.closest) return;
+
+                var option = event.target.closest('.chat-options button');
+                if (option) {
+                    event.preventDefault();
+                    var chat = chatState();
+                    var answer = trimmed(option.textContent);
+                    if (chat.send && answer) {
+                        statum.act(chat.send, { answer: answer }).catch(function () { });
+                    }
+                    return;
+                }
+
+                var decision = event.target.closest('[data-decision]');
+                if (decision) {
+                    event.preventDefault();
+                    var card = decision.closest('[data-suggestion-id]');
+                    if (!card) return;
+                    var row = suggestionRow(card.getAttribute('data-suggestion-id'));
+                    if (row && row.decide) {
+                        statum.act(row.decide, {
+                            decision: decision.getAttribute('data-decision')
+                        }).catch(function () { });
+                    }
+                }
+            });
+
+            // A transcript row's wrapper class: the kind picks the
+            // lane (user right, assistant left, tool/system wide).
+            window.chatMessageClass = function (message) {
+                if (!message || !message.kind) return 'chat-row';
+                return 'chat-row chat-row-' + message.kind;
+            };
+        })();
+    }
+</script>
+</head>
+<body>
+    <section>
+        <p stm-if="chat == null">Loading…</p>
+
+        <div stm-if="chat != null && chat.id == 0">
+            <h2>Chat not found</h2>
+            <p class="muted">That chat does not exist, or belongs to another user -
+                chats are private. <a href="/chats">Back to your chats.</a></p>
+        </div>
+
+        <div stm-if="chat != null && chat.id > 0" class="chat-page">
+            <div class="chat-head">
+                <h2 stm-text="chat.title">title</h2>
+                <p><span stm-attribute.class="'badge badge-chat-' + chat.status"
+                         stm-text="chat.status_display">status</span>
+                    <span class="muted">advisor chat</span>
+                    <form class="inline">
+                        <button type="button" class="chat-delete" stm-action="chat.delete"
+                                stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete chat</button>
+                    </form></p>
+                <p class="notice" stm-if="!chat.advisor_configured">The advisor is not
+                    configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
+                    <code>SADDLE_LLM_BASE_URL</code>) to enable it. Your messages wait
+                    until then.</p>
+            </div>
+
+            <div class="chat-thread" id="chat-thread">
+                <!-- The transcript, oldest first. Tool rows render as
+                     collapsed details; a question row carries option
+                     buttons until answered; errors and system
+                     notices render in their own muted styles. -->
+                <div stm-for-message-in="chat.messages" stm-key="message.id"
+                     stm-attribute.class="window.chatMessageClass(message)">
+                    <div stm-if="message.kind == 'tool'" class="chat-tool">
+                        <details>
+                            <summary><span class="badge badge-tool">tool</span>
+                                <code stm-text="message.tool_name">name</code></summary>
+                            <p class="muted">arguments</p>
+                            <pre stm-text="message.arguments">args</pre>
+                            <p class="muted">result</p>
+                            <pre stm-text="message.result">result</pre>
+                        </details>
+                    </div>
+                    <div stm-if="message.kind == 'user'" class="chat-bubble chat-user">
+                        <div class="md" stm-markdown="message.content">message</div>
+                        <p class="muted" stm-text="message.when_display">when</p>
+                    </div>
+                    <div stm-if="message.kind == 'assistant' && !message.is_question"
+                         class="chat-bubble chat-assistant">
+                        <div class="md" stm-markdown="message.content">message</div>
+                        <p class="muted" stm-text="message.when_display">when</p>
+                    </div>
+                    <div stm-if="message.kind == 'assistant' && message.is_question"
+                         class="chat-question">
+                        <div class="chat-bubble chat-assistant">
+                            <div class="md" stm-markdown="message.content">question</div>
+                        </div>
+                        <div stm-if="!message.answered && chat.status == 'awaiting_answer'"
+                             class="card chat-question-card"
+                             stm-attribute.data-message-id="message.id">
+                            <p stm-if="message.options.length > 0" class="muted">Choose an
+                                answer, or type your own below.</p>
+                            <div class="chat-options" stm-if="message.options.length > 0">
+                                <button type="button" stm-for-option-in="message.options"
+                                        stm-key="option" stm-text="option">option</button>
+                            </div>
+                            <form class="inline"
+                                  onsubmit="window.submitAdvisorOther(this); return false;">
+                                <input type="text" name="other"
+                                       placeholder="Or type your own answer">
+                                <button type="submit">Answer</button>
+                            </form>
+                        </div>
+                        <p stm-if="message.answered" class="muted">You answered:
+                            <strong stm-text="message.answer">answer</strong></p>
+                    </div>
+                    <div stm-if="message.kind == 'error'" class="chat-error">
+                        <p stm-text="message.content">error</p>
+                    </div>
+                    <div stm-if="message.kind == 'system'" class="chat-system">
+                        <p class="muted" stm-text="message.content">system</p>
+                    </div>
+                </div>
+
+                <div stm-if="chat.status == 'running'" class="chat-thinking">
+                    <p class="muted">thinking…</p>
+                </div>
+
+                <p stm-if="chat.messages.length == 0" class="muted">Nothing yet - say
+                    something below.</p>
+            </div>
+
+            <div class="cards" stm-if="chat.suggestions.length > 0">
+                <section stm-for-suggestion-in="chat.suggestions"
+                         stm-key="suggestion.id"
+                         stm-attribute.data-suggestion-id="suggestion.id"
+                         class="card chat-suggestion">
+                    <p><span stm-attribute.class="'badge badge-kind-' + suggestion.kind"
+                             stm-text="suggestion.kind">task</span>
+                        <span stm-attribute.class="'badge badge-suggestion-' + suggestion.status"
+                              stm-text="suggestion.status">pending</span>
+                        <span class="muted" stm-text="suggestion.created_display">when</span></p>
+                    <p><strong stm-text="suggestion.title">title</strong></p>
+                    <div class="md" stm-markdown="suggestion.body">body</div>
+                    <p class="muted" stm-if="suggestion.kind == 'task'">
+                        mode <span stm-text="suggestion.mode.length > 0 ? suggestion.mode : 'implement'">mode</span>
+                        - priority <span stm-text="suggestion.priority">5</span>
+                        <span stm-if="suggestion.project_id > 0"> - project
+                            <span stm-text="suggestion.project_id">id</span></span></p>
+                    <p class="muted" stm-if="suggestion.kind == 'goal'">
+                        <span stm-if="suggestion.branch_name.length > 0">branch
+                            <code stm-text="suggestion.branch_name">branch</code> - </span>
+                        <span stm-if="suggestion.project_id > 0">project
+                            <span stm-text="suggestion.project_id">id</span> - </span>
+                        created as an unqueued draft</p>
+                    <div stm-if="suggestion.status == 'pending'" class="chat-suggestion-actions">
+                        <button type="button" class="primary" data-decision="approve">Approve</button>
+                        <button type="button" data-decision="approve_enqueue">Approve + enqueue</button>
+                        <button type="button" data-decision="decline">Decline</button>
+                    </div>
+                    <p stm-if="suggestion.status != 'pending'">
+                        <a stm-if="suggestion.task_id > 0"
+                           stm-attribute.href="'/tasks/' + suggestion.task_id">task</a>
+                        <a stm-if="suggestion.goal_id > 0"
+                           stm-attribute.href="'/goals/' + suggestion.goal_id">goal</a>
+                    </p>
+                </section>
+            </div>
+
+            <div class="chat-composer-row">
+                <textarea id="chat-composer" rows="3"
+                          stm-attribute.disabled="chat.status == 'running'"
+                          placeholder="Reply to the advisor..."></textarea>
+                <button type="button" class="primary"
+                        stm-attribute.disabled="chat.status == 'running'"
+                        onclick="window.sendAdvisorMessage()">Send</button>
+            </div>
+            <p class="muted" stm-if="chat.status == 'awaiting_answer'">The advisor is
+                waiting for your answer above.</p>
+        </div>
+    </section>
+</body>
+</html>

+ 47 - 0
src/pages/chats.html

@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <pstm-uri>/chats</pstm-uri>
+    <pstm-template>main</pstm-template>
+</head>
+<body>
+    <section>
+        <h2>Chats</h2>
+        <p class="muted">Your advisor conversations. Ask the advisor on the
+            <a href="/">dashboard</a> to start a new one - it can read the fleet's
+            state, ask you questions and suggest tasks or goals, but never runs
+            commands itself.</p>
+        <p class="notice" stm-if="chats != null && !chats.advisor_configured">The
+            advisor is not configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
+            <code>SADDLE_LLM_BASE_URL</code>) to enable it. Chats you start now
+            will wait until then.</p>
+
+        <p stm-if="chats == null">Loading…</p>
+
+        <div stm-if="chats != null">
+            <table stm-if="chats.chats.length > 0">
+                <thead><tr><th>Chat</th><th>Status</th><th>Created</th><th>Updated</th><th></th></tr></thead>
+                <tbody>
+                    <tr stm-for-chat-in="chats.chats" stm-key="chat.id">
+                        <td><a stm-attribute.href="'/chats/' + chat.id"
+                               stm-text="chat.title">title</a></td>
+                        <td><span stm-attribute.class="'badge badge-chat-' + chat.status"
+                                  stm-text="chat.status_display">status</span></td>
+                        <td stm-text="chat.created_display">when</td>
+                        <td stm-text="chat.updated_display">when</td>
+                        <td>
+                            <form class="inline">
+                                <button type="button" stm-action="chat.delete"
+                                        stm-confirm="Delete this chat? Its messages and suggestions go with it.">Delete</button>
+                            </form>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+            <p stm-if="chats.chats.length == 0" class="muted">No chats yet - type a
+                question into the "Ask the advisor" box at the top of the
+                <a href="/">dashboard</a> to start one.</p>
+        </div>
+    </section>
+</body>
+</html>

+ 40 - 0
src/pages/home.html

@@ -44,6 +44,21 @@
                 return statum.read('dashboard') || {};
             }
 
+            // The hero composer (goal 27): starts a NEW advisor chat.
+            // statum.act rides the JSON transport, so free text (with
+            // its plusses and quotes) survives intact; the action
+            // answers with a navigation to the new chat page.
+            window.startAdvisorChat = function () {
+                var state = dashboard();
+                if (!state.start_advisor) return;
+                var box = document.getElementById('advisor-composer');
+                if (!box) return;
+                var text = box.value.replace(/^\s+|\s+$/g, '');
+                if (!text) return;
+                box.value = '';
+                statum.act(state.start_advisor, { message: text }).catch(function () { });
+            };
+
             function fail(error) {
                 if (window.showToast) {
                     showToast('Notifications: ' + (error && error.message ? error.message : error), 'error');
@@ -230,6 +245,31 @@
 </head>
 <body>
     <section>
+        <!-- The advisor hero (goal 27): the page's FIRST and
+             predominant control. Starting a chat here creates it
+             (titled from the first line) and lands on the chat page;
+             the advisor reads fleet state, asks questions and
+             suggests tasks/goals, but never runs commands. -->
+        <section stm-if="dashboard != null" class="advisor-hero card">
+            <form class="inline advisor-hero-form"
+                  onsubmit="window.startAdvisorChat(); return false;">
+                <div class="form-row">
+                    <label for="advisor-composer">Ask the advisor</label>
+                    <textarea id="advisor-composer" rows="2"
+                              stm-attribute.disabled="!dashboard.advisor_configured"
+                              placeholder="What should the fleet work on next?"></textarea>
+                    <button type="submit" class="primary"
+                            stm-attribute.disabled="!dashboard.advisor_configured">Start chat</button>
+                </div>
+            </form>
+            <p class="muted" stm-if="dashboard.advisor_configured">Reads your projects,
+                tasks, goals, rules and discoveries - asks you questions and suggests
+                work. <a href="/chats">Your chats</a></p>
+            <p class="notice" stm-if="!dashboard.advisor_configured">The advisor is not
+                configured - set <code>ADVISOR_LLM_BASE_URL</code> (or
+                <code>SADDLE_LLM_BASE_URL</code>) to enable it.</p>
+        </section>
+
         <h2>Dashboard</h2>
         <p stm-if="dashboard == null">Loading…</p>
 

+ 1 - 0
src/pages/main.html

@@ -23,6 +23,7 @@
                     <a href="/">Dashboard</a>
                     <a href="/tasks">Tasks</a>
                     <a class="nav-goal" href="/goals"><em class="goal-icon">🎯</em> Goals</a>
+                    <a href="/chats">Chats</a>
                 </div>
             </details>
             <details class="nav-menu" name="nav-menu">

+ 308 - 0
test/bundles/chatui.py

@@ -0,0 +1,308 @@
+"""The advisor chat UI bundle (goal 27, task 2): the /chats list, the
+/chats/{id} session page, the dashboard hero composer and the statum
+actions under them, driven end-to-end against the scripted LLM
+endpoint (the same ScriptedLlm the backend bundle uses). The scenes:
+
+- the pages render: /chats lists the user's chats (title, status
+  chip, delete action), the dashboard carries the hero composer
+- the hero: StartAdvisorChatAction creates the chat titled from the
+  first line (clamped), records the message, kicks the turn and
+  navigates to /chats/{id}
+- a chat page: the transcript slot carries the user message, the
+  scripted tool call round (name/arguments/result parsed from the
+  payload), the parked question with its options, and the send
+  action is sealed against the chat
+- answering: the question card's answer rides SendAdvisorMessage's
+  answer path -> "Human selected: ..." lands, the turn resumes and
+  the final assistant reply appears in the slot
+- the refresh action: RefreshAdvisorChatAction rebuilds the slot
+- suggestions: the card state (title/mandate/mode/priority + the
+  sealed decide action) renders from the inserted row
+- delete: the chat leaves the list and its page state reads as
+  not-found (id 0)
+- privacy: a second user's /chats slot sees none of the first
+  user's chats and their /chats/{id} slot renders not-found
+- the unconfigured dashboard: the hero renders with
+  advisor_configured false
+"""
+from harness import *  # noqa: F401,F403 — the shared wire helpers
+import harness  # the module itself (the unconfigured scene re-points its globals)
+from bundles.advisor import ScriptedLlm
+
+
+def restart_with_llm(ctx, llm):
+    """Points the app at `llm` and restarts it (env re-read)."""
+    os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
+    os.environ["ADVISOR_LLM_MODEL"] = "scripted"
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    ctx.app = start_app()
+    ctx.auth = login("admin", "adminpass123")
+    return ctx.auth
+
+
+def db_rows(sql, params=()):
+    conn = db()
+    try:
+        return conn.execute(sql, params).fetchall()
+    finally:
+        conn.close()
+
+
+def wait_status(chat_id, status, timeout=30):
+    def read():
+        rows = db_rows("select status from advisor_chats where id = ?",
+                       (chat_id,))
+        return len(rows) == 1 and rows[0][0] == status and status or False
+    return wait_for(read, timeout=timeout,
+                    what="chat %d -> %s" % (chat_id, status))
+
+
+def run(ctx):
+    auth = ctx.auth
+
+    print("== chatui: the pages render ==")
+    chats = slot_public(auth, "/chats", "chats")
+    check("chats" in chats, "the /chats slot carries the chat rows")
+    check(chats.get("advisor_configured") is True,
+          "the /chats slot reports the advisor configured")
+    home = slot_public(auth, "/", "dashboard")
+    check("start_advisor" in home, "the dashboard carries the hero action")
+    home_html = http_get_page(auth, "/")
+    check("advisor-composer" in home_html and "Ask the advisor" in home_html,
+          "the dashboard renders the hero composer")
+    check('stm-attribute.disabled="!dashboard.advisor_configured"'
+          in home_html,
+          "the hero composer disables itself on the configured flag")
+    chats_html = http_get_page(auth, "/chats")
+    check("Your advisor conversations" in chats_html
+          and "dashboard" in chats_html,
+          "the /chats page renders its copy and the empty-state pointer")
+
+    print("== chatui: the hero starts a chat ==")
+    llm = ScriptedLlm([
+        {"tools": [{"id": "ui-1", "name": "list_projects",
+                    "arguments": "{}"}]},
+        {"tools": [{"id": "ui-2", "name": "ask_question",
+                    "arguments": json.dumps({
+                        "question": "Which project is the priority?",
+                        "options": ["demo", "later"]})}]},
+        {"content": "Targeting demo, then."},  # after the answer
+    ])
+    llm.serve()
+    auth = restart_with_llm(ctx, llm)
+
+    # A first line well past the clamp: the title is its head, on a
+    # word boundary, with the ellipsis.
+    long_opening = ("Please review the whole fleet and tell me what to "
+                    "work on next, with reasoning and references")
+    home = slot_public(auth, "/", "dashboard")
+    result = act(auth, home["start_advisor"], {"message": long_opening})
+    navigations = [d for d in result if d.get("type") == "navigate"]
+    check(len(navigations) == 1
+          and navigations[0].get("uri", "").startswith("/chats/"),
+          "the hero action navigates to the new chat (%s)" % navigations)
+    chat_id = int(navigations[0]["uri"].rsplit("/", 1)[1])
+    title = db_rows("select title from advisor_chats where id = ?",
+                    (chat_id,))[0][0]
+    check(len(title) <= 81 and title.endswith("…"),
+          "the title is clamped from the first line (%d chars)" % len(title))
+    check(title.startswith("Please review"),
+          "the title leads with the message's first words (%s)" % title)
+    kinds = [r[0] for r in db_rows(
+        "select kind from advisor_messages where chat_id = ? order by id",
+        (chat_id,))]
+    check(kinds == ["user"], "the opening message is recorded as a user row")
+
+    print("== chatui: the chat page state (tool round + question) ==")
+    check(wait_status(chat_id, "awaiting_answer"),
+          "the scripted turn parks on ask_question")
+    page = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    check(page["id"] == chat_id and page["status"] == "awaiting_answer"
+          and page["status_display"] == "awaiting answer",
+          "the chat slot carries the chat row and its status label")
+    check("send" in page and "delete" in page and "refresh" in page,
+          "the chat slot carries the send/delete/refresh actions")
+    messages = page["messages"]
+    check(any(m["kind"] == "user" and long_opening in m["content"]
+              for m in messages), "the user message rides the slot")
+    check(any(m["kind"] == "tool" and m.get("tool_name") == "list_projects"
+              and "projects" in (m.get("result") or "")
+              for m in messages),
+          "the tool call renders with name, arguments and result")
+    questions = [m for m in messages if m.get("is_question")]
+    check(len(questions) == 1
+          and questions[0]["content"] == "Which project is the priority?"
+          and questions[0]["options"] == ["demo", "later"]
+          and not questions[0].get("answered"),
+          "the parked question renders with its options, unanswered")
+    detail_html = http_get_page(auth, "/chats/%d" % chat_id)
+    check("chat-thread" in detail_html and "chat-composer" in detail_html
+          and "chat-options" in detail_html,
+          "the chat page renders the thread, composer and options")
+
+    print("== chatui: answering the question via the action ==")
+    # The question card's option button rides the send action's
+    # answer path (the same statum.act the page script issues).
+    result = act(auth, page["send"], {"answer": "demo"})
+    sets = [d for d in result if d.get("type") == "set"]
+    check(len(sets) == 1, "the answer action answers with the rebuilt slot")
+    check(wait_status(chat_id, "idle"), "the answered turn ends idle")
+    check(any(m["kind"] == "user" and m["content"] == "Human selected: demo"
+              for m in slot_public(auth, "/chats/%d" % chat_id,
+                                   "chat")["messages"]),
+          'the option lands as "Human selected: demo" in the slot')
+    check(wait_for(lambda: [m for m in slot_public(
+        auth, "/chats/%d" % chat_id, "chat")["messages"]
+        if m["kind"] == "assistant"
+        and m["content"] == "Targeting demo, then."] or False,
+        timeout=15, what="final reply in slot") is not None,
+        "the resumed turn's reply reaches the slot")
+
+    print("== chatui: the composer sends a follow-up ==")
+    result = act(auth, page["send"], {"message": "thanks, that is all"})
+    check(wait_status(chat_id, "idle"), "the follow-up turn ends idle")
+    check(any(m["kind"] == "user" and m["content"] == "thanks, that is all"
+              for m in slot_public(auth, "/chats/%d" % chat_id,
+                                   "chat")["messages"]),
+          "the composer message lands in the transcript")
+    check(len(llm.requests) >= 3, "the follow-up reached the LLM")
+
+    print("== chatui: the list page and the refresh action ==")
+    chats = slot_public(auth, "/chats", "chats")
+    row = [c for c in chats["chats"] if str(c["id"]) == str(chat_id)]
+    check(len(row) == 1 and row[0]["status"] == "idle"
+          and "delete" in row[0],
+          "the list slot carries the chat with its status and delete")
+    result = act(auth, page["refresh"], {"chat_id": chat_id})
+    sets = [d for d in result if d.get("type") == "set"]
+    check(len(sets) == 1, "the refresh action rebuilds the slot")
+
+    print("== chatui: a suggestion card renders ==")
+    llm2 = ScriptedLlm([
+        {"tools": [{"id": "sug-1", "name": "suggest_task",
+                    "arguments": json.dumps({
+                        "title": "UI-driven task",
+                        "mandate": "Verify the chat UI suggestion card.",
+                        "mode": "test", "project_id": 1, "priority": 3})}]},
+        {"content": "Suggested."},
+    ])
+    llm2.serve()
+    auth = restart_with_llm(ctx, llm2)
+    page = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    act(auth, page["send"], {"message": "suggest one task"})
+    check(wait_status(chat_id, "idle"), "the suggestion turn ends idle")
+    check(wait_for(lambda: slot_public(auth, "/chats/%d" % chat_id,
+                                       "chat")["suggestions"] or False,
+                   timeout=15, what="suggestion in slot") is not None,
+          "the suggestion reaches the slot")
+    card = slot_public(auth, "/chats/%d" % chat_id, "chat")["suggestions"][0]
+    check(card["kind"] == "task" and card["status"] == "pending"
+          and card["title"] == "UI-driven task"
+          and card["body"] == "Verify the chat UI suggestion card."
+          and card["mode"] == "test" and str(card["priority"]) == "3",
+          "the card carries the payload fields")
+    check("decide" in card,
+          "the pending card carries the sealed decide action")
+
+    print("== chatui: per-user privacy ==")
+    second = "chatui-" + str(int(time.time()))
+    users_state = slot_public(auth, "/users", "users")
+    act(auth, users_state["create"], {
+        "username": second, "email": second + "@example.com",
+        "forename": "Chat", "surname": "Ui", "date_of_birth": "1990-01-01",
+        "password": "chatuipass1", "confirm_password": "chatuipass1"})
+    other = login(second, "chatuipass1")
+    other_chats = slot_public(other, "/chats", "chats")
+    check(other_chats["chats"] == [],
+          "the second user's list is empty (chats are private)")
+    other_page = slot_public(other, "/chats/%d" % chat_id, "chat")
+    check(other_page["id"] == 0,
+          "the second user's chat slot reads not-found (id 0)")
+    # their delete of someone else's chat: the slot carries no delete
+    # action (id 0 not-found state), so there is nothing to invoke —
+    # the row must survive untouched.
+    check("delete" not in other_page,
+          "the second user's chat slot carries no delete action")
+    check(db_rows("select count(*) from advisor_chats where id = ?",
+                  (chat_id,))[0][0] == 1,
+          "the second user cannot delete the first user's chat")
+
+    print("== chatui: delete the chat ==")
+    own_page = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    result = act(auth, own_page["delete"], {})
+    check(any(d.get("type") == "navigate" and d.get("uri") == "/chats"
+              for d in result), "delete navigates to /chats")
+    check(db_rows("select count(*) from advisor_chats where id = ?",
+                  (chat_id,))[0][0] == 0, "the chat row is gone")
+    check(db_rows("select count(*) from advisor_messages where chat_id = ?",
+                  (chat_id,))[0][0] == 0, "the messages cascade")
+    check(db_rows("select count(*) from advisor_suggestions"
+                  " where chat_id = ?", (chat_id,))[0][0] == 0,
+          "the suggestions cascade")
+    gone = slot_public(auth, "/chats/%d" % chat_id, "chat")
+    check(gone["id"] == 0, "the deleted chat's slot reads not-found")
+    chats = slot_public(auth, "/chats", "chats")
+    check(all(c["id"] != chat_id for c in chats["chats"]),
+          "the deleted chat left the list")
+
+    print("== chatui: the unconfigured hero ==")
+    # The advisor falls back to SADDLE_LLM_*, which start_app always
+    # points at the fake daemon — the honest unconfigured scene pins
+    # BOTH to an unreachable-but-set... no: `configured` only checks
+    # that a base URL RESOLVES. So the real unconfigured state needs
+    # both variables empty, which start_app's env.update forbids.
+    # Drive the last scene with a hand-spawned app instead.
+    llm_env = {name: os.environ.pop(name, None)
+               for name in ("ADVISOR_LLM_BASE_URL", "SADDLE_LLM_BASE_URL")}
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+
+    saved_web, saved_port = harness.WEB, harness.APP_PORT
+    harness.APP_PORT = free_port()
+    harness.WEB = "http://127.0.0.1:%d" % harness.APP_PORT
+    env = dict(os.environ)
+    env.update({
+        "SPRY_DB_PATH": DB_PATH,
+        "SPRY_SEED_ADMIN_USERNAME": "admin",
+        "SPRY_SEED_ADMIN_PASSWORD": "adminpass123",
+        "SADDLE_PROJECTS_ROOT": os.path.join(WORK, "workspace"),
+        "SADDLE_LLM_BASE_URL": "",
+        "ADVISOR_LLM_BASE_URL": "",
+        "LD_LIBRARY_PATH": "",
+    })
+    with open(os.path.join(WORK, "app.log"), "a") as log:
+        ctx.app = subprocess.Popen([APP, str(harness.APP_PORT)], cwd=WORK,
+                                   env=env, stdout=log,
+                                   stderr=subprocess.STDOUT)
+    if wait_for(app_ready, timeout=30, what="unconfigured app ready") is None:
+        raise SystemExit("unconfigured app did not come up")
+    ctx.auth = login("admin", "adminpass123")
+    home = slot_public(ctx.auth, "/", "dashboard")
+    check(home.get("advisor_configured") is False,
+          "the dashboard reports the advisor unconfigured")
+    home_html = http_get_page(ctx.auth, "/")
+    check("advisor is not" in home_html.lower()
+          or "ADVISOR_LLM_BASE_URL" in home_html,
+          "the unconfigured hero renders its notice")
+    unconfigured_chats = slot_public(ctx.auth, "/chats", "chats")
+    check(unconfigured_chats.get("advisor_configured") is False,
+          "the /chats slot reports the advisor unconfigured too")
+    ctx.app.terminate()
+    try:
+        ctx.app.wait(timeout=10)
+    except subprocess.TimeoutExpired:
+        ctx.app.kill()
+    harness.WEB, harness.APP_PORT = saved_web, saved_port
+    # Restore whatever the caller had (other bundles share the env).
+    for name, value in llm_env.items():
+        if value is not None:
+            os.environ[name] = value
+    llm2.kill()
+    llm.kill()

+ 1 - 1
test/harness.py

@@ -1324,7 +1324,7 @@ BUNDLES_ORDER = ("pages", "dispatch", "subtasks", "lineage",
                  "allocation", "lifecycle", "answers", "push", "goals",
                  "replay", "goalui", "forcewake", "users", "stacks", "notes",
                  "resurrect", "origins", "parity", "understanding",
-                 "discoveries", "knowledge_cycle", "advisor")
+                 "discoveries", "knowledge_cycle", "advisor", "chatui")
 
 
 def run_bundle(name):