Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ca4a6a5
docs(design): add UX gap roadmap — 13 competitive gap design docs
Oct 26, 2026
1df5410
feat(web,daemon): startup diagnostics panel + voice dictation input
Oct 26, 2026
a818801
feat(daemon,web): notification hub, preferences, and in-app delivery
Oct 26, 2026
51761ef
feat(daemon,web): interactive HTML artifacts (Gen UI preview)
Oct 26, 2026
3d333be
feat(daemon): non-loopback bind guard and bearer-token enforcement
Oct 26, 2026
78ae07e
feat(daemon): IM webhook destinations with egress pinning and signing
Oct 26, 2026
c862ddc
feat(daemon,contracts): skill marketplace index contract and inspect …
Oct 26, 2026
d9869a3
docs(design): record roadmap implementation status on this branch
Oct 26, 2026
7630deb
test(web): update pending-section specs for first-class startup diagn…
Oct 26, 2026
3c2b23f
fix(daemon): IM test route 404 + marketplace fetch fallback; add real…
Oct 27, 2026
3cb2cd8
fix: close review blockers for the UX gap roadmap
gansx Oct 27, 2026
25ff4a9
test(daemon): assert stored IM destinations structurally instead of U…
gansx Oct 27, 2026
94f0e7e
Merge branch 'main' into feat/ux-gap-roadmap
gansx Oct 27, 2026
ea49ed8
fix(previews): sandbox interactive HTML from the network with an offl…
gansx Oct 27, 2026
320f050
Merge branch 'feat/ux-gap-roadmap' of https://github.com/ling-stack-o…
Oct 27, 2026
addaebf
fix(contracts): scan HTML tags linearly in the CSP injector
gansx Oct 27, 2026
5587c41
fix(contracts): never skip CSP injection on document content
gansx Oct 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/web/e2e/diagnostics.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";
import { resolve } from "node:path";
import { mockDaemon } from "./support/mock-daemon";
import { createBaseState } from "./support/mock-state";

const evidence = resolve("../../output/playwright/diagnostics");

test("startup diagnostics panel shows daemon doctor checks and log tail", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await mockDaemon(page, createBaseState({ approvalStatus: "expired" }));
await page.goto("/");
await page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }).getByRole("button", { name: /^(Settings|设置)$/ }).click();
const sections = page.getByLabel(/^(Settings sections|设置分组)$/);
await expect(sections).toBeVisible();
await sections.getByRole("button", { name: /Startup and diagnostics|启动与诊断/u }).click();

const card = page.locator(".diagnostics-doctor-card");
await expect(card).toBeVisible();
await expect(card).toHaveAttribute("data-ui2-diagnostics-doctor", "true");
await expect(card.locator(".diagnostics-doctor-check")).toHaveCount(4);
await expect(card.locator('.diagnostics-doctor-check[data-status="ok"]')).toHaveCount(4);
await expect(card.locator(".diagnostics-doctor-heading")).toContainText(/All checks passed|全部检查通过/u);

const logSection = card.locator(".diagnostics-doctor-log-section");
await logSection.locator("summary").click();
await expect(logSection.locator(".diagnostics-doctor-log-lines")).toContainText("daemon listening on 127.0.0.1:17891");

await expect(page.locator("section[data-ui2-settings-startup-diagnostics]")).toBeVisible();
await mkdirIfMissing();
await page.screenshot({ path: `${evidence}/diagnostics-healthy.png`, animations: "disabled", fullPage: true });
expect(errors).toEqual([]);
});

async function mkdirIfMissing(): Promise<void> {
const { mkdir } = await import("node:fs/promises");
await mkdir(evidence, { recursive: true });
}
118 changes: 118 additions & 0 deletions apps/web/e2e/gen-ui-interactive-artifact.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { expect, test, type Page } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
import { mockDaemon } from "./support/mock-daemon";
import { createBaseState, type MockState } from "./support/mock-state";
import { openEngineeringChannel } from "./support/test-helpers";

const evidence = resolve("../../output/playwright/gen-ui");
const interactiveAttachmentName = "run-summary-dashboard.html";

function interactiveAttachmentState(): MockState {
const state = createBaseState({ approvalStatus: "expired" });
state.runs = [];
state.tasks = [];
state.approvals = [];
state.channelMessages = [{
id: "message_gen_ui_interactive",
workspaceId: "workspace_default",
channelId: "channel_engineering",
kind: "message",
intent: "discuss",
authorType: "local-user",
authorId: "tian",
body: "Here is the interactive run dashboard.",
mentions: [],
attachmentIds: ["attachment_gen_ui_interactive"],
linkPreviewIds: [],
fileRefs: [],
status: "ready",
createdAt: "2026-10-26T08:00:00.000Z",
updatedAt: "2026-10-26T08:00:00.000Z",
}];
state.channelAttachments = [{
id: "attachment_gen_ui_interactive",
workspaceId: "workspace_default",
channelId: "channel_engineering",
messageId: "message_gen_ui_interactive",
kind: "text",
fileName: interactiveAttachmentName,
contentType: "text/html; charset=utf-8",
sizeBytes: 2048,
path: `.ling-stack/attachments/${interactiveAttachmentName}`,
materializationStatus: "available",
previewStatus: "available",
createdAt: "2026-10-26T08:00:00.000Z",
}];
return state;
}

