diff --git a/.gitea/workflows/e2e-chat.yml b/.gitea/workflows/e2e-chat.yml new file mode 100644 index 00000000..35d5c204 --- /dev/null +++ b/.gitea/workflows/e2e-chat.yml @@ -0,0 +1,273 @@ +name: E2E Chat + +# Comprehensive Playwright E2E for the unified chat stack (desktop +# ChatTab + mobile MobileChat). Runs on every PR that touches canvas, +# workspace-server, or this workflow file. +# +# Architecture: +# 1. Ephemeral Postgres + Redis (docker, unique container names) +# 2. workspace-server built from source, started with +# MOLECULE_ENV=development (fail-open auth) +# 3. canvas dev server (npm run dev) on :3000 +# 4. Playwright tests create workspaces via API, point them at an +# in-process echo runtime, and exercise the full send/receive +# round-trip through the browser. +# +# Parallel-safety: same pattern as e2e-api.yml — per-run container names +# and ephemeral host ports so concurrent jobs on the host-network runner +# don't collide. + +on: + push: + branches: [main, staging] + pull_request: + branches: [main, staging] + +concurrency: + group: e2e-chat-${{ github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: false + +env: + GITHUB_SERVER_URL: https://git.moleculesai.app + +jobs: + # bp-exempt: helper job; real gate is E2E Chat / E2E Chat (pull_request) + detect-changes: + runs-on: ubuntu-latest + # Phase 3 (RFC #219 §1): surface broken workflows without blocking. + # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. + continue-on-error: true + outputs: + chat: ${{ steps.decide.outputs.chat }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - id: decide + run: | + BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}" + if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then + BASE="${{ github.event.pull_request.base.sha }}" + fi + if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then + echo "chat=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! git cat-file -e "$BASE" 2>/dev/null; then + git fetch --depth=1 origin "$BASE" 2>/dev/null || true + fi + if ! git cat-file -e "$BASE" 2>/dev/null; then + echo "chat=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + CHANGED=$(git diff --name-only "$BASE" HEAD) + if echo "$CHANGED" | grep -qE '^(canvas/|workspace-server/|\.gitea/workflows/e2e-chat\.yml$)'; then + echo "chat=true" >> "$GITHUB_OUTPUT" + else + echo "chat=false" >> "$GITHUB_OUTPUT" + fi + + # bp-required: pending #1142 — new E2E check; add to branch protection after 3 green runs. + e2e-chat: + needs: detect-changes + name: E2E Chat + runs-on: ubuntu-latest + # Phase 3 (RFC #219 §1): surface broken workflows without blocking. + # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. + continue-on-error: true + timeout-minutes: 15 + env: + PG_CONTAINER: pg-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }} + REDIS_CONTAINER: redis-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: No-op pass (paths filter excluded this commit) + if: needs.detect-changes.outputs.chat != 'true' + run: | + echo "No canvas / workspace-server / workflow changes — E2E Chat gate satisfied without running tests." + echo "::notice::E2E Chat no-op pass (paths filter excluded this commit)." + + - if: needs.detect-changes.outputs.chat == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - if: needs.detect-changes.outputs.chat == 'true' + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: 'stable' + cache: true + cache-dependency-path: workspace-server/go.sum + + - if: needs.detect-changes.outputs.chat == 'true' + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d6f5 # v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: canvas/package-lock.json + + - name: Start Postgres (docker) + if: needs.detect-changes.outputs.chat == 'true' + run: | + docker rm -f "$PG_CONTAINER" 2>/dev/null || true + docker run -d --name "$PG_CONTAINER" \ + -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \ + -p 0:5432 postgres:16 >/dev/null + PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}') + if [ -z "$PG_PORT" ]; then + PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}') + fi + if [ -z "$PG_PORT" ]; then + echo "::error::Could not resolve host port for $PG_CONTAINER" + exit 1 + fi + echo "PG_PORT=${PG_PORT}" >> "$GITHUB_ENV" + echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV" + echo "E2E_DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV" + for i in $(seq 1 30); do + if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then + echo "Postgres ready after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::Postgres did not become ready in 30s" + exit 1 + + - name: Start Redis (docker) + if: needs.detect-changes.outputs.chat == 'true' + run: | + docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true + docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null + REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}') + if [ -z "$REDIS_PORT" ]; then + REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}') + fi + if [ -z "$REDIS_PORT" ]; then + echo "::error::Could not resolve host port for $REDIS_CONTAINER" + exit 1 + fi + echo "REDIS_PORT=${REDIS_PORT}" >> "$GITHUB_ENV" + echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV" + for i in $(seq 1 15); do + if docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG; then + echo "Redis ready after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::Redis did not become ready in 15s" + exit 1 + + - name: Build platform + if: needs.detect-changes.outputs.chat == 'true' + working-directory: workspace-server + run: go build -o platform-server ./cmd/server + + - name: Pick platform port + if: needs.detect-changes.outputs.chat == 'true' + run: | + PLATFORM_PORT=$(python3 - <<'PY' + import socket + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + print(s.getsockname()[1]) + PY + ) + echo "PLATFORM_PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV" + echo "E2E_PLATFORM_URL=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV" + echo "Platform host port: ${PLATFORM_PORT}" + + - name: Start platform (background) + if: needs.detect-changes.outputs.chat == 'true' + working-directory: workspace-server + run: | + export MOLECULE_ENV=development + export DATABASE_URL="${DATABASE_URL}" + export REDIS_URL="${REDIS_URL}" + export PORT="${PLATFORM_PORT}" + ./platform-server > platform.log 2>&1 & + echo $! > platform.pid + + - name: Wait for /health + if: needs.detect-changes.outputs.chat == 'true' + run: | + for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:${PLATFORM_PORT}/health" > /dev/null; then + echo "Platform up after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::Platform did not become healthy in 30s" + cat workspace-server/platform.log || true + exit 1 + + - name: Install canvas dependencies + if: needs.detect-changes.outputs.chat == 'true' + working-directory: canvas + run: npm ci + + - name: Install Playwright browsers + if: needs.detect-changes.outputs.chat == 'true' + working-directory: canvas + run: npx playwright install --with-deps chromium + + - name: Start canvas dev server (background) + if: needs.detect-changes.outputs.chat == 'true' + working-directory: canvas + run: | + export NEXT_PUBLIC_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}" + export NEXT_PUBLIC_WS_URL="ws://127.0.0.1:${PLATFORM_PORT}/ws" + npm run dev > canvas.log 2>&1 & + echo $! > canvas.pid + for i in $(seq 1 30); do + if curl -sf http://localhost:3000 > /dev/null 2>&1; then + echo "Canvas up after ${i}s" + exit 0 + fi + sleep 1 + done + echo "::error::Canvas did not start in 30s" + cat canvas.log || true + exit 1 + + - name: Run Playwright E2E tests + if: needs.detect-changes.outputs.chat == 'true' + working-directory: canvas + run: | + export E2E_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}" + export E2E_DATABASE_URL="${DATABASE_URL}" + npx playwright test e2e/chat-desktop.spec.ts e2e/chat-mobile.spec.ts + + - name: Dump platform log on failure + if: failure() && needs.detect-changes.outputs.chat == 'true' + run: cat workspace-server/platform.log || true + + - name: Dump canvas log on failure + if: failure() && needs.detect-changes.outputs.chat == 'true' + run: cat canvas/canvas.log || true + + - name: Upload Playwright report + if: failure() && needs.detect-changes.outputs.chat == 'true' + uses: actions/upload-artifact@v3.2.2 + with: + name: playwright-report-chat + path: canvas/playwright-report/ + + - name: Stop canvas + if: always() && needs.detect-changes.outputs.chat == 'true' + run: | + if [ -f canvas/canvas.pid ]; then + kill "$(cat canvas/canvas.pid)" 2>/dev/null || true + fi + + - name: Stop platform + if: always() && needs.detect-changes.outputs.chat == 'true' + run: | + if [ -f workspace-server/platform.pid ]; then + kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true + fi + + - name: Stop service containers + if: always() && needs.detect-changes.outputs.chat == 'true' + run: | + docker rm -f "$PG_CONTAINER" 2>/dev/null || true + docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true diff --git a/canvas/e2e/chat-desktop.spec.ts b/canvas/e2e/chat-desktop.spec.ts new file mode 100644 index 00000000..2ef04159 --- /dev/null +++ b/canvas/e2e/chat-desktop.spec.ts @@ -0,0 +1,173 @@ +import { test, expect } from "@playwright/test"; +import { startEchoRuntime } from "./fixtures/echo-runtime"; +import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed"; + + +test.describe("Desktop ChatTab", () => { + let cleanup: () => Promise = async () => {}; + let workspaceId = ""; + let workspaceName = ""; + + test.beforeAll(async () => { + const echo = await startEchoRuntime(); + const ws = await seedWorkspace(echo.baseURL); + workspaceId = ws.id; + workspaceName = ws.name; + const stopHeartbeat = startHeartbeat(ws.id, ws.authToken); + + cleanup = async () => { + stopHeartbeat(); + await echo.stop(); + }; + }); + + test.afterAll(async () => { + await cleanupWorkspace(workspaceId); + await cleanup(); + }); + + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto("/"); + await page.waitForSelector(".react-flow__node", { timeout: 10_000 }); + // Dismiss onboarding guide if present. + const skipGuide = page.getByText("Skip guide"); + if (await skipGuide.isVisible().catch(() => false)) { + await skipGuide.click(); + } + // Click the workspace node by its exact name label. + await page.getByText(workspaceName, { exact: true }).first().click(); + // Wait for the side panel chat tab to be clickable, then click it. + await page.locator('#tab-chat').click(); + await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 }); + // Wait for the workspace status to flip to online and the textarea to be enabled. + await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 }); + }); + + test("chat panel loads without error", async ({ page }) => { + const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false); + const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3; + expect(hasEmptyState || hasHistory).toBeTruthy(); + }); + + test("send text message and receive echo response", async ({ page }) => { + const textarea = page.locator("textarea").first(); + await textarea.fill("What is the weather?"); + await page.getByRole("button", { name: /Send/ }).first().click(); + + await expect(page.getByText("What is the weather?")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText("Echo: What is the weather?")).toBeVisible({ timeout: 15_000 }); + }); + + test("history persists across reload", async ({ page }) => { + const textarea = page.locator("textarea").first(); + await textarea.fill("Persistence test"); + await page.getByRole("button", { name: /Send/ }).first().click(); + + await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 15_000 }); + + await page.reload(); + await page.waitForSelector(".react-flow__node", { timeout: 10_000 }); + await page.getByText(workspaceName, { exact: true }).first().click(); + await page.locator('#tab-chat').click(); + await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 }); + // Wait for the workspace status to flip to online and the textarea to be enabled. + await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 }); + + await expect(page.getByText("Persistence test", { exact: true })).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 5_000 }); + }); + + test("file attachment round-trip", async ({ page }) => { + const textarea = page.locator("textarea").first(); + await textarea.fill("Please read this file"); + + const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first(); + await fileInput.setInputFiles({ + name: "test.txt", + mimeType: "text/plain", + buffer: Buffer.from("secret content abc123"), + }); + + await expect(page.getByText("test.txt")).toBeVisible({ timeout: 3_000 }); + + await page.getByRole("button", { name: /Send/ }).first().click(); + + await expect(page.getByText("Echo: Please read this file")).toBeVisible({ timeout: 15_000 }); + }); + + test("activity log appears during send", async ({ page }) => { + const textarea = page.locator("textarea").first(); + await textarea.fill("Trigger activity"); + await page.getByRole("button", { name: /Send/ }).first().click(); + + // Activity log container should appear during the send flow. + await expect(page.locator("[data-testid='activity-log']").first()).toBeVisible({ timeout: 10_000 }).catch(() => { + // Activity log may not be present in all layouts. + }); + }); +}); + +test.describe("Desktop ChatTab — Markdown rendering", () => { + let cleanup: () => Promise = async () => {}; + let workspaceId = ""; + let workspaceName = ""; + + test.beforeAll(async () => { + const echo = await startEchoRuntime(); + const ws = await seedWorkspace(echo.baseURL); + workspaceId = ws.id; + workspaceName = ws.name; + const stopHeartbeat = startHeartbeat(ws.id, ws.authToken); + + cleanup = async () => { + stopHeartbeat(); + await echo.stop(); + }; + }); + + test.afterAll(async () => { + await cleanupWorkspace(workspaceId); + await cleanup(); + }); + + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto("/"); + await page.waitForSelector(".react-flow__node", { timeout: 10_000 }); + const skipGuide2 = page.getByText("Skip guide"); + if (await skipGuide2.isVisible().catch(() => false)) { + await skipGuide2.click(); + } + await page.getByText(workspaceName, { exact: true }).first().click(); + await page.locator('#tab-chat').click(); + await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 }); + // Wait for the workspace status to flip to online and the textarea to be enabled. + await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 }); + }); + + test("code block renders
", async ({ page }) => {
+    const textarea = page.locator("textarea").first();
+    await textarea.fill("```js\nconst x = 1;\n```");
+    await page.getByRole("button", { name: /Send/ }).first().click();
+
+    await expect(page.getByText("Echo: ```js")).toBeVisible({ timeout: 15_000 });
+
+    const pre = page.locator("pre").first();
+    await expect(pre).toBeVisible({ timeout: 5_000 });
+    await expect(pre).toContainText("const x = 1;");
+  });
+
+  test("table renders ", async ({ page }) => {
+    const textarea = page.locator("textarea").first();
+    await textarea.fill("| A | B |\n|---|---|\n| 1 | 2 |");
+    await page.getByRole("button", { name: /Send/ }).first().click();
+
+    await expect(page.getByText("Echo: | A | B |")).toBeVisible({ timeout: 15_000 });
+
+    const table = page.locator("table").first();
+    await expect(table).toBeVisible({ timeout: 5_000 });
+    await expect(table).toContainText("A");
+    await expect(table).toContainText("1");
+  });
+});
diff --git a/canvas/e2e/chat-mobile.spec.ts b/canvas/e2e/chat-mobile.spec.ts
new file mode 100644
index 00000000..e0404537
--- /dev/null
+++ b/canvas/e2e/chat-mobile.spec.ts
@@ -0,0 +1,97 @@
+import { test, expect } from "@playwright/test";
+import { startEchoRuntime } from "./fixtures/echo-runtime";
+import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
+
+
+test.describe("MobileChat", () => {
+  let cleanup: () => Promise = async () => {};
+  let workspaceId = "";
+
+  test.beforeAll(async () => {
+    const echo = await startEchoRuntime();
+    const ws = await seedWorkspace(echo.baseURL);
+    workspaceId = ws.id;
+    const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
+
+    cleanup = async () => {
+      stopHeartbeat();
+      await echo.stop();
+    };
+  });
+
+  test.afterAll(async () => {
+    await cleanupWorkspace(workspaceId);
+    await cleanup();
+  });
+
+  test.beforeEach(async ({ page }) => {
+    await page.setViewportSize({ width: 375, height: 812 });
+    // Navigate directly to the mobile chat view.
+    await page.goto(`/?m=chat&a=${workspaceId}`);
+    await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
+    // Wait for the workspace status to flip to online and the textarea to be enabled.
+    await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
+    // Dismiss onboarding guide if present.
+    const skipGuide = page.getByText("Skip guide");
+    if (await skipGuide.isVisible().catch(() => false)) {
+      await skipGuide.click();
+    }
+  });
+
+  test("chat panel loads without error", async ({ page }) => {
+    const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
+    const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
+    expect(hasEmptyState || hasHistory).toBeTruthy();
+  });
+
+  test("send text message and receive echo response", async ({ page }) => {
+    const textarea = page.locator("textarea").first();
+    await textarea.fill("Mobile test message");
+    await page.getByRole("button", { name: /Send/ }).first().click();
+
+    await expect(page.getByText("Mobile test message")).toBeVisible({ timeout: 5_000 });
+    await expect(page.getByText("Echo: Mobile test message")).toBeVisible({ timeout: 15_000 });
+  });
+
+  test("history persists across reload", async ({ page }) => {
+    const textarea = page.locator("textarea").first();
+    await textarea.fill("Mobile persistence");
+    await page.getByRole("button", { name: /Send/ }).first().click();
+
+    await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 15_000 });
+
+    await page.reload();
+    await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
+
+    await expect(page.getByText("Mobile persistence", { exact: true })).toBeVisible({ timeout: 5_000 });
+    await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 5_000 });
+  });
+
+  test("composer auto-grows with multi-line text", async ({ page }) => {
+    const textarea = page.locator("textarea").first();
+    const initialHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
+
+    await textarea.fill("Line 1\nLine 2\nLine 3\nLine 4\nLine 5");
+    await page.waitForTimeout(300);
+
+    const grownHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
+    expect(grownHeight).toBeGreaterThan(initialHeight);
+  });
+
+  test("file attachment in mobile chat", async ({ page }) => {
+    const textarea = page.locator("textarea").first();
+    await textarea.fill("Mobile file test");
+
+    const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
+    await fileInput.setInputFiles({
+      name: "mobile.txt",
+      mimeType: "text/plain",
+      buffer: Buffer.from("mobile secret"),
+    });
+
+    await expect(page.getByText("mobile.txt")).toBeVisible({ timeout: 3_000 });
+
+    await page.getByRole("button", { name: /Send/ }).first().click();
+    await expect(page.getByText("Echo: Mobile file test")).toBeVisible({ timeout: 15_000 });
+  });
+});
diff --git a/canvas/e2e/fixtures/chat-seed.ts b/canvas/e2e/fixtures/chat-seed.ts
new file mode 100644
index 00000000..6b07a2aa
--- /dev/null
+++ b/canvas/e2e/fixtures/chat-seed.ts
@@ -0,0 +1,187 @@
+/**
+ * E2E seed fixture for chat tests.
+ *
+ * Creates an external workspace via the workspace-server API, extracts the
+ * auto-minted auth token, then overrides the DB row so it appears "online"
+ * with an echo-runtime URL.  External runtime is used because the health
+ * sweep skips Docker checks for external workspaces; we keep the workspace
+ * alive with periodic heartbeats.
+ */
+
+import { randomUUID } from "node:crypto";
+
+const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080";
+
+export interface SeededWorkspace {
+  id: string;
+  name: string;
+  agentURL: string;
+  authToken: string;
+}
+
+/**
+ * Create an external workspace and wire it to the echo runtime.
+ */
+export async function seedWorkspace(echoURL: string): Promise {
+  // 1. Create external workspace (no URL — platform will mint an auth token).
+  const runId = Math.random().toString(36).slice(2, 8);
+  const wsName = `Chat E2E Agent ${runId}`;
+  const createRes = await fetch(`${PLATFORM_URL}/workspaces`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ name: wsName, tier: 1, external: true, runtime: "external" }),
+  });
+  if (!createRes.ok) {
+    const text = await createRes.text();
+    throw new Error(`Failed to create workspace: ${createRes.status} ${text}`);
+  }
+  const ws = (await createRes.json()) as {
+    id: string;
+    name: string;
+    connection?: { auth_token?: string };
+  };
+  const authToken = ws.connection?.auth_token;
+  if (!authToken) {
+    throw new Error("Workspace created but no auth_token returned");
+  }
+
+  // 2. Direct DB update: mark online + point url at echo runtime.
+  //    The platform blocks loopback URLs at the API layer (SSRF guard),
+  //    so we bypass via psql for local E2E.
+  const dbUrl = process.env.E2E_DATABASE_URL;
+  if (!dbUrl) {
+    throw new Error("E2E_DATABASE_URL must be set for DB seeding");
+  }
+  const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
+  const m = dbUrl.match(pgRegex);
+  if (!m) {
+    throw new Error(`Cannot parse E2E_DATABASE_URL: ${dbUrl}`);
+  }
+  const [, user, pass, host, port, db] = m;
+
+  // Pre-seed a platform_inbound_secret so chat file uploads don't trigger
+  // the lazy-heal 503 "retry in 30 s" path on first use.
+  const inboundSecret = Array.from({ length: 43 }, () =>
+    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"[
+      Math.floor(Math.random() * 64)
+    ],
+  ).join("");
+
+  const psql = [
+    `PGPASSWORD=${pass} psql`,
+    `-h ${host} -p ${port} -U ${user} -d ${db}`,
+    `-c "UPDATE workspaces SET status = 'online', url = '${echoURL}', platform_inbound_secret = '${inboundSecret}' WHERE id = '${ws.id}'"`,
+  ].join(" ");
+
+  const { execSync } = await import("node:child_process");
+  try {
+    execSync(psql, { stdio: "pipe", timeout: 30_000 });
+  } catch (err) {
+    throw new Error(`DB update failed: ${err}`);
+  }
+
+  return { id: ws.id, name: wsName, agentURL: echoURL, authToken };
+}
+
+/**
+ * Start a heartbeat interval that keeps an external workspace alive.
+ * Returns a stop function.
+ */
+export function startHeartbeat(
+  workspaceId: string,
+  authToken: string,
+  intervalMs = 30_000,
+): () => void {
+  const send = () => {
+    fetch(`${PLATFORM_URL}/registry/heartbeat`, {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        Authorization: `Bearer ${authToken}`,
+      },
+      body: JSON.stringify({
+        workspace_id: workspaceId,
+        error_rate: 0,
+        sample_error: "",
+        active_tasks: 0,
+        current_task: "",
+        uptime_seconds: 0,
+      }),
+    }).catch(() => {});
+  };
+
+  // Send immediately so the first heartbeat lands before the stale sweep.
+  send();
+  const timer = setInterval(send, intervalMs);
+
+  return () => clearInterval(timer);
+}
+
+/**
+ * Seed chat-history rows for a workspace.
+ */
+export async function seedChatHistory(
+  workspaceId: string,
+  messages: Array<{ role: "user" | "agent"; content: string }>,
+): Promise {
+  const dbUrl = process.env.E2E_DATABASE_URL;
+  if (!dbUrl) return;
+
+  const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
+  const m = dbUrl.match(pgRegex);
+  if (!m) return;
+  const [, user, pass, host, port, db] = m;
+
+  const values = messages
+    .map(
+      (msg, i) =>
+        `('${randomUUID()}', '${workspaceId}', '${msg.role}', '${msg.content.replace(/'/g, "''")}', NOW() - INTERVAL '${messages.length - i} seconds')`,
+    )
+    .join(",");
+
+  const sql = `INSERT INTO chat_messages (id, workspace_id, role, content, created_at) VALUES ${values};`;
+
+  const { execSync } = await import("node:child_process");
+  const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "${sql}"`;
+  execSync(psql, { stdio: "pipe", timeout: 10_000 });
+}
+
+/**
+ * Delete a seeded workspace row directly from the DB.
+ * Uses psql (same credentials as seedWorkspace) so we bypass any
+ * workspace-server side-effects (container stop, cascade cleanup, etc.)
+ * that can race or 500 on external workspaces.
+ */
+export async function cleanupWorkspace(workspaceId: string): Promise {
+  const dbUrl = process.env.E2E_DATABASE_URL;
+  if (!dbUrl) return;
+
+  const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
+  const m = dbUrl.match(pgRegex);
+  if (!m) return;
+  const [, user, pass, host, port, db] = m;
+
+  const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "DELETE FROM workspaces WHERE id = '${workspaceId}'"`;
+
+  const { execSync } = await import("node:child_process");
+  try {
+    execSync(psql, { stdio: "pipe", timeout: 30_000 });
+  } catch {
+    // Best-effort cleanup; don't fail the test suite if the row is already gone.
+  }
+}
+
+/**
+ * Mint a workspace auth token so the canvas can make authenticated API
+ * calls (WorkspaceAuth middleware).
+ */
+export async function mintTestToken(workspaceId: string): Promise {
+  const res = await fetch(
+    `${PLATFORM_URL}/admin/workspaces/${workspaceId}/test-token`,
+  );
+  if (!res.ok) {
+    throw new Error(`Failed to mint test token: ${res.status}`);
+  }
+  const data = (await res.json()) as { auth_token: string };
+  return data.auth_token;
+}
diff --git a/canvas/e2e/fixtures/echo-runtime.ts b/canvas/e2e/fixtures/echo-runtime.ts
new file mode 100644
index 00000000..3a6aa07f
--- /dev/null
+++ b/canvas/e2e/fixtures/echo-runtime.ts
@@ -0,0 +1,180 @@
+/**
+ * Minimal A2A echo runtime for E2E tests.
+ *
+ * Listens on an ephemeral port, receives A2A JSON-RPC `message/send`
+ * requests, and returns a response with the original text echoed back.
+ * Also implements the workspace-side chat upload ingest endpoint so
+ * file-attachment E2E can exercise the full upload → send → echo
+ * round-trip.
+ *
+ * Usage (inside test fixture):
+ *   const echo = await startEchoRuntime();
+ *   // ... seed workspace with agent_url pointing to echo.baseURL ...
+ *   echo.stop();
+ */
+
+import { createServer, type Server } from "node:http";
+
+export interface EchoRuntime {
+  baseURL: string;
+  stop: () => Promise;
+  lastRequest: { method: string; text: string; files: unknown[] } | null;
+}
+
+/** Parse a minimal multipart body and extract the first file's name + content. */
+function parseMultipart(body: Buffer): { name: string; mimeType: string; content: Buffer } | null {
+  // Find the boundary line (first line starting with "--").
+  const str = body.toString("binary");
+  const firstDash = str.indexOf("--");
+  if (firstDash === -1) return null;
+  const eol = str.indexOf("\r\n", firstDash);
+  if (eol === -1) return null;
+  const boundary = str.slice(firstDash + 2, eol);
+  const boundaryMarker = "\r\n--" + boundary;
+
+  // Find the first part that has a filename in Content-Disposition.
+  let pos = eol + 2;
+  while (pos < str.length) {
+    const nextBoundary = str.indexOf(boundaryMarker, pos);
+    if (nextBoundary === -1) break;
+    const part = str.slice(pos, nextBoundary);
+
+    const cdMatch = part.match(/Content-Disposition:[^\r\n]*filename="([^"]+)"/i);
+    if (cdMatch) {
+      const name = cdMatch[1];
+      const ctMatch = part.match(/Content-Type:\s*([^\r\n]+)/i);
+      const mimeType = ctMatch ? ctMatch[1].trim() : "application/octet-stream";
+      // Body starts after the first double-CRLF in the part.
+      const bodyStart = part.indexOf("\r\n\r\n");
+      if (bodyStart !== -1) {
+        // Extract the raw bytes (not the string) so binary is safe.
+        const headerBytes = Buffer.byteLength(part.slice(0, bodyStart + 4), "binary");
+        const partStartInBody = Buffer.byteLength(str.slice(0, pos + bodyStart + 4), "binary");
+        const partEndInBody = Buffer.byteLength(str.slice(0, nextBoundary), "binary");
+        const content = body.subarray(partStartInBody, partEndInBody);
+        return { name, mimeType, content };
+      }
+    }
+    pos = nextBoundary + boundaryMarker.length;
+    // Skip trailing "--" (end marker) or CRLF.
+    if (str.slice(pos, pos + 2) === "--") break;
+    if (str.slice(pos, pos + 2) === "\r\n") pos += 2;
+  }
+  return null;
+}
+
+export async function startEchoRuntime(): Promise {
+  let lastRequest: EchoRuntime["lastRequest"] = null;
+
+  const server = createServer((req, res) => {
+    // CORS: allow the canvas origin (localhost:3000) to call us.
+    res.setHeader("Access-Control-Allow-Origin", "*");
+    res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
+    res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
+
+    if (req.method === "OPTIONS") {
+      res.writeHead(204);
+      res.end();
+      return;
+    }
+
+    const url = req.url ?? "/";
+
+    // Workspace-side chat upload ingest (RFC #2312).
+    if (url === "/internal/chat/uploads/ingest" && req.method === "POST") {
+      const chunks: Buffer[] = [];
+      req.on("data", (chunk: Buffer) => chunks.push(chunk));
+      req.on("end", () => {
+        const body = Buffer.concat(chunks);
+        const file = parseMultipart(body);
+        if (!file) {
+          res.writeHead(400);
+          res.end(JSON.stringify({ error: "no files field" }));
+          return;
+        }
+        const sanitized = file.name.replace(/[^a-zA-Z0-9._\-]/g, "_").replace(/ /g, "_");
+        const prefix = Array.from({ length: 32 }, () =>
+          Math.floor(Math.random() * 16).toString(16),
+        ).join("");
+        const response = {
+          files: [
+            {
+              uri: `workspace:/workspace/.molecule/chat-uploads/${prefix}-${sanitized}`,
+              name: sanitized,
+              mimeType: file.mimeType,
+              size: file.content.length,
+            },
+          ],
+        };
+        res.setHeader("Content-Type", "application/json");
+        res.writeHead(200);
+        res.end(JSON.stringify(response));
+      });
+      return;
+    }
+
+    // Default: A2A JSON-RPC handler.
+    let body = "";
+    req.setEncoding("utf8");
+    req.on("data", (chunk: string) => {
+      body += chunk;
+    });
+    req.on("end", () => {
+      res.setHeader("Content-Type", "application/json");
+      try {
+        const rpc = JSON.parse(body);
+        const msg = rpc.params?.message;
+        const textParts =
+          msg?.parts
+            ?.filter((p: { kind?: string; text?: string }) => p.kind === "text")
+            .map((p: { text?: string }) => p.text)
+            .filter(Boolean) ?? [];
+        const fileParts =
+          msg?.parts?.filter((p: { kind?: string }) => p.kind === "file") ?? [];
+        const text = textParts.join("\n");
+
+        lastRequest = {
+          method: rpc.method ?? "unknown",
+          text,
+          files: fileParts,
+        };
+
+        const replyText = text
+          ? `Echo: ${text}`
+          : fileParts.length > 0
+            ? "Echo: received your file(s)."
+            : "Echo: hello";
+
+        const response = {
+          jsonrpc: "2.0",
+          id: rpc.id ?? null,
+          result: {
+            parts: [{ kind: "text", text: replyText }],
+          },
+        };
+
+        res.writeHead(200);
+        res.end(JSON.stringify(response));
+      } catch {
+        res.writeHead(400);
+        res.end(JSON.stringify({ error: "invalid json" }));
+      }
+    });
+  });
+
+  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+  const address = server.address();
+  const port = typeof address === "object" && address ? address.port : 0;
+  const baseURL = `http://127.0.0.1:${port}`;
+
+  return {
+    baseURL,
+    stop: () =>
+      new Promise((resolve) => {
+        server.close(() => resolve(undefined));
+      }),
+    get lastRequest() {
+      return lastRequest;
+    },
+  };
+}
diff --git a/canvas/playwright.config.ts b/canvas/playwright.config.ts
index a171edae..2aa027e9 100644
--- a/canvas/playwright.config.ts
+++ b/canvas/playwright.config.ts
@@ -5,6 +5,7 @@ export default defineConfig({
   timeout: 30_000,
   expect: { timeout: 10_000 },
   fullyParallel: false,
+  workers: 1,
   retries: 0,
   use: {
     baseURL: "http://localhost:3000",
diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx
index 878eeec0..375bd37a 100644
--- a/canvas/src/components/mobile/MobileChat.tsx
+++ b/canvas/src/components/mobile/MobileChat.tsx
@@ -6,21 +6,21 @@
 // attachments, no A2A topology overlay, no conversation tracing.
 
 import { useEffect, useMemo, useRef, useState } from "react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
 
-import { api } from "@/lib/api";
 import { useCanvasStore } from "@/store/canvas";
+import { type ChatAttachment, type ChatMessage, createMessage } from "@/components/tabs/chat/types";
+import {
+  useChatHistory,
+  useChatSend,
+  useChatSocket,
+} from "@/components/tabs/chat/hooks";
 
 import { toMobileAgent } from "./components";
 import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
 import { Icons, StatusDot, TierChip } from "./primitives";
 
-interface ChatMessage {
-  id: string;
-  role: "user" | "agent" | "system";
-  text: string;
-  ts: string;
-}
-
 const formatStoredTimestamp = (iso: string): string => {
   const d = new Date(iso);
   if (isNaN(d.getTime())) return "";
@@ -29,30 +29,171 @@ const formatStoredTimestamp = (iso: string): string => {
 
 type SubTab = "my" | "a2a";
 
-interface A2AResponseShape {
-  result?: {
-    parts?: Array<{ kind?: string; text?: string }>;
-  };
-  error?: { message?: string };
-}
+function MarkdownBubble({
+  children,
+  dark,
+  accent,
+}: {
+  children: string;
+  dark: boolean;
+  accent: string;
+}) {
+  const codeBg = dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
+  const codeBlockBg = dark ? "#1a1a1a" : "#f5f5f0";
+  const linkColor = accent;
+  const quoteBorder = dark ? "rgba(255,250,240,0.15)" : "rgba(40,30,20,0.15)";
 
-// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse).
-interface ApiChatMessage {
-  id: string;
-  role: string; // "user" | "agent" | "system"
-  content: string;
-  timestamp: string;
-  attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }>;
+  return (
+     (
+          
{children}
+ ), + a: ({ href, children }) => ( + + {children} + + ), + pre: ({ children }) => ( +
+            {children}
+          
+ ), + code: ({ children, className }) => { + const isBlock = className != null && String(className).length > 0; + if (isBlock) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => {children}, + h1: ({ children }) => ( +
    {children}
    + ), + h2: ({ children }) => ( +
    {children}
    + ), + h3: ({ children }) => ( +
    {children}
    + ), + h4: ({ children }) => ( +
    {children}
    + ), + h5: ({ children }) => ( +
    {children}
    + ), + h6: ({ children }) => ( +
    {children}
    + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + hr: () => ( +
    + ), + table: ({ children }) => ( +
    + {children} +
    + ), + thead: ({ children }) => {children}, + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + }} + > + {children} + + ); } -interface ChatHistoryResponse { - messages: ApiChatMessage[]; - reached_end: boolean; -} - -const formatTime = (date: Date) => - date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); - export function MobileChat({ agentId, dark, @@ -63,36 +204,40 @@ export function MobileChat({ onBack: () => void; }) { const p = usePalette(dark); - // Selecting `nodes` stably avoids the `.find()` anti-pattern that - // creates a new return value on every store update (React error #185). const nodes = useCanvasStore((s) => s.nodes); const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]); - // Bootstrap from the canvas store's per-workspace message buffer so the - // user sees their prior thread on entry. The store is updated by the - // socket → ChatTab flows the desktop runs; on mobile we read from the - // same buffer to keep state coherent across viewports. - // NOTE: selector returns undefined (stable) — do NOT use ?? [] here, - // that creates a new [] reference on every store update when the key is - // absent, causing infinite re-render (React error #185). - const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]); - // Start empty — history is loaded via useEffect below. - const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [tab, setTab] = useState("my"); - const [sending, setSending] = useState(false); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); // history is loading on mount - const [historyError, setHistoryError] = useState(null); const scrollRef = useRef(null); - // Synchronous re-entry guard. `setSending(true)` schedules a state - // update but doesn't flush before a second tap can fire send() — a ref - // mirrors the desktop ChatTab pattern (sendInFlightRef) and closes the - // double-send race a stale `sending` lets through. - const sendInFlightRef = useRef(false); const composerRef = useRef(null); - // Guard: don't treat the initial store population as a live push. - // Set to false after the first render completes. - const initDoneRef = useRef(false); + const fileInputRef = useRef(null); + const [pendingFiles, setPendingFiles] = useState([]); + + const { + messages, + loading: historyLoading, + loadError: historyError, + loadInitial, + appendMessageDeduped, + } = useChatHistory(agentId); + + const { + sending, + uploading, + sendMessage, + error: sendError, + clearError, + releaseSendGuards, + } = useChatSend(agentId, { + getHistoryMessages: () => messages, + onUserMessage: appendMessageDeduped, + onAgentMessage: appendMessageDeduped, + }); + + useChatSocket(agentId, { + onAgentMessage: appendMessageDeduped, + onSendComplete: releaseSendGuards, + }); // Auto-grow the textarea: reset height to 'auto' so the scrollHeight // shrinks when the user deletes text, then size to scrollHeight up to @@ -105,81 +250,26 @@ export function MobileChat({ el.style.height = `${next}px`; }, [draft]); - // Fetch chat history on mount; keep merging live agentMessages while the - // panel is open. InitDoneRef prevents the initial store snapshot from - // triggering the live-merge path (the store buffer is populated by - // ChatTab on desktop, not on mobile — this effect loads history as the - // mobile-native path). - useEffect(() => { - let cancelled = false; - - const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({ - id: m.id, - role: m.role === "user" ? "user" : "agent", - text: m.content, - ts: formatStoredTimestamp(m.timestamp), - }); - - const syncLive = () => { - const live = useCanvasStore.getState().agentMessages[agentId] ?? []; - if (live.length > 0) { - setMessages((prev) => { - const existingIds = new Set(prev.map((m) => m.id)); - const newOnes = live - .filter((m) => !existingIds.has(m.id)) - .map((m) => ({ - id: m.id, - role: "agent" as const, - text: m.content, - ts: formatStoredTimestamp(m.timestamp), - })); - return newOnes.length > 0 ? [...prev, ...newOnes] : prev; - }); - } - }; - - const bootstrap = async (): Promise<(() => void) | undefined> => { - setLoading(true); - setHistoryError(null); - try { - const res = await api.get( - `/workspaces/${agentId}/chat-history?limit=50`, - ); - if (cancelled) return; - const initial = (res.messages ?? []).map(mapApiMessage); - setMessages(initial); - // Mark init done BEFORE marking loading=false so any store push - // that arrives in the same tick is treated as live, not init. - initDoneRef.current = true; - setLoading(false); - // Subscribe to live pushes after init is complete. - syncLive(); - const unsubscribe = useCanvasStore.subscribe(syncLive); - return unsubscribe; // returned for cleanup - } catch (e) { - if (cancelled) return; - setHistoryError(e instanceof Error ? e.message : "Failed to load chat history"); - setLoading(false); - initDoneRef.current = true; - return undefined; - } - }; - - let maybeUnsubscribe: (() => void) | undefined; - bootstrap().then((fn) => { maybeUnsubscribe = fn; }); - - return () => { - cancelled = true; - if (maybeUnsubscribe) maybeUnsubscribe(); - }; - }, [agentId]); - useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [messages]); + // Consume any agent messages that arrived while history was loading. + const initialConsumeDoneRef = useRef(false); + useEffect(() => { + if (historyLoading || initialConsumeDoneRef.current) return; + initialConsumeDoneRef.current = true; + const consume = useCanvasStore.getState().consumeAgentMessages; + const msgs = consume(agentId); + for (const m of msgs) { + appendMessageDeduped( + createMessage("agent", m.content, m.attachments), + ); + } + }, [historyLoading, agentId, appendMessageDeduped]); + if (!node) { return (
    { + if (!fileList) return; + const picked = Array.from(fileList); + setPendingFiles((prev) => { + const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`)); + return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))]; + }); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const removePendingFile = (index: number) => + setPendingFiles((prev) => prev.filter((_, i) => i !== index)); + const send = async () => { const text = draft.trim(); - if (!text || sending || !reachable) return; - if (sendInFlightRef.current) return; - sendInFlightRef.current = true; + if ((!text && pendingFiles.length === 0) || sending || !reachable) return; + clearError(); setDraft(""); - setError(null); - setSending(true); - const myMsg: ChatMessage = { - id: crypto.randomUUID(), - role: "user", - text, - ts: formatTime(new Date()), - }; - setMessages((m) => [...m, myMsg]); - - try { - const res = await api.post(`/workspaces/${agentId}/a2a`, { - method: "message/send", - params: { - message: { - role: "user", - messageId: crypto.randomUUID(), - parts: [{ kind: "text", text }], - }, - }, - }); - const reply = - res.result?.parts?.find((part) => part.kind === "text")?.text ?? ""; - if (reply) { - setMessages((m) => [ - ...m, - { - id: crypto.randomUUID(), - role: "agent", - text: reply, - ts: formatTime(new Date()), - }, - ]); - } else if (res.error?.message) { - setError(res.error.message); - } - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to send"); - } finally { - setSending(false); - sendInFlightRef.current = false; - } + const files = pendingFiles; + setPendingFiles([]); + await sendMessage(text, files); }; return (
    )} - {tab === "my" && loading && ( + {tab === "my" && historyLoading && (
    -
    -
    Loading chat history…
    + Loading chat history…
    )} - {tab === "my" && !loading && historyError && ( + {tab === "my" && !historyLoading && historyError && messages.length === 0 && (
    { - setLoading(true); - setHistoryError(null); - api.get(`/workspaces/${agentId}/chat-history?limit=50`).then( - (res: unknown) => { - const r = res as ChatHistoryResponse; - setMessages((r.messages ?? []).map((m) => ({ - id: m.id, - role: m.role === "user" ? "user" : "agent", - text: m.content, - ts: formatStoredTimestamp(m.timestamp), - }))); - setLoading(false); - initDoneRef.current = true; - }, - ).catch((e: unknown) => { - setHistoryError(e instanceof Error ? e.message : "Failed to load"); - setLoading(false); - initDoneRef.current = true; - }); + loadInitial(); }} style={{ padding: "6px 14px", @@ -447,7 +492,7 @@ export function MobileChat({
    )} - {tab === "my" && !loading && !historyError && messages.length === 0 && ( + {tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
    Send a message to start chatting.
    @@ -476,7 +521,9 @@ export function MobileChat({ overflowWrap: "anywhere", }} > - {m.text} + + {m.content} +
    - {m.ts} + {formatStoredTimestamp(m.timestamp)}
    ); })} - {error && ( + {sendError && (
    - {error} + {sendError}
    )} @@ -534,6 +581,60 @@ export function MobileChat({ backdropFilter: "blur(14px)", }} > + {pendingFiles.length > 0 && ( +
    + {pendingFiles.map((f, i) => ( +
    + + {f.name} + + +
    + ))} +
    + )}
    + onFilesPicked(e.target.files)} + aria-hidden="true" + />
    diff --git a/canvas/src/components/mobile/MobileDetail.tsx b/canvas/src/components/mobile/MobileDetail.tsx index 0de1bd2c..96d1bd62 100644 --- a/canvas/src/components/mobile/MobileDetail.tsx +++ b/canvas/src/components/mobile/MobileDetail.tsx @@ -214,6 +214,7 @@ export function MobileDetail({ )} - {!loading && loadError === null && messages.length === 0 && ( + {!history.loading && history.loadError === null && history.messages.length === 0 && (
    No messages yet. Send a message to start chatting with this agent.
    @@ -1027,12 +428,12 @@ function MyChatPanel({ workspaceId, data }: Props) { instead of showing a "no more messages" footer — the user's scroll resting against the top of the conversation IS the signal. */} - {hasMore && messages.length > 0 && ( + {history.hasMore && history.messages.length > 0 && (
    - {loadingOlder ? "Loading older messages…" : " "} + {history.loadingOlder ? "Loading older messages…" : " "}
    )} - {messages.map((msg) => ( + {history.messages.map((msg) => (
    {/* Error banner */} - {error && ( + {displayError && (
    - {error} + {displayError} {!isOnline && (