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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ servediff review --no-browser
servediff --version
```

Each invocation collects once and creates an independent observation, including
repository, branch, worktree, HEAD, hostname and source metadata. The local
Each invocation collects once, including repository, branch, worktree, HEAD,
hostname and source metadata. Unchanged reviews of the same checkout and HEAD
reuse their snapshot and review state. Snapshots expire seven days after the
last fresh submission; transport retries do not extend retention. The local
server starts automatically when needed and stays running after the CLI exits.
Bare `servediff` prints help. There is no watcher and opening the dashboard does
not recollect Git data.
Expand Down Expand Up @@ -80,7 +82,9 @@ servediff review --trigger agent-hook --agent my-agent --run-id run-123 --no-bro

Use `--source-id` to supply a stable source/container identity. Hostnames,
branches and source-local paths are searchable metadata, not global identities.
Separate submissions remain independently reviewable, even on the same branch.
Different sources, checkouts, branches, HEADs, or diff contents remain
independently reviewable. Identical content from the same checkout reuses its
review context.

Build/install the remote server with Go:

Expand Down
196 changes: 196 additions & 0 deletions apps/web/src/PickerFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { ChevronDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
defaultPickerFilters,
type PickerFilters,
type pickerFilterOptions,
} from "./project-picker.ts";

const availabilityLabels = {
all: "All availability",
available: "Available",
unavailable: "Unavailable",
};
const changesLabels = {
all: "All changes",
changed: "Changed",
unchanged: "No changes",
};

export function pickerFilterSummary(filters: PickerFilters) {
return [
availabilityLabels[filters.availability],
...(filters.changes === "all" ? [] : [changesLabels[filters.changes]]),
...filters.hosts.map((host) => `Host: ${host}`),
...filters.branches.map((branch) => `Branch: ${branch}`),
...filters.worktrees.map((worktree) => `Worktree: ${worktree}`),
].join(" · ");
}

export function pickerFiltersChanged(filters: PickerFilters) {
return (
filters.availability !== defaultPickerFilters.availability ||
filters.changes !== defaultPickerFilters.changes ||
filters.hosts.length > 0 ||
filters.branches.length > 0 ||
filters.worktrees.length > 0
);
}

function filterTrigger(label: string) {
return (
<Button variant="outline-muted" size="sm">
{label}
<ChevronDownIcon aria-hidden="true" />
</Button>
);
}

function MultiFilter({
label,
options,
selected,
onChange,
}: {
label: string;
options: string[];
selected: string[];
onChange: (values: string[]) => void;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label={label}
render={filterTrigger(
`${label}${selected.length ? ` (${selected.length})` : ""}`,
)}
/>
<DropdownMenuContent className="project-picker-filter-menu">
{!options.length && (
<p className="project-picker-filter-empty">
No recorded {label.toLowerCase()}
</p>
)}
{options.map((value) => (
<DropdownMenuCheckboxItem
key={value}
checked={selected.includes(value)}
closeOnClick={false}
onCheckedChange={(checked) =>
onChange(
checked
? [...selected, value]
: selected.filter((item) => item !== value),
)
}
>
{value}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

export function PickerFilterControls({
filters,
options,
onChange,
}: {
filters: PickerFilters;
options: ReturnType<typeof pickerFilterOptions>;
onChange: (filters: PickerFilters) => void;
}) {
return (
<div
className="project-picker-filters"
role="group"
aria-label="Repository filters"
>
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Availability"
render={filterTrigger(availabilityLabels[filters.availability])}
/>
<DropdownMenuContent>
<DropdownMenuRadioGroup
value={filters.availability}
onValueChange={(value) => {
if (
value === "all" ||
value === "available" ||
value === "unavailable"
)
onChange({ ...filters, availability: value });
}}
>
<DropdownMenuRadioItem closeOnClick value="all">
All
</DropdownMenuRadioItem>
<DropdownMenuRadioItem closeOnClick value="available">
Available
</DropdownMenuRadioItem>
<DropdownMenuRadioItem closeOnClick value="unavailable">
Unavailable
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Changes"
render={filterTrigger(changesLabels[filters.changes])}
/>
<DropdownMenuContent>
<DropdownMenuRadioGroup
value={filters.changes}
onValueChange={(value) => {
if (
value === "all" ||
value === "changed" ||
value === "unchanged"
)
onChange({ ...filters, changes: value });
}}
>
<DropdownMenuRadioItem closeOnClick value="all">
All
</DropdownMenuRadioItem>
<DropdownMenuRadioItem closeOnClick value="changed">
Changed
</DropdownMenuRadioItem>
<DropdownMenuRadioItem closeOnClick value="unchanged">
No changes
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<MultiFilter
label="Host"
options={options.hosts}
selected={filters.hosts}
onChange={(hosts) => onChange({ ...filters, hosts })}
/>
<MultiFilter
label="Branch"
options={options.branches}
selected={filters.branches}
onChange={(branches) => onChange({ ...filters, branches })}
/>
<MultiFilter
label="Worktree"
options={options.worktrees}
selected={filters.worktrees}
onChange={(worktrees) => onChange({ ...filters, worktrees })}
/>
</div>
);
}
Loading
Loading