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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,3 +654,5 @@ a late prompt or starting inference.
Model capability gates include attachments replayed from history, including text-only follow-ups.
The bus's internal remote source skips SQLite persistence and local plugin event hooks;
ordinary live subscribers still receive the unchanged event. Existing publishers default local.

Space (`#/space`): labelled Base UI `Field` form (optional title, required content with inline validation), designed empty state and card list; copy in `live.space.*`. Evidence: `evidence/space-redesign/`.
17 changes: 17 additions & 0 deletions packages/app/src/kit/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -574,3 +574,20 @@ a.chip { display: inline-flex; align-items: center; text-decoration: none; }

/* Registry screen root (registry.tsx Root): layout-neutral wrapper carrying the per-screen testID. */
.screen-root { display: contents; }

/* ---------- Space (pages) ---------- */
.space { max-width: 680px; margin: 0 auto; padding-top: 8px; display: flex; flex-direction: column; }
.space-lead { margin: 0 0 20px; color: var(--t2); }
.space-form { display: flex; flex-direction: column; gap: 16px; padding: 18px; border-radius: var(--r-card); background: var(--card); box-shadow: var(--ring); }
.space-field { display: flex; flex-direction: column; gap: 6px; }
.space-label { display: flex; align-items: baseline; gap: 6px; font-weight: 500; }
.space-opt { font-weight: 400; color: var(--t3); font-size: 11px; }
.space-text { height: auto; min-height: 140px; resize: vertical; line-height: 20px; }
.space-error { margin: 0; color: var(--red); font-size: 12px; }
.space-actions { display: flex; justify-content: flex-end; }
.space-h2 { margin: 28px 0 10px; font-size: 15px; font-weight: 500; }
.space-row { width: 100%; text-align: left; }
.space-row:hover { background: var(--hover); }
.space-empty { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 32px 20px; border-radius: var(--r-card); background: var(--card); box-shadow: var(--ring); text-align: center; color: var(--t2); }
.space-empty h3 { margin: 10px 0 0; font-size: 13px; font-weight: 500; color: var(--t1); }
.space-empty p { margin: 0; }
30 changes: 22 additions & 8 deletions packages/app/src/screens/live/productivity.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from "react";
import { Field } from "@base-ui/react/field";
import { api } from "../../api";
import { useT } from "../../i18n";
import { useNav } from "../../shell/nav";
Expand All @@ -15,16 +16,29 @@ export function Space() {
function SpaceHome({ epoch }: { epoch: string }) {
const t = useT(), { go } = useNav(), c = useContract();
const q = useOp<{ items: File[] }>(epoch, "app.library");
const [title, setTitle] = React.useState(""), [text, setText] = React.useState("");
return <section data-testid="space">
<form className="list" data-testid="space-new" onSubmit={e => { e.preventDefault(); void c.run(async () => { const f = await call<File>(epoch, "app.note.create", {}, { title: title.trim() || undefined, text }); go("space-page", { id: f.id }); }); }}>
<label className="field">{t("live.space.pageTitle")}<input className="input" data-testid="space-title" maxLength={200} value={title} onChange={e => setTitle(e.target.value)} /></label>
<label className="field">{t("live.space.text")}<textarea className="input" data-testid="space-text" required value={text} onChange={e => setText(e.target.value)} /></label>
<button className="btn primary" data-testid="space-create" disabled={c.busy || !text.trim()}>{t("live.space.create")}</button>
const [title, setTitle] = React.useState(""), [text, setText] = React.useState(""), [touched, setTouched] = React.useState(false);
const invalid = touched && !text.trim();
return <section className="space" data-testid="space">
<p className="space-lead">{t("live.space.lead")}</p>
<form className="space-form" data-testid="space-new" noValidate onSubmit={e => { e.preventDefault(); setTouched(true); if (!text.trim()) return; void c.run(async () => { const f = await call<File>(epoch, "app.note.create", {}, { title: title.trim() || undefined, text }); go("space-page", { id: f.id }); }); }}>
<Field.Root className="space-field">
<Field.Label className="space-label">{t("live.space.pageTitle")}<span className="space-opt">{t("live.space.optional")}</span></Field.Label>
<Field.Control className="input" data-testid="space-title" maxLength={200} placeholder={t("live.space.titlePlaceholder")} value={title} onChange={e => setTitle(e.target.value)} />
</Field.Root>
<Field.Root className="space-field" invalid={invalid}>
<Field.Label className="space-label">{t("live.space.text")}</Field.Label>
<Field.Control render={<textarea className="input space-text" data-testid="space-text" rows={6} />} placeholder={t("live.space.textPlaceholder")} value={text} onChange={e => setText((e.target as unknown as HTMLTextAreaElement).value)} onBlur={() => setTouched(true)} />
{invalid && <p className="space-error" role="alert" data-testid="space-text-error">{t("live.space.textRequired")}</p>}
</Field.Root>
<ContractError code={c.error} />
<div className="space-actions">
<button className="btn primary" data-testid="space-create" aria-busy={c.busy} disabled={c.busy || !text.trim()}>{c.busy ? t("live.space.creating") : t("live.space.create")}</button>
</div>
</form>
<h2>{t("live.space.pages")}</h2>
<Loaded q={q} empty={d => !d?.items.length}>{d => <div className="list">{d!.items.map(f => <button key={f.id} className="li" data-testid="space-row" onClick={() => go("space-page", { id: f.id })}><Icon name="file" /><span className="grow">{f.filename}</span><span className="code-meta">{when(f.created_at)}</span><Icon name="chevron-right" /></button>)}</div>}</Loaded>
<h2 className="space-h2">{t("live.space.pages")}</h2>
<Loaded q={q}>{d => d?.items.length
? <div className="list" data-testid="space-list">{d.items.map(f => <button key={f.id} className="li space-row" data-testid="space-row" onClick={() => go("space-page", { id: f.id })}><span className="li-ic"><Icon name="file" /></span><span className="grow"><span className="ttl">{f.filename}</span></span><span className="code-meta">{when(f.created_at)}</span><Icon name="chevron-right" /></button>)}</div>
: <div className="space-empty" data-testid="live-empty"><span className="li-ic"><Icon name="file" /></span><h3>{t("live.space.emptyTitle")}</h3><p>{t("live.space.emptyText")}</p></div>}</Loaded>
</section>;
}

Expand Down
10 changes: 9 additions & 1 deletion packages/i18n/locales/de/live.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,13 @@
"scheduled.heading": "Geplante Aufgaben",
"scheduled.workspace": "Workspace",
"scheduled.tab": "Geplant",
"plugins.tab": "Erweiterungen"
"plugins.tab": "Erweiterungen",
"space.lead": "Notizen und Seiten, die dein Bot lesen und weiterentwickeln kann.",
"space.optional": "Optional",
"space.titlePlaceholder": "Unbenannte Seite",
"space.textPlaceholder": "Schreibe oder füge ein, was du behalten möchtest",
"space.textRequired": "Füge Inhalt hinzu, um eine Seite zu erstellen.",
"space.creating": "Wird erstellt…",
"space.emptyTitle": "Noch keine Seiten",
"space.emptyText": "Seiten, die du erstellst, erscheinen hier."
}
10 changes: 9 additions & 1 deletion packages/i18n/locales/en/live.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,13 @@
"scheduled.heading": "Scheduled tasks",
"scheduled.workspace": "Workspace",
"scheduled.tab": "Scheduled",
"plugins.tab": "Extensions"
"plugins.tab": "Extensions",
"space.lead": "Notes and pages your Bot can read and build on.",
"space.optional": "Optional",
"space.titlePlaceholder": "Untitled page",
"space.textPlaceholder": "Write or paste anything you want to keep",
"space.textRequired": "Add some content to create a page.",
"space.creating": "Creating…",
"space.emptyTitle": "No pages yet",
"space.emptyText": "Pages you create appear here."
}
10 changes: 9 additions & 1 deletion packages/i18n/locales/es/live.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,13 @@
"scheduled.heading": "Tareas programadas",
"scheduled.workspace": "Espacio de trabajo",
"scheduled.tab": "Programadas",
"plugins.tab": "Extensiones"
"plugins.tab": "Extensiones",
"space.lead": "Notas y páginas que tu Bot puede leer y ampliar.",
"space.optional": "Opcional",
"space.titlePlaceholder": "Página sin título",
"space.textPlaceholder": "Escribe o pega lo que quieras guardar",
"space.textRequired": "Añade contenido para crear una página.",
"space.creating": "Creando…",
"space.emptyTitle": "Aún no hay páginas",
"space.emptyText": "Las páginas que crees aparecerán aquí."
}
10 changes: 9 additions & 1 deletion packages/i18n/locales/fr/live.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,13 @@
"scheduled.heading": "Tâches planifiées",
"scheduled.workspace": "Espace de travail",
"scheduled.tab": "Planifiées",
"plugins.tab": "Extensions"
"plugins.tab": "Extensions",
"space.lead": "Notes et pages que ton Bot peut lire et enrichir.",
"space.optional": "Facultatif",
"space.titlePlaceholder": "Page sans titre",
"space.textPlaceholder": "Écris ou colle ce que tu veux garder",
"space.textRequired": "Ajoute du contenu pour créer une page.",
"space.creating": "Création…",
"space.emptyTitle": "Aucune page pour l'instant",
"space.emptyText": "Les pages que tu crées apparaissent ici."
}
10 changes: 9 additions & 1 deletion packages/i18n/locales/ja/live.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,13 @@
"scheduled.heading": "スケジュール済みのタスク",
"scheduled.workspace": "ワークスペース",
"scheduled.tab": "スケジュール",
"plugins.tab": "拡張機能"
"plugins.tab": "拡張機能",
"space.lead": "Bot が読んで活用できるメモとページ。",
"space.optional": "任意",
"space.titlePlaceholder": "無題のページ",
"space.textPlaceholder": "残しておきたい内容を入力または貼り付け",
"space.textRequired": "ページを作成するには内容を追加してください。",
"space.creating": "作成中…",
"space.emptyTitle": "まだページがありません",
"space.emptyText": "作成したページはここに表示されます。"
}
10 changes: 9 additions & 1 deletion packages/i18n/locales/ko/live.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,13 @@
"scheduled.heading": "예약된 작업",
"scheduled.workspace": "워크스페이스",
"scheduled.tab": "예약됨",
"plugins.tab": "확장 프로그램"
"plugins.tab": "확장 프로그램",
"space.lead": "Bot이 읽고 활용할 수 있는 메모와 페이지입니다.",
"space.optional": "선택 사항",
"space.titlePlaceholder": "제목 없는 페이지",
"space.textPlaceholder": "보관하고 싶은 내용을 입력하거나 붙여 넣으세요",
"space.textRequired": "페이지를 만들려면 내용을 추가하세요.",
"space.creating": "만드는 중…",
"space.emptyTitle": "아직 페이지가 없습니다",
"space.emptyText": "만든 페이지가 여기에 표시됩니다."
}
10 changes: 9 additions & 1 deletion packages/i18n/locales/pt-BR/live.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,13 @@
"scheduled.heading": "Tarefas agendadas",
"scheduled.workspace": "Espaço de trabalho",
"scheduled.tab": "Agendadas",
"plugins.tab": "Extensões"
"plugins.tab": "Extensões",
"space.lead": "Notas e páginas que seu Bot pode ler e expandir.",
"space.optional": "Opcional",
"space.titlePlaceholder": "Página sem título",
"space.textPlaceholder": "Escreva ou cole o que quiser guardar",
"space.textRequired": "Adicione conteúdo para criar uma página.",
"space.creating": "Criando…",
"space.emptyTitle": "Nenhuma página ainda",
"space.emptyText": "As páginas que você criar aparecem aqui."
}
10 changes: 9 additions & 1 deletion packages/i18n/locales/zh-Hans/live.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,13 @@
"scheduled.heading": "定时任务",
"scheduled.workspace": "工作区",
"scheduled.tab": "定时",
"plugins.tab": "扩展"
"plugins.tab": "扩展",
"space.lead": "你的 Bot 可以阅读和扩展的笔记与页面。",
"space.optional": "可选",
"space.titlePlaceholder": "无标题页面",
"space.textPlaceholder": "写下或粘贴你想保留的内容",
"space.textRequired": "添加内容以创建页面。",
"space.creating": "正在创建…",
"space.emptyTitle": "还没有页面",
"space.emptyText": "你创建的页面会显示在这里。"
}
54 changes: 54 additions & 0 deletions tests/e2e/space-pages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import fs from "node:fs";
import { test, expect, type ElectronApplication, type Page } from "@playwright/test";
import { launch } from "./fixtures";

