diff --git a/apps/web/e2e/pages.mjs b/apps/web/e2e/pages.mjs new file mode 100644 index 0000000..95a3ab7 --- /dev/null +++ b/apps/web/e2e/pages.mjs @@ -0,0 +1,144 @@ +/** + * One tree, both kinds of page — and an assistant that remembers what you keep asking for. + * + * The model this checks is the one the app is built on and the interface kept contradicting: a + * recording *is* a note. It has audio and a transcript attached, and everything else about it — how + * it is filed, searched, titled, opened — is what a typed note does. So the sidebar lists them + * together, in the folders the user made, the way pages sit in Notion. + * + * And the second half: what somebody asks an agent to do is worth remembering. Ask twice and the + * words come back as a button, so the fourth report costs a click rather than a paragraph. That + * list is `vault/agents/HABITS.md` and deleting a line forgets it — asserted here, because a + * memory the user cannot delete is the kind nobody wants. + */ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { chromium } from "playwright"; + +import { boot } from "./daemon.mjs"; + +const problems = []; +const engine = await boot({ name: "pages" }); +const browser = await chromium.launch(); +const context = await browser.newContext({ + locale: "vi-VN", + viewport: { width: 1280, height: 950 }, +}); +const page = await context.newPage(); +page.on("pageerror", (e) => problems.push(`pageerror: ${e.message}`)); + +await page.goto(`${engine.url}?port=${engine.port}&token=${engine.token}#/`, { + waitUntil: "networkidle", +}); + +// ---- the tree holds recordings and notes alike ---------------------------- +{ + const tree = page.getByLabel("Thư mục"); + await tree.waitFor({ timeout: 10000 }); + + // The seeded vault has a meeting filed under a folder and a note. Expanding the folder must show + // the meeting as a page, not merely narrow a list somewhere else on screen. + const folder = tree.getByRole("button", { name: "khach-hang" }); + if ((await folder.count()) === 0) { + problems.push("the seeded folder is not in the tree"); + } else { + await tree.getByRole("button", { name: /Mở rộng khach-hang|Thu gọn khach-hang/ }).click(); + await page.waitForTimeout(400); + const listed = await tree.innerText(); + if (!/Demo khách hàng|Ý tưởng giá|Họp/.test(listed)) { + problems.push(`no pages appeared under the folder: ${JSON.stringify(listed)}`); + } + } +} + +// ---- a page in the tree opens --------------------------------------------- +{ + const tree = page.getByLabel("Thư mục"); + const anyPage = tree + .locator("button") + .filter({ hasText: /Họp|Ý tưởng|Demo/ }) + .first(); + if ((await anyPage.count()) === 0) { + problems.push("no page to open"); + } else { + await anyPage.click(); + await page.waitForTimeout(1200); + const url = page.url(); + if (!/#\/(meetings\/|notes\?)/.test(url)) { + problems.push(`clicking a page went nowhere useful: ${url}`); + } + } +} + +// ---- a new page, from the tree -------------------------------------------- +{ + await page.getByRole("button", { name: "Trang mới", exact: true }).click(); + await page.waitForTimeout(1500); + if (!/#\/notes\?open=/.test(page.url())) { + problems.push(`"new page" did not open the page it made: ${page.url()}`); + } +} + +// ---- what you keep asking for becomes a button ---------------------------- +{ + // Written straight into the vault rather than by asking twice through the interface: an agent run + // needs a language model, and this is a test about the memory, not about the model. + // The roster is seeded on first use, so on a vault nobody has asked anything of yet the + // directory is not there — which is exactly the state this is testing from. + mkdirSync(join(engine.home, "vault", "agents"), { recursive: true }); + const habits = join(engine.home, "vault", "agents", "HABITS.md"); + writeFileSync( + habits, + "# Thói quen\n\n- 2026-08-01 — viết báo cáo sau họp\n- 2026-08-08 — viết báo cáo sau họp\n" + + "- 2026-08-09 — chỉ nhờ một lần\n", + ); + + const response = await fetch(`${engine.url}/agent/habits?token=${engine.token}`); + const learned = await response.json(); + if (learned.length !== 1) { + problems.push(`expected one habit, got ${JSON.stringify(learned)}`); + } else if (learned[0].times !== 2) { + problems.push(`the habit was not counted: ${JSON.stringify(learned[0])}`); + } + + // And it reaches the meeting screen, where the asking happens. + const meeting = await (await fetch(`${engine.url}/library?token=${engine.token}`)).json(); + const first = meeting.groups.flatMap((g) => g.meetings).find((m) => m.kind === "meeting"); + await page.goto(`${engine.url}?port=${engine.port}&token=${engine.token}#/meetings/${first.id}`, { + waitUntil: "networkidle", + }); + const offered = page.getByTestId("ask-habits"); + await offered + .waitFor({ timeout: 10000 }) + .catch(() => problems.push("the habit was never offered on the meeting")); + if ((await offered.count()) > 0) { + const text = await offered.innerText(); + if (!/viết báo cáo sau họp/.test(text)) problems.push(`wrong habit offered: ${text}`); + if (/chỉ nhờ một lần/.test(text)) problems.push("asked once is not a habit, and was offered"); + } +} + +// ---- deleting a line forgets it ------------------------------------------- +{ + const habits = join(engine.home, "vault", "agents", "HABITS.md"); + const kept = readFileSync(habits, "utf8") + .split("\n") + .filter((line) => !line.includes("2026-08-08")) + .join("\n"); + writeFileSync(habits, kept); + + const learned = await (await fetch(`${engine.url}/agent/habits?token=${engine.token}`)).json(); + if (learned.length !== 0) { + problems.push(`deleting the line did not forget it: ${JSON.stringify(learned)}`); + } +} + +await browser.close(); +await engine.stop(); + +if (problems.length > 0) { + console.error(problems.map((p) => ` - ${p}`).join("\n")); + process.exit(1); +} +console.log("pages ok"); diff --git a/apps/web/package.json b/apps/web/package.json index 942dc53..1084614 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ "preview": "vite preview", "test": "vitest run", "tauri": "tauri", - "e2e": "node e2e/shell.mjs && node e2e/library.mjs && node e2e/meeting.mjs && node e2e/tasks.mjs && node e2e/draft.mjs && node e2e/nudges.mjs && node e2e/chat.mjs && node e2e/languages.mjs && node e2e/overlap.mjs && node e2e/models.mjs && node e2e/search.mjs && node e2e/assistant.mjs && node e2e/language.mjs && node e2e/permissions.mjs && node e2e/calendar.mjs && node e2e/notes.mjs && node e2e/density.mjs", + "e2e": "node e2e/shell.mjs && node e2e/library.mjs && node e2e/meeting.mjs && node e2e/tasks.mjs && node e2e/draft.mjs && node e2e/nudges.mjs && node e2e/chat.mjs && node e2e/languages.mjs && node e2e/overlap.mjs && node e2e/models.mjs && node e2e/search.mjs && node e2e/assistant.mjs && node e2e/language.mjs && node e2e/permissions.mjs && node e2e/calendar.mjs && node e2e/notes.mjs && node e2e/pages.mjs && node e2e/density.mjs", "lint": "eslint . --max-warnings 0", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/apps/web/src/components/meeting/Ask.tsx b/apps/web/src/components/meeting/Ask.tsx new file mode 100644 index 0000000..921a032 --- /dev/null +++ b/apps/web/src/components/meeting/Ask.tsx @@ -0,0 +1,120 @@ +import { Sparkles } from "lucide-react"; +import { useCallback, useState } from "react"; + +import { useI18n } from "../../i18n/context"; +import { useEngine } from "../../lib/engine-context"; +import { useErrorText } from "../../lib/errors"; +import { askAgent, fetchHabits, type Habit } from "../../lib/ask"; +import { useLoad } from "../../lib/use-load"; +import { Button, Input } from "../ui"; + +/** + * Ask for something, about this note. + * + * There was a panel here with four buttons — email, message, recap, actions — and three tones, and + * it was the wrong shape for what it did. Writing the follow-up email is not a feature beside + * recording and summarising; it is one of the things a person asks for, and what comes back is a + * note like every other note. A fixed menu of four could only ever be wrong for the fifth thing. + * + * So: a sentence, in the user's own words, handed to the agent — and above it, the sentences they + * have used before. That list is not a guess. It is `vault/agents/HABITS.md`, the instructions they + * have typed more than once, offered back so the fourth report costs one click instead of one + * paragraph of typing. The agent is given the same list, so the fourth report also *looks* like the + * first three, which is the part people actually complain about. + */ +export function AskPanel({ meeting }: { meeting: string }) { + const { handshake } = useEngine(); + const { t } = useI18n(); + const say = useErrorText(); + + const [instruction, setInstruction] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + + const habits = useLoad( + useCallback(async () => fetchHabits(handshake), [handshake]), + [handshake], + ); + + const ask = async (text: string) => { + const wanted = text.trim(); + if (!wanted) return; + setBusy(true); + setError(null); + setDone(false); + try { + await askAgent(handshake, wanted, meeting); + setDone(true); + setInstruction(""); + // Asked once more is asked twice: re-read so a habit appears the moment it becomes one. + habits.reload(); + } catch (e) { + setError(say(e)); + } finally { + setBusy(false); + } + }; + + const usual: Habit[] = habits.data ?? []; + + return ( +
+
+
+