async function routeInteractivePreview(page: Page): Promise<void> {
await page.route(/\/attachments\/attachment_gen_ui_interactive\/preview(?:\?.*)?$/u, async (route) => {
await route.fulfill({
json: {
ref: {
id: "attachment_gen_ui_interactive",
source: "attachment",
scope: "channel",
label: interactiveAttachmentName,
displayPath: `.ling-stack/attachments/${interactiveAttachmentName}`,
contentType: "text/html; charset=utf-8",
sizeBytes: 2048,
previewable: true,
},
kind: "html_interactive",
htmlInteractive: {
sandbox: ["allow-scripts"],
html: [
"<!doctype html><html><head><style>body{font-family:sans-serif;padding:16px}button{padding:8px 16px}</style></head>",
"<body><h3>Run dashboard</h3><button id='counter'>Clicked 0</button>",
"<script>let n=0;document.getElementById('counter').onclick=function(){n++;this.textContent='Clicked '+n;};</script>",
"</body></html>",
].join(""),
},
truncated: false,
sizeBytes: 2048,
redactionNotes: ["Interactive preview runs in a sandboxed frame and is not included in AI context."],
},
});
});
}

test("interactive HTML artifact renders in a sandboxed frame inside the file preview", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.addInitScript(() => {
localStorage.setItem("ling-stack.locale", "en-US");
});
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await mockDaemon(page, interactiveAttachmentState());
await routeInteractivePreview(page);
await page.goto("/");
await openEngineeringChannel(page);

const feed = page.getByLabel("Channel message feed");
await feed.getByRole("button", { name: `Preview: ${interactiveAttachmentName}` }).click();

const preview = page.getByLabel("File preview");
const frame = preview.locator("iframe[data-ui2-artifact-interactive]");
await expect(frame).toBeVisible();
const sandbox = await frame.getAttribute("sandbox");
expect(sandbox).toBe("allow-scripts");
expect(sandbox?.includes("allow-same-origin")).toBe(false);

// Interactivity: click inside the sandboxed document and observe its state.
const frameLocator = page.frameLocator("iframe[data-ui2-artifact-interactive]");
await frameLocator.locator("#counter").click();
await expect(frameLocator.locator("#counter")).toHaveText("Clicked 1");
await frameLocator.locator("#counter").click();
await expect(frameLocator.locator("#counter")).toHaveText("Clicked 2");

await mkdir(evidence, { recursive: true });
await page.screenshot({ path: `${evidence}/interactive-preview.png`, animations: "disabled" });
// Sandboxed frames get an opaque origin, so any storage access inside the
// artifact is denied — that rejection surfaces as a benign page error and
// is exactly the sandbox working as designed.
const unexpected = errors.filter((error) => !error.includes("lacks the 'allow-same-origin' flag"));
expect(unexpected).toEqual([]);
});
37 changes: 37 additions & 0 deletions apps/web/e2e/notifications.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
import { mockDaemon } from "./support/mock-daemon";
import { createBaseState } from "./support/mock-state";

const evidence = resolve("../../output/playwright/notifications");

test("daemon notification journal surfaces an in-app toast and preferences card", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.addInitScript(() => {
localStorage.setItem("ling-stack.locale", "zh-CN");
});
await mockDaemon(page, createBaseState({ approvalStatus: "expired" }));
await page.goto("/");

// The bridge polls on mount; the mocked journal has one failed-run record.
// The toast shows the daemon-provided record text (English fixture data),
// even when the UI chrome is zh-CN.
const toast = page.locator(".ling-ui-toast", { hasText: "Automation run failed" });
await expect(toast).toBeVisible({ timeout: 15_000 });

// Preferences card lives in the startup diagnostics settings section.
await page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }).getByRole("button", { name: /^(Settings|设置)$/ }).click();
await page.getByLabel(/^(Settings sections|设置分组)$/).getByRole("button", { name: /Startup and diagnostics|启动与诊断/u }).click();

const card = page.locator(".notifications-preferences-card");
await expect(card).toBeVisible();
await expect(card).toHaveAttribute("data-ui2-notifications-preferences", "true");
await expect(card.getByRole("switch", { name: "启用通知" })).toBeVisible();
await expect(card.getByRole("combobox").first()).toBeVisible();

await mkdir(evidence, { recursive: true });
await page.screenshot({ path: `${evidence}/notifications-preferences.png`, animations: "disabled", fullPage: true });
expect(errors).toEqual([]);
});
6 changes: 3 additions & 3 deletions apps/web/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,10 @@ test("settings hides pending startup and diagnostics", async ({ page }) => {

await page.getByRole("complementary", { name: /Sidebar display|侧栏显示/u }).getByRole("button", { name: "Settings" }).click();
const settingsSections = page.getByLabel("Settings sections");
// Startup and diagnostics is a first-class section now (ux-gap-roadmap-13);
// only the retired Startup readiness placeholder stays absent.
await expect(settingsSections.getByRole("button", { name: /Startup readiness/ })).toHaveCount(0);
await expect(settingsSections.getByRole("button", { name: /Startup and diagnostics/ })).toHaveCount(0);
await expect(settingsSections.locator('[data-settings-section="startup-diagnostics"]')).toBeHidden();
await expect(page.locator("[data-ui2-settings-startup-diagnostics]")).toHaveCount(0);
await expect(settingsSections.getByRole("button", { name: /Startup and diagnostics/ })).toBeVisible();
});

