diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 6c98159e..1d9ab7ff 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -145,10 +145,10 @@ jobs:
# the diagnostic step with its own continue-on-error: true (line 203).
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
continue-on-error: false
- # Job-level ceiling. The go test step below runs with a per-step 10m timeout;
- # this cap catches any step that leaks past that. Set well above 10m so
+ # Job-level ceiling. The go test step below runs with a per-step 30m timeout;
+ # this cap catches any step that leaks past that. Set well above 30m so
# the per-step timeout is the active constraint.
- timeout-minutes: 15
+ timeout-minutes: 35
defaults:
run:
working-directory: workspace-server
@@ -176,12 +176,14 @@ jobs:
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: always()
- name: Diagnostic — per-package verbose 60s
+ name: Diagnostic — per-package verbose (300s timeout)
run: |
set +e
- go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
+ # 300s allows handlers + pendinguploads packages to complete on cold
+ # runners with -race instrumentation (~60-120s each vs ~14s non-race).
+ go test -race -v -timeout 300s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
handlers_exit=$?
- go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
+ go test -race -v -timeout 300s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
pu_exit=$?
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
tail -100 /tmp/test-handlers.log
@@ -194,10 +196,10 @@ jobs:
- if: always()
name: Run tests with race detection and coverage
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
- # full ./... suite with race detection + coverage. A 10m per-step timeout
- # lets the suite complete on cold cache (~5-7m) while failing cleanly
- # instead of OOM-killing. The job-level timeout (15m) is a backstop.
- run: go test -race -timeout 10m -coverprofile=coverage.out ./...
+ # full ./... suite with race detection + coverage. A 30m per-step timeout
+ # lets the suite complete on cold cache (~13-25m) while failing cleanly
+ # instead of OOM-killing. The job-level timeout (35m) is a backstop.
+ run: go test -race -timeout 30m -coverprofile=coverage.out ./...
- if: always()
name: Per-file coverage report
diff --git a/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts b/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts
new file mode 100644
index 00000000..43a47fbe
--- /dev/null
+++ b/canvas/src/components/__tests__/AuditTrailPanel.format.test.ts
@@ -0,0 +1,55 @@
+// @vitest-environment jsdom
+/**
+ * Tests for formatAuditRelativeTime exported from AuditTrailPanel.
+ */
+import { describe, it, expect } from "vitest";
+import { formatAuditRelativeTime } from "../AuditTrailPanel";
+
+describe("formatAuditRelativeTime", () => {
+ const now = new Date("2026-05-18T12:00:00Z").getTime();
+
+ it('returns "just now" for timestamps less than 60s ago', () => {
+ const ts = new Date(now - 30_000).toISOString(); // 30s ago
+ expect(formatAuditRelativeTime(ts, now)).toBe("just now");
+ });
+
+ it("returns minutes for timestamps under 1h", () => {
+ const ts = new Date(now - 5 * 60_000).toISOString(); // 5m ago
+ expect(formatAuditRelativeTime(ts, now)).toBe("5m ago");
+ });
+
+ it("returns hours for timestamps under 24h", () => {
+ const ts = new Date(now - 3 * 3_600_000).toISOString(); // 3h ago
+ expect(formatAuditRelativeTime(ts, now)).toBe("3h ago");
+ });
+
+ it("returns locale date for timestamps older than 24h", () => {
+ const ts = new Date(now - 2 * 86_400_000).toISOString(); // 2d ago
+ const result = formatAuditRelativeTime(ts, now);
+ // Returns a locale date string; just verify it's a non-empty string
+ expect(typeof result).toBe("string");
+ expect(result.length).toBeGreaterThan(0);
+ expect(result).not.toBe("just now");
+ expect(result).not.toMatch(/m ago$/);
+ expect(result).not.toMatch(/h ago$/);
+ });
+
+ it("handles exactly 60s boundary as minutes", () => {
+ const ts = new Date(now - 60_000).toISOString(); // exactly 1m ago
+ expect(formatAuditRelativeTime(ts, now)).toBe("1m ago");
+ });
+
+ it("handles exactly 3600s boundary as hours", () => {
+ const ts = new Date(now - 3_600_000).toISOString(); // exactly 1h ago
+ expect(formatAuditRelativeTime(ts, now)).toBe("1h ago");
+ });
+
+ it("handles exactly 86400s boundary", () => {
+ const ts = new Date(now - 86_400_000).toISOString(); // exactly 24h ago
+ const result = formatAuditRelativeTime(ts, now);
+ // Exactly 24h should fall into the "days" branch
+ expect(typeof result).toBe("string");
+ expect(result).not.toMatch(/m ago$/);
+ expect(result).not.toMatch(/h ago$/);
+ });
+});
diff --git a/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts b/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts
new file mode 100644
index 00000000..c2b2d935
--- /dev/null
+++ b/canvas/src/components/__tests__/MemoryInspectorPanel.helpers.test.ts
@@ -0,0 +1,82 @@
+// @vitest-environment jsdom
+/**
+ * Tests for exported helpers from MemoryInspectorPanel:
+ * isPluginUnavailableError, formatTTL.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel";
+
+describe("isPluginUnavailableError", () => {
+ it("returns true when error message contains MEMORY_PLUGIN_URL", () => {
+ const err = new Error("MEMORY_PLUGIN_URL is not configured");
+ expect(isPluginUnavailableError(err)).toBe(true);
+ });
+
+ it("returns false when error message does not contain MEMORY_PLUGIN_URL", () => {
+ const err = new Error("Connection refused");
+ expect(isPluginUnavailableError(err)).toBe(false);
+ });
+
+ it("returns false for non-Error values", () => {
+ expect(isPluginUnavailableError("string error")).toBe(false);
+ expect(isPluginUnavailableError(null)).toBe(false);
+ expect(isPluginUnavailableError(undefined)).toBe(false);
+ expect(isPluginUnavailableError({})).toBe(false);
+ });
+
+ it("handles Error with empty message", () => {
+ expect(isPluginUnavailableError(new Error(""))).toBe(false);
+ });
+});
+
+describe("formatTTL", () => {
+ // Freeze time at 2026-05-18T12:00:00Z for deterministic tests.
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-05-18T12:00:00Z"));
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns empty string for null", () => {
+ expect(formatTTL(null)).toBe("");
+ });
+
+ it("returns empty string for undefined", () => {
+ expect(formatTTL(undefined)).toBe("");
+ });
+
+ it("returns empty string for empty string", () => {
+ expect(formatTTL("")).toBe("");
+ });
+
+ it("returns 'expired' for past timestamps", () => {
+ const past = new Date(Date.now() - 60_000).toISOString();
+ expect(formatTTL(past)).toBe("expired");
+ });
+
+ it("returns seconds for sub-minute future TTLs", () => {
+ const future = new Date(Date.now() + 30_000).toISOString();
+ expect(formatTTL(future)).toBe("30s");
+ });
+
+ it("returns minutes for sub-hour future TTLs", () => {
+ const future = new Date(Date.now() + 5 * 60_000).toISOString();
+ expect(formatTTL(future)).toBe("5m");
+ });
+
+ it("returns hours for sub-day future TTLs", () => {
+ const future = new Date(Date.now() + 3 * 3_600_000).toISOString();
+ expect(formatTTL(future)).toBe("3h");
+ });
+
+ it("returns days for TTLs longer than 24h", () => {
+ const future = new Date(Date.now() + 2 * 86_400_000).toISOString();
+ expect(formatTTL(future)).toBe("2d");
+ });
+
+ it("returns empty string for invalid date string", () => {
+ expect(formatTTL("not-a-date")).toBe("");
+ });
+});
diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx
index b5940a0e..39b80268 100644
--- a/canvas/src/components/mobile/MobileChat.tsx
+++ b/canvas/src/components/mobile/MobileChat.tsx
@@ -242,6 +242,8 @@ export function MobileChat({
useChatSocket(agentId, {
onAgentMessage: appendMessageDeduped,
+ // Fan-out user's own outbound message to all sessions (issue #228).
+ onUserMessage: appendMessageDeduped,
onSendComplete: releaseSendGuards,
});
@@ -748,7 +750,14 @@ export function MobileChat({
border: "none",
outline: "none",
background: "transparent",
- fontSize: 14.5,
+ // 16px floor: iOS Safari/WebKit auto-zooms the viewport on
+ // focus when a focused field's font-size is < 16px. Anything
+ // below this re-introduces the tap-to-zoom layout jump on the
+ // mobile PWA. Do NOT lower this without also adding a
+ // maximum-scale/user-scalable viewport lock — and that lock
+ // breaks pinch-to-zoom accessibility, so 16px here is the
+ // correct trade.
+ fontSize: 16,
lineHeight: 1.4,
color: p.text,
padding: "6px 0",
diff --git a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx
index 0c8e2459..2ef96e86 100644
--- a/canvas/src/components/mobile/__tests__/MobileChat.test.tsx
+++ b/canvas/src/components/mobile/__tests__/MobileChat.test.tsx
@@ -263,6 +263,20 @@ describe("MobileChat — composer", () => {
const sendBtn = container.querySelector('[aria-label="Send"]') as HTMLButtonElement;
expect(sendBtn.disabled).toBe(true);
});
+
+ // iOS Safari/WebKit auto-zooms the viewport on focus when a focused
+ // /