Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
144 changes: 144 additions & 0 deletions apps/web/e2e/pages.mjs
Original file line number Diff line number Diff line change
@@ -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");
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
120 changes: 120 additions & 0 deletions apps/web/src/components/meeting/Ask.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<section
className="border-line bg-bg-soft rounded-[var(--radius-panel)] border p-4"
data-testid="ask"
>
<div className="flex items-center gap-2">
<Sparkles className="text-ai size-4 shrink-0" aria-hidden="true" />
<h2 className="flex-1 text-sm font-semibold">{t("ask.title")}</h2>
</div>
<p className="text-fg-faint text-micro mt-1">{t("ask.hint")}</p>

{usual.length > 0 && (
<div className="mt-3">
<span className="text-fg-faint text-micro">{t("ask.usual")}</span>
<ul className="mt-1 flex flex-wrap gap-1.5" data-testid="ask-habits">
{usual.slice(0, 4).map((habit) => (
<li key={habit.instruction}>
<button
type="button"
disabled={busy}
onClick={() => void ask(habit.instruction)}
className="border-line bg-bg text-meta hover:border-accent rounded-full border px-2.5 py-1"
>
{habit.instruction}
<span className="text-fg-faint">
{" · "}
{t("ask.times", { count: habit.times })}
</span>
</button>
</li>
))}
</ul>
</div>
)}

<div className="mt-3 flex flex-wrap items-center gap-2">
<Input
value={instruction}
onChange={(e) => 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"
/>
<Button onClick={() => void ask(instruction)} disabled={busy || !instruction.trim()}>
{busy ? t("ask.working") : t("ask.run")}
</Button>
</div>

{error && (
<p role="alert" className="text-danger mt-3 text-sm">
{error}
</p>
)}
{done && !error && <p className="text-fg-dim text-meta mt-3">{t("ask.done")}</p>}
</section>
);
}
Loading
Loading