{t("ask.hint")}

+ + {usual.length > 0 && ( +
+ {t("ask.usual")} +
    + {usual.slice(0, 4).map((habit) => ( +
  • + +
  • + ))} +
+
+ )} + +
+ setInstruction(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void ask(instruction); + }} + placeholder={t("ask.placeholder")} + className="min-w-[16rem] flex-1" + data-testid="ask-input" + /> + +
+ + {error && ( +

+ {error} +

+ )} + {done && !error &&

{t("ask.done")}

} +
+ ); +} diff --git a/apps/web/src/components/meeting/Compose.tsx b/apps/web/src/components/meeting/Compose.tsx deleted file mode 100644 index 06ec4d9..0000000 --- a/apps/web/src/components/meeting/Compose.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { Mail } from "lucide-react"; -import { useState } from "react"; - -import { useI18n } from "../../i18n/context"; -import { - ComposeClient, - copy, - mailto, - type Composed, - type Kind, - type Tone, -} from "../../lib/compose"; -import { useEngine } from "../../lib/engine-context"; -import { useErrorText } from "../../lib/errors"; -import { Button, Input, Labelled, SegmentedControl, TextArea } from "../ui"; - -const KINDS: Kind[] = ["email", "message", "recap", "actions"]; -const TONES: Tone[] = ["neutral", "friendly", "formal"]; - -/** - * Write the follow-up, out of the meeting that is already on screen. - * - * Four shapes rather than a prompt box, because the shape is the part a model gets wrong: an email - * needs a subject and a sign-off, a chat message must fit in a glance, a recap cannot say "as - * discussed" to people who were not there, and a list of actions is a list. - * - * Everything it produces is editable before it goes anywhere, and it goes nowhere by itself: the - * buttons are copy, open in your own mail app, and keep as a note. That is deliberate. A model - * writing a customer email will occasionally invent a deadline — the prompt makes it mark gaps with - * `[…]` instead, but the real defence is that a person reads it and presses send themselves. - */ -export function ComposePanel({ meeting, title }: { meeting: string; title: string }) { - const { handshake } = useEngine(); - const { t } = useI18n(); - const say = useErrorText(); - const client = new ComposeClient(handshake); - - const [open, setOpen] = useState(false); - const [kind, setKind] = useState("email"); - const [tone, setTone] = useState("neutral"); - const [audience, setAudience] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [draft, setDraft] = useState(null); - const [subject, setSubject] = useState(""); - const [body, setBody] = useState(""); - const [note, setNote] = useState(null); - const [copied, setCopied] = useState(false); - - const run = async () => { - setBusy(true); - setError(null); - setNote(null); - try { - const composed = await client.compose(meeting, { - kind, - tone, - audience: audience.trim() || undefined, - }); - setDraft(composed); - setSubject(composed.subject ?? ""); - setBody(composed.body); - } catch (e) { - setError(say(e)); - } finally { - setBusy(false); - } - }; - - const keep = async () => { - setError(null); - try { - setNote(await client.save(meeting, subject.trim() || title, body)); - } catch (e) { - setError(say(e)); - } - }; - - const wholeMessage = subject.trim() ? `${subject}\n\n${body}` : body; - - return ( -
-
-
- - {open && ( - <> -

{t("compose.hint")}

- -
- ({ value, label: t(`compose.kind_${value}`) }))} - label={t("compose.kind")} - /> - ({ value, label: t(`compose.tone_${value}`) }))} - label={t("compose.tone")} - /> -
- -
- - setAudience(e.target.value)} - placeholder={t("compose.audience_placeholder")} - /> - - -
- - {error && ( -

- {error} -

- )} - - {draft && ( -
- {draft.kind === "email" && ( - - setSubject(e.target.value)} /> - - )} - -