test("settings archived items use one remembered category list with scoped search", async ({ page }) => {
Expand Down
82 changes: 82 additions & 0 deletions apps/web/e2e/support/mock-daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7954,6 +7954,88 @@ export async function handleDaemonRoute(
});
return;
}
if (request.method() === "GET" && path === "/notifications/preferences") {
await route.fulfill({
json: {
schemaVersion: 1,
preferences: {
schemaVersion: 1,
globalEnabled: true,
events: {
automation_run_finished: "failures",
automation_run_failed: "all",
approval_required: "all",
},
},
},
});
return;
}
if (request.method() === "PATCH" && path === "/notifications/preferences") {
await route.fulfill({
json: {
schemaVersion: 1,
preferences: {
schemaVersion: 1,
globalEnabled: true,
events: {
automation_run_finished: "failures",
automation_run_failed: "all",
approval_required: "all",
},
},
},
});
return;
}
if (request.method() === "GET" && (path === "/notifications" || path === "/notifications/stream")) {
await route.fulfill({
json: {
schemaVersion: 1,
notifications: [
{
id: "notification-1",
kind: "automation_run_failed",
title: "Automation run failed",
body: "Daily brief could not start.",
createdAt: "2026-10-26T00:00:00.000Z",
automationId: "automation_daily_brief",
cwd,
},
],
nextSince: "notification-1",
},
});
return;
}
if (request.method() === "GET" && path === "/diagnostics/doctor") {
await route.fulfill({
json: {
doctorVersion: 1,
cwd,
overallStatus: "ok",
checks: [
{ id: "workspace_store", category: "workspace", status: "ok", summary: "Workspace run store opened", details: `cwd: ${cwd}` },
{ id: "startup_phases", category: "startup", status: "ok", summary: "Startup state: ready" },
{ id: "log_file", category: "logs", status: "ok", summary: "Daemon log file readable" },
{ id: "feature_flags", category: "config", status: "ok", summary: "Feature configuration loaded" },
],
generatedAt: "2026-10-26T00:00:00.000Z",
},
});
return;
}
if (request.method() === "GET" && path === "/diagnostics/logs") {
await route.fulfill({
json: {
logVersion: 1,
logPath: "/tmp/ling/daemon.log",
truncated: false,
lines: ["2026-10-26T00:00:00.000Z info daemon listening on 127.0.0.1:17891"],
},
});
return;
}
if (request.method() === "GET" && path === "/provider-templates") {
await route.fulfill({ json: { templates: options.templates ?? [] } });
return;
Expand Down
8 changes: 5 additions & 3 deletions apps/web/e2e/support/pending-settings-sections.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { expect, type Locator, type Page } from "@playwright/test";

export async function openPendingStartupDiagnostics(page: Page): Promise<Locator> {
// Startup & diagnostics graduated from the pending placeholder to a
// first-class section (ux-gap-roadmap-13): the entry is visible and
// clickable like every other section now.
const navigation = page.getByLabel(/Settings sections|设置分组/);
const pendingEntry = navigation.locator('[data-settings-section="startup-diagnostics"]');
await expect(pendingEntry).toBeHidden();
await pendingEntry.evaluate((element) => (element as HTMLElement).click());
const entry = navigation.locator('[data-settings-section="startup-diagnostics"]');
await entry.click();
const panel = page.locator("[data-ui2-settings-startup-diagnostics]");
await expect(panel).toBeVisible();
return panel;
Expand Down
4 changes: 2 additions & 2 deletions apps/web/e2e/ui2-settings-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ test("Settings shell owns navigation, embedded tools, and exact return at 1440x9
}
await expect(settingsNavigation.locator('[aria-current="page"]')).toHaveCount(1);
await expect(settingsNavigation.getByRole("button", { name: "User profile", exact: true })).toHaveAttribute("aria-current", "page");
await expect(settingsNavigation.getByRole("button", { name: "Startup and diagnostics", exact: true })).toHaveCount(0);
await expect(settingsNavigation.locator('[data-settings-section="startup-diagnostics"]')).toBeHidden();
// Startup and diagnostics is a first-class section now (ux-gap-roadmap-13).
await expect(settingsNavigation.getByRole("button", { name: "Startup and diagnostics", exact: true })).toBeVisible();
await expect(settingsNavigation.getByRole("group", { name: "Device" })).toHaveCount(0);
await expect(settingsNavigation.getByRole("button", { name: "Back to app" })).toHaveClass(/ling-ui-button/u);
await expectSettingsScrollOwnership(settingsNavigation, content);
Expand Down
Loading
Loading