diff --git a/apps/web/e2e/diagnostics.spec.ts b/apps/web/e2e/diagnostics.spec.ts new file mode 100644 index 000000000..f2c8010b5 --- /dev/null +++ b/apps/web/e2e/diagnostics.spec.ts @@ -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 { + const { mkdir } = await import("node:fs/promises"); + await mkdir(evidence, { recursive: true }); +} diff --git a/apps/web/e2e/gen-ui-interactive-artifact.spec.ts b/apps/web/e2e/gen-ui-interactive-artifact.spec.ts new file mode 100644 index 000000000..4da15b275 --- /dev/null +++ b/apps/web/e2e/gen-ui-interactive-artifact.spec.ts @@ -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 { + 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: [ + "", + "

Run dashboard

", + "", + "", + ].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([]); +}); diff --git a/apps/web/e2e/notifications.spec.ts b/apps/web/e2e/notifications.spec.ts new file mode 100644 index 000000000..adda687a9 --- /dev/null +++ b/apps/web/e2e/notifications.spec.ts @@ -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([]); +}); diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts index 1c1f923f5..20c6159b1 100644 --- a/apps/web/e2e/settings.spec.ts +++ b/apps/web/e2e/settings.spec.ts @@ -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 }) => { diff --git a/apps/web/e2e/support/mock-daemon.ts b/apps/web/e2e/support/mock-daemon.ts index 759744671..2077224f3 100644 --- a/apps/web/e2e/support/mock-daemon.ts +++ b/apps/web/e2e/support/mock-daemon.ts @@ -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; diff --git a/apps/web/e2e/support/pending-settings-sections.ts b/apps/web/e2e/support/pending-settings-sections.ts index 26e2d2af4..699d6874b 100644 --- a/apps/web/e2e/support/pending-settings-sections.ts +++ b/apps/web/e2e/support/pending-settings-sections.ts @@ -1,10 +1,12 @@ import { expect, type Locator, type Page } from "@playwright/test"; export async function openPendingStartupDiagnostics(page: Page): Promise { + // 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; diff --git a/apps/web/e2e/ui2-settings-shell.spec.ts b/apps/web/e2e/ui2-settings-shell.spec.ts index 2f39ca9c6..62bc3cb75 100644 --- a/apps/web/e2e/ui2-settings-shell.spec.ts +++ b/apps/web/e2e/ui2-settings-shell.spec.ts @@ -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); diff --git a/apps/web/e2e/voice-input.spec.ts b/apps/web/e2e/voice-input.spec.ts new file mode 100644 index 000000000..62d07211b --- /dev/null +++ b/apps/web/e2e/voice-input.spec.ts @@ -0,0 +1,108 @@ +import { expect, test, type Page } 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/voice-input"); + +async function installFakeRecognition(page: Page): Promise { + await page.addInitScript(() => { + class FakeSpeechRecognition { + onresult: ((event: unknown) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onend: (() => void) | null = null; + lang = ""; + continuous = false; + interimResults = false; + started = false; + constructor() { + const existing = (window as unknown as { __fakeRecognitions?: FakeSpeechRecognition[] }).__fakeRecognitions ?? []; + existing.push(this); + (window as unknown as { __fakeRecognitions?: FakeSpeechRecognition[] }).__fakeRecognitions = existing; + } + start(): void { this.started = true; } + stop(): void { this.onend?.(); } + abort(): void { this.onend?.(); } + emit(transcript: string, isFinal: boolean): void { + this.onresult?.({ + resultIndex: 0, + results: { length: 1, 0: { isFinal, 0: { transcript } } }, + }); + } + fail(code: string): void { this.onerror?.({ error: code }); } + } + Object.defineProperty(window, "SpeechRecognition", { configurable: true, value: FakeSpeechRecognition }); + }); +} + +function fakeRecognitions(page: Page) { + return page.evaluate(() => { + const instances = (window as unknown as { __fakeRecognitions?: Array<{ started: boolean; emit(transcript: string, isFinal: boolean): void; fail(code: string): void; onend: (() => void) | null }> }).__fakeRecognitions ?? []; + return instances; + }); +} + +test("voice dictation appends transcript to the draft and never auto-sends", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await installFakeRecognition(page); + await page.addInitScript(() => { + localStorage.setItem("ling-stack.locale", "en-US"); + }); + const state = createBaseState({ approvalStatus: "expired" }); + state.runs = []; + state.tasks = []; + state.approvals = []; + await mockDaemon(page, state); + await page.goto("/"); + + const composer = page.getByLabel("New conversation composer", { exact: true }); + await expect(composer).toBeVisible(); + const mic = page.getByRole("button", { name: "Dictate message", exact: true }); + await expect(mic).toBeVisible(); + await mic.click(); + + const body = page.locator(".normal-conversation-composer-body"); + await expect(body).toHaveAttribute("data-dictating", "true"); + await expect(page.locator(".composer-dictating-badge")).toContainText("Dictating"); + + const recognitions = await fakeRecognitions(page); + expect(recognitions.length).toBe(1); + await page.evaluate(() => { + const instances = (window as unknown as { __fakeRecognitions?: Array<{ emit(transcript: string, isFinal: boolean): void }> }).__fakeRecognitions ?? []; + instances[0].emit("hello from voice", true); + }); + + const input = page.getByRole("textbox", { name: "Conversation message", exact: true }); + await expect(input).toHaveAttribute("data-value", "hello from voice"); + + // Dictation must not send by itself. + await expect(page.locator(".normal-conversation-feed .message-row, .conversation-home-feed .message-row")).toHaveCount(0); + + await page.screenshot({ path: `${evidence}/dictating.png`, animations: "disabled" }); + + await page.getByRole("button", { name: "Stop dictation", exact: true }).click(); + await expect(body).not.toHaveAttribute("data-dictating"); + expect(errors).toEqual([]); +}); + +test("denied microphone access surfaces the recovery message", async ({ page }) => { + await installFakeRecognition(page); + await page.addInitScript(() => { + localStorage.setItem("ling-stack.locale", "en-US"); + }); + const state = createBaseState({ approvalStatus: "expired" }); + state.runs = []; + state.tasks = []; + state.approvals = []; + await mockDaemon(page, state); + await page.goto("/"); + + const mic = page.getByRole("button", { name: "Dictate message", exact: true }); + await mic.click(); + await page.evaluate(() => { + const instances = (window as unknown as { __fakeRecognitions?: Array<{ fail(code: string): void }> }).__fakeRecognitions ?? []; + instances[instances.length - 1].fail("not-allowed"); + }); + await expect(page.locator(".normal-conversation-composer-feedback")).toContainText("Microphone access was denied"); +}); diff --git a/apps/web/src/app/AppShell.tsx b/apps/web/src/app/AppShell.tsx index 6270426f3..8ea7fe83a 100644 --- a/apps/web/src/app/AppShell.tsx +++ b/apps/web/src/app/AppShell.tsx @@ -181,6 +181,7 @@ import { useWorkspaceSettingsController } from "./use-workspace-settings-control import { useDesktopNavigationIntents } from "./use-desktop-navigation-intents.js"; import { useAppNarrowViewport } from "./use-app-viewport-state.js"; import { useShellErrorFeedback } from "./use-shell-error-feedback.js"; +import { useNotificationBridge } from "../domains/notifications/use-notification-bridge.js"; import { buildSidebarObjectContextSeeds } from "./sidebar-object-context.js"; import { useSidebarObjectContexts } from "./use-sidebar-object-contexts.js"; import { @@ -712,6 +713,7 @@ export function AppShell(): React.JSX.Element { }), }); const computerControl = useComputerControlController({ plugins: pluginsDomain }); + useNotificationBridge({ cwd: form.cwd.trim() }); const { plugins, pluginsLoading, diff --git a/apps/web/src/domains/notifications/use-notification-bridge.ts b/apps/web/src/domains/notifications/use-notification-bridge.ts new file mode 100644 index 000000000..89d110a81 --- /dev/null +++ b/apps/web/src/domains/notifications/use-notification-bridge.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { NotificationRecord } from "@ling-stack/contracts"; +import { daemonClient } from "../../api/client.js"; +import { useLingToast, type LingToastInput } from "../../shared/ui/index.js"; + +const POLL_INTERVAL_MS = 10_000; + +export interface NotificationBridgeState { + readonly connected: boolean; + readonly lastError: string; +} + +/** + * Polls the daemon notification journal and raises toasts for delivered + * records. The `since` cursor lives for the page session only: on reload the + * last 200 journal records exist server-side but a fresh page starts from the + * current head so users are not replayed old notifications. + * + * OS-level desktop delivery is owned by the Electron main process; this hook + * is the in-app (web) delivery path only. + */ +export function useNotificationBridge(input: { readonly cwd: string }): NotificationBridgeState { + const { cwd } = input; + const { showToast } = useLingToast(); + const [state, setState] = useState({ connected: true, lastError: "" }); + const sinceRef = useRef(""); + const busyRef = useRef(false); + + const poll = useCallback(async () => { + if (busyRef.current) return; + busyRef.current = true; + try { + const response = await daemonClient.listNotifications(sinceRef.current || undefined); + for (const record of response.notifications) { + showRecord(record, showToast, cwd); + } + sinceRef.current = response.nextSince || sinceRef.current; + setState({ connected: true, lastError: "" }); + } catch (error) { + setState({ connected: false, lastError: error instanceof Error ? error.message : String(error) }); + } finally { + busyRef.current = false; + } + }, [cwd, showToast]); + + useEffect(() => { + void poll(); + const timer = window.setInterval(() => void poll(), POLL_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [poll]); + + return state; +} + +function showRecord( + record: NotificationRecord, + showToast: (toast: LingToastInput) => string, + cwd: string, +): void { + if (record.cwd && record.cwd !== cwd) return; + showToast({ + message: `${record.title}: ${record.body}`, + scope: `notification:${record.id}`, + tone: record.kind === "automation_run_failed" ? "error" : "info", + }); +} diff --git a/apps/web/src/features/settings/DiagnosticsDoctorCard.tsx b/apps/web/src/features/settings/DiagnosticsDoctorCard.tsx new file mode 100644 index 000000000..9e4d940bf --- /dev/null +++ b/apps/web/src/features/settings/DiagnosticsDoctorCard.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useState } from "react"; +import { CheckCircle2, CircleAlert, CircleDashed, ClipboardCopy, RotateCw, XCircle } from "lucide-react"; +import type { + DaemonHealthResponse, + DiagnosticsDoctorResponse, + DiagnosticsLogTailResponse, +} from "@ling-stack/contracts"; +import { daemonClient } from "../../api/client.js"; +import { useI18n } from "../../i18n.js"; +import { Button, InlineFeedback, InspectorSection, StatusIndicator } from "../../shared/ui/index.js"; +import { + buildDiagnosticsBundle, + projectDiagnosticsDoctor, + projectLogTailLines, + type DiagnosticsDoctorViewModel, +} from "./diagnostics-doctor-view-model.js"; + +interface DiagnosticsDoctorCardProps { + readonly cwd: string; +} + +interface DiagnosticsState { + readonly health?: DaemonHealthResponse; + readonly doctor?: DiagnosticsDoctorResponse; + readonly logs?: DiagnosticsLogTailResponse; + readonly loading: boolean; + readonly errorMessage: string; +} + +export function DiagnosticsDoctorCard({ cwd }: DiagnosticsDoctorCardProps): React.JSX.Element { + const { t } = useI18n(); + const [state, setState] = useState({ loading: true, errorMessage: "" }); + const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); + + const refresh = useCallback(async () => { + setState((previous) => ({ ...previous, loading: true, errorMessage: "" })); + try { + const [health, doctor, logs] = await Promise.all([ + daemonClient.getHealth(), + daemonClient.getDiagnosticsDoctor(cwd), + daemonClient.getDiagnosticsLogs(200), + ]); + setState({ health, doctor, logs, loading: false, errorMessage: "" }); + } catch (error) { + setState((previous) => ({ + ...previous, + loading: false, + errorMessage: error instanceof Error ? error.message : String(error), + })); + } + }, [cwd]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const doctorView: DiagnosticsDoctorViewModel = projectDiagnosticsDoctor( + state.doctor, + state.loading, + state.errorMessage, + ); + + const copyBundle = useCallback(async () => { + try { + await navigator.clipboard.writeText(buildDiagnosticsBundle({ + health: state.health, + doctor: state.doctor, + logs: state.logs, + })); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + }, [state.doctor, state.health, state.logs]); + + useEffect(() => { + if (copyState === "idle") return; + const timer = window.setTimeout(() => setCopyState("idle"), 2000); + return () => window.clearTimeout(timer); + }, [copyState]); + + return ( +
+
+ {t("Daemon diagnostics")} +
+ + + +
+
+ + {doctorView.errorMessage ? ( + + {doctorView.errorMessage} + + ) : null} + +
    + {doctorView.checks.map((check) => ( +
  • + +
    + {check.summary} + {check.details ? {check.details} : null} + {check.remediation ? {check.remediation} : null} +
    +
  • + ))} +
+ + +
+          {projectLogTailLines(state.logs).length > 0
+            ? projectLogTailLines(state.logs).join("\n")
+            : t("No daemon log available")}
+        
+ {state.logs?.truncated ? {t("Older log lines were truncated")} : null} +
+
+ ); +} + +function overallStatusLabel(status: DiagnosticsDoctorViewModel["overallStatus"], t: (key: string) => string): string { + switch (status) { + case "ok": return t("All checks passed"); + case "warning": return t("Warnings detected"); + case "fail": return t("Failures detected"); + case "loading": return t("Checking daemon health..."); + case "error": return t("Daemon diagnostics unavailable"); + } +} + +function overallStatusTone(status: DiagnosticsDoctorViewModel["overallStatus"]): "success" | "warning" | "danger" | "neutral" { + switch (status) { + case "ok": return "success"; + case "warning": return "warning"; + case "fail": + case "error": return "danger"; + case "loading": return "neutral"; + } +} diff --git a/apps/web/src/features/settings/NotificationsPreferencesCard.tsx b/apps/web/src/features/settings/NotificationsPreferencesCard.tsx new file mode 100644 index 000000000..9a7fcbfab --- /dev/null +++ b/apps/web/src/features/settings/NotificationsPreferencesCard.tsx @@ -0,0 +1,160 @@ +import { useCallback, useEffect, useState } from "react"; +import { BellRing, Save } from "lucide-react"; +import type { NotificationPreferences } from "@ling-stack/contracts"; +import { daemonClient } from "../../api/client.js"; +import { useI18n } from "../../i18n.js"; +import { Button, Field, InlineFeedback, Input, InspectorSection, Select, Switch } from "../../shared/ui/index.js"; + +type EventKind = "automation_run_finished" | "automation_run_failed" | "approval_required"; + +const EVENT_KINDS: readonly EventKind[] = ["automation_run_finished", "automation_run_failed", "approval_required"]; + +interface NotificationsPreferencesCardProps { + readonly cwd: string; +} + +export function NotificationsPreferencesCard({ cwd }: NotificationsPreferencesCardProps): React.JSX.Element { + const { t } = useI18n(); + const [preferences, setPreferences] = useState(undefined); + const [error, setError] = useState(""); + const [saved, setSaved] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + daemonClient.getNotificationPreferences() + .then((response) => { + if (!cancelled) setPreferences(response.preferences); + }) + .catch((cause: unknown) => { + if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause)); + }); + return () => { + cancelled = true; + }; + }, []); + + const save = useCallback(async () => { + if (!preferences) return; + setSaving(true); + setError(""); + try { + const response = await daemonClient.updateNotificationPreferences({ + globalEnabled: preferences.globalEnabled, + events: preferences.events, + quietHours: preferences.quietHours ?? null, + }); + setPreferences(response.preferences); + setSaved(true); + window.setTimeout(() => setSaved(false), 2000); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSaving(false); + } + }, [preferences]); + + return ( +
+
+ {t("Notifications")} + +
+ + {error ? ( + + {error} + + ) : null} + + {preferences ? ( +
+ + + {EVENT_KINDS.map((kind) => ( + + + + ))} + + +
+ + setPreferences({ + ...preferences, + quietHours: event.currentTarget.value || preferences.quietHours + ? { start: event.currentTarget.value, end: preferences.quietHours?.end ?? "" } + : undefined, + })} + /> + + + setPreferences({ + ...preferences, + quietHours: event.currentTarget.value || preferences.quietHours + ? { start: preferences.quietHours?.start ?? "", end: event.currentTarget.value } + : undefined, + })} + /> + +
+
+
+ ) : null} +
+ ); +} + +function notificationKindLabel(kind: EventKind, t: (key: string) => string): string { + switch (kind) { + case "automation_run_finished": return t("Automation run finished"); + case "automation_run_failed": return t("Automation run failed"); + case "approval_required": return t("Approval required"); + } +} diff --git a/apps/web/src/features/settings/SettingsPage.tsx b/apps/web/src/features/settings/SettingsPage.tsx index df0b7441a..903e8a7d3 100644 --- a/apps/web/src/features/settings/SettingsPage.tsx +++ b/apps/web/src/features/settings/SettingsPage.tsx @@ -26,6 +26,8 @@ import { ArchivedSettingsPanel } from "./ArchivedSettingsPanel.js"; import { ExecutionPermissionsSettingsPanel } from "./ExecutionPermissionsSettingsPanel.js"; import { ExternalWorkSettingsPanel } from "./ExternalWorkSettingsPanel.js"; import { StartupDiagnosticsSettingsPanel } from "./MaintenanceSettingsPanel.js"; +import { DiagnosticsDoctorCard } from "./DiagnosticsDoctorCard.js"; +import { NotificationsPreferencesCard } from "./NotificationsPreferencesCard.js"; import { HumanQaSettingsPanel } from "./human-qa/HumanQaSettingsPanel.js"; import { InterfacePreferencesSettingsPanel, PersonalSettingsPanel } from "./PersonalInterfaceSettingsPanel.js"; import { PersonalizationSettingsPanel } from "./PersonalizationSettingsPanel.js"; @@ -405,6 +407,8 @@ export function SettingsPage({ onRefreshStartup={onRefreshStartup} onOpenRunDiagnostics={onOpenRunDiagnostics} /> + + ) : null} diff --git a/apps/web/src/features/settings/diagnostics-doctor-view-model.ts b/apps/web/src/features/settings/diagnostics-doctor-view-model.ts new file mode 100644 index 000000000..88aca1b2e --- /dev/null +++ b/apps/web/src/features/settings/diagnostics-doctor-view-model.ts @@ -0,0 +1,70 @@ +import type { + DaemonHealthResponse, + DiagnosticsCheck, + DiagnosticsDoctorResponse, + DiagnosticsLogTailResponse, +} from "@ling-stack/contracts"; + +export interface DiagnosticsCheckView { + readonly id: string; + readonly status: DiagnosticsCheck["status"]; + readonly summary: string; + readonly details?: string; + readonly remediation?: string; +} + +export interface DiagnosticsDoctorViewModel { + readonly overallStatus: "ok" | "warning" | "fail" | "loading" | "error"; + readonly checks: readonly DiagnosticsCheckView[]; + readonly generatedAt?: string; + readonly errorMessage?: string; +} + +export function projectDiagnosticsDoctor( + response: DiagnosticsDoctorResponse | undefined, + loading: boolean, + errorMessage: string, +): DiagnosticsDoctorViewModel { + if (errorMessage.length > 0 && !response) { + return { overallStatus: "error", checks: [], errorMessage }; + } + if (!response) { + return { overallStatus: loading ? "loading" : "error", checks: [], errorMessage: errorMessage || undefined }; + } + return { + overallStatus: response.overallStatus, + checks: response.checks.map((check) => ({ + id: check.id, + status: check.status, + summary: check.summary, + details: check.details, + remediation: check.remediation, + })), + generatedAt: response.generatedAt, + }; +} + +export const DIAGNOSTICS_BUNDLE_VERSION = 1; + +export function buildDiagnosticsBundle(input: { + readonly health?: DaemonHealthResponse; + readonly doctor?: DiagnosticsDoctorResponse; + readonly logs?: DiagnosticsLogTailResponse; +}): string { + return JSON.stringify( + { + bundleVersion: DIAGNOSTICS_BUNDLE_VERSION, + generatedAt: new Date().toISOString(), + health: input.health ?? null, + doctor: input.doctor ?? null, + logs: input.logs ?? null, + }, + null, + 2, + ); +} + +export function projectLogTailLines(response: DiagnosticsLogTailResponse | undefined): readonly string[] { + if (!response) return []; + return response.lines; +} diff --git a/apps/web/src/features/settings/settings-shell-model.ts b/apps/web/src/features/settings/settings-shell-model.ts index da6424f3e..b0a425e1d 100644 --- a/apps/web/src/features/settings/settings-shell-model.ts +++ b/apps/web/src/features/settings/settings-shell-model.ts @@ -93,7 +93,7 @@ export const settingsSectionGroups: readonly SettingsSectionGroupDefinition[] = heading: "Maintenance", items: [ { id: "archived-items", label: "Archived items", icon: Archive }, - { id: "startup-diagnostics", label: "Startup and diagnostics", pending: true, icon: Database }, + { id: "startup-diagnostics", label: "Startup and diagnostics", icon: Database }, ], }, ]; diff --git a/apps/web/src/features/workspace/NormalConversationComposer.tsx b/apps/web/src/features/workspace/NormalConversationComposer.tsx index d3f866e54..8c186c50c 100644 --- a/apps/web/src/features/workspace/NormalConversationComposer.tsx +++ b/apps/web/src/features/workspace/NormalConversationComposer.tsx @@ -1,5 +1,5 @@ -import { type DragEvent, type KeyboardEvent, type Ref } from "react"; -import { ArrowUp, CircleAlert, FileText, Plus, RotateCcw, SlidersHorizontal, Square } from "lucide-react"; +import { type DragEvent, type KeyboardEvent, type Ref, useRef } from "react"; +import { ArrowUp, CircleAlert, FileText, Mic, MicOff, Plus, RotateCcw, SlidersHorizontal, Square } from "lucide-react"; import type { ComposerSlashCommandOption } from "@ling-stack/core/composer-slash-intents"; import { useI18n } from "../../i18n.js"; import { @@ -15,6 +15,8 @@ import { StatusIndicator, } from "../../shared/ui/index.js"; import { ComposerPendingAssetStrip, type ActiveSlashCommandPicker, type PendingAttachmentItem } from "../../shared/components/index.js"; +import { useVoiceDictation } from "../../shared/conversation/use-voice-dictation.js"; +import { appendDictation, voiceErrorMessage } from "../../shared/conversation/voice-dictation-machine.js"; import type { AuthorizationMode, ConversationAttachment, PermissionPreset, ProviderThinkingMode } from "../../types.js"; import { normalConversationModelLabel } from "./normal-conversation-composer-options.js"; import { ConversationModelControl } from "./ConversationModelControl.js"; @@ -110,12 +112,23 @@ export function NormalConversationComposer({ slashPicker, stopping, }: NormalConversationComposerProps): React.JSX.Element { - const { t } = useI18n(); + const { t, locale } = useI18n(); const activeProviderPresetId = selectedProviderPresetId || defaultProviderPresetId; const activeModelOption = modelOptions.find((option) => option.id === activeProviderPresetId); const modelReady = Boolean(activeModelOption?.runnable ?? activeModelOption?.enabled); const activeModelLabel = activeModelOption ? normalConversationModelLabel(activeModelOption) : defaultProviderLabel; + const draftRef = useRef(draft); + draftRef.current = draft; + const voice = useVoiceDictation({ + locale, + onTranscript: (text) => { + const next = appendDictation(draftRef.current, text); + onDraftChange(next, next.length); + }, + }); + const dictating = voice.status === "recording"; + const configurationLocked = sendBusy || activeRun || !scopeReady; const attachmentsLocked = sendBusy || !scopeReady; const stopAvailable = activeRun && !canSend && Boolean(onStop); @@ -153,7 +166,13 @@ export function NormalConversationComposer({ onDragLeave={onDragLeave} onDrop={onDrop} inputNode={( -
+
+ {dictating ? ( + + + {t("Dictating")}{voice.interim ? ` · ${voice.interim}` : "…"} + + ) : null} + {voice.supported ? ( + + ) : null} )} - feedbackNode={notice ? ( + feedbackNode={(notice || voice.error) ? ( - {notice} + {voice.error ? voiceErrorMessage(voice.error, t) : notice} ) : null} /> diff --git a/apps/web/src/i18n/locales/en-US.ts b/apps/web/src/i18n/locales/en-US.ts index c5f9b1d9d..728441c86 100644 --- a/apps/web/src/i18n/locales/en-US.ts +++ b/apps/web/src/i18n/locales/en-US.ts @@ -4569,4 +4569,42 @@ export const enUS = { "Choose how much provider-safe reasoning summary appears in conversations. Raw reasoning is never shown.": "Choose how much provider-safe reasoning summary appears in conversations. Raw reasoning is never shown.", "Reasoning summary preference applied": "Reasoning summary preference applied", "Reasoning summary preference was not applied.": "Reasoning summary preference was not applied.", - } as const satisfies Readonly>; + + "Daemon diagnostics": "Daemon diagnostics", + "Refresh diagnostics": "Refresh diagnostics", + "Refreshing diagnostics...": "Refreshing diagnostics...", + "Copy diagnostics bundle": "Copy diagnostics bundle", + "Daemon diagnostics unavailable": "Daemon diagnostics unavailable", + "Daemon log tail": "Daemon log tail", + "Last daemon log lines": "Last daemon log lines", + "No daemon log available": "No daemon log available", + "Older log lines were truncated": "Older log lines were truncated", + "All checks passed": "All checks passed", + "Warnings detected": "Warnings detected", + "Failures detected": "Failures detected", + "Checking daemon health...": "Checking daemon health...", + "Dictate message": "Dictate message", + "Stop dictation": "Stop dictation", + "Dictating": "Dictating", + "Microphone access was denied. Enable it in your browser settings to dictate.": "Microphone access was denied. Enable it in your browser settings to dictate.", + "No speech detected. Try again.": "No speech detected. Try again.", + "Dictation stopped.": "Dictation stopped.", + "Dictation service is unreachable. Check your connection.": "Dictation service is unreachable. Check your connection.", + "Voice input is not supported in this browser.": "Voice input is not supported in this browser.", + "Save notification preferences": "Save notification preferences", + "Saving notification preferences...": "Saving notification preferences...", + "Notification preferences unavailable": "Notification preferences unavailable", + "Enable notifications": "Enable notifications", + "Show desktop and in-app notifications for automation runs.": "Show desktop and in-app notifications for automation runs.", + "All runs": "All runs", + "Failures only": "Failures only", + "Quiet hours": "Quiet hours", + "Quiet hours start": "Quiet hours start", + "Quiet hours end": "Quiet hours end", + "Suppress notifications during a daily time window": "Suppress notifications during a daily time window", + "Start": "Start", + "End": "End", + "Automation run finished": "Automation run finished", + "Automation run failed": "Automation run failed", + "Approval required": "Approval required", +} as const satisfies Readonly>; diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 6f089bdc6..6477d18b8 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -6443,4 +6443,40 @@ export const zhCN = { "Choose how much provider-safe reasoning summary appears in conversations. Raw reasoning is never shown.": "选择会话中展示多少由模型提供的安全推理摘要;原始推理过程永不展示。", "Reasoning summary preference applied": "推理摘要偏好已应用", "Reasoning summary preference was not applied.": "推理摘要偏好未能应用。", + "Daemon diagnostics": "守护进程诊断", + "Refresh diagnostics": "刷新诊断", + "Refreshing diagnostics...": "正在刷新诊断…", + "Copy diagnostics bundle": "复制诊断包", + "Daemon diagnostics unavailable": "守护进程诊断不可用", + "Daemon log tail": "守护进程日志尾部", + "Last daemon log lines": "守护进程最近的日志行", + "No daemon log available": "暂无守护进程日志", + "Older log lines were truncated": "更早的日志行已截断", + "All checks passed": "全部检查通过", + "Warnings detected": "检测到警告", + "Failures detected": "检测到失败项", + "Checking daemon health...": "正在检查守护进程健康状态…", + "Dictate message": "语音输入", + "Stop dictation": "停止语音输入", + "Dictating": "正在听写", + "Microphone access was denied. Enable it in your browser settings to dictate.": "麦克风权限被拒绝。请在浏览器设置中开启后使用语音输入。", + "No speech detected. Try again.": "未检测到语音,请重试。", + "Dictation stopped.": "听写已停止。", + "Dictation service is unreachable. Check your connection.": "听写服务不可达,请检查网络连接。", + "Voice input is not supported in this browser.": "当前浏览器不支持语音输入。", + "Save notification preferences": "保存通知偏好", + "Saving notification preferences...": "正在保存通知偏好…", + "Notification preferences unavailable": "通知偏好不可用", + "Enable notifications": "启用通知", + "Show desktop and in-app notifications for automation runs.": "为自动化运行显示桌面与应用内通知。", + "All runs": "全部运行", + "Failures only": "仅失败时", + "Quiet hours": "免打扰时段", + "Quiet hours start": "免打扰开始", + "Quiet hours end": "免打扰结束", + "Suppress notifications during a daily time window": "在每日时段内抑制通知", + "Start": "开始", + "End": "结束", + "Automation run finished": "自动化运行完成", + "Automation run failed": "自动化运行失败", } as const satisfies Readonly>; diff --git a/apps/web/src/shared/conversation/preview/ArtifactPreview.tsx b/apps/web/src/shared/conversation/preview/ArtifactPreview.tsx index 2ba213217..5a28ac5ac 100644 --- a/apps/web/src/shared/conversation/preview/ArtifactPreview.tsx +++ b/apps/web/src/shared/conversation/preview/ArtifactPreview.tsx @@ -16,6 +16,18 @@ export function ArtifactPreview({ preview }: ArtifactPreviewProps): React.JSX.El return ; } + if (preview.kind === "html_interactive" && preview.htmlInteractive) { + return ( +