Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ A compact toolbar separates execution from save, share, and AI tools. Live execu

- **Trace-aware AI.** Ask for an explanation, a hint, or the limits of a fix. A real Qwen 2.5 model runs through WebLLM in a dedicated browser worker. Its context is rebuilt from the deterministic engine; verified facts and generated explanations are displayed separately.
- **Private workspace.** Save an execution with a title and notes, reopen the exact schedule, and edit or delete it later. Investigations are stored in Cloudflare D1 and scoped to the signed-in account.
- **Find saved work.** Search investigation titles, notes, or experiment names; combine experiment and bug/fix filters, and sort by update time or title. A live result count and Clear filters keep larger workspaces easy to navigate. Search stays in the browser and is not added to replay URLs.
- **Safe concurrent edits.** Revision checks reject stale edits or deletes instead of silently overwriting a newer version. An atomic quota check limits each account to 50 investigations.
- **Public sharing, deliberately.** Share a replay URL or export Markdown. Replay URLs contain the experiment and schedule, not your private title or notes.

Expand Down
40 changes: 40 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,8 @@ kbd {
}
.workspace-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
justify-content: space-between;
align-items: center;
padding: 22px 0 16px;
Expand All @@ -1344,6 +1346,44 @@ kbd {
font-size: 14px;
color: #bbc8b3;
}
.investigation-filters {
display: grid;
grid-template-columns: minmax(200px, 2fr) repeat(3, minmax(130px, 1fr));
gap: 16px;
margin-top: 28px;
}
.investigation-filters label,
.investigation-field {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
color: #c6d1bf;
font-size: 13px;
}
.investigation-filters select {
width: 100%;
min-height: 36px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #1b2123;
color: var(--foreground);
}
.investigation-filters select:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
@media (max-width: 850px) {
.investigation-filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 500px) {
.investigation-filters {
grid-template-columns: 1fr;
}
}
.workspace-empty {
min-height: 310px;
display: flex;
Expand Down
108 changes: 103 additions & 5 deletions features/workspace/workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import {
Workflow,
Expand Down Expand Up @@ -32,6 +32,10 @@ import {
} from '@/components/ui/alert-dialog';
import { experiments } from '@/lib/experiments';
import { encodeReplay } from '@/lib/engine';
import {
filterInvestigations,
type InvestigationSort,
} from '@/lib/investigation-search';
import {
decodeTrace,
MAX_INVESTIGATIONS,
Expand All @@ -45,6 +49,23 @@ export function Workspace({ email }: { email: string }) {
const [deleting, setDeleting] = useState<Investigation | null>(null);
const [busy, setBusy] = useState(false);
const [editError, setEditError] = useState('');
const [query, setQuery] = useState('');
const [experimentId, setExperimentId] = useState('');
const [mode, setMode] = useState<'' | Investigation['mode']>('');
const [sort, setSort] = useState<InvestigationSort>('recent');
const visibleItems = useMemo(
() => filterInvestigations(items, { query, experimentId, mode, sort }),
[items, query, experimentId, mode, sort],
);
const hasFilters = Boolean(
query || experimentId || mode || sort !== 'recent',
);
function resetFilters() {
setQuery('');
setExperimentId('');
setMode('');
setSort('recent');
}
const refresh = useCallback((signal?: AbortSignal) => {
return fetch('/api/investigations', { signal })
.then(async (r) => {
Expand Down Expand Up @@ -151,10 +172,76 @@ export function Workspace({ email }: { email: string }) {
New investigation
</Link>
</div>
<section
className="investigation-filters"
aria-label="Find saved investigations"
>
<label className="investigation-search" htmlFor="investigation-query">
Search investigations
<Input
id="investigation-query"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Title, notes, or experiment"
/>
</label>
<div className="investigation-field">
<label htmlFor="investigation-experiment">Experiment</label>
<select
id="investigation-experiment"
value={experimentId}
onChange={(event) => setExperimentId(event.target.value)}
>
<option value="">All experiments</option>
{experiments.map((experiment) => (
<option key={experiment.id} value={experiment.id}>
{experiment.title}
</option>
))}
</select>
</div>
<div className="investigation-field">
<label htmlFor="investigation-mode">Implementation</label>
<select
id="investigation-mode"
value={mode}
onChange={(event) =>
setMode(event.target.value as '' | Investigation['mode'])
}
>
<option value="">Bug and fix</option>
<option value="buggy">Bug</option>
<option value="fixed">Fix</option>
</select>
</div>
<div className="investigation-field">
<label htmlFor="investigation-sort">Sort by</label>
<select
id="investigation-sort"
value={sort}
onChange={(event) =>
setSort(event.target.value as InvestigationSort)
}
>
<option value="recent">Recently updated</option>
<option value="oldest">Oldest updated</option>
<option value="title">Title A–Z</option>
</select>
</div>
</section>
<div className="workspace-toolbar">
<span>
{items.length} / {MAX_INVESTIGATIONS} saved
</span>
<output>
{loading
? 'Loading…'
: `${visibleItems.length} of ${items.length} shown`}{' '}
· {MAX_INVESTIGATIONS} save limit
</output>
{hasFilters && (
<Button variant="ghost" onClick={resetFilters}>
Clear filters
</Button>
)}
<Button
variant="ghost"
onClick={() => {
Expand Down Expand Up @@ -188,9 +275,20 @@ export function Workspace({ email }: { email: string }) {
Explore the lab →
</Link>
</section>
) : visibleItems.length === 0 ? (
<section className="workspace-empty">
<h2>No investigations match.</h2>
<p>
Try a different search or clear the filters to see all saved
investigations.
</p>
<Button variant="outline" onClick={resetFilters}>
Clear filters
</Button>
</section>
) : (
<div className="investigation-grid">
{items.map((item) => (
{visibleItems.map((item) => (
<article key={item.id} className="investigation-card">
<div className="investigation-meta">
<span>
Expand Down
50 changes: 50 additions & 0 deletions lib/investigation-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { experiments } from './experiments.ts';
import type { Investigation } from './investigations.ts';

export type InvestigationSort = 'recent' | 'oldest' | 'title';
export type InvestigationFilters = {
query?: string;
experimentId?: string;
mode?: '' | Investigation['mode'];
sort?: InvestigationSort;
};

/** Search the authenticated workspace snapshot without modifying saved records. */
export function filterInvestigations(
items: readonly Investigation[],
{
query = '',
experimentId = '',
mode = '',
sort = 'recent',
}: InvestigationFilters = {},
): Investigation[] {
const term = query.trim().toLocaleLowerCase();
const titles = new Map(
experiments.map((experiment) => [experiment.id, experiment.title]),
);
return items
.filter(
(item) =>
(!experimentId || item.experimentId === experimentId) &&
(!mode || item.mode === mode) &&
(!term ||
[
item.title,
item.notes,
titles.get(item.experimentId) ?? item.experimentId,
].some((text) => text.toLocaleLowerCase().includes(term))),
)
.sort((a, b) => {
const order =
sort === 'title'
? a.title.localeCompare(b.title, undefined, {
sensitivity: 'base',
numeric: true,
})
: sort === 'oldest'
? a.updatedAt - b.updatedAt
: b.updatedAt - a.updatedAt;
return order || a.id.localeCompare(b.id);
});
}
94 changes: 94 additions & 0 deletions tests/investigation-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { filterInvestigations } from '../lib/investigation-search.ts';
import type { Investigation } from '../lib/investigations.ts';

const items: Investigation[] = [
{
id: 'a',
title: 'Retry analysis',
notes: 'A second payment must wait.',
experimentId: 'double-payment',
mode: 'buggy',
trace: '',
version: 1,
createdAt: 1,
updatedAt: 30,
},
{
id: 'b',
title: 'Atomic fix',
notes: 'Counter increments safely.',
experimentId: 'lost-update',
mode: 'fixed',
trace: '',
version: 1,
createdAt: 2,
updatedAt: 10,
},
{
id: 'c',
title: 'Counter race',
notes: 'Both workers read zero.',
experimentId: 'lost-update',
mode: 'buggy',
trace: '',
version: 1,
createdAt: 3,
updatedAt: 20,
},
];
const ids = (rows: Investigation[]) => rows.map((row) => row.id);

void test('searches titles, private notes, and experiment names with trimmed case-insensitive text', () => {
assert.deepEqual(ids(filterInvestigations(items, { query: ' RETRY ' })), [
'a',
]);
assert.deepEqual(ids(filterInvestigations(items, { query: 'read zero' })), [
'c',
]);
assert.deepEqual(ids(filterInvestigations(items, { query: 'lost update' })), [
'c',
'b',
]);
});

void test('combines search, experiment and implementation filters', () => {
assert.deepEqual(
ids(
filterInvestigations(items, {
query: 'counter',
experimentId: 'lost-update',
mode: 'fixed',
}),
),
['b'],
);
assert.deepEqual(
filterInvestigations(items, {
experimentId: 'double-payment',
mode: 'fixed',
}),
[],
);
assert.deepEqual(filterInvestigations(items, { query: 'no match' }), []);
assert.deepEqual(filterInvestigations([], { query: 'anything' }), []);
});

void test('sorts by updated time or title without changing the source snapshot', () => {
const snapshot = structuredClone(items);
assert.deepEqual(ids(filterInvestigations(items)), ['a', 'c', 'b']);
assert.deepEqual(ids(filterInvestigations(items, { sort: 'oldest' })), [
'b',
'c',
'a',
]);
assert.deepEqual(ids(filterInvestigations(items, { sort: 'title' })), [
'b',
'c',
'a',
]);
assert.deepEqual(items, snapshot);
const tied = [{ ...items[0], updatedAt: 20 }, items[2]];
assert.deepEqual(ids(filterInvestigations(tied.toReversed())), ['a', 'c']);
});
Loading