diff --git a/components/chat.tsx b/components/chat.tsx
index f9ed13d..10e311b 100644
--- a/components/chat.tsx
+++ b/components/chat.tsx
@@ -212,14 +212,15 @@ export function Chat({
// The reattach settled terminal → the sync-and-continue seam (below).
setReattachSettled(() => reattachSettledRef.current());
- // New chat with a topic: send the first message exactly once.
+ // New chat with a topic: send the first message exactly once
+ // (initialPromptOwed — the pure gate, pinned in the test suite).
const sentInitial = useRef(false);
useEffect(() => {
- if (!sentInitial.current && initialPrompt && initialMessages.length === 0) {
+ if (!sentInitial.current && initialPromptOwed(initialMessages, initialPrompt)) {
sentInitial.current = true;
- void send({ type: "text", text: initialPrompt });
+ void send({ type: "text", text: initialPrompt! });
}
- }, [initialPrompt, initialMessages.length, send]);
+ }, [initialPrompt, initialMessages, send]);
// A turn running server-side, attached to no live view of ours (navigate
// away mid-turn, or a wake turn discovered mid-session): the reattach
@@ -1202,6 +1203,26 @@ export function Chat({
// assistant group — is gone). Never a user bubble: the wake is
// scaffolding; the model-facing instruction lives in the adapter.
+ ) : m.role === "user" && m.parts.length > 0 && m.parts.every((p) => p.type.startsWith("data-")) ? (
+ // Marker cards (custom messages, 2026-10-04): user-side
+ // scaffolding — the graph-context pick, the practice
+ // thinking trace — render as cards in the transcript
+ // column directly, NO chat bubble (the subagent-steer
+ // precedent). The user branch below renders text only;
+ // without this hoist a data-only message read as an
+ // EMPTY bubble (owner report, 2026-10-04).
+
+ {m.parts.map((part, pi) => {
+ switch (part.type) {
+ case "data-graph-context":
+ return ;
+ case "data-practice-thinking":
+ return ;
+ default:
+ return null;
+ }
+ })}
+
) : (
{/* THE INFERRED TEACH SEAM (owner ruling 2026-09-26, the verdict
@@ -1259,20 +1280,6 @@ export function Chat({
);
case "step-start":
return pi > 0 ? : null;
- case "data-graph-context":
- // The session's map pick (2026-10-04): a minimal
- // collapsible card — scaffolding above the first
- // user message, never a bubble.
- return (
-
- );
- case "data-practice-thinking":
- // The learner's practice submission (2026-10-04):
- // user-side — the register (thinking trace /
- // committed answer) is the card's title.
- return (
-
- );
case "data-plan-block":
// The plan in the chat (wave 4): a frozen snapshot
// block — rendered as carried, never re-derived.
@@ -1508,6 +1515,19 @@ export function Chat({
* then setup (self-closing — the gate only must not fire), then the
* 2026-10-02 abort chain proper (question before turn). */
export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "problem" | "turn" | null;
+ /**
+ * Does the ?prompt topic still owe its first send? The gate is "no real
+ * conversation yet" (no user/assistant message) — NOT emptiness: a
+ * session started with map picks carries a graph-context MARKER as
+ * message[0], and that scaffolding must never swallow the first send
+ * (owner report, 2026-10-04: the turn went dead behind it — the old
+ * `length === 0` check saw the marker and refused to fire).
+ */
+export function initialPromptOwed(messages: readonly { role: string }[], prompt: string | undefined): boolean {
+ if (!prompt) return false;
+ return !messages.some((m) => m.role === "user" || m.role === "assistant");
+}
+
export function escAbortTarget(s: {
abortAsk: boolean;
mermaidFocus: boolean;
diff --git a/tests/graph-context.test.ts b/tests/graph-context.test.ts
index 937a84d..d999c17 100644
--- a/tests/graph-context.test.ts
+++ b/tests/graph-context.test.ts
@@ -29,6 +29,7 @@ const { toLlmMessages } = await import("../src/core/native-messages.ts");
import type { LhMessage } from "../src/core/native-messages.ts";
const { projectView } = await import("../src/core/view.ts");
const { POST } = await import("../app/api/chats/route.ts");
+const { initialPromptOwed } = await import("../components/chat.tsx");
const { chatStore } = await import("../src/store/chats.ts");
const { graphStore } = await import("../src/store/graph.ts");
@@ -80,6 +81,26 @@ describe("newGraphContextMessage", () => {
});
});
+// ── the first-send gate (the dead first turn, owner report 2026-10-04) ──────
+
+describe("initialPromptOwed", () => {
+ it("a marker-only record still owes the prompt — scaffolding is not conversation", () => {
+ const marker = newGraphContextMessage(PICKS, []);
+ assert.equal(initialPromptOwed([marker as never], "drill me on logic"), true);
+ });
+
+ it("once a user or assistant message exists, the prompt is spent", () => {
+ const marker = newGraphContextMessage(PICKS, []);
+ const user = { role: "user" } as never;
+ assert.equal(initialPromptOwed([marker as never, user], "drill me"), false);
+ assert.equal(initialPromptOwed([{ role: "assistant" } as never], "drill me"), false);
+ });
+
+ it("no prompt → nothing owed (the empty-draft case)", () => {
+ assert.equal(initialPromptOwed([], undefined), false);
+ });
+});
+
// ── the route (picks ride the creation) ─────────────────────────────────────
describe("POST /api/chats — graphContext becomes message[0]", () => {