feat: add URL-driven workspace state and streamed Codex research

This commit is contained in:
2026-09-28 20:08:28 +09:00
parent 85d23a328b
commit 8085ad2f90
207 changed files with 15287 additions and 16026 deletions
+128 -138
View File
@@ -1,177 +1,167 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import type { Connection } from '../connections/model'
import { ColumnEditor } from './column-editor'
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import type { Connection } from "../connections/model";
import { ColumnEditor } from "./column-editor";
const lists = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('#/features/platforms/mastodon-server-functions', () => ({
const lists = vi.hoisted(() =>
vi.fn<typeof import("../platforms/mastodon-server-functions").loadMastodonLists>(),
);
vi.mock("@tanstack/react-start", () => ({ useServerFn: (fn: unknown) => fn }));
vi.mock("#/features/platforms/mastodon-server-functions", () => ({
loadMastodonLists: lists,
}))
vi.mock('#/features/posts/use-list-choices', () => ({
}));
vi.mock("#/features/posts/use-list-choices", () => ({
useListChoices: () => ({
isPending: false,
isError: false,
data: [],
refetch: vi.fn(),
refetch: vi.fn<() => void>(),
}),
}))
}));
const connections: Connection[] = [
{
id: 'twitter',
platform: 'twitter',
origin: 'https://relay.invalid',
id: "twitter",
platform: "twitter",
origin: "https://relay.invalid",
accountId: null,
displayName: 'Twitter account',
status: 'connected',
displayName: "Twitter account",
status: "connected",
},
{
id: 'mastodon-first',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '1',
displayName: '@first',
status: 'connected',
id: "mastodon-first",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "1",
displayName: "@first",
status: "connected",
},
{
id: 'mastodon-second',
platform: 'mastodon',
origin: 'https://mastodon.invalid',
accountId: '2',
displayName: '@second',
status: 'connected',
id: "mastodon-second",
platform: "mastodon",
origin: "https://mastodon.invalid",
accountId: "2",
displayName: "@second",
status: "connected",
},
]
];
beforeEach(() => {
lists
.mockReset()
.mockImplementation(
async ({ data }: { data: { connectionId: string } }) => ({
lists: [
{
id: data.connectionId === 'mastodon-first' ? '10' : '20',
name: data.connectionId,
isPrivate: true,
},
],
}),
)
})
lists.mockReset().mockImplementation(async ({ data }: { data: { connectionId: string } }) => ({
lists: [
{
id: data.connectionId === "mastodon-first" ? "10" : "20",
name: data.connectionId,
isPrivate: true,
},
],
}));
});
function show() {
const onSave = vi.fn().mockResolvedValue(true)
const onSave = vi
.fn<(column: import("./model").DeckColumn) => Promise<boolean>>()
.mockResolvedValue(true);
render(
<QueryClientProvider
client={
new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ColumnEditor
connections={connections}
onSave={onSave}
onCancel={vi.fn()}
/>
<ColumnEditor connections={connections} onSave={onSave} onCancel={vi.fn<() => void>()} />
</QueryClientProvider>,
)
fireEvent.change(screen.getByLabelText('Column name'), {
target: { value: '調査' },
})
return onSave
);
fireEvent.change(screen.getByLabelText("Column name"), {
target: { value: "調査" },
});
return onSave;
}
it('creates a Mastodon search without Twitter ordering or following fields', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
expect(screen.queryByLabelText('Sort by')).toBeNull()
expect(screen.queryByLabelText('Following only')).toBeNull()
expect(screen.getByText(/Zero results/)).toBeVisible()
fireEvent.change(screen.getByLabelText('Mastodon search query'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
it("creates a Mastodon search without Twitter ordering or following fields", async () => {
const save = show();
fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: "mastodon-first" },
});
expect(screen.queryByLabelText("Sort by")).toBeNull();
expect(screen.queryByLabelText("Following only")).toBeNull();
expect(screen.getByText(/Zero results/)).toBeVisible();
fireEvent.change(screen.getByLabelText("Mastodon search query"), {
target: { value: "WebMCP" },
});
fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
connectionId: 'mastodon-first',
source: { platform: 'mastodon', kind: 'search', query: 'WebMCP' },
connectionId: "mastodon-first",
source: { platform: "mastodon", kind: "search", query: "WebMCP" },
}),
),
)
})
);
});
it('creates hashtags and resets unsupported source kind when changing to Twitter', async () => {
const save = show()
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('Column type'), {
target: { value: 'hashtag' },
})
fireEvent.change(screen.getByLabelText('Hashtag'), {
target: { value: 'WebMCP' },
})
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
it("creates hashtags and resets unsupported source kind when changing to Twitter", async () => {
const save = show();
fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: "mastodon-first" },
});
fireEvent.change(screen.getByLabelText("Column type"), {
target: { value: "hashtag" },
});
fireEvent.change(screen.getByLabelText("Hashtag"), {
target: { value: "WebMCP" },
});
fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() =>
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' },
source: { platform: "mastodon", kind: "hashtag", target: "WebMCP" },
}),
),
)
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'twitter' },
})
expect(screen.getByLabelText('Column type')).toHaveValue('search')
expect(screen.getByLabelText('Twitter search query')).toHaveValue('')
expect(screen.queryByRole('option', { name: 'Hashtag' })).toBeNull()
})
);
fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: "twitter" },
});
expect(screen.getByLabelText("Column type")).toHaveValue("search");
expect(screen.getByLabelText("Twitter search query")).toHaveValue("");
expect(screen.queryByRole("option", { name: "Hashtag" })).toBeNull();
});
it('loads lists for each account and clears the selected list when the binding changes', async () => {
show()
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-first' },
})
fireEvent.change(screen.getByLabelText('Column type'), {
target: { value: 'list' },
})
fireEvent.click(await screen.findByRole('button', { name: 'mastodon-first' }))
expect(screen.getByLabelText('List')).toHaveValue('10')
fireEvent.change(screen.getByLabelText('Connection profile'), {
target: { value: 'mastodon-second' },
})
expect(screen.getByLabelText('List')).toHaveValue('')
fireEvent.click(
await screen.findByRole('button', { name: 'mastodon-second' }),
)
expect(screen.getByLabelText('List')).toHaveValue('20')
it("loads lists for each account and clears the selected list when the binding changes", async () => {
show();
fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: "mastodon-first" },
});
fireEvent.change(screen.getByLabelText("Column type"), {
target: { value: "list" },
});
fireEvent.click(await screen.findByRole("button", { name: "mastodon-first" }));
expect(screen.getByLabelText("List")).toHaveValue("10");
fireEvent.change(screen.getByLabelText("Connection profile"), {
target: { value: "mastodon-second" },
});
expect(screen.getByLabelText("List")).toHaveValue("");
fireEvent.click(await screen.findByRole("button", { name: "mastodon-second" }));
expect(screen.getByLabelText("List")).toHaveValue("20");
expect(lists).toHaveBeenCalledWith({
data: { connectionId: 'mastodon-second' },
})
})
data: { connectionId: "mastodon-second" },
});
});
it('preserves title and Twitter search options after a rejected save', async () => {
const save = show()
save.mockResolvedValue(false)
fireEvent.change(screen.getByLabelText('Twitter search query'), {
target: { value: 'from:owner WebMCP' },
})
fireEvent.change(screen.getByLabelText('Sort by'), {
target: { value: 'Top' },
})
fireEvent.click(screen.getByRole('checkbox', { name: 'Following only' }))
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(screen.getByLabelText('Column name')).toHaveValue('調査')
expect(screen.getByLabelText('Twitter search query')).toHaveValue(
'from:owner WebMCP',
)
expect(screen.getByLabelText('Sort by')).toHaveValue('Top')
expect(screen.getByRole('checkbox', { name: 'Following only' })).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'Add column' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
})
it("preserves title and Twitter search options after a rejected save", async () => {
const save = show();
save.mockResolvedValue(false);
fireEvent.change(screen.getByLabelText("Twitter search query"), {
target: { value: "from:owner WebMCP" },
});
fireEvent.change(screen.getByLabelText("Sort by"), {
target: { value: "Top" },
});
fireEvent.click(screen.getByRole("checkbox", { name: "Following only" }));
fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() => expect(save).toHaveBeenCalledOnce());
expect(screen.getByLabelText("Column name")).toHaveValue("調査");
expect(screen.getByLabelText("Twitter search query")).toHaveValue("from:owner WebMCP");
expect(screen.getByLabelText("Sort by")).toHaveValue("Top");
expect(screen.getByRole("checkbox", { name: "Following only" })).toBeChecked();
fireEvent.submit(screen.getByRole("form", { name: "Add column" }));
await waitFor(() => expect(save).toHaveBeenCalledTimes(2));
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source);
});
+40 -53
View File
@@ -1,15 +1,15 @@
import { useId, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import type { Connection } from '#/features/connections/model'
import { useId, useState } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import type { Connection } from "#/features/connections/model";
import {
ColumnSourceEditor,
defaultColumnSource,
rebindColumnSource,
} from './column-source-editor'
import { columnSchema, type DeckColumn } from './model'
} from "./column-source-editor";
import { columnSchema, type DeckColumn } from "./model";
export function ColumnEditor({
column,
@@ -17,50 +17,41 @@ export function ColumnEditor({
onSave,
onCancel,
}: {
column?: DeckColumn
connections: Connection[]
onSave: (column: DeckColumn) => Promise<boolean>
onCancel: () => void
column?: DeckColumn;
connections: Connection[];
onSave: (column: DeckColumn) => Promise<boolean>;
onCancel: () => void;
}) {
const available = connections.filter(
(connection) => connection.status === 'connected',
)
const formId = useId()
const [error, setError] = useState<string>()
const [connectionId, setConnectionId] = useState(
column?.connectionId ?? available[0]?.id ?? '',
)
const available = connections.filter((connection) => connection.status === "connected");
const formId = useId();
const [error, setError] = useState<string>();
const [connectionId, setConnectionId] = useState(column?.connectionId ?? available[0]?.id ?? "");
const [source, setSource] = useState(
column?.source ?? defaultColumnSource(available[0]?.platform ?? 'twitter'),
)
const validConnection = available.some(
(connection) => connection.id === connectionId,
)
column?.source ?? defaultColumnSource(available[0]?.platform ?? "twitter"),
);
const validConnection = available.some((connection) => connection.id === connectionId);
return (
<form
className="deck-editor"
aria-label={column ? 'Edit column' : 'Add column'}
aria-label={column ? "Edit column" : "Add column"}
onSubmit={async (event) => {
event.preventDefault()
setError(undefined)
const data = new FormData(event.currentTarget)
event.preventDefault();
setError(undefined);
const data = new FormData(event.currentTarget);
const parsed = columnSchema.safeParse({
id:
column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join('-'),
title: data.get('title'),
id: column?.id ?? crypto.getRandomValues(new Uint32Array(4)).join("-"),
title: data.get("title"),
connectionId,
source,
})
});
if (!parsed.success || !validConnection) {
setError(
parsed.success
? 'Select a connection profile.'
: parsed.error.issues[0]?.message,
)
return
parsed.success ? "Select a connection profile." : parsed.error.issues[0]?.message,
);
return;
}
await onSave(parsed.data)
await onSave(parsed.data);
}}
>
<div className="deck-editor-fields">
@@ -69,7 +60,7 @@ export function ColumnEditor({
<Input
id={`${formId}-title`}
name="title"
defaultValue={column?.title ?? ''}
defaultValue={column?.title ?? ""}
maxLength={80}
placeholder="e.g. Announcements"
required
@@ -83,12 +74,10 @@ export function ColumnEditor({
value={connectionId}
required
onChange={(event) => {
const connection = available.find(
(value) => value.id === event.target.value,
)
if (!connection) return
setConnectionId(connection.id)
setSource(rebindColumnSource(source, connection.platform))
const connection = available.find((value) => value.id === event.target.value);
if (!connection) return;
setConnectionId(connection.id);
setSource(rebindColumnSource(source, connection.platform));
}}
>
<NativeSelectOption value="" disabled>
@@ -102,7 +91,7 @@ export function ColumnEditor({
{available.map((connection) => (
<NativeSelectOption key={connection.id} value={connection.id}>
{connection.displayName}
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
{connection.platform === "mastodon" ? " · Mastodon" : ""}
</NativeSelectOption>
))}
</NativeSelect>
@@ -110,12 +99,10 @@ export function ColumnEditor({
</div>
<ColumnSourceEditor
source={source}
connectionId={validConnection ? connectionId : ''}
connectionId={validConnection ? connectionId : ""}
onChange={setSource}
/>
<p className="deck-help">
This column uses the selected connection profile.
</p>
<p className="deck-help">This column uses the selected connection profile.</p>
{error && (
<p role="alert" className="deck-error">
{error}
@@ -123,12 +110,12 @@ export function ColumnEditor({
)}
<div className="deck-actions">
<Button type="submit" disabled={!validConnection}>
{column ? 'Save changes' : 'Add and search'}
{column ? "Save changes" : "Add and search"}
</Button>
<Button variant="outline" type="button" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
)
);
}
+26 -45
View File
@@ -1,41 +1,30 @@
import type { ReactElement } from 'react'
import type { Connection } from '../connections/model'
import {
defaultMastodonSource,
MastodonSourceEditor,
} from './mastodon-source-editor'
import type { DeckColumn } from './model'
import {
defaultTwitterSource,
TwitterSourceEditor,
} from './twitter-source-editor'
import type { ReactElement } from "react";
import type { Connection } from "../connections/model";
import { defaultMastodonSource, MastodonSourceEditor } from "./mastodon-source-editor";
import type { DeckColumn } from "./model";
import { defaultTwitterSource, TwitterSourceEditor } from "./twitter-source-editor";
type Source = DeckColumn['source']
type Source = DeckColumn["source"];
export function defaultColumnSource(platform: Connection['platform']): Source {
export function defaultColumnSource(platform: Connection["platform"]): Source {
switch (platform) {
case 'twitter':
return defaultTwitterSource()
case 'mastodon':
return defaultMastodonSource()
case "twitter":
return defaultTwitterSource();
case "mastodon":
return defaultMastodonSource();
}
}
/** Account-local targets never carry across bindings; compatible searches may. */
export function rebindColumnSource(
source: Source,
platform: Connection['platform'],
): Source {
export function rebindColumnSource(source: Source, platform: Connection["platform"]): Source {
if (source.platform === platform) {
return source.kind === 'search' ? source : { ...source, target: '' }
return source.kind === "search" ? source : { ...source, target: "" };
}
switch (platform) {
case 'twitter':
return defaultTwitterSource(
source.kind === 'hashtag' ? 'search' : source.kind,
)
case 'mastodon':
return defaultMastodonSource(source.kind)
case "twitter":
return defaultTwitterSource(source.kind === "hashtag" ? "search" : source.kind);
case "mastodon":
return defaultMastodonSource(source.kind);
}
}
@@ -44,26 +33,18 @@ export function ColumnSourceEditor({
connectionId,
onChange,
}: {
source: Source
connectionId: string
onChange: (source: Source) => void
source: Source;
connectionId: string;
onChange: (source: Source) => void;
}): ReactElement {
switch (source.platform) {
case 'twitter':
case "twitter":
return (
<TwitterSourceEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case 'mastodon':
<TwitterSourceEditor source={source} connectionId={connectionId} onChange={onChange} />
);
case "mastodon":
return (
<MastodonSourceEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
<MastodonSourceEditor source={source} connectionId={connectionId} onChange={onChange} />
);
}
}
+55 -69
View File
@@ -1,117 +1,103 @@
import { useEffect, useRef } from 'react'
import { useWebMCP } from 'usewebmcp'
import { z } from 'zod'
import {
flattenResearchPages,
type useResearchFeed,
} from '#/features/platforms/use-research-feed'
import { PostLoadError } from '#/features/posts/use-post-feed'
import type { DeckColumn } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import { toolResult } from './webmcp-contracts'
import { useEffect, useRef } from "react";
import { useWebMCP } from "usewebmcp";
import { z } from "zod";
import { flattenResearchPages, type useResearchFeed } from "#/features/platforms/use-research-feed";
import { PostLoadError } from "#/features/posts/use-post-feed";
import type { DeckColumn } from "./model";
import { useWebMCPSupported } from "./use-webmcp-supported";
import { toolResult } from "./webmcp-contracts";
type ColumnFeed = {
column: DeckColumn
query: ReturnType<typeof useResearchFeed>
}
export type ColumnRegistry = Map<string, { current: ColumnFeed }>
column: DeckColumn;
query: ReturnType<typeof useResearchFeed>;
};
export type ColumnRegistry = Map<string, { current: ColumnFeed }>;
export function useRegisterColumn(
registry: ColumnRegistry,
column: DeckColumn,
query: ReturnType<typeof useResearchFeed>,
) {
const entry = useRef({ column, query })
entry.current = { column, query }
const entry = useRef({ column, query });
useEffect(() => {
registry.set(column.id, entry)
entry.current = { column, query };
}, [column, query]);
useEffect(() => {
registry.set(column.id, entry);
return () => {
if (registry.get(column.id) === entry) registry.delete(column.id)
}
}, [registry, column.id])
if (registry.get(column.id) === entry) registry.delete(column.id);
};
}, [registry, column.id]);
}
const columnInput = z.object({ columnId: z.string().min(1) }).strict()
const columnInput = z.object({ columnId: z.string().min(1) }).strict();
const readInput = columnInput.extend({
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(50).default(20),
})
});
function read(feed: ColumnFeed, offset = 0, limit = 20) {
const posts = flattenResearchPages(feed.query.data?.pages ?? [])
const selected = posts.slice(offset, offset + limit)
const posts = flattenResearchPages(feed.query.data?.pages ?? []);
const selected = posts.slice(offset, offset + limit);
return {
column: feed.column,
status: feed.query.isPending
? 'loading'
: feed.query.isError
? 'error'
: 'ready',
status: feed.query.isPending ? "loading" : feed.query.isError ? "error" : "ready",
loading: feed.query.isFetching,
error:
feed.query.error instanceof PostLoadError
? feed.query.error.detail
: null,
error: feed.query.error instanceof PostLoadError ? feed.query.error.detail : null,
posts: selected,
loadedCount: posts.length,
offset,
nextOffset:
offset + selected.length < posts.length ? offset + selected.length : null,
nextOffset: offset + selected.length < posts.length ? offset + selected.length : null,
hasMore: Boolean(feed.query.hasNextPage),
}
};
}
export function useColumnTools(registry: ColumnRegistry, ready: boolean) {
const supported = useWebMCPSupported()
const enabled = supported && ready
const supported = useWebMCPSupported();
const enabled = supported && ready;
function requireColumn(id: string) {
const entry = registry.get(id)
const entry = registry.get(id);
if (!entry)
throw new Error(
'Column is not loaded in the active deck. Select its deck and wait for it to render.',
)
return entry
"Column is not loaded in the active deck. Select its deck and wait for it to render.",
);
return entry;
}
useWebMCP({
name: 'get_column_posts',
name: "get_column_posts",
description:
'Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.',
"Read a slice of posts already loaded in a column of the active deck. Returns bound source/profile, loading/errors, original URLs, nextOffset for remaining loaded posts and hasMore for upstream continuation. No network request.",
inputSchema: readInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
const { columnId, offset, limit } = readInput.parse(input)
return read(requireColumn(columnId).current, offset, limit)
const { columnId, offset, limit } = readInput.parse(input);
return read(requireColumn(columnId).current, offset, limit);
}),
})
});
useWebMCP({
name: 'load_more_column',
name: "load_more_column",
description:
'Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.',
"Load or retry one continuation page of a column in the active deck using its bound profile. Joins concurrent pagination. Returns newly appended posts and nextOffset if more loaded posts remain. Wait for the initial load before calling.",
inputSchema: columnInput,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(async () => {
const { columnId } = columnInput.parse(input)
const entry = requireColumn(columnId)
const { column, query } = entry.current
const identity = JSON.stringify(column)
const { columnId } = columnInput.parse(input);
const entry = requireColumn(columnId);
const { column, query } = entry.current;
const identity = JSON.stringify(column);
if (query.isPending || (query.isFetching && !query.isFetchingNextPage))
throw new Error('Wait for the column to finish loading.')
if (query.isError && !query.isFetchNextPageError) throw query.error
const offset = flattenResearchPages(query.data?.pages ?? []).length
if (!query.hasNextPage) return read(entry.current, offset)
const result = await query.fetchNextPage({ cancelRefetch: false })
if (
registry.get(columnId) !== entry ||
JSON.stringify(entry.current.column) !== identity
)
throw new Error(
'The deck or column changed while loading. Read the current deck first.',
)
if (result.isError) throw result.error
return read({ column, query: result }, offset)
throw new Error("Wait for the column to finish loading.");
if (query.isError && !query.isFetchNextPageError) throw query.error;
const offset = flattenResearchPages(query.data?.pages ?? []).length;
if (!query.hasNextPage) return read(entry.current, offset);
const result = await query.fetchNextPage({ cancelRefetch: false });
if (registry.get(columnId) !== entry || JSON.stringify(entry.current.column) !== identity)
throw new Error("The deck or column changed while loading. Read the current deck first.");
if (result.isError) throw result.error;
return read({ column, query: result }, offset);
}),
})
});
}
+64 -59
View File
@@ -1,63 +1,72 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import type { ResearchPost } from '../platforms/types'
import type { CitationTarget } from '../research/use-citation-navigation'
import { ResearchColumn } from './deck-column'
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import type { ResearchPost } from "../platforms/types";
import type { CitationTarget } from "../research/use-citation-navigation";
import { ResearchColumn } from "./deck-column";
const feed = vi.hoisted(() => ({ use: vi.fn(), next: vi.fn() }))
vi.mock('../platforms/use-research-feed', async (importOriginal) => ({
...(await importOriginal<typeof import('../platforms/use-research-feed')>()),
const feed = vi.hoisted(() => ({
use: vi.fn<
() => {
data: { pages: readonly { posts: readonly ResearchPost[]; nextCursor?: string }[] };
hasNextPage?: boolean;
fetchNextPage?: () => void;
}
>(),
next: vi.fn<() => void>(),
}));
vi.mock("../platforms/use-research-feed", async (importOriginal) => ({
...(await importOriginal<typeof import("../platforms/use-research-feed")>()),
useResearchFeed: feed.use,
}))
vi.mock('./research-post-card', () => ({
}));
vi.mock("./research-post-card", () => ({
ResearchPostCard: ({
post,
citationSnapshot,
}: {
post: ResearchPost
citationSnapshot?: boolean
post: ResearchPost;
citationSnapshot?: boolean;
}) => (
<article>
{post.text}
{citationSnapshot ? 'Post saved during research' : ''}
{citationSnapshot ? "Post saved during research" : ""}
</article>
),
}))
}));
const citation: CitationTarget = {
deckId: 'deck',
deckId: "deck",
request: 1,
column: {
id: 'column',
title: 'Research',
connectionId: 'account',
id: "column",
title: "Research",
connectionId: "account",
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "WebMCP",
product: "Latest",
following: false,
},
},
post: {
key: 'twitter:123',
platform: 'twitter',
nativeId: '123',
url: 'https://x.com/alice/status/123',
text: 'Snapshot',
author: { name: 'Alice', handle: 'alice' },
key: "twitter:123",
platform: "twitter",
nativeId: "123",
url: "https://x.com/alice/status/123",
text: "Snapshot",
author: { name: "Alice", handle: "alice" },
},
}
};
it('temporarily renders missing evidence without inserting it into feed pages or changing pagination', () => {
it("temporarily renders missing evidence without inserting it into feed pages or changing pagination", () => {
const page = Object.freeze({
posts: Object.freeze([]),
nextCursor: 'original-cursor',
})
nextCursor: "original-cursor",
});
feed.use.mockReturnValue({
data: { pages: [page] },
hasNextPage: true,
fetchNextPage: feed.next,
})
});
render(
<ResearchColumn
column={citation.column}
@@ -65,21 +74,19 @@ it('temporarily renders missing evidence without inserting it into feed pages or
registry={new Map()}
first
last
onEdit={vi.fn()}
onMove={vi.fn()}
onRemove={vi.fn()}
onEdit={vi.fn<() => void>()}
onMove={vi.fn<() => void>()}
onRemove={vi.fn<() => void>()}
/>,
)
expect(screen.getByRole('article')).toHaveTextContent(
'SnapshotPost saved during research',
)
expect(page.posts).toHaveLength(0)
expect(page.nextCursor).toBe('original-cursor')
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false })
})
);
expect(screen.getByRole("article")).toHaveTextContent("SnapshotPost saved during research");
expect(page.posts).toHaveLength(0);
expect(page.nextCursor).toBe("original-cursor");
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false });
});
it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias', () => {
it("keeps the live post instead of a duplicate snapshot with a Twitter URL alias", () => {
feed.use.mockReturnValue({
data: {
pages: [
@@ -87,14 +94,14 @@ it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias
posts: [
{
...citation.post,
url: 'https://mobile.twitter.com/alice/status/123',
text: 'Live',
url: "https://mobile.twitter.com/alice/status/123",
text: "Live",
},
],
},
],
},
})
});
render(
<ResearchColumn
column={citation.column}
@@ -102,14 +109,12 @@ it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias
registry={new Map()}
first
last
onEdit={vi.fn()}
onMove={vi.fn()}
onRemove={vi.fn()}
onEdit={vi.fn<() => void>()}
onMove={vi.fn<() => void>()}
onRemove={vi.fn<() => void>()}
/>,
)
expect(screen.getAllByRole('article')).toHaveLength(1)
expect(screen.getByRole('article')).toHaveTextContent('Live')
expect(
screen.queryByText(/Snapshot|Post saved during research/),
).not.toBeInTheDocument()
})
);
expect(screen.getAllByRole("article")).toHaveLength(1);
expect(screen.getByRole("article")).toHaveTextContent("Live");
expect(screen.queryByText(/Snapshot|Post saved during research/)).not.toBeInTheDocument();
});
+37 -47
View File
@@ -1,25 +1,22 @@
import { Icon } from '#/components/icon'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import { Icon } from "#/components/icon";
import { Badge } from "#/components/ui/badge";
import { Button } from "#/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '#/components/ui/dropdown-menu'
import {
flattenResearchPages,
useResearchFeed,
} from '#/features/platforms/use-research-feed'
import { PostLoadError } from '#/features/posts/use-post-feed'
import { citationUrlKey } from '#/features/research/citation-url'
import type { CitationTarget } from '#/features/research/use-citation-navigation'
import { PlatformIcon } from '../platforms/platform-icon'
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
import type { DeckColumn } from './model'
import { ResearchPostCard } from './research-post-card'
import { describeSource } from './source-description'
} from "#/components/ui/dropdown-menu";
import { flattenResearchPages, useResearchFeed } from "#/features/platforms/use-research-feed";
import { PostLoadError } from "#/features/posts/use-post-feed";
import { citationUrlKey } from "#/features/research/citation-url";
import type { CitationTarget } from "#/features/research/use-citation-navigation";
import { PlatformIcon } from "../platforms/platform-icon";
import { type ColumnRegistry, useRegisterColumn } from "./column-tools";
import type { DeckColumn } from "./model";
import { ResearchPostCard } from "./research-post-card";
import { describeSource } from "./source-description";
export function ResearchColumn({
column,
@@ -32,30 +29,30 @@ export function ResearchColumn({
registry,
citation,
}: {
column: DeckColumn
connectionLabel?: string
onEdit: () => void
onRemove: () => void
onMove: (direction: -1 | 1) => void
first: boolean
last: boolean
registry: ColumnRegistry
citation?: CitationTarget
column: DeckColumn;
connectionLabel?: string;
onEdit: () => void;
onRemove: () => void;
onMove: (direction: -1 | 1) => void;
first: boolean;
last: boolean;
registry: ColumnRegistry;
citation?: CitationTarget;
}) {
const query = useResearchFeed(column)
useRegisterColumn(registry, column, query)
const posts = flattenResearchPages(query.data?.pages ?? [])
const citedKey = citation ? citationUrlKey(citation.post.url) : null
const query = useResearchFeed(column);
useRegisterColumn(registry, column, query);
const posts = flattenResearchPages(query.data?.pages ?? []);
const citedKey = citation ? citationUrlKey(citation.post.url) : null;
const visiblePosts =
citation && !posts.some((post) => citationUrlKey(post.url) === citedKey)
? [citation.post, ...posts]
: posts
: posts;
const error =
query.error instanceof PostLoadError
? query.error.detail
: { message: 'Unable to load posts.', retryable: true }
const headingId = `column-${column.id}`
const sourceDescription = describeSource(column.source)
: { message: "Unable to load posts.", retryable: true };
const headingId = `column-${column.id}`;
const sourceDescription = describeSource(column.source);
return (
<section className="deck-column" aria-labelledby={headingId}>
<header className="deck-column-header">
@@ -114,10 +111,8 @@ export function ResearchColumn({
</DropdownMenu>
</div>
<div className="deck-column-label">
<span
title={`Connection profile: ${connectionLabel ?? 'Connection not found'}`}
>
{connectionLabel ?? 'Connection not found'}
<span title={`Connection profile: ${connectionLabel ?? "Connection not found"}`}>
{connectionLabel ?? "Connection not found"}
</span>
<Badge variant="outline" className="deck-source-label">
<Icon name={column.source.kind} />
@@ -126,12 +121,12 @@ export function ResearchColumn({
</div>
<p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status">
{query.isFetching ? 'Loading posts…' : `${posts.length} posts loaded`}
{query.isFetching ? "Loading posts…" : `${posts.length} posts loaded`}
</p>
</header>
<section
className="deck-column-results"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Make the scrolling feed keyboard-accessible.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Make the scrolling feed keyboard-accessible.
tabIndex={0}
aria-label={`Posts in ${column.title}`}
>
@@ -140,9 +135,7 @@ export function ResearchColumn({
key={post.key}
post={post}
citationRequest={
citation && citationUrlKey(post.url) === citedKey
? citation.request
: undefined
citation && citationUrlKey(post.url) === citedKey ? citation.request : undefined
}
citationSnapshot={citation?.post === post && !posts.includes(post)}
/>
@@ -181,13 +174,10 @@ export function ResearchColumn({
</Button>
</div>
) : null}
{!query.isPending &&
!query.isError &&
posts.length > 0 &&
!query.hasNextPage ? (
{!query.isPending && !query.isError && posts.length > 0 && !query.hasNextPage ? (
<p className="deck-message">No more posts.</p>
) : null}
</section>
</section>
)
);
}
+99 -127
View File
@@ -1,11 +1,11 @@
import { useQuery } from '@tanstack/react-query'
import { useLocation } from '@tanstack/react-router'
import { useServerFn } from '@tanstack/react-start'
import { useRef, useState } from 'react'
import { AppShell } from '#/components/app-shell'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { Button } from '#/components/ui/button'
import { useQuery } from "@tanstack/react-query";
import { useLocation } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { useState } from "react";
import { AppShell } from "#/components/app-shell";
import { Dialog } from "#/components/dialog";
import { Icon } from "#/components/icon";
import { Button } from "#/components/ui/button";
import {
Empty,
EmptyContent,
@@ -13,36 +13,34 @@ import {
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '#/components/ui/empty'
import { Input } from '#/components/ui/input'
import { loadConnections } from '#/features/connections/server-functions'
import { syncResearchDeck } from '#/features/research/research-deck-sync'
import { ResearchPanel } from '#/features/research/research-panel'
} from "#/components/ui/empty";
import { Input } from "#/components/ui/input";
import { loadConnections } from "#/features/connections/server-functions";
import { syncResearchDeck } from "#/features/research/research-deck-sync";
import { ResearchPanel } from "#/features/research/research-panel";
import {
sameCitationSource,
useCitationNavigation,
} from '#/features/research/use-citation-navigation'
import { ColumnEditor } from './column-editor'
import { type ColumnRegistry, useColumnTools } from './column-tools'
import { ResearchColumn } from './deck-column'
import { DeckSwitcher } from './deck-switcher'
import { type DeckColumn, MAX_COLUMNS, moveColumn } from './model'
import { useDeck } from './use-deck'
import { useDeckTools } from './webmcp-tools'
import './decks.css'
} from "#/features/research/use-citation-navigation";
import { ColumnEditor } from "./column-editor";
import { type ColumnRegistry, useColumnTools } from "./column-tools";
import { ResearchColumn } from "./deck-column";
import { DeckSwitcher } from "./deck-switcher";
import { type DeckColumn, MAX_COLUMNS, moveColumn } from "./model";
import { useDeck } from "./use-deck";
import { useDeckTools } from "./webmcp-tools";
export function DeckPage() {
const oauthResult = useLocation({
select: (location) =>
new URLSearchParams(location.searchStr).get('mastodon'),
})
const registry = useRef<ColumnRegistry>(new Map()).current
const researchDeckBindings = useRef(new Map<string, string>()).current
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false)
const [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null)
const [newDeckTitle, setNewDeckTitle] = useState('')
const switcherEditing = deckDialog !== null
select: (location) => new URLSearchParams(location.searchStr).get("mastodon"),
});
const [registry] = useState<ColumnRegistry>(() => new Map());
const [researchDeckBindings] = useState(() => new Map<string, string>());
const [editing, setEditing] = useState<{ id: string } | "new" | null>(null);
const [renaming, setRenaming] = useState(false);
const [deckDialog, setDeckDialog] = useState<"create" | "delete" | null>(null);
const [newDeckTitle, setNewDeckTitle] = useState("");
const switcherEditing = deckDialog !== null;
const {
deck,
workspace,
@@ -59,24 +57,24 @@ export function DeckPage() {
select,
remove,
getWorkspace,
} = useDeck(editing !== null || renaming || switcherEditing)
} = useDeck(editing !== null || renaming || switcherEditing);
const citationNavigation = useCitationNavigation({
registry,
getWorkspace,
select,
createTemporary,
})
const fetchProfiles = useServerFn(loadConnections)
});
const fetchProfiles = useServerFn(loadConnections);
const profiles = useQuery({
queryKey: ['connections'],
queryKey: ["connections"],
queryFn: () => fetchProfiles(),
retry: false,
})
useColumnTools(registry, ready)
});
useColumnTools(registry, ready);
const [removed, setRemoved] = useState<{
column: DeckColumn
index: number
}>()
column: DeckColumn;
index: number;
}>();
useDeckTools({
ready,
@@ -90,28 +88,26 @@ export function DeckPage() {
persist,
connections: profiles.data?.connections,
onApplied: clearEditors,
})
});
function clearEditors() {
setDeckDialog(null)
setNewDeckTitle('')
setEditing(null)
setRenaming(false)
setRemoved(undefined)
setDeckDialog(null);
setNewDeckTitle("");
setEditing(null);
setRenaming(false);
setRemoved(undefined);
}
async function saveColumn(column: DeckColumn) {
const saved = await save({
...deck,
columns:
editing === 'new'
editing === "new"
? [...deck.columns, column]
: deck.columns.map((current) =>
current.id === column.id ? column : current,
),
})
if (saved) setEditing(null)
return saved
: deck.columns.map((current) => (current.id === column.id ? column : current)),
});
if (saved) setEditing(null);
return saved;
}
const storageErrorNotice = storageError ? (
@@ -122,14 +118,14 @@ export function DeckPage() {
type="button"
disabled={saving}
onClick={() => {
clearEditors()
void refresh(true)
clearEditors();
void refresh(true);
}}
>
Reload latest deck
</Button>
</p>
) : null
) : null;
return (
<ResearchPanel
@@ -147,8 +143,8 @@ export function DeckPage() {
createTemporary,
save,
select,
})
if (activate) clearEditors()
});
if (activate) clearEditors();
}}
>
{(renderResearchPanel) => (
@@ -167,22 +163,22 @@ export function DeckPage() {
newTitle={newDeckTitle}
onNewTitleChange={setNewDeckTitle}
onDialogChange={(dialog) => {
setDeckDialog(dialog)
if (dialog !== 'create') setNewDeckTitle('')
setDeckDialog(dialog);
if (dialog !== "create") setNewDeckTitle("");
}}
onSelect={(id) => {
select(id)
clearEditors()
select(id);
clearEditors();
}}
onCreate={async (title) => {
const saved = await create(title)
if (saved) clearEditors()
return saved
const saved = await create(title);
if (saved) clearEditors();
return saved;
}}
onDelete={async () => {
const saved = await remove(deck.id)
if (saved) clearEditors()
return saved
const saved = await remove(deck.id);
if (saved) clearEditors();
return saved;
}}
/>
<Button
@@ -190,18 +186,16 @@ export function DeckPage() {
aria-label="Add column"
title="Add column"
type="button"
disabled={
!ready || saving || deck.columns.length >= MAX_COLUMNS
}
onClick={() => setEditing('new')}
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
onClick={() => setEditing("new")}
>
<Icon name="plus" />
<span>Add column</span>
</Button>
<p className="sidebar-note">
{deck.persisted
? 'Saved to server · Shared across devices'
: 'Temporary view · This tab only'}
? "Saved to server · Shared across devices"
: "Temporary view · This tab only"}
</p>
</>
}
@@ -220,8 +214,8 @@ export function DeckPage() {
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title} copy`, deck.columns)
clearEditors()
createTemporary(`${deck.title} copy`, deck.columns);
clearEditors();
}}
>
Create temporary copy
@@ -238,7 +232,6 @@ export function DeckPage() {
)}
<Button
variant="outline"
className="icon-button"
aria-label="Rename"
title="Rename"
type="button"
@@ -275,22 +268,17 @@ export function DeckPage() {
{profiles.isError ? (
<p role="alert" className="deck-error">
Unable to load connection profiles.
<Button
variant="outline"
type="button"
onClick={() => void profiles.refetch()}
>
<Button variant="outline" type="button" onClick={() => void profiles.refetch()}>
Retry
</Button>
</p>
) : null}
{oauthResult === 'failed' ? (
{oauthResult === "failed" ? (
<p className="deck-error" role="alert">
Could not complete Mastodon authorization. Try again from
Connected accounts.
Could not complete Mastodon authorization. Try again from Connected accounts.
</p>
) : null}
{oauthResult === 'connected' ? (
{oauthResult === "connected" ? (
<p className="deck-save-status" role="status">
Mastodon account connected. You can now use it in a column.
</p>
@@ -300,23 +288,18 @@ export function DeckPage() {
{profiles.data.relayError}
</p>
) : null}
{profiles.isPending ? (
<p role="status">Loading connection profiles…</p>
) : null}
{!renaming && editing === null && !switcherEditing
? storageErrorNotice
: null}
{profiles.isPending ? <p role="status">Loading connection profiles…</p> : null}
{!renaming && editing === null && !switcherEditing ? storageErrorNotice : null}
{renaming ? (
<Dialog title="Rename deck" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
if (await save({ ...deck, title })) setRenaming(false)
event.preventDefault();
const rawTitle = new FormData(event.currentTarget).get("title");
const title = typeof rawTitle === "string" ? rawTitle.trim() : "";
if (!title) return;
if (await save({ ...deck, title })) setRenaming(false);
}}
>
<label htmlFor="deck-title">
@@ -338,14 +321,14 @@ export function DeckPage() {
) : null}
{editing !== null ? (
<Dialog
title={editing === 'new' ? 'Add column' : 'Edit column'}
title={editing === "new" ? "Add column" : "Edit column"}
onClose={() => setEditing(null)}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
key={`${deck.id}-${editing === "new" ? "new" : editing.id}`}
connections={profiles.data?.connections ?? []}
column={
editing === 'new'
editing === "new"
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
@@ -361,13 +344,11 @@ export function DeckPage() {
<Button
variant="outline"
type="button"
disabled={
deck.columns.length >= MAX_COLUMNS || editing === 'new'
}
disabled={deck.columns.length >= MAX_COLUMNS || editing === "new"}
onClick={async () => {
const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column)
if (await save({ ...deck, columns })) setRemoved(undefined)
const columns = [...deck.columns];
columns.splice(removed.index, 0, removed.column);
if (await save({ ...deck, columns })) setRemoved(undefined);
}}
>
Undo
@@ -384,13 +365,12 @@ export function DeckPage() {
<h2>Start a deck with a question</h2>
</EmptyTitle>
<EmptyDescription>
Tell the assistant what you want to research to create
relevant columns. You can also add search, user posts, and
list columns yourself.
Tell the assistant what you want to research to create relevant columns. You can
also add search, user posts, and list columns yourself.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={() => setEditing('new')}>
<Button type="button" onClick={() => setEditing("new")}>
Add column manually
</Button>
</EmptyContent>
@@ -400,7 +380,7 @@ export function DeckPage() {
<section
className="deck-board"
aria-label="Research columns"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
// oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- Keyboard users must be able to scroll the column board.
tabIndex={0}
>
{deck.columns.map((column, index) => (
@@ -423,22 +403,14 @@ export function DeckPage() {
first={index === 0}
last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })}
onMove={(direction) =>
save(moveColumn(deck, column.id, direction))
}
onMove={(direction) => save(moveColumn(deck, column.id, direction))}
onRemove={async () => {
const saved = await save({
...deck,
columns: deck.columns.filter(
(current) => current.id !== column.id,
),
})
if (saved) setRemoved({ column, index })
if (
typeof editing === 'object' &&
editing?.id === column.id
)
setEditing(null)
columns: deck.columns.filter((current) => current.id !== column.id),
});
if (saved) setRemoved({ column, index });
if (typeof editing === "object" && editing?.id === column.id) setEditing(null);
}}
/>
))}
@@ -447,5 +419,5 @@ export function DeckPage() {
</AppShell>
)}
</ResearchPanel>
)
);
}
+34 -44
View File
@@ -1,11 +1,11 @@
import type { ReactNode } from 'react'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import type { DeckView } from './model'
import type { ReactNode } from "react";
import { Dialog } from "#/components/dialog";
import { Icon } from "#/components/icon";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import type { DeckView } from "./model";
export function DeckSwitcher({
decks,
@@ -20,17 +20,17 @@ export function DeckSwitcher({
onNewTitleChange,
errorNotice,
}: {
decks: DeckView[]
activeId: string
ready: boolean
onSelect: (id: string) => void
onCreate: (title: string) => Promise<boolean>
onDelete: () => Promise<boolean>
dialog: 'create' | 'delete' | null
onDialogChange: (dialog: 'create' | 'delete' | null) => void
newTitle: string
onNewTitleChange: (title: string) => void
errorNotice?: ReactNode
decks: DeckView[];
activeId: string;
ready: boolean;
onSelect: (id: string) => void;
onCreate: (title: string) => Promise<boolean>;
onDelete: () => Promise<boolean>;
dialog: "create" | "delete" | null;
onDialogChange: (dialog: "create" | "delete" | null) => void;
newTitle: string;
onNewTitleChange: (title: string) => void;
errorNotice?: ReactNode;
}) {
return (
<div className="deck-workspaces">
@@ -42,26 +42,25 @@ export function DeckSwitcher({
value={activeId}
disabled={!ready}
onChange={(event) => {
onSelect(event.target.value)
onDialogChange(null)
onSelect(event.target.value);
onDialogChange(null);
}}
>
{decks.map((deck) => (
<NativeSelectOption key={deck.id} value={deck.id}>
{deck.title}
{deck.persisted ? '' : ' (temporary)'}
{deck.persisted ? "" : " (temporary)"}
</NativeSelectOption>
))}
</NativeSelect>
<Button
variant="outline"
className="workspace-create"
aria-label="Create deck"
title="Create deck"
type="button"
disabled={!ready}
onClick={() => {
onDialogChange('create')
onDialogChange("create");
}}
>
<Icon name="plus" />
@@ -69,28 +68,27 @@ export function DeckSwitcher({
</Button>
<Button
variant="ghost"
className="workspace-delete"
aria-label="Delete deck"
title="Delete deck"
type="button"
disabled={!ready}
onClick={() => {
onDialogChange('delete')
onDialogChange("delete");
}}
>
<Icon name="trash" />
<span>Delete deck</span>
</Button>
</div>
{dialog === 'create' ? (
{dialog === "create" ? (
<Dialog title="Create deck" onClose={() => onDialogChange(null)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = newTitle.trim()
if (!title) return
if (await onCreate(title)) onDialogChange(null)
event.preventDefault();
const title = newTitle.trim();
if (!title) return;
if (await onCreate(title)) onDialogChange(null);
}}
>
<Label htmlFor="new-deck-title">
@@ -105,18 +103,14 @@ export function DeckSwitcher({
/>
</Label>
<Button type="submit">Create</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
<Button variant="outline" type="button" onClick={() => onDialogChange(null)}>
Cancel
</Button>
</form>
{errorNotice}
</Dialog>
) : null}
{dialog === 'delete' ? (
{dialog === "delete" ? (
<Dialog title="Delete deck" onClose={() => onDialogChange(null)}>
<div className="deck-undo">
<p>Delete this deck and its column settings.</p>
@@ -124,16 +118,12 @@ export function DeckSwitcher({
variant="destructive"
type="button"
onClick={async () => {
if (await onDelete()) onDialogChange(null)
if (await onDelete()) onDialogChange(null);
}}
>
Delete
</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
<Button variant="outline" type="button" onClick={() => onDialogChange(null)}>
Cancel
</Button>
</div>
@@ -141,5 +131,5 @@ export function DeckSwitcher({
</Dialog>
) : null}
</div>
)
);
}
+28 -34
View File
@@ -1,49 +1,43 @@
import { createHash, randomUUID } from 'node:crypto'
import { and, eq } from 'drizzle-orm'
import { getDatabase } from '../storage/database.server'
import { connections, legacyImports } from '../storage/schema'
import { legacyWorkspaceSchema } from './legacy-import'
import {
createDeck,
DeckPersistenceError,
listDecks,
} from './repository.server'
import { createHash, randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { getDatabase } from "../storage/database.server";
import { connections, legacyImports } from "../storage/schema";
import { legacyWorkspaceSchema } from "./legacy-import";
import { createDeck, DeckPersistenceError, listDecks } from "./repository.server";
export function importLegacyWorkspace(
input: unknown,
database = getDatabase(),
relayOrigin = new URL(process.env.TWITTER_RELAY_BASE_URL ?? '').origin,
relayOrigin = new URL(process.env.TWITTER_RELAY_BASE_URL ?? "").origin,
) {
const legacy = legacyWorkspaceSchema.parse(input)
const payloadHash = createHash('sha256')
.update(JSON.stringify(legacy.decks))
.digest('hex')
const legacy = legacyWorkspaceSchema.parse(input);
const payloadHash = createHash("sha256").update(JSON.stringify(legacy.decks)).digest("hex");
return database.transaction(
() => {
const marker = database
.select()
.from(legacyImports)
.where(eq(legacyImports.id, 'local-storage-v2'))
.get()
.where(eq(legacyImports.id, "local-storage-v2"))
.get();
if (marker) {
if (marker.payloadHash !== payloadHash)
throw new DeckPersistenceError(
'conflict',
'Legacy decks have already been imported and cannot be overwritten.',
)
return listDecks(database)
"conflict",
"Legacy decks have already been imported and cannot be overwritten.",
);
return listDecks(database);
}
const available = database
.select()
.from(connections)
.where(
and(
eq(connections.platform, 'twitter'),
eq(connections.platform, "twitter"),
eq(connections.origin, relayOrigin),
eq(connections.status, 'connected'),
eq(connections.status, "connected"),
),
)
.all()
.all();
const imported = legacy.decks.map((deck) =>
createDeck(
{
@@ -52,29 +46,29 @@ export function importLegacyWorkspace(
columns: deck.columns.map(({ profileName, ...column }) => {
const connection = available.find(
(connection) => connection.relayProfile === profileName,
)
);
if (!connection)
throw new DeckPersistenceError(
'connection-unavailable',
"connection-unavailable",
`No connection matches the legacy profile “${profileName}”. Refresh your connections.`,
)
return { ...column, connectionId: connection.id }
);
return { ...column, connectionId: connection.id };
}),
},
database,
),
)
);
database
.insert(legacyImports)
.values({
id: 'local-storage-v2',
id: "local-storage-v2",
payloadHash,
deckIds: imported.map((deck) => deck.id),
createdAt: Date.now(),
})
.run()
return listDecks(database)
.run();
return listDecks(database);
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
+55 -65
View File
@@ -1,98 +1,88 @@
// @vitest-environment node
import { afterEach, beforeEach, expect, it } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import { connections, legacyImports } from '../storage/schema'
import { importLegacyWorkspace } from './import.server'
import { createDeck, listDecks } from './repository.server'
import { afterEach, beforeEach, expect, it } from "vitest";
import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connections, legacyImports } from "../storage/schema";
import { importLegacyWorkspace } from "./import.server";
import { createDeck, listDecks } from "./repository.server";
let database: AppDatabase
let database: AppDatabase;
const legacy = {
version: 2,
activeDeckId: 'same-id',
activeDeckId: "same-id",
decks: [
{
id: 'same-id',
title: 'Legacy',
id: "same-id",
title: "Legacy",
columns: [
{
id: 'a',
title: 'Account',
profileName: 'old-profile',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
id: "a",
title: "Account",
profileName: "old-profile",
source: { platform: "twitter", kind: "user", target: "alice" },
},
],
},
],
}
};
beforeEach(() => {
database = openDatabase(':memory:')
database = openDatabase(":memory:");
database
.insert(connections)
.values({
id: 'new-connection-id',
platform: 'twitter',
origin: 'https://relay.invalid',
relayProfile: 'old-profile',
displayName: 'Old account',
status: 'connected',
id: "new-connection-id",
platform: "twitter",
origin: "https://relay.invalid",
relayProfile: "old-profile",
displayName: "Old account",
status: "connected",
createdAt: 1,
updatedAt: 1,
})
.run()
})
afterEach(() => database.$client.close())
it('imports legacy profiles as connection bindings without overwriting existing same-ID decks', () => {
.run();
});
afterEach(() => database.$client.close());
it("imports legacy profiles as connection bindings without overwriting existing same-ID decks", () => {
const existing = createDeck(
{ id: 'same-id', title: 'Existing server deck', columns: [] },
{ id: "same-id", title: "Existing server deck", columns: [] },
database,
)
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
expect(result).toHaveLength(2)
expect(result.find((deck) => deck.id === existing.id)).toEqual(existing)
const imported = result.find((deck) => deck.title === 'Legacy')
expect(imported?.id).not.toBe('same-id')
);
const result = importLegacyWorkspace(legacy, database, "https://relay.invalid");
expect(result).toHaveLength(2);
expect(result.find((deck) => deck.id === existing.id)).toEqual(existing);
const imported = result.find((deck) => deck.title === "Legacy");
expect(imported?.id).not.toBe("same-id");
expect(imported?.columns[0]).toEqual({
id: 'a',
title: 'Account',
connectionId: 'new-connection-id',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
})
expect(
importLegacyWorkspace(legacy, database, 'https://relay.invalid'),
).toEqual(result)
})
it('blocks a different browser snapshot after the one-time import', () => {
const result = importLegacyWorkspace(
legacy,
database,
'https://relay.invalid',
)
id: "a",
title: "Account",
connectionId: "new-connection-id",
source: { platform: "twitter", kind: "user", target: "alice" },
});
expect(importLegacyWorkspace(legacy, database, "https://relay.invalid")).toEqual(result);
});
it("blocks a different browser snapshot after the one-time import", () => {
const result = importLegacyWorkspace(legacy, database, "https://relay.invalid");
expect(() =>
importLegacyWorkspace(
{ ...legacy, decks: [{ ...legacy.decks[0], title: 'Different' }] },
{ ...legacy, decks: [{ ...legacy.decks[0], title: "Different" }] },
database,
'https://relay.invalid',
"https://relay.invalid",
),
).toThrow('Legacy decks have already been imported')
expect(listDecks(database)).toEqual(result)
})
it('rolls back all imported decks and marker when any profile is unavailable', () => {
).toThrow("Legacy decks have already been imported");
expect(listDecks(database)).toEqual(result);
});
it("rolls back all imported decks and marker when any profile is unavailable", () => {
const second = {
...legacy.decks[0],
id: 'second',
columns: [{ ...legacy.decks[0]?.columns[0], profileName: 'missing' }],
}
id: "second",
columns: [{ ...legacy.decks[0]?.columns[0], profileName: "missing" }],
};
expect(() =>
importLegacyWorkspace(
{ ...legacy, decks: [...legacy.decks, second] },
database,
'https://relay.invalid',
"https://relay.invalid",
),
).toThrow('No connection matches')
expect(listDecks(database)).toEqual([])
expect(database.select().from(legacyImports).all()).toEqual([])
})
).toThrow("No connection matches");
expect(listDecks(database)).toEqual([]);
expect(database.select().from(legacyImports).all()).toEqual([]);
});
+10 -13
View File
@@ -1,10 +1,10 @@
import { z } from 'zod'
import { columnSchema, deckSchema, MAX_COLUMNS } from './model'
import { z } from "zod";
import { columnSchema, deckSchema, MAX_COLUMNS } from "./model";
export const LEGACY_STORAGE_KEY = 'twitter-lite-research-deck'
export const LEGACY_STORAGE_KEY = "twitter-lite-research-deck";
const legacyColumn = columnSchema
.omit({ connectionId: true })
.extend({ profileName: z.string().min(1) })
.extend({ profileName: z.string().min(1) });
const legacyDeck = z
.object({
id: deckSchema.shape.id,
@@ -13,11 +13,9 @@ const legacyDeck = z
})
.strict()
.refine(
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'Column IDs must be unique.',
)
(deck) => new Set(deck.columns.map((column) => column.id)).size === deck.columns.length,
"Column IDs must be unique.",
);
export const legacyWorkspaceSchema = z
.object({
version: z.literal(2),
@@ -26,7 +24,6 @@ export const legacyWorkspaceSchema = z
})
.strict()
.refine(
(value) =>
new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
'Deck IDs must be unique.',
)
(value) => new Set(value.decks.map((deck) => deck.id)).size === value.decks.length,
"Deck IDs must be unique.",
);
+62 -95
View File
@@ -1,30 +1,28 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { type ReactElement, useId } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { loadMastodonLists } from '../platforms/mastodon-server-functions'
import type { MastodonSource } from '../platforms/mastodon-source'
import { useQuery } from "@tanstack/react-query";
import { useServerFn } from "@tanstack/react-start";
import { type ReactElement, useId } from "react";
import { Button } from "#/components/ui/button";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import { loadMastodonLists } from "../platforms/mastodon-server-functions";
import type { MastodonSource } from "../platforms/mastodon-source";
type EditorProps<T extends MastodonSource = MastodonSource> = {
source: T
onChange: (source: MastodonSource) => void
}
source: T;
onChange: (source: MastodonSource) => void;
};
export function defaultMastodonSource(
kind: MastodonSource['kind'] = 'search',
): MastodonSource {
export function defaultMastodonSource(kind: MastodonSource["kind"] = "search"): MastodonSource {
switch (kind) {
case 'search':
return { platform: 'mastodon', kind, query: '' }
case 'user':
return { platform: 'mastodon', kind, target: '' }
case 'list':
return { platform: 'mastodon', kind, target: '' }
case 'hashtag':
return { platform: 'mastodon', kind, target: '' }
case "search":
return { platform: "mastodon", kind, query: "" };
case "user":
return { platform: "mastodon", kind, target: "" };
case "list":
return { platform: "mastodon", kind, target: "" };
case "hashtag":
return { platform: "mastodon", kind, target: "" };
}
}
@@ -33,7 +31,7 @@ export function MastodonSourceEditor({
connectionId,
onChange,
}: EditorProps & { connectionId: string }) {
const id = useId()
const id = useId();
return (
<>
<Label htmlFor={id}>
@@ -43,11 +41,7 @@ export function MastodonSourceEditor({
id={id}
value={source.kind}
onChange={(event) =>
onChange(
defaultMastodonSource(
event.target.value as MastodonSource['kind'],
),
)
onChange(defaultMastodonSource(event.target.value as MastodonSource["kind"]))
}
>
<NativeSelectOption value="search">Search</NativeSelectOption>
@@ -56,13 +50,9 @@ export function MastodonSourceEditor({
<NativeSelectOption value="hashtag">Hashtag</NativeSelectOption>
</NativeSelect>
</Label>
<MastodonSourceFields
source={source}
connectionId={connectionId}
onChange={onChange}
/>
<MastodonSourceFields source={source} connectionId={connectionId} onChange={onChange} />
</>
)
);
}
function MastodonSourceFields({
@@ -71,28 +61,22 @@ function MastodonSourceFields({
onChange,
}: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) {
case 'search':
return <MastodonSearchEditor source={source} onChange={onChange} />
case 'user':
return <MastodonUserEditor source={source} onChange={onChange} />
case 'list':
return (
<MastodonListEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case 'hashtag':
return <MastodonHashtagEditor source={source} onChange={onChange} />
case "search":
return <MastodonSearchEditor source={source} onChange={onChange} />;
case "user":
return <MastodonUserEditor source={source} onChange={onChange} />;
case "list":
return <MastodonListEditor source={source} connectionId={connectionId} onChange={onChange} />;
case "hashtag":
return <MastodonHashtagEditor source={source} onChange={onChange} />;
}
}
function MastodonSearchEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'search' }>>) {
const id = useId()
}: EditorProps<Extract<MastodonSource, { kind: "search" }>>) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -100,27 +84,25 @@ function MastodonSearchEditor({
<Input
id={id}
value={source.query}
onChange={(event) =>
onChange({ ...source, query: event.target.value })
}
onChange={(event) => onChange({ ...source, query: event.target.value })}
maxLength={512}
placeholder="e.g. WebMCP"
required
/>
</Label>
<p className="deck-help">
Full-text search depends on server settings. Zero results do not
necessarily mean search is supported.
Full-text search depends on server settings. Zero results do not necessarily mean search is
supported.
</p>
</div>
)
);
}
function MastodonUserEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'user' }>>) {
const id = useId()
}: EditorProps<Extract<MastodonSource, { kind: "user" }>>) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -128,23 +110,21 @@ function MastodonUserEditor({
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
onChange={(event) => onChange({ ...source, target: event.target.value })}
placeholder="user@server or account ID"
maxLength={256}
required
/>
</Label>
</div>
)
);
}
function MastodonHashtagEditor({
source,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'hashtag' }>>) {
const id = useId()
}: EditorProps<Extract<MastodonSource, { kind: "hashtag" }>>) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -152,26 +132,24 @@ function MastodonHashtagEditor({
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
onChange={(event) => onChange({ ...source, target: event.target.value })}
placeholder="Tag without #"
maxLength={128}
required
/>
</Label>
</div>
)
);
}
function MastodonListEditor({
source,
connectionId,
onChange,
}: EditorProps<Extract<MastodonSource, { kind: 'list' }>> & {
connectionId: string
}: EditorProps<Extract<MastodonSource, { kind: "list" }>> & {
connectionId: string;
}) {
const id = useId()
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -179,9 +157,7 @@ function MastodonListEditor({
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
onChange={(event) => onChange({ ...source, target: event.target.value })}
placeholder="List ID for this account"
maxLength={32}
required
@@ -194,52 +170,43 @@ function MastodonListEditor({
/>
)}
</div>
)
);
}
function MastodonListChoices({
connectionId,
onSelect,
}: {
connectionId: string
onSelect: (target: string) => void
connectionId: string;
onSelect: (target: string) => void;
}) {
const fetchLists = useServerFn(loadMastodonLists)
const fetchLists = useServerFn(loadMastodonLists);
const lists = useQuery({
queryKey: ['mastodon-lists', connectionId],
queryKey: ["mastodon-lists", connectionId],
queryFn: () => fetchLists({ data: { connectionId } }),
retry: false,
})
if (lists.isPending) return <p role="status">Loading lists…</p>
});
if (lists.isPending) return <p role="status">Loading lists…</p>;
if (lists.isError)
return (
<p role="alert">
Unable to load lists.
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
<Button variant="outline" type="button" onClick={() => void lists.refetch()}>
Retry
</Button>
</p>
)
);
return (
<section className="deck-list-choices" aria-label="Lists for this account">
{lists.data.lists.length === 0 ? (
<p>This account has no lists.</p>
) : (
lists.data.lists.map((list) => (
<Button
variant="outline"
type="button"
key={list.id}
onClick={() => onSelect(list.id)}
>
<Button variant="outline" type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
</Button>
))
)}
</section>
)
);
}
+44 -47
View File
@@ -1,39 +1,39 @@
import { describe, expect, it } from 'vitest'
import { type Deck, deckSchema, moveColumn } from './model'
import { describe, expect, it } from "vitest";
import { type Deck, deckSchema, moveColumn } from "./model";
const deck: Deck = {
id: 'research',
title: '調査',
columns: ['a', 'b'].map((id) => ({
id: "research",
title: "調査",
columns: ["a", "b"].map((id) => ({
id,
title: id,
connectionId: 'main',
connectionId: "main",
source: {
platform: 'twitter',
kind: 'search',
query: 'AI lang:ja',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "AI lang:ja",
product: "Latest",
following: false,
},
})),
}
describe('deck definitions', () => {
it('preserves native search and the bound relay profile when serialized', () => {
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck)
})
it('normalizes user and list targets and rejects invalid targets', () => {
const column = deck.columns[0]
};
describe("deck definitions", () => {
it("preserves native search and the bound relay profile when serialized", () => {
expect(deckSchema.parse(JSON.parse(JSON.stringify(deck)))).toEqual(deck);
});
it("normalizes user and list targets and rejects invalid targets", () => {
const column = deck.columns[0];
expect(
deckSchema.parse({
...deck,
columns: [
{
...column,
source: { platform: 'twitter', kind: 'user', target: '@alice' },
source: { platform: "twitter", kind: "user", target: "@alice" },
},
],
}).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'user', target: 'alice' })
).toEqual({ platform: "twitter", kind: "user", target: "alice" });
expect(
deckSchema.parse({
...deck,
@@ -41,41 +41,41 @@ describe('deck definitions', () => {
{
...column,
source: {
platform: 'twitter',
kind: 'list',
target: 'https://x.com/i/lists/123',
platform: "twitter",
kind: "list",
target: "https://x.com/i/lists/123",
},
},
],
}).columns[0]?.source,
).toEqual({ platform: 'twitter', kind: 'list', target: '123' })
).toEqual({ platform: "twitter", kind: "list", target: "123" });
expect(
deckSchema.safeParse({
...deck,
columns: [
{
...column,
source: { platform: 'twitter', kind: 'list', target: 'invalid' },
source: { platform: "twitter", kind: "list", target: "invalid" },
},
],
}).success,
).toBe(false)
})
it('rejects duplicate identities and missing profiles', () => {
).toBe(false);
});
it("rejects duplicate identities and missing profiles", () => {
expect(
deckSchema.safeParse({
...deck,
columns: [deck.columns[0], deck.columns[0]],
}).success,
).toBe(false)
).toBe(false);
expect(
deckSchema.safeParse({
...deck,
columns: [{ ...deck.columns[0], connectionId: '' }],
columns: [{ ...deck.columns[0], connectionId: "" }],
}).success,
).toBe(false)
})
it('rejects queries exceeding the upstream limit after adding follows', () => {
).toBe(false);
});
it("rejects queries exceeding the upstream limit after adding follows", () => {
expect(
deckSchema.safeParse({
...deck,
@@ -83,23 +83,20 @@ describe('deck definitions', () => {
{
...deck.columns[0],
source: {
platform: 'twitter',
kind: 'search',
query: 'a'.repeat(512),
product: 'Latest',
platform: "twitter",
kind: "search",
query: "a".repeat(512),
product: "Latest",
following: true,
},
},
],
}).success,
).toBe(false)
})
it('moves a column without changing its conditions or the previous deck', () => {
expect(moveColumn(deck, 'b', -1).columns).toEqual([
deck.columns[1],
deck.columns[0],
])
expect(deck.columns.map((column) => column.id)).toEqual(['a', 'b'])
expect(moveColumn(deck, 'a', -1)).toBe(deck)
})
})
).toBe(false);
});
it("moves a column without changing its conditions or the previous deck", () => {
expect(moveColumn(deck, "b", -1).columns).toEqual([deck.columns[1], deck.columns[0]]);
expect(deck.columns.map((column) => column.id)).toEqual(["a", "b"]);
expect(moveColumn(deck, "a", -1)).toBe(deck);
});
});
+45 -52
View File
@@ -1,33 +1,32 @@
import { z } from 'zod'
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
import { z } from "zod";
import { mastodonSourceSchema } from "#/features/platforms/mastodon-source";
import {
buildSearchQuery,
normalizeListTarget,
normalizeUserTarget,
} from '#/features/posts/inputs'
} from "#/features/posts/inputs";
export const MAX_COLUMNS = 6
export const MAX_COLUMNS = 6;
const searchSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('search'),
platform: z.literal("twitter"),
kind: z.literal("search"),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']),
product: z.enum(["Latest", "Top"]),
following: z.boolean(),
})
.strict()
.superRefine((source, ctx) => {
try {
buildSearchQuery(source.query, source.following)
buildSearchQuery(source.query, source.following);
} catch (error) {
ctx.addIssue({
code: 'custom',
message:
error instanceof Error ? error.message : 'Check the search query.',
})
code: "custom",
message: error instanceof Error ? error.message : "Check the search query.",
});
}
})
});
function targetSchema(normalize: (target: string) => string) {
return z
@@ -37,31 +36,31 @@ function targetSchema(normalize: (target: string) => string) {
.max(256)
.transform((target, ctx) => {
try {
return normalize(target)
return normalize(target);
} catch (error) {
ctx.addIssue({
code: 'custom',
message: error instanceof Error ? error.message : 'Check the target.',
})
return z.NEVER
code: "custom",
message: error instanceof Error ? error.message : "Check the target.",
});
return z.NEVER;
}
})
});
}
const userSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('user'),
platform: z.literal("twitter"),
kind: z.literal("user"),
target: targetSchema(normalizeUserTarget),
})
.strict()
.strict();
const listSourceSchema = z
.object({
platform: z.literal('twitter'),
kind: z.literal('list'),
platform: z.literal("twitter"),
kind: z.literal("list"),
target: targetSchema(normalizeListTarget),
})
.strict()
.strict();
export const columnSchema = z
.object({
@@ -69,15 +68,11 @@ export const columnSchema = z
title: z.string().trim().min(1).max(80),
connectionId: z.string().min(1),
source: z.union([
z.discriminatedUnion('kind', [
searchSourceSchema,
userSourceSchema,
listSourceSchema,
]),
z.discriminatedUnion("kind", [searchSourceSchema, userSourceSchema, listSourceSchema]),
mastodonSourceSchema,
]),
})
.strict()
.strict();
export const deckSchema = z
.object({
@@ -87,35 +82,33 @@ export const deckSchema = z
})
.strict()
.refine(
(deck) =>
new Set(deck.columns.map((column) => column.id)).size ===
deck.columns.length,
'Column IDs must be unique.',
)
(deck) => new Set(deck.columns.map((column) => column.id)).size === deck.columns.length,
"Column IDs must be unique.",
);
export type Deck = z.infer<typeof deckSchema>
export type DeckColumn = z.infer<typeof columnSchema>
export type DeckView = Deck & { persisted: boolean; revision?: number }
export type Workspace = { activeDeckId: string; decks: DeckView[] }
export type Deck = z.infer<typeof deckSchema>;
export type DeckColumn = z.infer<typeof columnSchema>;
export type DeckView = Deck & { persisted: boolean; revision?: number };
export type Workspace = { activeDeckId: string; decks: DeckView[] };
export const EMPTY_DECK: Deck = {
id: 'initial',
title: 'New research',
id: "initial",
title: "New research",
columns: [],
}
};
export const EMPTY_WORKSPACE: Workspace = {
activeDeckId: EMPTY_DECK.id,
decks: [{ ...EMPTY_DECK, persisted: false }],
}
};
export function newId() {
return crypto.getRandomValues(new Uint32Array(4)).join('-')
return crypto.getRandomValues(new Uint32Array(4)).join("-");
}
export function moveColumn(deck: Deck, id: string, direction: -1 | 1): Deck {
const index = deck.columns.findIndex((column) => column.id === id)
const target = index + direction
if (index < 0 || target < 0 || target >= deck.columns.length) return deck
const columns = [...deck.columns]
const [column] = columns.splice(index, 1)
if (column) columns.splice(target, 0, column)
return { ...deck, columns }
const index = deck.columns.findIndex((column) => column.id === id);
const target = index + direction;
if (index < 0 || target < 0 || target >= deck.columns.length) return deck;
const columns = [...deck.columns];
const [column] = columns.splice(index, 1);
if (column) columns.splice(target, 0, column);
return { ...deck, columns };
}
+17 -17
View File
@@ -1,36 +1,36 @@
import { z } from 'zod'
import { deckSchema } from './model'
import { z } from "zod";
import { deckSchema } from "./model";
export const savedDeckDefinitionSchema = deckSchema
export const savedDeckDefinitionSchema = deckSchema;
export const savedDeckSchema = savedDeckDefinitionSchema.safeExtend({
revision: z.number().int().positive(),
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative(),
})
});
export const replaceDeckInputSchema = z
.object({
deck: savedDeckDefinitionSchema,
expectedRevision: z.number().int().positive(),
})
.strict()
.strict();
export const deleteDeckInputSchema = z
.object({
id: deckSchema.shape.id,
expectedRevision: z.number().int().positive(),
})
.strict()
export type SavedDeckDefinition = z.infer<typeof savedDeckDefinitionSchema>
export type SavedDeck = z.infer<typeof savedDeckSchema>
.strict();
export type SavedDeckDefinition = z.infer<typeof savedDeckDefinitionSchema>;
export type SavedDeck = z.infer<typeof savedDeckSchema>;
export type DeckPersistenceFailure = {
ok: false
ok: false;
error: {
code:
| 'conflict'
| 'not-found'
| 'connection-unavailable'
| 'invalid-input'
| 'storage-unavailable'
message: string
}
}
| "conflict"
| "not-found"
| "connection-unavailable"
| "invalid-input"
| "storage-unavailable";
message: string;
};
};
+60 -72
View File
@@ -1,6 +1,6 @@
import { and, asc, eq } from 'drizzle-orm'
import { type AppDatabase, getDatabase } from '../storage/database.server'
import { connections, deckColumns, decks } from '../storage/schema'
import { and, asc, eq } from "drizzle-orm";
import { type AppDatabase, getDatabase } from "../storage/database.server";
import { connections, deckColumns, decks } from "../storage/schema";
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
@@ -9,33 +9,27 @@ import {
type SavedDeckDefinition,
savedDeckDefinitionSchema,
savedDeckSchema,
} from './persistence-model'
} from "./persistence-model";
export class DeckPersistenceError extends Error {
constructor(
readonly code: DeckPersistenceFailure['error']['code'],
readonly code: DeckPersistenceFailure["error"]["code"],
message: string,
) {
super(message)
super(message);
}
}
const conflict = () =>
new DeckPersistenceError(
'conflict',
'This deck changed on another device. Reload the latest deck.',
)
"conflict",
"This deck changed on another device. Reload the latest deck.",
);
const missing = () =>
new DeckPersistenceError(
'not-found',
'This deck was deleted or could not be found.',
)
new DeckPersistenceError("not-found", "This deck was deleted or could not be found.");
export function loadDeck(
id: string,
database = getDatabase(),
): SavedDeck | null {
const record = database.select().from(decks).where(eq(decks.id, id)).get()
if (!record) return null
export function loadDeck(id: string, database = getDatabase()): SavedDeck | null {
const record = database.select().from(decks).where(eq(decks.id, id)).get();
if (!record) return null;
const columns = database
.select({
id: deckColumns.id,
@@ -46,8 +40,8 @@ export function loadDeck(
.from(deckColumns)
.where(eq(deckColumns.deckId, id))
.orderBy(asc(deckColumns.position))
.all()
return savedDeckSchema.parse({ ...record, columns })
.all();
return savedDeckSchema.parse({ ...record, columns });
}
export function listDecks(database = getDatabase()): SavedDeck[] {
@@ -58,11 +52,11 @@ export function listDecks(database = getDatabase()): SavedDeck[] {
.orderBy(asc(decks.createdAt), asc(decks.id))
.all()
.map(({ id }) => {
const deck = loadDeck(id, database)
if (!deck) throw missing()
return deck
const deck = loadDeck(id, database);
if (!deck) throw missing();
return deck;
}),
)
);
}
function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
@@ -71,22 +65,22 @@ function validateConnections(deck: SavedDeckDefinition, database: AppDatabase) {
.select({ platform: connections.platform, status: connections.status })
.from(connections)
.where(eq(connections.id, column.connectionId))
.get()
.get();
if (
!connection ||
connection.platform !== column.source.platform ||
connection.status !== 'connected'
connection.status !== "connected"
) {
throw new DeckPersistenceError(
'connection-unavailable',
'The account for this column is unavailable. Check your connections.',
)
"connection-unavailable",
"The account for this column is unavailable. Check your connections.",
);
}
}
}
function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run()
database.delete(deckColumns).where(eq(deckColumns.deckId, deck.id)).run();
if (deck.columns.length)
database
.insert(deckColumns)
@@ -97,29 +91,26 @@ function writeColumns(deck: SavedDeckDefinition, database: AppDatabase) {
position,
})),
)
.run()
.run();
}
export function createDeck(
input: unknown,
database = getDatabase(),
): SavedDeck {
const deck = savedDeckDefinitionSchema.parse(input)
export function createDeck(input: unknown, database = getDatabase()): SavedDeck {
const deck = savedDeckDefinitionSchema.parse(input);
return database.transaction(
() => {
const existing = loadDeck(deck.id, database)
const existing = loadDeck(deck.id, database);
if (existing) {
const original = savedDeckDefinitionSchema.parse({
id: existing.id,
title: existing.title,
columns: existing.columns,
})
if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict()
return existing
});
if (JSON.stringify(original) !== JSON.stringify(deck)) throw conflict();
return existing;
}
validateConnections(deck, database)
const now = Date.now()
const result = { ...deck, revision: 1, createdAt: now, updatedAt: now }
validateConnections(deck, database);
const now = Date.now();
const result = { ...deck, revision: 1, createdAt: now, updatedAt: now };
database
.insert(decks)
.values({
@@ -129,56 +120,53 @@ export function createDeck(
createdAt: now,
updatedAt: now,
})
.run()
writeColumns(deck, database)
return result
.run();
writeColumns(deck, database);
return result;
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
export function replaceDeck(
input: unknown,
database = getDatabase(),
): SavedDeck {
const { deck, expectedRevision } = replaceDeckInputSchema.parse(input)
export function replaceDeck(input: unknown, database = getDatabase()): SavedDeck {
const { deck, expectedRevision } = replaceDeckInputSchema.parse(input);
return database.transaction(
() => {
const existing = loadDeck(deck.id, database)
if (!existing) throw missing()
if (existing.revision !== expectedRevision) throw conflict()
validateConnections(deck, database)
const updatedAt = Date.now()
const existing = loadDeck(deck.id, database);
if (!existing) throw missing();
if (existing.revision !== expectedRevision) throw conflict();
validateConnections(deck, database);
const updatedAt = Date.now();
const result = database
.update(decks)
.set({ title: deck.title, revision: expectedRevision + 1, updatedAt })
.where(and(eq(decks.id, deck.id), eq(decks.revision, expectedRevision)))
.run()
if (result.changes !== 1) throw conflict()
writeColumns(deck, database)
.run();
if (result.changes !== 1) throw conflict();
writeColumns(deck, database);
return {
...deck,
revision: expectedRevision + 1,
createdAt: existing.createdAt,
updatedAt,
}
};
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
export function deleteDeck(input: unknown, database = getDatabase()): string {
const { id, expectedRevision } = deleteDeckInputSchema.parse(input)
const { id, expectedRevision } = deleteDeckInputSchema.parse(input);
return database.transaction(
() => {
if (!loadDeck(id, database)) throw missing()
if (!loadDeck(id, database)) throw missing();
const result = database
.delete(decks)
.where(and(eq(decks.id, id), eq(decks.revision, expectedRevision)))
.run()
if (result.changes !== 1) throw conflict()
return id
.run();
if (result.changes !== 1) throw conflict();
return id;
},
{ behavior: 'immediate' },
)
{ behavior: "immediate" },
);
}
+128 -157
View File
@@ -1,183 +1,154 @@
// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { type AppDatabase, openDatabase } from '../storage/database.server'
import {
connectionCredentials,
connections,
deckColumns,
} from '../storage/schema'
import type { SavedDeckDefinition } from './persistence-model'
import {
createDeck,
deleteDeck,
listDecks,
loadDeck,
replaceDeck,
} from './repository.server'
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { afterEach, beforeEach, expect, it } from "vitest";
import { type AppDatabase, openDatabase } from "../storage/database.server";
import { connectionCredentials, connections, deckColumns } from "../storage/schema";
import type { SavedDeckDefinition } from "./persistence-model";
import { createDeck, deleteDeck, listDecks, loadDeck, replaceDeck } from "./repository.server";
let directory: string
let database: AppDatabase
let second: AppDatabase
let directory: string;
let database: AppDatabase;
let second: AppDatabase;
const deck: SavedDeckDefinition = {
id: 'request-123',
title: 'Research',
id: "request-123",
title: "Research",
columns: [
{
id: 'first',
title: 'First',
connectionId: 'twitter-one',
id: "first",
title: "First",
connectionId: "twitter-one",
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "WebMCP",
product: "Latest",
following: false,
},
},
{
id: 'second',
title: 'Second',
connectionId: 'twitter-two',
source: { platform: 'twitter', kind: 'user', target: 'alice' },
id: "second",
title: "Second",
connectionId: "twitter-two",
source: { platform: "twitter", kind: "user", target: "alice" },
},
],
}
};
beforeEach(() => {
directory = mkdtempSync(join(tmpdir(), 'twitter-lite-deck-repository-'))
database = openDatabase(join(directory, 'app.db'))
second = openDatabase(join(directory, 'app.db'))
directory = mkdtempSync(join(tmpdir(), "twitter-lite-deck-repository-"));
database = openDatabase(join(directory, "app.db"));
second = openDatabase(join(directory, "app.db"));
database
.insert(connections)
.values(
['twitter-one', 'twitter-two'].map((id) => ({
["twitter-one", "twitter-two"].map((id) => ({
id,
platform: 'twitter' as const,
origin: 'https://relay.invalid',
platform: "twitter" as const,
origin: "https://relay.invalid",
relayProfile: id,
displayName: id,
status: 'connected' as const,
status: "connected" as const,
createdAt: 1,
updatedAt: 1,
})),
)
.run()
})
.run();
});
afterEach(() => {
second.$client.close()
database.$client.close()
rmSync(directory, { recursive: true })
})
second.$client.close();
database.$client.close();
rmSync(directory, { recursive: true });
});
it('shares deck order and per-column account bindings with a separate database client', () => {
const saved = createDeck(deck, database)
expect(saved).toMatchObject({ ...deck, revision: 1 })
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(listDecks(second)).toEqual([saved])
})
it("shares deck order and per-column account bindings with a separate database client", () => {
const saved = createDeck(deck, database);
expect(saved).toMatchObject({ ...deck, revision: 1 });
expect(loadDeck(deck.id, second)).toEqual(saved);
expect(listDecks(second)).toEqual([saved]);
});
it('makes retried creation idempotent but rejects different content using the same ID', () => {
const saved = createDeck(deck, database)
expect(createDeck(deck, second)).toEqual(saved)
expect(() =>
createDeck({ ...deck, title: 'Different request' }, second),
).toThrow('This deck changed on another device')
expect(listDecks(database)).toEqual([saved])
})
it("makes retried creation idempotent but rejects different content using the same ID", () => {
const saved = createDeck(deck, database);
expect(createDeck(deck, second)).toEqual(saved);
expect(() => createDeck({ ...deck, title: "Different request" }, second)).toThrow(
"This deck changed on another device",
);
expect(listDecks(database)).toEqual([saved]);
});
it('replaces title, ordered columns and bindings atomically, then rejects stale edits', () => {
const initial = createDeck(deck, database)
it("replaces title, ordered columns and bindings atomically, then rejects stale edits", () => {
const initial = createDeck(deck, database);
const reversed = {
...deck,
title: 'Changed',
title: "Changed",
columns: [...deck.columns].reverse(),
}
const saved = replaceDeck(
{ deck: reversed, expectedRevision: initial.revision },
database,
)
};
const saved = replaceDeck({ deck: reversed, expectedRevision: initial.revision }, database);
expect(saved).toMatchObject({
...reversed,
revision: 2,
createdAt: initial.createdAt,
})
});
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'This deck changed on another device',
)
expect(loadDeck(deck.id, second)).toEqual(saved)
})
"This deck changed on another device",
);
expect(loadDeck(deck.id, second)).toEqual(saved);
});
it('clears all columns and prevents stale deletion from another device', () => {
createDeck(deck, database)
const saved = replaceDeck(
{ deck: { ...deck, columns: [] }, expectedRevision: 1 },
database,
)
expect(loadDeck(deck.id, second)?.columns).toEqual([])
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('This deck changed on another device')
expect(loadDeck(deck.id, second)).toEqual(saved)
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id)
expect(loadDeck(deck.id, database)).toBeNull()
})
it("clears all columns and prevents stale deletion from another device", () => {
createDeck(deck, database);
const saved = replaceDeck({ deck: { ...deck, columns: [] }, expectedRevision: 1 }, database);
expect(loadDeck(deck.id, second)?.columns).toEqual([]);
expect(() => deleteDeck({ id: deck.id, expectedRevision: 1 }, second)).toThrow(
"This deck changed on another device",
);
expect(loadDeck(deck.id, second)).toEqual(saved);
expect(deleteDeck({ id: deck.id, expectedRevision: 2 }, second)).toBe(deck.id);
expect(loadDeck(deck.id, database)).toBeNull();
});
it('cascades deleted deck columns and reports already-deleted decks', () => {
createDeck(deck, database)
deleteDeck({ id: deck.id, expectedRevision: 1 }, database)
expect(database.select().from(deckColumns).all()).toEqual([])
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow(
'This deck was deleted',
)
expect(() =>
deleteDeck({ id: deck.id, expectedRevision: 1 }, second),
).toThrow('This deck was deleted')
})
it("cascades deleted deck columns and reports already-deleted decks", () => {
createDeck(deck, database);
deleteDeck({ id: deck.id, expectedRevision: 1 }, database);
expect(database.select().from(deckColumns).all()).toEqual([]);
expect(() => replaceDeck({ deck, expectedRevision: 1 }, second)).toThrow("This deck was deleted");
expect(() => deleteDeck({ id: deck.id, expectedRevision: 1 }, second)).toThrow(
"This deck was deleted",
);
});
it.each([
'disconnected',
'expired',
] as const)('rejects %s connections without partially saving a deck', (status) => {
const initial = createDeck(deck, database)
it.each(["disconnected", "expired"] as const)(
"rejects %s connections without partially saving a deck",
(status) => {
const initial = createDeck(deck, database);
database.update(connections).set({ status }).where(eq(connections.id, "twitter-two")).run();
expect(() =>
replaceDeck({ deck: { ...deck, title: "Should not save" }, expectedRevision: 1 }, database),
).toThrow("The account for this column is unavailable");
expect(loadDeck(deck.id, second)).toEqual(initial);
},
);
it("rejects a connection from another platform", () => {
database
.update(connections)
.set({ status })
.where(eq(connections.id, 'twitter-two'))
.run()
expect(() =>
replaceDeck(
{ deck: { ...deck, title: 'Should not save' }, expectedRevision: 1 },
database,
),
).toThrow('The account for this column is unavailable')
expect(loadDeck(deck.id, second)).toEqual(initial)
})
.set({ platform: "mastodon" })
.where(eq(connections.id, "twitter-one"))
.run();
expect(() => createDeck(deck, database)).toThrow("The account for this column is unavailable");
expect(listDecks(database)).toEqual([]);
});
it('rejects a connection from another platform', () => {
database
.update(connections)
.set({ platform: 'mastodon' })
.where(eq(connections.id, 'twitter-one'))
.run()
expect(() => createDeck(deck, database)).toThrow(
'The account for this column is unavailable',
)
expect(listDecks(database)).toEqual([])
})
it("rejects unknown connections without creating rows", () => {
database.delete(connections).where(eq(connections.id, "twitter-two")).run();
expect(() => createDeck(deck, database)).toThrow("The account for this column is unavailable");
expect(listDecks(database)).toEqual([]);
});
it('rejects unknown connections without creating rows', () => {
database.delete(connections).where(eq(connections.id, 'twitter-two')).run()
expect(() => createDeck(deck, database)).toThrow(
'The account for this column is unavailable',
)
expect(listDecks(database)).toEqual([])
})
it('rejects duplicate column IDs without changing the saved version', () => {
const saved = createDeck(deck, database)
it("rejects duplicate column IDs without changing the saved version", () => {
const saved = createDeck(deck, database);
expect(() =>
replaceDeck(
{
@@ -186,29 +157,29 @@ it('rejects duplicate column IDs without changing the saved version', () => {
},
database,
),
).toThrow('Column IDs must be unique')
expect(loadDeck(deck.id, database)).toEqual(saved)
})
).toThrow("Column IDs must be unique");
expect(loadDeck(deck.id, database)).toEqual(saved);
});
it('returns only deck definitions and revision metadata, never credential data', () => {
it("returns only deck definitions and revision metadata, never credential data", () => {
database
.insert(connectionCredentials)
.values({
connectionId: 'twitter-one',
encryptedToken: 'secret-envelope',
connectionId: "twitter-one",
encryptedToken: "secret-envelope",
updatedAt: 1,
})
.run()
createDeck(deck, database)
const loaded = listDecks(database)
.run();
createDeck(deck, database);
const loaded = listDecks(database);
expect(Object.keys(loaded[0] ?? {}).sort()).toEqual([
'columns',
'createdAt',
'id',
'revision',
'title',
'updatedAt',
])
expect(JSON.stringify(loaded)).not.toContain('secret-envelope')
expect(JSON.stringify(loaded)).not.toContain('relayProfile')
})
"columns",
"createdAt",
"id",
"revision",
"title",
"updatedAt",
]);
expect(JSON.stringify(loaded)).not.toContain("secret-envelope");
expect(JSON.stringify(loaded)).not.toContain("relayProfile");
});
+71 -75
View File
@@ -1,89 +1,85 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { ResearchPost } from '../platforms/types'
import { ResearchPostCard } from './research-post-card'
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { ResearchPost } from "../platforms/types";
import { ResearchPostCard } from "./research-post-card";
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('focuses and briefly highlights a cited card without revealing sensitive content', () => {
vi.useFakeTimers()
vi.stubGlobal('matchMedia', () => ({ matches: true }))
it("focuses and briefly highlights a cited card without revealing sensitive content", () => {
vi.useFakeTimers();
vi.stubGlobal("matchMedia", () => ({ matches: true }));
const post: ResearchPost = {
key: 'mastodon:1',
platform: 'mastodon',
nativeId: '1',
url: 'https://social.invalid/@a/1',
text: 'Hidden content',
author: { name: 'Alice', handle: '[email protected]' },
contentWarning: 'Spoiler',
key: "mastodon:1",
platform: "mastodon",
nativeId: "1",
url: "https://social.invalid/@a/1",
text: "Hidden content",
author: { name: "Alice", handle: "[email protected]" },
contentWarning: "Spoiler",
sensitive: true,
}
const { rerender } = render(<ResearchPostCard post={post} />)
const article = screen.getByRole('article')
const scroll = vi.fn()
article.scrollIntoView = scroll
rerender(
<ResearchPostCard post={post} citationRequest={1} citationSnapshot />,
)
expect(article).toHaveFocus()
expect(article).toHaveClass('citation-highlight')
};
const { rerender } = render(<ResearchPostCard post={post} />);
const article = screen.getByRole("article");
const scroll = vi.fn<() => void>();
article.scrollIntoView = scroll;
rerender(<ResearchPostCard post={post} citationRequest={1} citationSnapshot />);
expect(article).toHaveFocus();
expect(article).toHaveClass("citation-highlight");
expect(scroll).toHaveBeenCalledWith({
block: 'center',
inline: 'center',
behavior: 'instant',
})
expect(screen.getByText('Post saved during research')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Show content' })).toHaveAttribute(
'aria-expanded',
'false',
)
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
rerender(<ResearchPostCard post={post} citationRequest={1} />)
expect(scroll).toHaveBeenCalledTimes(2)
expect(article).toHaveFocus()
expect(
screen.queryByText('Post saved during research'),
).not.toBeInTheDocument()
block: "center",
inline: "center",
behavior: "instant",
});
expect(screen.getByText("Post saved during research")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show content" })).toHaveAttribute(
"aria-expanded",
"false",
);
expect(screen.queryByText("Hidden content")).not.toBeInTheDocument();
rerender(<ResearchPostCard post={post} citationRequest={1} />);
expect(scroll).toHaveBeenCalledTimes(2);
expect(article).toHaveFocus();
expect(screen.queryByText("Post saved during research")).not.toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(2500)
})
expect(article).not.toHaveClass('citation-highlight')
rerender(<ResearchPostCard post={post} citationRequest={2} />)
expect(article).toHaveClass('citation-highlight')
rerender(<ResearchPostCard post={post} />)
expect(article).not.toHaveClass('citation-highlight')
})
vi.advanceTimersByTime(2500);
});
expect(article).not.toHaveClass("citation-highlight");
rerender(<ResearchPostCard post={post} citationRequest={2} />);
expect(article).toHaveClass("citation-highlight");
rerender(<ResearchPostCard post={post} />);
expect(article).not.toHaveClass("citation-highlight");
});
it('waits for reveal before mounting CW content or sensitive media', () => {
it("waits for reveal before mounting CW content or sensitive media", () => {
const post: ResearchPost = {
key: 'mastodon:1',
platform: 'mastodon',
nativeId: '1',
url: 'https://social.invalid/@a/1',
text: 'Hidden content',
html: '<p>Hidden <strong>content</strong></p>',
author: { name: 'Alice', handle: '[email protected]' },
contentWarning: 'Spoiler',
key: "mastodon:1",
platform: "mastodon",
nativeId: "1",
url: "https://social.invalid/@a/1",
text: "Hidden content",
html: "<p>Hidden <strong>content</strong></p>",
author: { name: "Alice", handle: "[email protected]" },
contentWarning: "Spoiler",
sensitive: true,
boostedBy: { name: 'Bob', handle: '[email protected]' },
boostedBy: { name: "Bob", handle: "[email protected]" },
media: [
{
type: 'photo',
url: 'https://social.invalid/image.png',
alt: 'Hidden image',
type: "photo",
url: "https://social.invalid/image.png",
alt: "Hidden image",
},
],
}
render(<ResearchPostCard post={post} />)
expect(screen.getByText('Boosted by Bob')).toBeInTheDocument()
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Show content' }))
expect(screen.getByAltText('Hidden image')).toBeInTheDocument()
expect(screen.getByText('content').tagName).toBe('STRONG')
fireEvent.click(screen.getByRole('button', { name: 'Hide content' }))
expect(screen.queryByAltText('Hidden image')).not.toBeInTheDocument()
})
};
render(<ResearchPostCard post={post} />);
expect(screen.getByText("Boosted by Bob")).toBeInTheDocument();
expect(screen.queryByAltText("Hidden image")).not.toBeInTheDocument();
expect(screen.queryByText("content")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Show content" }));
expect(screen.getByAltText("Hidden image")).toBeInTheDocument();
expect(screen.getByText("content").tagName).toBe("STRONG");
fireEvent.click(screen.getByRole("button", { name: "Hide content" }));
expect(screen.queryByAltText("Hidden image")).not.toBeInTheDocument();
});
+46 -64
View File
@@ -1,63 +1,56 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from '#/components/icon'
import { PostText } from '#/components/post-text'
import { Avatar, AvatarFallback, AvatarImage } from '#/components/ui/avatar'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import type { ResearchPost } from '#/features/platforms/types'
import { useEffect, useRef, useState } from "react";
import { Icon } from "#/components/icon";
import { PostText } from "#/components/post-text";
import { Avatar, AvatarFallback, AvatarImage } from "#/components/ui/avatar";
import { Badge } from "#/components/ui/badge";
import { Button } from "#/components/ui/button";
import type { ResearchPost } from "#/features/platforms/types";
const dateFormatter = new Intl.DateTimeFormat('en-US', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Asia/Tokyo',
})
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "Asia/Tokyo",
});
export function ResearchPostCard({
post,
citationRequest,
citationSnapshot,
}: {
post: ResearchPost
citationRequest?: number
citationSnapshot?: boolean
post: ResearchPost;
citationRequest?: number;
citationSnapshot?: boolean;
}) {
const [revealed, setRevealed] = useState(false)
const [highlighted, setHighlighted] = useState(false)
const article = useRef<HTMLElement>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: Re-scroll when a snapshot moves to its live feed position.
const [revealed, setRevealed] = useState(false);
const [expiredHighlight, setExpiredHighlight] = useState<string>();
const highlightKey = `${citationRequest}:${citationSnapshot}`;
const highlighted = citationRequest !== undefined && expiredHighlight !== highlightKey;
const article = useRef<HTMLElement>(null);
useEffect(() => {
if (citationRequest === undefined) {
setHighlighted(false)
return
}
setHighlighted(true)
article.current?.focus({ preventScroll: true })
if (citationRequest === undefined) return;
article.current?.focus({ preventScroll: true });
article.current?.scrollIntoView({
block: 'center',
inline: 'center',
behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
? 'instant'
: 'smooth',
})
const timer = setTimeout(() => setHighlighted(false), 2500)
return () => clearTimeout(timer)
}, [citationRequest, citationSnapshot])
const concealed = Boolean(post.contentWarning || post.sensitive)
block: "center",
inline: "center",
behavior: window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
? "instant"
: "smooth",
});
const timer = setTimeout(() => setExpiredHighlight(highlightKey), 2500);
return () => clearTimeout(timer);
}, [citationRequest, highlightKey]);
const concealed = Boolean(post.contentWarning || post.sensitive);
return (
<article
ref={article}
tabIndex={-1}
className={`research-post${highlighted ? ' citation-highlight' : ''}`}
className={`research-post${highlighted ? " citation-highlight" : ""}`}
data-post-url={post.url}
>
{citationSnapshot ? (
<Badge variant="secondary">Post saved during research</Badge>
) : null}
{post.boostedBy ? (
<p className="handle">Boosted by {post.boostedBy.name}</p>
) : null}
{citationSnapshot ? <Badge variant="secondary">Post saved during research</Badge> : null}
{post.boostedBy ? <p className="handle">Boosted by {post.boostedBy.name}</p> : null}
<header>
<Avatar className="size-9 shrink-0">
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
@@ -65,9 +58,7 @@ export function ResearchPostCard({
</Avatar>
<div>
<strong>{post.author.name}</strong>
<span className="handle">
@{post.author.handle.replace(/^@/, '')}
</span>
<span className="handle">@{post.author.handle.replace(/^@/, "")}</span>
</div>
{post.createdAt ? (
<time dateTime={post.createdAt} title={post.createdAt}>
@@ -77,7 +68,7 @@ export function ResearchPostCard({
</header>
{concealed ? (
<div>
<p>{post.contentWarning || 'This post contains sensitive media'}</p>
<p>{post.contentWarning || "This post contains sensitive media"}</p>
<Button
variant="outline"
size="sm"
@@ -85,7 +76,7 @@ export function ResearchPostCard({
aria-expanded={revealed}
onClick={() => setRevealed(!revealed)}
>
{revealed ? 'Hide content' : 'Show content'}
{revealed ? "Hide content" : "Show content"}
</Button>
</div>
) : null}
@@ -94,7 +85,7 @@ export function ResearchPostCard({
{post.html !== undefined ? (
<div
className="research-post-text"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Mastodon HTML is sanitized by the server allowlist before normalization.
// Mastodon content trust boundary: Mastodon HTML is sanitized by the server allowlist before normalization.
dangerouslySetInnerHTML={{ __html: post.html }}
/>
) : (
@@ -103,21 +94,16 @@ export function ResearchPostCard({
</p>
)}
{post.media?.map((media) =>
media.type === 'photo' ? (
media.type === "photo" ? (
<img
className="research-media"
key={media.url}
src={media.url}
alt={media.alt ?? ''}
alt={media.alt ?? ""}
loading="lazy"
/>
) : (
<a
key={media.url}
href={media.url}
target="_blank"
rel="noreferrer noopener"
>
<a key={media.url} href={media.url} target="_blank" rel="noreferrer noopener">
Open video
</a>
),
@@ -128,11 +114,7 @@ export function ResearchPostCard({
<p>
<PostText text={post.quotedPost.text} />
</p>
<a
href={post.quotedPost.url}
target="_blank"
rel="noreferrer noopener"
>
<a href={post.quotedPost.url} target="_blank" rel="noreferrer noopener">
Open quoted post
</a>
</blockquote>
@@ -145,5 +127,5 @@ export function ResearchPostCard({
</a>
</footer>
</article>
)
);
}
+26 -28
View File
@@ -1,60 +1,58 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { legacyWorkspaceSchema } from './legacy-import'
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { legacyWorkspaceSchema } from "./legacy-import";
import {
type DeckPersistenceFailure,
deleteDeckInputSchema,
replaceDeckInputSchema,
savedDeckDefinitionSchema,
} from './persistence-model'
} from "./persistence-model";
async function run<T>(
operation: (
repository: typeof import('./repository.server'),
) => T | Promise<T>,
operation: (repository: typeof import("./repository.server")) => T | Promise<T>,
): Promise<{ ok: true; data: T } | DeckPersistenceFailure> {
const repository = await import('./repository.server')
const repository = await import("./repository.server");
try {
return { ok: true, data: await operation(repository) }
return { ok: true, data: await operation(repository) };
} catch (error) {
if (error instanceof repository.DeckPersistenceError)
return { ok: false, error: { code: error.code, message: error.message } }
return { ok: false, error: { code: error.code, message: error.message } };
if (error instanceof z.ZodError)
return {
ok: false,
error: {
code: 'invalid-input',
message: 'Check the deck details.',
code: "invalid-input",
message: "Check the deck details.",
},
}
};
return {
ok: false,
error: {
code: 'storage-unavailable',
message: 'Unable to save or load the deck. Please try again.',
code: "storage-unavailable",
message: "Unable to save or load the deck. Please try again.",
},
}
};
}
}
export const loadSavedDecks = createServerFn({ method: 'GET' }).handler(() =>
export const loadSavedDecks = createServerFn({ method: "GET" }).handler(() =>
run((repository) => repository.listDecks()),
)
export const createSavedDeck = createServerFn({ method: 'POST' })
);
export const createSavedDeck = createServerFn({ method: "POST" })
.validator(savedDeckDefinitionSchema)
.handler(({ data }) => run((repository) => repository.createDeck(data)))
export const replaceSavedDeck = createServerFn({ method: 'POST' })
.handler(({ data }) => run((repository) => repository.createDeck(data)));
export const replaceSavedDeck = createServerFn({ method: "POST" })
.validator(replaceDeckInputSchema)
.handler(({ data }) => run((repository) => repository.replaceDeck(data)))
export const deleteSavedDeck = createServerFn({ method: 'POST' })
.handler(({ data }) => run((repository) => repository.replaceDeck(data)));
export const deleteSavedDeck = createServerFn({ method: "POST" })
.validator(deleteDeckInputSchema)
.handler(({ data }) => run((repository) => repository.deleteDeck(data)))
.handler(({ data }) => run((repository) => repository.deleteDeck(data)));
export const importBrowserDecks = createServerFn({ method: 'POST' })
export const importBrowserDecks = createServerFn({ method: "POST" })
.validator(legacyWorkspaceSchema)
.handler(({ data }) =>
run(async () => {
const { importLegacyWorkspace } = await import('./import.server')
return importLegacyWorkspace(data)
const { importLegacyWorkspace } = await import("./import.server");
return importLegacyWorkspace(data);
}),
)
);
+25 -25
View File
@@ -1,37 +1,37 @@
import type { DeckColumn } from './model'
import type { DeckColumn } from "./model";
/** Describe the bound source once, keeping platform/kind decisions out of the column layout. */
export function describeSource(source: DeckColumn['source']): {
label: string
query: string
export function describeSource(source: DeckColumn["source"]): {
label: string;
query: string;
} {
switch (source.platform) {
case 'twitter':
case "twitter":
switch (source.kind) {
case 'search':
case "search":
return {
label: `${source.product === 'Latest' ? 'Latest' : 'Top'}${source.following ? ' · Following' : ''}`,
label: `${source.product === "Latest" ? "Latest" : "Top"}${source.following ? " · Following" : ""}`,
query: source.query,
}
case 'user':
return { label: 'User posts', query: source.target }
case 'list':
return { label: 'List', query: source.target }
};
case "user":
return { label: "User posts", query: source.target };
case "list":
return { label: "List", query: source.target };
}
break
case 'mastodon':
break;
case "mastodon":
switch (source.kind) {
case 'search':
return { label: 'Full-text search', query: source.query }
case 'user':
return { label: 'User posts', query: source.target }
case 'list':
return { label: 'List', query: source.target }
case 'hashtag':
return { label: 'Hashtag', query: `#${source.target}` }
case "search":
return { label: "Full-text search", query: source.query };
case "user":
return { label: "User posts", query: source.target };
case "list":
return { label: "List", query: source.target };
case "hashtag":
return { label: "Hashtag", query: `#${source.target}` };
}
break
break;
}
const exhaustive: never = source
return exhaustive
const exhaustive: never = source;
return exhaustive;
}
+56 -90
View File
@@ -1,32 +1,32 @@
import { type ReactElement, useId } from 'react'
import { Button } from '#/components/ui/button'
import { Checkbox } from '#/components/ui/checkbox'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { useListChoices } from '../posts/use-list-choices'
import type { DeckColumn } from './model'
import { type ReactElement, useId } from "react";
import { Button } from "#/components/ui/button";
import { Checkbox } from "#/components/ui/checkbox";
import { Input } from "#/components/ui/input";
import { Label } from "#/components/ui/label";
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
import { useListChoices } from "../posts/use-list-choices";
import type { DeckColumn } from "./model";
type Source = Extract<DeckColumn['source'], { platform: 'twitter' }>
type Source = Extract<DeckColumn["source"], { platform: "twitter" }>;
type EditorProps<T extends Source = Source> = {
source: T
onChange: (source: Source) => void
}
source: T;
onChange: (source: Source) => void;
};
export function defaultTwitterSource(kind: Source['kind'] = 'search'): Source {
export function defaultTwitterSource(kind: Source["kind"] = "search"): Source {
switch (kind) {
case 'search':
case "search":
return {
platform: 'twitter',
platform: "twitter",
kind,
query: '',
product: 'Latest',
query: "",
product: "Latest",
following: false,
}
case 'user':
return { platform: 'twitter', kind, target: '' }
case 'list':
return { platform: 'twitter', kind, target: '' }
};
case "user":
return { platform: "twitter", kind, target: "" };
case "list":
return { platform: "twitter", kind, target: "" };
}
}
@@ -35,7 +35,7 @@ export function TwitterSourceEditor({
connectionId,
onChange,
}: EditorProps & { connectionId: string }) {
const id = useId()
const id = useId();
return (
<>
<Label htmlFor={id}>
@@ -44,22 +44,16 @@ export function TwitterSourceEditor({
className="w-full"
id={id}
value={source.kind}
onChange={(event) =>
onChange(defaultTwitterSource(event.target.value as Source['kind']))
}
onChange={(event) => onChange(defaultTwitterSource(event.target.value as Source["kind"]))}
>
<NativeSelectOption value="search">Search</NativeSelectOption>
<NativeSelectOption value="user">User posts</NativeSelectOption>
<NativeSelectOption value="list">List</NativeSelectOption>
</NativeSelect>
</Label>
<TwitterSourceFields
source={source}
connectionId={connectionId}
onChange={onChange}
/>
<TwitterSourceFields source={source} connectionId={connectionId} onChange={onChange} />
</>
)
);
}
function TwitterSourceFields({
@@ -68,26 +62,20 @@ function TwitterSourceFields({
onChange,
}: EditorProps & { connectionId: string }): ReactElement {
switch (source.kind) {
case 'search':
return <TwitterSearchEditor source={source} onChange={onChange} />
case 'user':
return <TwitterUserEditor source={source} onChange={onChange} />
case 'list':
return (
<TwitterListEditor
source={source}
connectionId={connectionId}
onChange={onChange}
/>
)
case "search":
return <TwitterSearchEditor source={source} onChange={onChange} />;
case "user":
return <TwitterUserEditor source={source} onChange={onChange} />;
case "list":
return <TwitterListEditor source={source} connectionId={connectionId} onChange={onChange} />;
}
}
function TwitterSearchEditor({
source,
onChange,
}: EditorProps<Extract<Source, { kind: 'search' }>>) {
const id = useId()
}: EditorProps<Extract<Source, { kind: "search" }>>) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={`${id}-query`}>
@@ -95,9 +83,7 @@ function TwitterSearchEditor({
<Input
id={`${id}-query`}
value={source.query}
onChange={(event) =>
onChange({ ...source, query: event.target.value })
}
onChange={(event) => onChange({ ...source, query: event.target.value })}
maxLength={512}
placeholder="e.g. WebMCP lang:en"
required
@@ -112,7 +98,7 @@ function TwitterSearchEditor({
onChange={(event) =>
onChange({
...source,
product: event.target.value as 'Latest' | 'Top',
product: event.target.value as "Latest" | "Top",
})
}
>
@@ -120,27 +106,20 @@ function TwitterSearchEditor({
<NativeSelectOption value="Top">Top</NativeSelectOption>
</NativeSelect>
</Label>
<p className="deck-help">
Use Twitter search operators such as from:, lang:, and since:.
</p>
<p className="deck-help">Use Twitter search operators such as from:, lang:, and since:.</p>
<Label className="deck-checkbox">
<Checkbox
checked={source.following}
onCheckedChange={(checked) =>
onChange({ ...source, following: checked })
}
onCheckedChange={(checked) => onChange({ ...source, following: checked })}
/>
Following only
</Label>
</div>
)
);
}
function TwitterUserEditor({
source,
onChange,
}: EditorProps<Extract<Source, { kind: 'user' }>>) {
const id = useId()
function TwitterUserEditor({ source, onChange }: EditorProps<Extract<Source, { kind: "user" }>>) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -148,24 +127,22 @@ function TwitterUserEditor({
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
onChange={(event) => onChange({ ...source, target: event.target.value })}
placeholder="@handle or profile URL"
maxLength={256}
required
/>
</Label>
</div>
)
);
}
function TwitterListEditor({
source,
connectionId,
onChange,
}: EditorProps<Extract<Source, { kind: 'list' }>> & { connectionId: string }) {
const id = useId()
}: EditorProps<Extract<Source, { kind: "list" }>> & { connectionId: string }) {
const id = useId();
return (
<div className="deck-source-fields">
<Label htmlFor={id}>
@@ -173,9 +150,7 @@ function TwitterListEditor({
<Input
id={id}
value={source.target}
onChange={(event) =>
onChange({ ...source, target: event.target.value })
}
onChange={(event) => onChange({ ...source, target: event.target.value })}
placeholder="List URL or ID"
maxLength={256}
required
@@ -188,48 +163,39 @@ function TwitterListEditor({
/>
)}
</div>
)
);
}
function TwitterListChoices({
connectionId,
onSelect,
}: {
connectionId: string
onSelect: (target: string) => void
connectionId: string;
onSelect: (target: string) => void;
}) {
const lists = useListChoices(connectionId)
if (lists.isPending) return <p role="status">Loading lists…</p>
const lists = useListChoices(connectionId);
if (lists.isPending) return <p role="status">Loading lists…</p>;
if (lists.isError)
return (
<p role="alert">
Unable to load lists.
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
<Button variant="outline" type="button" onClick={() => void lists.refetch()}>
Retry
</Button>
</p>
)
);
return (
<section className="deck-list-choices" aria-label="Lists for this profile">
{lists.data.length === 0 ? (
<p>No lists found. You can enter a URL or ID instead.</p>
) : (
lists.data.map((list) => (
<Button
variant="outline"
type="button"
key={list.id}
onClick={() => onSelect(list.id)}
>
<Button variant="outline" type="button" key={list.id} onClick={() => onSelect(list.id)}>
{list.name}
{list.isPrivate ? ' · Private' : ''}
{list.isPrivate ? " · Private" : ""}
</Button>
))
)}
</section>
)
);
}
+248 -193
View File
@@ -1,214 +1,217 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, expect, it, vi } from 'vitest'
import { LEGACY_STORAGE_KEY } from './legacy-import'
import type { SavedDeck } from './persistence-model'
import { ACTIVE_DECK_KEY, useDeck } from './use-deck'
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { LEGACY_STORAGE_KEY } from "./legacy-import";
import type { SavedDeck } from "./persistence-model";
import { useDeck } from "./use-deck";
const navigation = vi.hoisted(() => ({
search: {} as { deck?: string; citation?: string },
set: vi.fn<
(patch: { deck?: string; citation?: string }, options?: { replace?: boolean }) => void
>(),
}));
vi.mock("../navigation/use-url-state", () => ({
useUrlState: () => [navigation.search, navigation.set],
}));
const server = vi.hoisted(() => ({
load: vi.fn(),
create: vi.fn(),
replace: vi.fn(),
remove: vi.fn(),
import: vi.fn(),
}))
vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn }))
vi.mock('./server-functions', () => ({
load: vi.fn<typeof import("./server-functions").loadSavedDecks>(),
create: vi.fn<typeof import("./server-functions").createSavedDeck>(),
replace: vi.fn<typeof import("./server-functions").replaceSavedDeck>(),
remove: vi.fn<typeof import("./server-functions").deleteSavedDeck>(),
import: vi.fn<typeof import("./server-functions").importBrowserDecks>(),
}));
vi.mock("@tanstack/react-start", () => ({ useServerFn: (fn: unknown) => fn }));
vi.mock("./server-functions", () => ({
loadSavedDecks: server.load,
createSavedDeck: server.create,
replaceSavedDeck: server.replace,
deleteSavedDeck: server.remove,
importBrowserDecks: server.import,
}))
}));
const saved: SavedDeck = {
id: 'saved',
title: 'Shared research',
id: "saved",
title: "Shared research",
columns: [],
revision: 1,
createdAt: 1,
updatedAt: 1,
}
};
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
server.load.mockResolvedValue({ ok: true, data: [] })
navigation.search = {};
localStorage.clear();
vi.clearAllMocks();
server.load.mockResolvedValue({ ok: true, data: [] });
server.create.mockImplementation(async ({ data }) => ({
ok: true,
data: { ...data, revision: 1, createdAt: 1, updatedAt: 1 },
}))
})
}));
});
it('creates and edits temporary views without writing shared decks', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
it("creates and edits temporary views without writing shared decks", async () => {
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
result.current.createTemporary('AI research')
})
const id = result.current.deck.id
result.current.createTemporary("AI research");
});
const id = result.current.deck.id;
await act(async () => {
await result.current.save({ id, title: 'Revised research', columns: [] })
})
await result.current.save({ id, title: "Revised research", columns: [] });
});
expect(result.current.deck).toMatchObject({
title: 'Revised research',
title: "Revised research",
persisted: false,
})
expect(server.create).not.toHaveBeenCalled()
expect(server.replace).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
});
expect(server.create).not.toHaveBeenCalled();
expect(server.replace).not.toHaveBeenCalled();
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull();
await act(async () => {
await result.current.persist(id)
})
await result.current.persist(id);
});
expect(result.current.deck).toMatchObject({
id,
persisted: true,
revision: 1,
})
expect(server.create).toHaveBeenCalledTimes(1)
});
expect(server.create).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.persist(id)
})
expect(server.create).toHaveBeenCalledTimes(1)
})
await result.current.persist(id);
});
expect(server.create).toHaveBeenCalledTimes(1);
});
it('keeps a temporary view on failed save and retries with the same identity', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
it("keeps a temporary view on failed save and retries with the same identity", async () => {
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
result.current.createTemporary('Keep this')
})
const id = result.current.deck.id
result.current.createTemporary("Keep this");
});
const id = result.current.deck.id;
server.create.mockResolvedValueOnce({
ok: false,
error: { message: 'Server disk unavailable' },
})
error: { code: "storage-unavailable", message: "Server disk unavailable" },
});
await act(async () => {
expect(await result.current.persist(id)).toBe(false)
})
expect(await result.current.persist(id)).toBe(false);
});
expect(result.current.deck).toMatchObject({
id,
title: 'Keep this',
title: "Keep this",
persisted: false,
})
expect(result.current.storageError).toBe('Server disk unavailable')
});
expect(result.current.storageError).toBe("Server disk unavailable");
await act(async () => {
expect(await result.current.persist(id)).toBe(true)
})
expect(server.create.mock.calls.map((call) => call[0].data.id)).toEqual([
id,
id,
])
})
expect(await result.current.persist(id)).toBe(true);
});
expect(server.create.mock.calls.map((call) => call[0].data.id)).toEqual([id, id]);
});
it('retains the accepted revision and surfaces conflicts instead of claiming failed edits were saved', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
it("retains the accepted revision and surfaces conflicts instead of claiming failed edits were saved", async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] });
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
server.replace.mockResolvedValue({
ok: false,
error: { message: 'Revision conflict' },
})
error: { code: "storage-unavailable", message: "Revision conflict" },
});
await act(async () => {
expect(await result.current.save({ ...saved, title: 'My draft' })).toBe(
false,
)
})
expect(result.current.deck).toMatchObject({ title: saved.title, revision: 1 })
expect(result.current.storageError).toBe('Revision conflict')
expect(await result.current.save({ ...saved, title: "My draft" })).toBe(false);
});
expect(result.current.deck).toMatchObject({ title: saved.title, revision: 1 });
expect(result.current.storageError).toBe("Revision conflict");
expect(server.replace).toHaveBeenCalledWith({
data: {
deck: { id: saved.id, title: 'My draft', columns: [] },
deck: { id: saved.id, title: "My draft", columns: [] },
expectedRevision: 1,
},
})
})
});
});
it('refreshes on focus but holds remote updates while a form is being edited', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
it("refreshes on focus but holds remote updates while a form is being edited", async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] });
const { result, rerender } = renderHook(({ editing }) => useDeck(editing), {
initialProps: { editing: false },
})
await waitFor(() => expect(result.current.ready).toBe(true))
rerender({ editing: true })
});
await waitFor(() => expect(result.current.ready).toBe(true));
rerender({ editing: true });
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Remote edit', revision: 2 }],
})
data: [{ ...saved, title: "Remote edit", revision: 2 }],
});
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
expect(server.load).toHaveBeenCalledTimes(1)
expect(result.current.deck.title).toBe(saved.title)
rerender({ editing: false })
window.dispatchEvent(new Event("focus"));
});
expect(server.load).toHaveBeenCalledTimes(1);
expect(result.current.deck.title).toBe(saved.title);
rerender({ editing: false });
await act(async () => {
window.dispatchEvent(new Event('focus'))
})
window.dispatchEvent(new Event("focus"));
});
expect(result.current.deck).toMatchObject({
title: 'Remote edit',
title: "Remote edit",
revision: 2,
})
})
});
});
it('keeps the active deck local and copies saved content into a separate temporary view', async () => {
it("restores the active deck from the URL and copies saved content into a separate temporary view", async () => {
server.load.mockResolvedValue({
ok: true,
data: [saved, { ...saved, id: 'second', title: 'Second' }],
})
localStorage.setItem(ACTIVE_DECK_KEY, 'second')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.deck.id).toBe('second'))
data: [saved, { ...saved, id: "second", title: "Second" }],
});
navigation.search = { deck: "second" };
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.deck.id).toBe("second"));
act(() => {
result.current.createTemporary('A copy', result.current.deck.columns)
})
const copy = result.current.deck
expect(copy.persisted).toBe(false)
expect(copy.id).not.toBe('second')
result.current.createTemporary("A copy", result.current.deck.columns);
});
const copy = result.current.deck;
expect(copy.persisted).toBe(false);
expect(copy.id).not.toBe("second");
await act(async () => {
await result.current.save({ ...copy, title: 'Changed copy' })
})
expect(
result.current.workspace.decks.find((deck) => deck.id === 'second')?.title,
).toBe('Second')
expect(server.replace).not.toHaveBeenCalled()
})
await result.current.save({ ...copy, title: "Changed copy" });
});
expect(result.current.workspace.decks.find((deck) => deck.id === "second")?.title).toBe("Second");
expect(server.replace).not.toHaveBeenCalled();
});
it('does not import old browser data without an explicit action', async () => {
it("does not import old browser data without an explicit action", async () => {
const legacy = {
version: 2,
activeDeckId: 'old',
decks: [{ id: 'old', title: 'Old deck', columns: [] }],
}
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(legacy))
server.import.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.legacyAvailable).toBe(true)
expect(server.import).not.toHaveBeenCalled()
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe(JSON.stringify(legacy))
activeDeckId: "old",
decks: [{ id: "old", title: "Old deck", columns: [] }],
};
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(legacy));
server.import.mockResolvedValue({ ok: true, data: [saved] });
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.legacyAvailable).toBe(true);
expect(server.import).not.toHaveBeenCalled();
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe(JSON.stringify(legacy));
await act(async () => {
await result.current.importLegacy()
})
expect(server.import).toHaveBeenCalledWith({ data: legacy })
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull()
})
await result.current.importLegacy();
});
expect(server.import).toHaveBeenCalledWith({ data: legacy });
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBeNull();
});
it('leaves unreadable legacy data untouched', async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, '{broken')
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
expect(result.current.storageError).toContain('Unable to load')
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe('{broken')
})
it("leaves unreadable legacy data untouched", async () => {
localStorage.setItem(LEGACY_STORAGE_KEY, "{broken");
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.storageError).toContain("Unable to load");
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).toBe("{broken");
});
it('recognizes a committed save after a lost response without duplicating its temporary view', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
it("recognizes a committed save after a lost response without duplicating its temporary view", async () => {
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
result.current.createTemporary('Lost response')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
result.current.createTemporary("Lost response");
});
const view = result.current.deck;
server.create.mockRejectedValueOnce(new Error("Network disconnected"));
await act(async () => {
await result.current.persist(view.id)
})
await result.current.persist(view.id);
});
server.load.mockResolvedValue({
ok: true,
data: [
@@ -221,30 +224,28 @@ it('recognizes a committed save after a lost response without duplicating its te
updatedAt: 1,
},
],
})
});
await act(async () => {
await result.current.refresh()
})
expect(
result.current.workspace.decks.filter((deck) => deck.id === view.id),
).toHaveLength(1)
expect(result.current.deck.persisted).toBe(true)
})
await result.current.refresh();
});
expect(result.current.workspace.decks.filter((deck) => deck.id === view.id)).toHaveLength(1);
expect(result.current.deck.persisted).toBe(true);
});
it('preserves a divergent temporary edit when a lost-response save appears remotely', async () => {
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
it("preserves a divergent temporary edit when a lost-response save appears remotely", async () => {
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
result.current.createTemporary('Before request')
})
const view = result.current.deck
server.create.mockRejectedValueOnce(new Error('Network disconnected'))
result.current.createTemporary("Before request");
});
const view = result.current.deck;
server.create.mockRejectedValueOnce(new Error("Network disconnected"));
await act(async () => {
await result.current.persist(view.id)
})
await result.current.persist(view.id);
});
await act(async () => {
await result.current.save({ ...view, title: 'Edited after failure' })
})
await result.current.save({ ...view, title: "Edited after failure" });
});
server.load.mockResolvedValue({
ok: true,
data: [
@@ -257,47 +258,101 @@ it('preserves a divergent temporary edit when a lost-response save appears remot
updatedAt: 1,
},
],
})
});
await act(async () => {
await result.current.refresh()
})
await result.current.refresh();
});
expect(result.current.deck).toMatchObject({
title: 'Edited after failure',
title: "Edited after failure",
persisted: false,
})
expect(result.current.deck.id).not.toBe(view.id)
expect(
result.current.workspace.decks.find((deck) => deck.id === view.id),
).toMatchObject({ title: 'Before request', persisted: true })
})
});
expect(result.current.deck.id).not.toBe(view.id);
expect(result.current.workspace.decks.find((deck) => deck.id === view.id)).toMatchObject({
title: "Before request",
persisted: true,
});
});
it('ignores an older refresh response after a newer request has completed', async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] })
const { result } = renderHook(() => useDeck())
await waitFor(() => expect(result.current.ready).toBe(true))
let resolveOlder: (value: unknown) => void = () => {}
it("ignores an older refresh response after a newer request has completed", async () => {
server.load.mockResolvedValue({ ok: true, data: [saved] });
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
let resolveOlder: (value: Awaited<ReturnType<typeof server.load>>) => void = () => {};
server.load.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOlder = resolve
resolveOlder = resolve;
}),
)
);
act(() => {
void result.current.refresh()
})
void result.current.refresh();
});
server.load.mockResolvedValue({
ok: true,
data: [{ ...saved, title: 'Newest', revision: 3 }],
})
data: [{ ...saved, title: "Newest", revision: 3 }],
});
await act(async () => {
await result.current.refresh()
})
expect(result.current.deck.revision).toBe(3)
await result.current.refresh();
});
expect(result.current.deck.revision).toBe(3);
await act(async () => {
resolveOlder({
ok: true,
data: [{ ...saved, title: 'Older', revision: 2 }],
})
})
expect(result.current.deck).toMatchObject({ title: 'Newest', revision: 3 })
})
data: [{ ...saved, title: "Older", revision: 2 }],
});
});
expect(result.current.deck).toMatchObject({ title: "Newest", revision: 3 });
});
it("restores deck selection when browser history changes", async () => {
server.load.mockResolvedValue({
ok: true,
data: [saved, { ...saved, id: "second" }],
});
navigation.search = { deck: "second" };
const { result, rerender } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.deck.id).toBe("second"));
act(() => {
navigation.search = { deck: "saved" };
rerender();
});
expect(result.current.deck.id).toBe("saved");
act(() => {
result.current.select("second");
});
expect(navigation.set).toHaveBeenCalledWith({
deck: "second",
citation: undefined,
});
});
it("reconstructs a temporary research deck with its bookmarked identity without writing saved data", async () => {
navigation.search = { deck: "bookmarked-plan" };
server.load.mockResolvedValue({ ok: true, data: [saved] });
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
const view = result.current.createTemporary("Restored plan", [], false, "bookmarked-plan");
result.current.select(view.id, false);
});
expect(result.current.deck).toMatchObject({
id: "bookmarked-plan",
title: "Restored plan",
persisted: false,
});
expect(navigation.set).not.toHaveBeenCalled();
expect(server.create).not.toHaveBeenCalled();
expect(server.replace).not.toHaveBeenCalled();
});
it("preserves a bookmarked citation when restoring the already requested deck", async () => {
navigation.search = { deck: saved.id, citation: "twitter:123" };
server.load.mockResolvedValue({ ok: true, data: [saved] });
const { result } = renderHook(() => useDeck());
await waitFor(() => expect(result.current.ready).toBe(true));
act(() => {
result.current.select(saved.id);
});
expect(result.current.deck.id).toBe(saved.id);
expect(navigation.set).not.toHaveBeenCalled();
});
+203 -188
View File
@@ -1,6 +1,7 @@
import { useServerFn } from '@tanstack/react-start'
import { useCallback, useEffect, useRef, useState } from 'react'
import { LEGACY_STORAGE_KEY, legacyWorkspaceSchema } from './legacy-import'
import { useServerFn } from "@tanstack/react-start";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useUrlState } from "../navigation/use-url-state";
import { LEGACY_STORAGE_KEY, legacyWorkspaceSchema } from "./legacy-import";
import {
type Deck,
type DeckView,
@@ -9,17 +10,32 @@ import {
EMPTY_WORKSPACE,
newId,
type Workspace,
} from './model'
import type { SavedDeck } from './persistence-model'
} from "./model";
import type { SavedDeck } from "./persistence-model";
import {
createSavedDeck,
deleteSavedDeck,
importBrowserDecks,
loadSavedDecks,
replaceSavedDeck,
} from './server-functions'
} from "./server-functions";
const subscribeToLegacy = (notify: () => void) => {
window.addEventListener("storage", notify);
return () => window.removeEventListener("storage", notify);
};
function getLegacyStatus(): "none" | "available" | "invalid" {
try {
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
if (!raw) return "none";
legacyWorkspaceSchema.parse(JSON.parse(raw));
return "available";
} catch {
return "invalid";
}
}
const getServerLegacyStatus = () => "none" as const;
export const ACTIVE_DECK_KEY = 'twitter-lite-active-deck'
function asView(deck: SavedDeck): DeckView {
return {
id: deck.id,
@@ -27,193 +43,175 @@ function asView(deck: SavedDeck): DeckView {
columns: deck.columns,
revision: deck.revision,
persisted: true,
}
};
}
function definition(deck: Deck): Deck {
return deckSchema.parse({
id: deck.id,
title: deck.title,
columns: deck.columns,
})
});
}
export function useDeck(editing = false) {
const [search, setSearch] = useUrlState();
const url = useRef({ search, setSearch });
useEffect(() => {
url.current = { search, setSearch };
}, [search, setSearch]);
const services = {
load: useServerFn(loadSavedDecks),
create: useServerFn(createSavedDeck),
replace: useServerFn(replaceSavedDeck),
remove: useServerFn(deleteSavedDeck),
import: useServerFn(importBrowserDecks),
}
const servicesRef = useRef(services)
servicesRef.current = services
const editingRef = useRef(editing)
editingRef.current = editing
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE)
const current = useRef(workspace)
const [ready, setReady] = useState(false)
const initialized = useRef(false)
const [storageError, setStorageError] = useState<string>()
const [saving, setSaving] = useState(false)
const busy = useRef(false)
const generation = useRef(0)
const refreshSequence = useRef(0)
const [legacyAvailable, setLegacyAvailable] = useState(false)
};
const servicesRef = useRef(services);
useEffect(() => {
servicesRef.current = services;
});
const editingRef = useRef(editing);
useEffect(() => {
editingRef.current = editing;
}, [editing]);
const [workspace, setWorkspace] = useState<Workspace>(EMPTY_WORKSPACE);
const current = useRef(workspace);
const [ready, setReady] = useState(false);
const initialized = useRef(false);
const [storageError, setStorageError] = useState<string>();
const [saving, setSaving] = useState(false);
const busy = useRef(false);
const generation = useRef(0);
const refreshSequence = useRef(0);
const legacyStatus = useSyncExternalStore(
subscribeToLegacy,
getLegacyStatus,
getServerLegacyStatus,
);
const [legacyImported, setLegacyImported] = useState(false);
const legacyAvailable = !legacyImported && legacyStatus === "available";
const apply = useCallback((next: Workspace) => {
current.current = next
setWorkspace(next)
try {
localStorage.setItem(ACTIVE_DECK_KEY, next.activeDeckId)
} catch {
/* Selection is a device-local preference, not saved deck data. */
}
}, [])
current.current = next;
setWorkspace(next);
}, []);
const reconcile = useCallback(
(saved: SavedDeck[]) => {
let preferred = current.current.activeDeckId
let temporary = current.current.decks.filter((deck) => !deck.persisted)
let preferred = url.current.search.deck ?? current.current.activeDeckId;
let temporary = current.current.decks.filter((deck) => !deck.persisted);
if (!initialized.current)
temporary = saved.length
? []
: [{ ...EMPTY_DECK, id: newId(), persisted: false }]
temporary = saved.length ? [] : [{ ...EMPTY_DECK, id: newId(), persisted: false }];
temporary = temporary.flatMap((view) => {
const accepted = saved.find((deck) => deck.id === view.id)
if (!accepted) return [view]
if (
JSON.stringify(definition(accepted)) ===
JSON.stringify(definition(view))
)
return []
const id = newId()
if (preferred === view.id) preferred = id
setStorageError(
'Your unsaved changes have been kept as a temporary copy.',
)
return [{ ...view, id }]
})
const all = [...saved.map(asView), ...temporary]
if (!all.length)
all.push({ ...EMPTY_DECK, id: newId(), persisted: false })
if (!initialized.current) {
try {
preferred = localStorage.getItem(ACTIVE_DECK_KEY) ?? preferred
} catch {
/* Optional local preference. */
}
}
const accepted = saved.find((deck) => deck.id === view.id);
if (!accepted) return [view];
if (JSON.stringify(definition(accepted)) === JSON.stringify(definition(view))) return [];
const id = newId();
if (preferred === view.id) preferred = id;
setStorageError("Your unsaved changes have been kept as a temporary copy.");
return [{ ...view, id }];
});
const all = [...saved.map(asView), ...temporary];
if (!all.length) all.push({ ...EMPTY_DECK, id: newId(), persisted: false });
const activeDeckId = all.some((deck) => deck.id === preferred)
? preferred
: (all[0]?.id ?? '')
: (all[0]?.id ?? "");
if (initialized.current && activeDeckId !== preferred)
setStorageError('This deck was deleted on another device.')
initialized.current = true
apply({ activeDeckId, decks: all })
setStorageError("This deck was deleted on another device.");
initialized.current = true;
apply({ activeDeckId, decks: all });
},
[apply],
)
);
const refresh = useCallback(
async (force = false) => {
if (busy.current || (!force && editingRef.current)) return false
const requestGeneration = generation.current
const requestSequence = ++refreshSequence.current
if (busy.current || (!force && editingRef.current)) return false;
const requestGeneration = generation.current;
const requestSequence = ++refreshSequence.current;
try {
const result = await servicesRef.current.load()
const result = await servicesRef.current.load();
if (
requestGeneration !== generation.current ||
requestSequence !== refreshSequence.current ||
busy.current ||
(!force && editingRef.current)
)
return false
if (!result.ok) throw new Error(result.error.message)
if (force) setStorageError(undefined)
reconcile(result.data)
setReady(true)
return true
return false;
if (!result.ok) throw new Error(result.error.message);
if (force) setStorageError(undefined);
reconcile(result.data);
setReady(true);
return true;
} catch {
if (
requestSequence !== refreshSequence.current ||
requestGeneration !== generation.current
)
return false
setStorageError('Unable to load saved decks. Please try again.')
return false
if (requestSequence !== refreshSequence.current || requestGeneration !== generation.current)
return false;
setStorageError("Unable to load saved decks. Please try again.");
return false;
}
},
[reconcile],
)
);
useEffect(() => {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
if (legacy) {
legacyWorkspaceSchema.parse(JSON.parse(legacy))
setLegacyAvailable(true)
}
} catch {
setStorageError(
'Unable to load legacy decks. Your browser data has not been changed.',
)
}
void refresh()
// oxlint-disable-next-line react/set-state-in-effect -- Loading saved decks synchronizes external storage; refresh updates state only after awaiting the server.
void refresh();
const onFocus = () => {
if (document.visibilityState === 'visible') void refresh()
}
const timer = setInterval(onFocus, 5000)
window.addEventListener('focus', onFocus)
if (document.visibilityState === "visible") void refresh();
};
const timer = setInterval(onFocus, 5000);
window.addEventListener("focus", onFocus);
return () => {
clearInterval(timer)
window.removeEventListener('focus', onFocus)
}
}, [refresh])
clearInterval(timer);
window.removeEventListener("focus", onFocus);
};
}, [refresh]);
async function mutate<T>(
operation: () => Promise<T>,
accept: (value: T) => void,
) {
useEffect(() => {
if (!ready) return;
const selected =
current.current.decks.find((deck) => deck.id === search.deck) ?? current.current.decks[0];
if (selected && current.current.activeDeckId !== selected.id)
apply({ ...current.current, activeDeckId: selected.id });
}, [search.deck, ready, apply]);
async function mutate<T>(operation: () => Promise<T>, accept: (value: T) => void) {
if (busy.current) {
setStorageError('Please wait until saving finishes.')
return false
setStorageError("Please wait until saving finishes.");
return false;
}
busy.current = true
generation.current += 1
setSaving(true)
busy.current = true;
generation.current += 1;
setSaving(true);
try {
const result = await operation()
accept(result)
setStorageError(undefined)
return true
const result = await operation();
accept(result);
setStorageError(undefined);
return true;
} catch (error) {
setStorageError(
error instanceof Error
? error.message
: 'Unable to save. Please try again.',
)
return false
setStorageError(error instanceof Error ? error.message : "Unable to save. Please try again.");
return false;
} finally {
busy.current = false
setSaving(false)
busy.current = false;
setSaving(false);
}
}
function upsert(view: DeckView) {
const previous = current.current
const previous = current.current;
apply({
activeDeckId: view.id,
decks: previous.decks.some((deck) => deck.id === view.id)
? previous.decks.map((deck) => (deck.id === view.id ? view : deck))
: [...previous.decks, view],
})
});
}
async function save(next: Deck, expectedRevision?: number) {
const parsed = definition(next)
const existing = current.current.decks.find((deck) => deck.id === parsed.id)
const parsed = definition(next);
const existing = current.current.decks.find((deck) => deck.id === parsed.id);
if (!existing?.persisted) {
if (busy.current) return false
generation.current += 1
upsert({ ...parsed, persisted: false })
return true
if (busy.current) return false;
generation.current += 1;
upsert({ ...parsed, persisted: false });
return true;
}
return mutate(
async () => {
@@ -222,103 +220,120 @@ export function useDeck(editing = false) {
deck: parsed,
expectedRevision: expectedRevision ?? existing.revision ?? 0,
},
})
if (!result.ok) throw new Error(result.error.message)
return result.data
});
if (!result.ok) throw new Error(result.error.message);
return result.data;
},
(result) => upsert(asView(result)),
)
);
}
function select(id: string) {
if (!current.current.decks.some((deck) => deck.id === id))
throw new Error('Deck not found.')
apply({ ...current.current, activeDeckId: id })
return true
function select(id: string, updateUrl = true) {
if (!current.current.decks.some((deck) => deck.id === id)) throw new Error("Deck not found.");
apply({ ...current.current, activeDeckId: id });
if (updateUrl && url.current.search.deck !== id)
url.current.setSearch({ deck: id, citation: undefined });
return true;
}
function createTemporary(title: string, columns: Deck['columns'] = []) {
function createTemporary(
title: string,
columns: Deck["columns"] = [],
activate = true,
preferredId?: string,
) {
const view = {
...definition({ id: newId(), title, columns }),
...definition({
id:
preferredId && !current.current.decks.some((deck) => deck.id === preferredId)
? preferredId
: newId(),
title,
columns,
}),
persisted: false,
}
upsert(view)
return view
};
upsert(view);
if (activate) url.current.setSearch({ deck: view.id, citation: undefined });
return view;
}
async function persist(id: string) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('Deck not found.')
if (view.persisted) return true
const view = current.current.decks.find((deck) => deck.id === id);
if (!view) throw new Error("Deck not found.");
if (view.persisted) return true;
return mutate(
async () => {
const result = await servicesRef.current.create({
data: definition(view),
})
if (!result.ok) throw new Error(result.error.message)
return result.data
});
if (!result.ok) throw new Error(result.error.message);
return result.data;
},
(result) => upsert(asView(result)),
)
);
}
async function create(title: string) {
const view = createTemporary(title)
return persist(view.id)
const view = createTemporary(title);
return persist(view.id);
}
async function remove(id: string, expectedRevision?: number) {
const view = current.current.decks.find((deck) => deck.id === id)
if (!view) throw new Error('Deck not found.')
const view = current.current.decks.find((deck) => deck.id === id);
if (!view) throw new Error("Deck not found.");
const accept = () => {
const previous = current.current
const decks = previous.decks.filter((deck) => deck.id !== id)
if (!decks.length)
decks.push({ ...EMPTY_DECK, id: newId(), persisted: false })
const previous = current.current;
const decks = previous.decks.filter((deck) => deck.id !== id);
if (!decks.length) decks.push({ ...EMPTY_DECK, id: newId(), persisted: false });
apply({
activeDeckId:
previous.activeDeckId === id
? (decks[0]?.id ?? '')
: previous.activeDeckId,
activeDeckId: previous.activeDeckId === id ? (decks[0]?.id ?? "") : previous.activeDeckId,
decks,
})
}
});
if (previous.activeDeckId === id)
url.current.setSearch({ deck: decks[0]?.id, citation: undefined }, { replace: true });
};
if (!view.persisted) {
if (busy.current) return false
accept()
return true
if (busy.current) return false;
accept();
return true;
}
return mutate(async () => {
const result = await servicesRef.current.remove({
data: { id, expectedRevision: expectedRevision ?? view.revision ?? 0 },
})
if (!result.ok) throw new Error(result.error.message)
}, accept)
});
if (!result.ok) throw new Error(result.error.message);
}, accept);
}
async function importLegacy() {
return mutate(
async () => {
const data = legacyWorkspaceSchema.parse(
JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) ?? 'null'),
)
const result = await servicesRef.current.import({ data })
if (!result.ok) throw new Error(result.error.message)
return result.data
JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) ?? "null"),
);
const result = await servicesRef.current.import({ data });
if (!result.ok) throw new Error(result.error.message);
return result.data;
},
(result) => {
reconcile(result)
setLegacyAvailable(false)
reconcile(result);
setLegacyImported(true);
try {
localStorage.removeItem(LEGACY_STORAGE_KEY)
localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch {
/* Server marker prevents duplicate imports on retry. */
}
},
)
);
}
return {
deck: workspace.decks.find(
(deck) => deck.id === workspace.activeDeckId,
) ?? { ...EMPTY_DECK, persisted: false },
deck: workspace.decks.find((deck) => deck.id === workspace.activeDeckId) ?? {
...EMPTY_DECK,
persisted: false,
},
workspace,
ready,
saving,
storageError,
storageError:
storageError ??
(legacyStatus === "invalid"
? "Unable to load legacy decks. Your browser data has not been changed."
: undefined),
legacyAvailable,
save,
create,
@@ -329,5 +344,5 @@ export function useDeck(editing = false) {
refresh,
importLegacy,
getWorkspace: () => current.current,
}
};
}
+7 -6
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'
import { useSyncExternalStore } from "react";
const subscribe = () => () => {};
const getSnapshot = () => Boolean(document.modelContext);
const getServerSnapshot = () => false;
export function useWebMCPSupported() {
const [supported, setSupported] = useState(false)
useEffect(() => {
setSupported(Boolean(document.modelContext))
}, [])
return supported
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
+71 -89
View File
@@ -1,140 +1,122 @@
import { describe, expect, it } from 'vitest'
import type { Connection } from '#/features/connections/model'
import { prepareDeck } from './webmcp-contracts'
import { describe, expect, it } from "vitest";
import type { Connection } from "#/features/connections/model";
import { prepareDeck } from "./webmcp-contracts";
const account = (id: string): Connection => ({
id,
platform: 'twitter',
origin: 'https://relay.invalid',
platform: "twitter",
origin: "https://relay.invalid",
accountId: null,
displayName: id,
status: 'connected',
})
status: "connected",
});
const column = {
title: '観点',
connectionId: 'main',
source: { kind: 'search', query: 'AI' },
}
describe('deck tool input', () => {
it('fills Twitter defaults and assigns independent deck and column IDs', () => {
const deck = prepareDeck({ title: '調査', columns: [column, column] }, [
account('main'),
])
title: "観点",
connectionId: "main",
source: { kind: "search", query: "AI" },
};
describe("deck tool input", () => {
it("fills Twitter defaults and assigns independent deck and column IDs", () => {
const deck = prepareDeck({ title: "調査", columns: [column, column] }, [account("main")]);
expect(deck.columns[0]?.source).toEqual({
platform: 'twitter',
kind: 'search',
query: 'AI',
product: 'Latest',
platform: "twitter",
kind: "search",
query: "AI",
product: "Latest",
following: false,
})
});
expect(new Set([deck.id, ...deck.columns.map((column) => column.id)]).size).toBe(3);
});
it("keeps supplied identities and rejects duplicates", () => {
const identified = { ...column, id: "existing" };
expect(
new Set([deck.id, ...deck.columns.map((column) => column.id)]).size,
).toBe(3)
})
it('keeps supplied identities and rejects duplicates', () => {
const identified = { ...column, id: 'existing' }
expect(
prepareDeck(
{ deckId: 'research', title: '調査', columns: [identified] },
[account('main')],
),
).toMatchObject({ id: 'research', columns: [{ id: 'existing' }] })
prepareDeck({ deckId: "research", title: "調査", columns: [identified] }, [account("main")]),
).toMatchObject({ id: "research", columns: [{ id: "existing" }] });
expect(() =>
prepareDeck({ title: '調査', columns: [identified, identified] }, [
account('main'),
]),
).toThrow()
})
it('rejects unavailable profiles before applying a definition', () => {
expect(() =>
prepareDeck({ title: '調査', columns: [column] }, [account('work')]),
).toThrow('Unavailable connection')
})
it('accepts user and list columns bound to separate profiles', () => {
prepareDeck({ title: "調査", columns: [identified, identified] }, [account("main")]),
).toThrow("Column IDs must be unique.");
});
it("rejects unavailable profiles before applying a definition", () => {
expect(() => prepareDeck({ title: "調査", columns: [column] }, [account("work")])).toThrow(
"Unavailable connection",
);
});
it("accepts user and list columns bound to separate profiles", () => {
const deck = prepareDeck(
{
title: '調査',
title: "調査",
columns: [
{ ...column, source: { kind: 'user', target: '@alice' } },
{ ...column, source: { kind: "user", target: "@alice" } },
{
...column,
connectionId: 'work',
source: { kind: 'list', target: '123' },
connectionId: "work",
source: { kind: "list", target: "123" },
},
],
},
[account('main'), account('work')],
)
expect(deck.columns.map((column) => column.connectionId)).toEqual([
'main',
'work',
])
expect(deck.columns.map((column) => column.source.kind)).toEqual([
'user',
'list',
])
})
it('rejects unsupported platforms, overlong queries and more than six columns', () => {
[account("main"), account("work")],
);
expect(deck.columns.map((column) => column.connectionId)).toEqual(["main", "work"]);
expect(deck.columns.map((column) => column.source.kind)).toEqual(["user", "list"]);
});
it("rejects unsupported platforms, overlong queries and more than six columns", () => {
expect(() =>
prepareDeck(
{
title: '調査',
title: "調査",
columns: [
{
...column,
source: { kind: 'search', platform: 'nostr', query: 'AI' },
source: { kind: "search", platform: "nostr", query: "AI" },
},
],
},
[account('main')],
[account("main")],
),
).toThrow()
).toThrow("Invalid input");
expect(() =>
prepareDeck(
{
title: '調査',
title: "調査",
columns: [
{
...column,
source: {
kind: 'search',
query: 'a'.repeat(512),
kind: "search",
query: "a".repeat(512),
following: true,
},
},
],
},
[account('main')],
[account("main")],
),
).toThrow()
).toThrow("512 characters");
expect(() =>
prepareDeck(
{ title: '調査', columns: Array.from({ length: 7 }, () => column) },
[account('main')],
),
).toThrow()
})
})
prepareDeck({ title: "調査", columns: Array.from({ length: 7 }, () => column) }, [
account("main"),
]),
).toThrow("Too big");
});
});
it('builds a mixed-platform deck with explicit account bindings', () => {
it("builds a mixed-platform deck with explicit account bindings", () => {
const deck = prepareDeck(
{
title: 'Mixed',
title: "Mixed",
columns: [
column,
{
title: 'Mastodon tag',
connectionId: 'masto',
source: { platform: 'mastodon', kind: 'hashtag', target: 'WebMCP' },
title: "Mastodon tag",
connectionId: "masto",
source: { platform: "mastodon", kind: "hashtag", target: "WebMCP" },
},
],
},
[account('main'), { ...account('masto'), platform: 'mastodon' }],
)
expect(
deck.columns.map((column) => [column.connectionId, column.source.platform]),
).toEqual([
['main', 'twitter'],
['masto', 'mastodon'],
])
})
[account("main"), { ...account("masto"), platform: "mastodon" }],
);
expect(deck.columns.map((column) => [column.connectionId, column.source.platform])).toEqual([
["main", "twitter"],
["masto", "mastodon"],
]);
});
+37 -49
View File
@@ -1,82 +1,73 @@
import { z } from 'zod'
import type { Connection } from '#/features/connections/model'
import { mastodonSourceSchema } from '#/features/platforms/mastodon-source'
import { InputError } from '#/features/posts/inputs'
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from './model'
import { z } from "zod";
import type { Connection } from "#/features/connections/model";
import { mastodonSourceSchema } from "#/features/platforms/mastodon-source";
import { InputError } from "#/features/posts/inputs";
import { columnSchema, deckSchema, MAX_COLUMNS, newId } from "./model";
const toolColumn = columnSchema.omit({ id: true, source: true }).extend({
id: columnSchema.shape.id
.optional()
.describe(
'Keep an existing column ID when editing; omit for a new column.',
),
.describe("Keep an existing column ID when editing; omit for a new column."),
source: z.union([
mastodonSourceSchema,
z.discriminatedUnion('kind', [
z.discriminatedUnion("kind", [
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('search'),
platform: z.literal("twitter").default("twitter"),
kind: z.literal("search"),
query: z.string().trim().min(1).max(512),
product: z.enum(['Latest', 'Top']).default('Latest'),
product: z.enum(["Latest", "Top"]).default("Latest"),
following: z.boolean().default(false),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('user'),
platform: z.literal("twitter").default("twitter"),
kind: z.literal("user"),
target: z.string().min(1).max(256),
})
.strict(),
z
.object({
platform: z.literal('twitter').default('twitter'),
kind: z.literal('list'),
platform: z.literal("twitter").default("twitter"),
kind: z.literal("list"),
target: z.string().min(1).max(256),
})
.strict(),
]),
]),
})
export const emptyToolInput = z.object({}).strict()
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict()
});
export const emptyToolInput = z.object({}).strict();
export const deckIdInput = z.object({ deckId: deckSchema.shape.id }).strict();
export const deleteDeckToolInput = deckIdInput
.extend({ expectedRevision: z.number().int().positive().optional() })
.strict()
export const getDeckInput = z
.object({ deckId: deckSchema.shape.id.optional() })
.strict()
.strict();
export const getDeckInput = z.object({ deckId: deckSchema.shape.id.optional() }).strict();
export const setDeckInput = z
.object({
deckId: deckSchema.shape.id
.optional()
.describe(
'Omit to create a temporary view in this tab; supply an existing ID to replace and activate it.',
"Omit to create a temporary view in this tab; supply an existing ID to replace and activate it.",
),
expectedRevision: z
.number()
.int()
.positive()
.optional()
.describe(
'Required when replacing a saved deck; omit for a temporary view.',
),
.describe("Required when replacing a saved deck; omit for a temporary view."),
title: deckSchema.shape.title,
columns: z
.array(toolColumn)
.max(MAX_COLUMNS)
.describe(
'Complete ordered columns, each bound to a connection ID from list_connections. Omitted existing columns are removed.',
"Complete ordered columns, each bound to a connection ID from list_connections. Omitted existing columns are removed.",
),
})
.strict()
.strict();
export function prepareDeck(
input: unknown,
connections: readonly Connection[],
) {
const parsed = setDeckInput.parse(input)
export function prepareDeck(input: unknown, connections: readonly Connection[]) {
const parsed = setDeckInput.parse(input);
const deck = deckSchema.parse({
id: parsed.deckId ?? newId(),
title: parsed.title,
@@ -84,45 +75,42 @@ export function prepareDeck(
...column,
id: column.id ?? newId(),
})),
})
});
for (const column of deck.columns) {
if (
!connections.some(
(connection) =>
connection.id === column.connectionId &&
connection.platform === column.source.platform &&
connection.status === 'connected',
connection.status === "connected",
)
)
throw new InputError(`Unavailable connection: ${column.connectionId}`)
throw new InputError(`Unavailable connection: ${column.connectionId}`);
}
return deck
return deck;
}
export async function toolResult(run: () => unknown | Promise<unknown>) {
export async function toolResult(run: () => unknown) {
try {
return {
content: [
{ type: 'text' as const, text: JSON.stringify(await run()) },
] as const,
}
content: [{ type: "text" as const, text: JSON.stringify(await run()) }] as const,
};
} catch (error) {
return {
isError: true,
content: [
{
type: 'text' as const,
type: "text" as const,
text: JSON.stringify({
code:
error instanceof z.ZodError || error instanceof InputError
? 'invalid-input'
: 'tool-error',
message:
error instanceof Error ? error.message : 'Tool execution failed.',
? "invalid-input"
: "tool-error",
message: error instanceof Error ? error.message : "Tool execution failed.",
retryable: false,
}),
},
] as const,
}
};
}
}
+77 -82
View File
@@ -1,7 +1,7 @@
import { useWebMCP } from 'usewebmcp'
import type { Connection } from '#/features/connections/model'
import type { Deck, DeckView, Workspace } from './model'
import { useWebMCPSupported } from './use-webmcp-supported'
import { useWebMCP } from "usewebmcp";
import type { Connection } from "#/features/connections/model";
import type { Deck, DeckView, Workspace } from "./model";
import { useWebMCPSupported } from "./use-webmcp-supported";
import {
deckIdInput,
deleteDeckToolInput,
@@ -10,7 +10,7 @@ import {
prepareDeck,
setDeckInput,
toolResult,
} from './webmcp-contracts'
} from "./webmcp-contracts";
export function useDeckTools({
ready,
@@ -25,67 +25,65 @@ export function useDeckTools({
createTemporary,
persist,
}: {
ready: boolean
saving: boolean
storageError?: string
save: (deck: Deck, expectedRevision?: number) => Promise<boolean>
select: (id: string) => boolean
remove: (id: string, expectedRevision?: number) => Promise<boolean>
getWorkspace: () => Workspace
connections: Connection[] | undefined
onApplied: () => void
createTemporary: (title: string, columns?: Deck['columns']) => DeckView
persist: (id: string) => Promise<boolean>
ready: boolean;
saving: boolean;
storageError?: string;
save: (deck: Deck, expectedRevision?: number) => Promise<boolean>;
select: (id: string) => boolean;
remove: (id: string, expectedRevision?: number) => Promise<boolean>;
getWorkspace: () => Workspace;
connections: Connection[] | undefined;
onApplied: () => void;
createTemporary: (title: string, columns?: Deck["columns"]) => DeckView;
persist: (id: string) => Promise<boolean>;
}) {
const enabled = useWebMCPSupported() && ready
const enabled = useWebMCPSupported() && ready;
function requireDeck(id?: string) {
const workspace = getWorkspace()
const deck = workspace.decks.find(
(deck) => deck.id === (id ?? workspace.activeDeckId),
)
if (!deck) throw new Error('Deck not found.')
return deck
const workspace = getWorkspace();
const deck = workspace.decks.find((deck) => deck.id === (id ?? workspace.activeDeckId));
if (!deck) throw new Error("Deck not found.");
return deck;
}
function requireIdle() {
if (saving) throw new Error('Wait for the current save to finish.')
if (saving) throw new Error("Wait for the current save to finish.");
}
function applied(success: boolean) {
if (!success)
throw new Error(
'The operation was not saved. Read the deck and latest revision before retrying. Temporary views and unsaved forms remain available.',
)
onApplied()
"The operation was not saved. Read the deck and latest revision before retrying. Temporary views and unsaved forms remain available.",
);
onApplied();
}
useWebMCP({
name: 'list_connections',
name: "list_connections",
description:
'List available connected accounts and stable connection IDs for each platform. Credentials are never returned.',
"List available connected accounts and stable connection IDs for each platform. Credentials are never returned.",
inputSchema: emptyToolInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
return { connections: connections ?? null }
emptyToolInput.parse(input);
return { connections: connections ?? null };
}),
})
});
useWebMCP({
name: 'list_decks',
name: "list_decks",
description:
'List shared saved decks and this tab’s temporary views, with persistence flags, revisions and activeDeckId. Active selection is local to this device.',
"List shared saved decks and this tab’s temporary views, with persistence flags, revisions and activeDeckId. Active selection is local to this device.",
inputSchema: emptyToolInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
execute: (input) =>
toolResult(() => {
emptyToolInput.parse(input)
return { ...getWorkspace(), storageError: storageError ?? null }
emptyToolInput.parse(input);
return { ...getWorkspace(), storageError: storageError ?? null };
}),
})
});
useWebMCP({
name: 'get_deck',
name: "get_deck",
description:
'Read the definition, persistence flag and revision of a saved deck or temporary view. Omit deckId for the active view. Does not fetch posts.',
"Read the definition, persistence flag and revision of a saved deck or temporary view. Omit deckId for the active view. Does not fetch posts.",
inputSchema: getDeckInput,
enabled,
annotations: { readOnlyHint: true, untrustedContentHint: true },
@@ -94,86 +92,83 @@ export function useDeckTools({
deck: requireDeck(getDeckInput.parse(input).deckId),
storageError: storageError ?? null,
})),
})
});
useWebMCP({
name: 'set_deck',
name: "set_deck",
description:
'Create a TEMPORARY view when deckId is omitted. It exists only in this tab until save_deck is explicitly called. To replace an existing view supply its deckId; saved decks additionally require expectedRevision from get_deck. Complete ordered columns replace all previous columns, at most six, bound to connection IDs from list_connections. Activates the view and closes unsaved forms only on success; posts load asynchronously.',
"Create a TEMPORARY view when deckId is omitted. It exists only in this tab until save_deck is explicitly called. To replace an existing view supply its deckId; saved decks additionally require expectedRevision from get_deck. Complete ordered columns replace all previous columns, at most six, bound to connection IDs from list_connections. Activates the view and closes unsaved forms only on success; posts load asynchronously.",
inputSchema: setDeckInput,
enabled,
annotations: { readOnlyHint: false, untrustedContentHint: true },
execute: (input) =>
toolResult(async () => {
requireIdle()
const parsed = setDeckInput.parse(input)
if (!connections) throw new Error('Connections are not available yet.')
const next = prepareDeck(parsed, connections)
requireIdle();
const parsed = setDeckInput.parse(input);
if (!connections) throw new Error("Connections are not available yet.");
const next = prepareDeck(parsed, connections);
if (!parsed.deckId) {
const deck = createTemporary(next.title, next.columns)
onApplied()
return { deck, persisted: false, posts: 'loading-asynchronously' }
const deck = createTemporary(next.title, next.columns);
onApplied();
return { deck, persisted: false, posts: "loading-asynchronously" };
}
const previous = requireDeck(parsed.deckId)
const previous = requireDeck(parsed.deckId);
if (previous.persisted && parsed.expectedRevision === undefined)
throw new Error(
'expectedRevision is required to update a saved deck.',
)
applied(await save(next, parsed.expectedRevision))
const deck = requireDeck(next.id)
throw new Error("expectedRevision is required to update a saved deck.");
applied(await save(next, parsed.expectedRevision));
select(next.id);
const deck = requireDeck(next.id);
return {
deck,
persisted: deck.persisted,
posts: 'loading-asynchronously',
}
posts: "loading-asynchronously",
};
}),
})
});
useWebMCP({
name: 'save_deck',
name: "save_deck",
description:
'Explicitly persist a temporary view as a new shared deck. Keeps its ID to make retries idempotent. Success makes it available to other devices; failure keeps the temporary view intact. Already saved decks are unchanged.',
"Explicitly persist a temporary view as a new shared deck. Keeps its ID to make retries idempotent. Success makes it available to other devices; failure keeps the temporary view intact. Already saved decks are unchanged.",
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(async () => {
requireIdle()
const { deckId } = deckIdInput.parse(input)
applied(await persist(deckId))
return { deck: requireDeck(deckId), persisted: true }
requireIdle();
const { deckId } = deckIdInput.parse(input);
applied(await persist(deckId));
return { deck: requireDeck(deckId), persisted: true };
}),
})
});
useWebMCP({
name: 'select_deck',
name: "select_deck",
description:
'Select a saved deck or temporary view in this tab. Active selection is stored only on this device. Closes unsaved forms and loads columns asynchronously.',
"Select a saved deck or temporary view in this tab. Active selection is stored only on this device. Closes unsaved forms and loads columns asynchronously.",
inputSchema: deckIdInput,
enabled,
annotations: { readOnlyHint: false },
execute: (input) =>
toolResult(() => {
requireIdle()
const { deckId } = deckIdInput.parse(input)
applied(select(deckId))
return getWorkspace()
requireIdle();
const { deckId } = deckIdInput.parse(input);
applied(select(deckId));
return getWorkspace();
}),
})
});
useWebMCP({
name: 'delete_deck',
name: "delete_deck",
description:
'Discard a temporary view or permanently delete a saved deck from the server for all devices. Saved deletion requires expectedRevision. Temporary deletion does not change server data.',
"Discard a temporary view or permanently delete a saved deck from the server for all devices. Saved deletion requires expectedRevision. Temporary deletion does not change server data.",
inputSchema: deleteDeckToolInput,
enabled,
annotations: { readOnlyHint: false, destructiveHint: true },
execute: (input) =>
toolResult(async () => {
requireIdle()
const { deckId, expectedRevision } = deleteDeckToolInput.parse(input)
requireIdle();
const { deckId, expectedRevision } = deleteDeckToolInput.parse(input);
if (requireDeck(deckId).persisted && expectedRevision === undefined)
throw new Error(
'expectedRevision is required to delete a saved deck.',
)
applied(await remove(deckId, expectedRevision))
return getWorkspace()
throw new Error("expectedRevision is required to delete a saved deck.");
applied(await remove(deckId, expectedRevision));
return getWorkspace();
}),
})
});
}