type Wire = { url: string; method: string; body?: string };
const FILES = [{ id: "f1", filename: "Launch notes", created_at: "2026-10-07T09:00:00Z", byte_size: 120 }];

async function install(app: ElectronApplication, items: unknown[]) {
await app.evaluate(({ ipcMain }, items) => {
const handlers = (ipcMain as unknown as { _invokeHandlers: Map<string, (e: unknown, r: Wire) => Promise<unknown>> })._invokeHandlers;
const original = handlers.get("cortex:fetch")!;
ipcMain.removeHandler("cortex:fetch");
ipcMain.handle("cortex:fetch", async (event, request: Wire) => {
const path = new URL(request.url).pathname;
let body: unknown;
if (path === "/api/connection") body = { mode: "cloud", signedIn: true };
else if (path === "/api/code/models") body = { epoch: "e", models: [] };
else if (path === "/api/code/contract") body = { data: JSON.parse(request.body ?? "{}").op === "app.library" ? { items } : { id: "f2", filename: "x", created_at: "2026-10-07T09:00:00Z", byte_size: 1 } };
else return original(event, request);
return { status: 200, headers: [["Content-Type", "application/json"]], body: JSON.stringify(body) };
});
}, items);
}
async function shot(page: Page, name: string) {
const p = test.info().outputPath(`${name}.png`);
await page.screenshot({ path: p, animations: "disabled" });
await test.info().attach(name, { path: p, contentType: "image/png" });
}

