diff --git a/.env.example b/.env.example index 680e3a7..00e9906 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,6 @@ TWITTER_LITE_MASTODON_ORIGINS=https://fedi.yutakobayashi.com # Optional initial owner setup code (32+ characters). Otherwise generated beside DB. # WORKSPACE_SETUP_TOKEN= + +# Optional real Jev reply evaluation through Vercel AI Gateway. +# AI_GATEWAY_API_KEY= diff --git a/README.md b/README.md index 3fd0e1a..aa2fbe4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ controls for routine steps, tasks, replies and weight entries. Messages uses a Beeper-inspired conversation layout with visible source messages, editable drafts and a contextual side panel. These execution surfaces remain mocks; their records share browser-session state and external sending is simulated. +Reply review can call Jev through Vercel AI Gateway when the server has +`AI_GATEWAY_API_KEY`. It checks the draft against the sample conversation; +Beeper synchronization and sending remain unconnected. ## Workspaces and current status diff --git a/docs/execution-support.md b/docs/execution-support.md index 38b2804..e5fd136 100644 --- a/docs/execution-support.md +++ b/docs/execution-support.md @@ -39,8 +39,9 @@ the new communication and journal prototypes do not consume those accounts. `/` presents a greeting, a large input, and a Tiimo-inspired flexible day flow grouped as Start here, Routines, and If there’s room. There are no fixed time slots. Tasks can be reordered, completed, deferred, restored, or followed as routine -steps. Communications can be reviewed directly in a home dialog; changes also -appear in `/support`. Journal entries and corrections appear on the home, and +steps. Communications open their Messages page at +`/support?view=all&conversation=`; browser Back returns to the home with +shared outcomes intact. Journal entries and corrections appear on the home, and reader samples open for reading. Research remains a separate work surface. Activity blocks expand in place into controls suited to their content: routine @@ -79,3 +80,21 @@ The home greeting includes a short prose brief derived from current task and communication state. Completed or deferred items leave the brief; the next contact opens directly from the text. A current resumption note takes precedence over the optional-task suggestion. This is local templated copy, not an LLM report. + +## Jev reply review + +The message thread keeps the reply composer at the bottom on wide screens; +summary and source context live in the side panel. Expand Reply suggestions to +compare candidate wording before editing. + +Opening Review reply calls an authenticated server function using AI SDK +`experimental_evaluate` with `typesafe-ai/jev`. Set `AI_GATEWAY_API_KEY` on +the server. The draft, recipient role, and original sample messages are sent +through Vercel AI Gateway to TypeSafe AI. The model evaluates whether the reply +answers the request, stays grounded, and uses an appropriate tone. The UI shows +boolean probabilities, not a calibrated pass/fail verdict or generated advice. +Editing the draft invalidates its evaluation. Missing configuration and provider +failures show an unavailable state; no mock approval is substituted. Review +results do not trigger sending, which remains a simulation. + +See [Vercel’s Jev integration](https://vercel.com/changelog/typesafe-ai-jev-now-available-on-ai-gateway). diff --git a/e2e/integrations/home.test.ts b/e2e/integrations/home.test.ts index d82162f..e1c2678 100644 --- a/e2e/integrations/home.test.ts +++ b/e2e/integrations/home.test.ts @@ -94,35 +94,37 @@ test("adds tasks only after confirmation and can defer and restore them", async ).toBeVisible(); }); -test("resolves communication within home and carries the status to the shared queue", async ({ +test("opens a conversation page from home and retains outcomes on return", async ({ page, a11y, }) => { await page.goto("/"); await page.waitForLoadState("networkidle"); - await page.getByRole("button", { name: "Open conversation with Haruka Aoki" }).click(); - const dialog = page.getByRole("dialog", { - name: "Conversation with Haruka Aoki", - }); - await expect(dialog.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); + const conversationLink = page.getByRole("link", { name: "Open conversation with Haruka Aoki" }); + await expect(conversationLink).toHaveAttribute("href", /conversation=design-review/); + await conversationLink.click(); + await expect(page).toHaveURL(/\/support\?.*conversation=design-review/); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); expect((await a11y().analyze()).violations).toEqual([]); - await dialog.getByRole("button", { name: "Leave for later" }).click(); - await expect(dialog).toHaveCount(0); - await expect( - page.getByRole("button", { name: "Open conversation with Haruka Aoki" }), - ).toContainText("Set aside"); - await page.getByRole("link", { name: "Messages", exact: true }).first().click(); - const rows = page.locator(".support-row"); - await expect(rows.filter({ hasText: "Schedule next week’s design review" })).toHaveCount(0); - await page.getByRole("button", { name: "Set aside", exact: true }).click(); - await rows.filter({ hasText: "Schedule next week’s design review" }).click(); + await page.goBack(); + await expect(conversationLink).toBeVisible(); + await page.goForward(); + await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); + await page.reload(); + await page.waitForLoadState("networkidle"); + await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Leave for later" }).click(); + await page.getByRole("link", { name: "Home", exact: true }).first().click(); + await expect(conversationLink).toContainText("Set aside"); + await conversationLink.click(); + await expect(page).toHaveURL(/conversation=design-review/); + await expect(page.getByRole("button", { name: "Move to Inbox" })).toBeVisible(); await page.getByRole("button", { name: "Move to Inbox" }).click(); const back = page.getByRole("button", { name: "Back to messages" }); if (await back.isVisible()) await back.click(); await page.getByRole("link", { name: "Home", exact: true }).first().click(); - await expect( - page.getByRole("button", { name: "Open conversation with Haruka Aoki" }), - ).toBeVisible(); + await expect(conversationLink).toBeVisible(); }); test("updates the prose brief as priorities change and opens the referenced conversation", async ({ @@ -136,11 +138,8 @@ test("updates the prose brief as priorities change and opens the referenced conv await page.getByRole("button", { name: "Complete Review the documents", exact: true }).click(); await expect(brief).not.toContainText("Review the documents"); await brief.getByRole("button", { name: "Reply to Haruka Aoki" }).click(); - const dialog = page.getByRole("dialog", { - name: "Conversation with Haruka Aoki", - exact: true, - }); - await dialog.getByRole("button", { name: "Leave for later" }).click(); + await page.getByRole("button", { name: "Leave for later" }).click(); + await page.getByRole("link", { name: "Home", exact: true }).first().click(); await expect(brief).toContainText("2 conversations"); await expect(brief).not.toContainText("Haruka Aoki"); await page.getByRole("textbox", { name: "Home input" }).fill("再開地点:資料の2ページ目から"); @@ -164,18 +163,15 @@ test("keeps completed actions in their original positions with visible outcomes" await expect( page.getByRole("button", { name: "Review the documents Completed", exact: true }), ).toBeVisible(); - await page.getByRole("button", { name: "Open conversation with Haruka Aoki" }).click(); - const conversation = page.getByRole("dialog", { - name: "Conversation with Haruka Aoki", - exact: true, - }); - await conversation.getByRole("button", { name: "Review reply", exact: true }).click(); + await page.getByRole("link", { name: "Open conversation with Haruka Aoki" }).click(); + await page.getByRole("button", { name: "Review reply", exact: true }).click(); await page .getByRole("dialog", { name: "Review reply", exact: true }) .getByRole("button", { name: "Try sending" }) .click(); + await page.getByRole("link", { name: "Home", exact: true }).first().click(); await expect( - page.getByRole("button", { name: "Open conversation with Haruka Aoki" }), + page.getByRole("link", { name: "Open conversation with Haruka Aoki" }), ).toContainText("Reply recorded"); await page.getByRole("button", { name: /Record weight A quick check-in/ }).click(); await page.getByRole("spinbutton", { name: "Weight", exact: true }).fill("65.2"); diff --git a/e2e/integrations/support.test.ts b/e2e/integrations/support.test.ts index 9883db1..84d95e0 100644 --- a/e2e/integrations/support.test.ts +++ b/e2e/integrations/support.test.ts @@ -12,7 +12,8 @@ test("reviews an edited reply and keeps the simulated result in history", async const rows = page.locator(".support-row"); await expect(rows).toHaveCount(3); await rows.filter({ hasText: reviewSubject }).click(); - await page.getByRole("button", { name: "Polite", exact: true }).click(); + await page.locator(".conversation-suggestions summary:visible").click(); + await page.getByRole("button", { name: /^Polite/ }).click(); const reply = page.getByRole("textbox", { name: "Reply", exact: true }); await expect(reply).toHaveValue(/Thank you for sharing/); const edited = "I reviewed the materials. Let’s discuss the first-time experience."; @@ -107,7 +108,10 @@ test("keeps corrections as new records and summarizes only selected current fact expect((await a11y().analyze()).violations).toEqual([]); }); -test("fits communication and journal interactions in narrow screens", async ({ page, a11y }) => { +test("fits communication and journal interactions in narrow screens", async ({ + page, + a11y, +}, testInfo) => { for (const width of [320, 375, 414, 768]) { await page.setViewportSize({ width, height: 850 }); await page.goto("/support"); @@ -117,6 +121,7 @@ test("fits communication and journal interactions in narrow screens", async ({ p await expect(page.getByRole("textbox", { name: "Reply", exact: true })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(width); if (width === 320) expect((await a11y().analyze()).violations).toEqual([]); + await page.screenshot({ path: testInfo.outputPath(`messages-${width}.png`) }); const back = page.getByRole("button", { name: "Back to messages" }); if (await back.isVisible()) await back.click(); await page.getByRole("link", { name: "Journal", exact: true }).click(); diff --git a/src/features/home/execution-workbench.tsx b/src/features/home/execution-workbench.tsx index 5fff2ce..b53ab4e 100644 --- a/src/features/home/execution-workbench.tsx +++ b/src/features/home/execution-workbench.tsx @@ -1,3 +1,4 @@ +import { Link } from "@tanstack/react-router"; import { ArrowDown, ArrowUp, @@ -26,14 +27,9 @@ import { type ExecutionWorkbenchProps = { selectedId?: string; onSelect: (id: string) => void; - onOpenContact: (id: string) => void; }; -export function ExecutionWorkbench({ - selectedId, - onSelect, - onOpenContact, -}: ExecutionWorkbenchProps) { +export function ExecutionWorkbench({ selectedId, onSelect }: ExecutionWorkbenchProps) { const { tasks, setTasks, statuses, entries } = useWorkspaceState(); const currentEntries = entries.filter( (entry) => !entries.some((other) => other.correctionOf === entry.id), @@ -152,11 +148,11 @@ export function ExecutionWorkbench({ )} - +