Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default componentEntry<Props>({
],
load: async () => (await import("./galleryFixture")).ImportHistoryGallery,
styles: async () => {
await import("../../ui/ui.css");
await import("../shared/pageBase.css");
await import("./styles.css");
},
Expand Down
96 changes: 56 additions & 40 deletions webviews/src/pages/import-history/ImportHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,46 +25,52 @@ export function ImportHistory({ store, strings, layout = "list" }: ImportHistory
else void store.run();
}}
>
<header>
<h1>{t("history.title")}</h1>
<p className="history-lead">{t("history.subtitle")}</p>
</header>
<output className="history-notice" aria-live="polite">
{t(`history.state.${snapshot.phase}`)}
</output>
<div className="history-sources">
{snapshot.rows.map((row) => (
<SourceRow
key={row.id}
row={row}
store={store}
strings={strings}
busy={busy || snapshot.phase === "detecting"}
/>
))}
</div>
<button
className="history-link history-dot"
type="button"
disabled={busy || Boolean(snapshot.picking)}
onClick={() => void store.pickZip("dot")}
>
{t("history.savedDot")}
</button>
{(snapshot.error || snapshot.phase === "error") && (
<div className="history-error" role="alert">
<p>
{t("history.error")}
{detail && <span className="history-error-detail">{detail}</span>}
</p>
{snapshot.error && !busy && (
<button type="button" className="history-link" onClick={() => void store.retryDetection()}>
{t("history.retry")}
</button>
<div className="history-scroll">
<div className="history-content">
<header>
<h1>{t("history.title")}</h1>
<p className="history-lead">{t("history.subtitle")}</p>
</header>
<output className="history-notice" aria-live="polite">
{t(`history.state.${snapshot.phase}`)}
</output>
<div className="history-sources">
{snapshot.rows.map((row) => (
<SourceRow
key={row.id}
row={row}
store={store}
strings={strings}
busy={busy || snapshot.phase === "detecting"}
/>
))}
{!snapshot.rows.some((row) => row.id === "dot") && (
<SourceRow
key="dot"
row={SAVED_DOT_ROW}
title={t("history.savedDot")}
store={store}
strings={strings}
busy={busy}
/>
)}
</div>
{(snapshot.error || snapshot.phase === "error") && (
<div className="history-error" role="alert">
<p>
{t("history.error")}
{detail && <span className="history-error-detail">{detail}</span>}
</p>
{snapshot.error && !busy && (
<button type="button" className="history-link" onClick={() => void store.retryDetection()}>
{t("history.retry")}
</button>
)}
</div>
)}
<p className="history-safety">{t("history.safety")}</p>
</div>
)}
<p className="history-safety">{t("history.safety")}</p>
</div>
<footer className="history-footer">
<DialogButton dialogKey={null} className="history-button" onClick={() => void store.finish(false)}>
{t("history.skip")}
Expand Down Expand Up @@ -99,20 +105,30 @@ export function ImportHistory({ store, strings, layout = "list" }: ImportHistory
);
}

/** New Computer's discontinued Dot has no detectable source: until its saved zip is picked, it is an
* ordinary unchecked zip row (same checkbox column and Choose action), and picking replaces it with
* the model's real `dot` row. */
const SAVED_DOT_ROW: HistoryRow = { id: "dot", kind: "zip", selected: false, phase: "idle" };

function SourceRow({
row,
title: titleOverride,
store,
strings,
busy,
}: {
row: HistoryRow;
title?: string;
store: ImportHistoryStore;
strings: Strings;
busy: boolean;
}) {
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
const t = strings.t;
const title = t(`history.source.${row.id}`);
// The Choose action names the source ("Choose a Dot (New Computer) export (.zip)"), not the
// placeholder's sentence.
const sourceName = t(`history.source.${row.id}`);
const title = titleOverride ?? sourceName;
const unavailable = row.kind === "local" && !row.source;
const selected = row.kind === "text" ? Boolean(row.expanded) : row.selected;
return (
Expand Down Expand Up @@ -162,7 +178,7 @@ function SourceRow({
className="history-link"
disabled={busy || Boolean(snapshot.picking)}
onClick={() => void store.pickZip(row.id as "chatgpt" | "claude" | "dot")}
aria-label={strings.format("history.chooseFor", title)}
aria-label={strings.format("history.chooseFor", sourceName)}
>
{t(snapshot.picking === row.id ? "history.choosing" : "history.choose")}
</button>
Expand Down
1 change: 1 addition & 0 deletions webviews/src/pages/import-history/main.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "../shared/desktop";
import "../../ui/ui.css";
import { createRoot } from "react-dom/client";
import { UiProvider, languageDirection } from "../../ui/UiProvider";
import { createPageClient, type PageClient } from "../shared/pageClient";
Expand Down
49 changes: 36 additions & 13 deletions webviews/src/pages/import-history/styles.css
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
/* One quiet list, using the app's own type and color tokens. Alternate layouts remain gallery choices. */
/* The page fills the sheet: the list scrolls above a footer that stays put. The footer paints no
background of its own, so it shows the same see-through sheet as the list (an opaque footer
strip read as a black bar over the glass, #34569). */
.import-history {
display: flex;
flex-direction: column;
height: 100vh;
user-select: text;
}
.history-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.history-content,
.history-footer {
width: 100%;
max-width: 680px;
margin: 0 auto;
padding: 32px 24px;
user-select: text;
padding-inline: 24px;
}
.history-content {
padding-block: 32px 0;
}
.import-history h1 {
margin: 0;
Expand Down Expand Up @@ -71,10 +89,6 @@
opacity: 0.5;
cursor: default;
}
.history-dot {
margin: 12px 0 0;
color: var(--page-text-2);
}
.history-file,
.history-report,
.history-row-status,
Expand Down Expand Up @@ -121,11 +135,10 @@
color: var(--page-text-2);
}
.history-footer {
flex-shrink: 0;
justify-content: space-between;
position: sticky;
bottom: 0;
padding: 12px 0;
background: var(--cmux-surface-solid, Canvas);
padding-block: 12px 16px;
border-top: 1px solid var(--page-separator);
}
.history-button {
border: 1px solid var(--page-separator);
Expand All @@ -135,6 +148,11 @@
color: inherit;
font: inherit;
}
/* The keycap (ui/DialogButton) reads one step quieter than its button's label, on both fills. */
.history-button .ui-keyhint {
color: inherit;
opacity: 0.7;
}
.history-button.is-primary {
background: var(--page-text);
color: var(--page-elevated);
Expand All @@ -143,7 +161,8 @@
.history-button:hover:not(:disabled) {
filter: brightness(0.94);
}
.layout-cards {
.layout-cards .history-content,
.layout-cards .history-footer {
max-width: 860px;
}
.layout-cards .history-sources {
Expand All @@ -164,8 +183,12 @@
margin-bottom: 0;
}
@media (max-width: 560px) {
.import-history {
padding: 24px 16px;
.history-content,
.history-footer {
padding-inline: 16px;
}
.history-content {
padding-top: 24px;
}
.layout-cards .history-sources {
grid-template-columns: minmax(0, 1fr);
Expand Down
97 changes: 97 additions & 0 deletions webviews/test/import-history-sheet.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Bring your history sheet (#34569): the saved Dot export is one of the aligned source rows (same
// checkbox column and Choose action as the other zip rows), picking it replaces the placeholder with
// the real Dot row, and the footer's Import button carries its keycap as a separate element.
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
import { JSDOM } from "jsdom";
import { act } from "react";
import type { Root } from "react-dom/client";

const scope = globalThis as Record<string, unknown>;
const names = [
"window",
"document",
"navigator",
"HTMLElement",
"Element",
"Node",
"IS_REACT_ACT_ENVIRONMENT",
] as const;
const saved = names.map((name) => [name, scope[name]] as const);
let dom: JSDOM;
let root: Root | undefined;
let host: HTMLElement;

beforeAll(() => {
dom = new JSDOM("<!doctype html><html><body><main id=root></main></body></html>", {
url: "http://localhost/import-history/",
pretendToBeVisual: true,
});
const win = dom.window as unknown as Record<string, unknown>;
for (const name of names) scope[name] = name === "window" ? dom.window : win[name];
scope.IS_REACT_ACT_ENVIRONMENT = true;
});

afterEach(() => {
act(() => root?.unmount());
root = undefined;
});

afterAll(() => {
dom.window.close();
for (const [name, value] of saved) {
if (value === undefined) delete scope[name];
else scope[name] = value;
}
});

const { createRoot } = await import("react-dom/client");
const { ImportHistory } = await import("../src/pages/import-history/ImportHistory");
const { ImportHistoryStore } = await import("../src/pages/import-history/model");
const { MockImportHistoryProvider } = await import("../src/pages/import-history/mockProvider");
const { createStrings } = await import("../src/pages/shared/i18n");
const table = (await import("../src/pages/import-history/generated/strings.json")).default as Record<
string,
Record<string, string>
>;

async function mount() {
const store = new ImportHistoryStore(new MockImportHistoryProvider());
const strings = createStrings(table, ["en"]);
host = dom.window.document.getElementById("root")!;
root = createRoot(host);
await act(async () => root!.render(<ImportHistory store={store} strings={strings} />));
await act(async () => store.start());
return { store };
}

const sourceRows = () => [...host.querySelectorAll<HTMLElement>(".history-sources > .history-source")];
const titleOf = (row: HTMLElement) => row.querySelector(".history-source-label")?.textContent?.trim();

test("the saved Dot export is an aligned source row with a checkbox and a Choose action", async () => {
await mount();
const dot = sourceRows().find((row) => titleOf(row) === "I have a saved Dot export (.zip)");
expect(dot).toBeDefined();
expect(dot!.querySelector('.history-source-label input[type="checkbox"]')).not.toBeNull();
expect(dot!.querySelector(".history-source-action button")?.textContent).toBe("Choose export…");
// No second, unaligned Dot control outside the list.
expect(host.querySelectorAll("form > button, .history-content > button").length).toBe(0);
});

test("picking the saved Dot export replaces the placeholder with one checked Dot row", async () => {
const { store } = await mount();
await act(async () => store.pickZip("dot"));
const dots = sourceRows().filter((row) => /^Dot \(|saved Dot/.test(titleOf(row) ?? ""));
expect(dots.map(titleOf)).toEqual(["Dot (New Computer) export (.zip)"]);
expect(dots[0]!.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked).toBe(true);
});

test("the footer sits outside the scrolling list and Import keeps its keycap as its own element", async () => {
await mount();
const footer = host.querySelector<HTMLElement>(".history-footer")!;
expect(footer.closest(".history-scroll")).toBeNull();
expect(host.querySelector(".history-scroll .history-sources")).not.toBeNull();
const primary = footer.querySelector<HTMLButtonElement>("button.is-primary")!;
expect(primary.classList.contains("ui-dialog-button")).toBe(true);
expect(primary.querySelector("kbd.ui-keyhint")?.textContent).toBe("↩");
expect(primary.firstChild?.textContent).toBe("Import");
});
Loading