for (const theme of ["dark", "light"]) for (const [state, items] of [["empty", []], ["list", FILES]] as const) {
test(`Space page form, validation and ${state} state — ${theme}`, async () => {
const { app, page, dataDir } = await launch({ hash: `#/space?theme=${theme}`, locale: "en" });
try {
await page.emulateMedia({ reducedMotion: "reduce" });
await install(app, [...items]);
await page.evaluate(() => { location.hash = "#/home"; });
await page.evaluate(() => { location.hash = "#/space"; });
const create = page.getByTestId("space-create");
await expect(create).toBeDisabled();
await expect(page.getByLabel("Title")).toBeVisible();
await expect(page.locator("details, select")).toHaveCount(0);
if (state === "empty") await expect(page.getByTestId("live-empty")).toContainText("No pages yet");
else await expect(page.getByTestId("space-row")).toContainText("Launch notes");
await page.getByTestId("space-text").focus();
await page.getByTestId("space-text").blur();
await expect(page.getByTestId("space-text-error")).toBeVisible();
await page.getByTestId("space-text").fill("Hello");
await expect(create).toBeEnabled();
await expect(page.getByTestId("space-text-error")).toHaveCount(0);
await page.getByTestId("space-text").fill("");
await shot(page, `space-${state}-${theme}`);
} finally { await app.close(); fs.rmSync(dataDir, { recursive: true, force: true }); }
});
}
Loading