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
8 changes: 5 additions & 3 deletions packages/skills/diffity-tour/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ The tour UI has a dedicated explanation panel. The intro (from `tour-start --bod

- `--file`: Path relative to repo root (e.g. `src/server.ts`)
- `--line` / `--end-line`: The exact line range to highlight. Keep it focused on the relevant section.
- `--annotation`: A short label (3-6 words) shown as the step title. Think of it as a chapter heading.
- `--annotation`: A short label (3-6 words) shown as the step title. Think of it as a chapter heading. In a review tour it is also the one-line label beside the file in the reordered file list, so make it say why this file is read at this point ("the primitive", "first consumer", "the P1 lives here") rather than restating the file name.
- `--body`: The narrative shown in the explanation panel. This has generous space — use it to write thorough explanations using markdown:

**Verify targets before committing to a step.** Before calling `tour-step`, verify that the `--file` path is readable from the repo root and that the function or block you're describing actually lives at the advertised `--line`/`--end-line` range — read the range, don't trust memory from earlier in the session. The same applies to every `goto:` link in the body: a broken goto link is worse than no link because the reader trusts it and gets dropped in the wrong place without any signal that the link is wrong. If you're not sure of the exact line, use plain backtick code instead of a goto link.
Expand Down Expand Up @@ -235,8 +235,10 @@ The tour UI has a dedicated explanation panel. The intro (from `tour-start --bod

### Phase 3: Open in browser

1. Get the running instance port from `{{binary}} list --json`.
2. Open the tour: `open "http://localhost:<port>/tour/<tour-id>"` (or the appropriate command for the user's OS).
1. Get the running instance port and `ref` from `{{binary}} list --json`.
2. Open it, which differs by mode:
- **Review mode**: `open "http://localhost:<port>/diff?ref=<ref>"`. The walkthrough drives the diff itself — the file list is reordered into your reading order, each file shows its `--annotation` as a one-line label, and a stepper walks the stops. Do **not** send the reader to `/tour/<id>` for a review; that route browses the repository, not the change.
- **Every other mode**: `open "http://localhost:<port>/tour/<tour-id>"`.
3. Tell the user the tour is ready:

> Your tour is ready — check your browser.
Expand Down
70 changes: 58 additions & 12 deletions packages/ui/src/components/diff/diff-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { useTheme } from '../../hooks/use-theme';
import { useWrapLines } from '../../hooks/use-wrap-lines';
import { useKeyboard } from '../../hooks/use-keyboard';
import { useReviewThreads } from '../../hooks/use-review-threads';
import { useTours } from '../../hooks/use-tours';
import { pickActiveTour, orderPathsByTour, stopsByPath } from '../../lib/tour-order';
import { TourStepper } from './tour-stepper';
import { useCommentActions } from '../../hooks/use-comment-actions';
import { Toolbar } from '../layout/toolbar';
import { DiffView, type DiffViewHandle } from './diff-view';
Expand Down Expand Up @@ -74,6 +77,23 @@ export function DiffPage() {
const commentActions = useCommentActions(sessionId, reviewsEnabled);
const commentCountsByFile = useMemo(() => buildThreadCountsByFile(threads), [threads]);

const { data: tours } = useTours(reviewsEnabled ? sessionId : null);
const activeTour = useMemo(() => pickActiveTour(tours), [tours]);
const [reviewOrderEnabled, setReviewOrderEnabled] = useState(true);
const [tourStepIndex, setTourStepIndex] = useState(0);

const diffPaths = useMemo(() => (diff ? diff.files.map(file => getFilePath(file)) : []), [diff]);
const tourStops = useMemo(() => stopsByPath(activeTour, diffPaths), [activeTour, diffPaths]);

const orderedDiff = useMemo(() => {
if (!diff || !activeTour || !reviewOrderEnabled || activeTour.steps.length === 0) {
return diff;
}
const order = orderPathsByTour(diffPaths, activeTour.steps.map(step => step.filePath));
const byPath = new Map(diff.files.map(file => [getFilePath(file), file]));
return { ...diff, files: order.map(path => byPath.get(path)!) };
}, [diff, activeTour, reviewOrderEnabled, diffPaths]);

const filesWithComments = useMemo(() => {
return new Set(commentCountsByFile.keys());
}, [commentCountsByFile]);
Expand Down Expand Up @@ -190,21 +210,31 @@ export function DiffPage() {
}, []);

const getCurrentFilePath = useCallback((): string | null => {
if (!diff) {
if (!orderedDiff) {
return null;
}
return getFilePath(diff.files[currentFileIdx.current]);
}, [diff]);
return getFilePath(orderedDiff.files[currentFileIdx.current]);
}, [orderedDiff]);

const navigateFile = useCallback((direction: number) => {
if (!diff) {
if (!orderedDiff) {
return;
}
const nextIdx = Math.max(0, Math.min(diff.files.length - 1, currentFileIdx.current + direction));
const nextIdx = Math.max(0, Math.min(orderedDiff.files.length - 1, currentFileIdx.current + direction));
currentFileIdx.current = nextIdx;
const path = getFilePath(diff.files[nextIdx]);
const path = getFilePath(orderedDiff.files[nextIdx]);
diffViewRef.current?.scrollToFile(path);
}, [diff]);
}, [orderedDiff]);

const handleTourStepChange = useCallback((index: number) => {
const steps = activeTour ? [...activeTour.steps].sort((a, b) => a.sortOrder - b.sortOrder) : [];
if (steps.length === 0) {
return;
}
const clamped = Math.max(0, Math.min(steps.length - 1, index));
setTourStepIndex(clamped);
diffViewRef.current?.scrollToFile(steps[clamped].filePath);
}, [activeTour]);

const navigateHunk = useCallback((direction: number) => {
const hunks = getHunkHeaders();
Expand Down Expand Up @@ -239,10 +269,10 @@ export function DiffPage() {
}
},
onCollapseAll: () => {
if (!diff) {
if (!orderedDiff) {
return;
}
const allPaths = diff.files.map((f) => getFilePath(f));
const allPaths = orderedDiff.files.map((f) => getFilePath(f));
const anyExpanded = allPaths.some((p) => !collapsedFiles.has(p));
manuallyToggledRef.current = new Set();
if (anyExpanded) {
Expand Down Expand Up @@ -380,18 +410,34 @@ export function DiffPage() {
onGitHubPulled={() => queryClient.invalidateQueries({ queryKey: ['threads'] })}
/>
{isStale && <StaleDiffBanner onRefresh={handleRefreshDiff} />}
{activeTour && (
<TourStepper
tour={activeTour}
stepIndex={tourStepIndex}
onStepChange={handleTourStepChange}
/>
)}
<div className="flex flex-1 overflow-hidden">
<Sidebar
files={diff?.files || []}
files={orderedDiff?.files || []}
activeFile={activeFile}
reviewedFiles={reviewedFiles}
commentCountsByFile={commentCountsByFile}
onFileClick={handleSidebarFileClick}
onCommentedFileClick={handleSidebarCommentedFileClick}
reviewOrder={
activeTour && activeTour.steps.length > 0
? {
stops: tourStops,
enabled: reviewOrderEnabled,
onToggle: () => setReviewOrderEnabled(prev => !prev),
}
: undefined
}
/>
{diff ? (
{orderedDiff ? (
<DiffView
diff={diff}
diff={orderedDiff}
viewMode={viewMode}
theme={theme}
collapsedFiles={collapsedFiles}
Expand Down
93 changes: 93 additions & 0 deletions packages/ui/src/components/diff/review-order-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { DiffFile } from '@diffity/parser';
import { getFilePath } from '../../lib/diff-utils';
import type { TourFileStop } from '../../lib/tour-order';
import { CheckIcon } from '../icons/check-icon';
import { CommentIcon } from '../icons/comment-icon';

interface ReviewOrderListProps {
files: DiffFile[];
stops: Map<string, TourFileStop>;
activeFile: string | null;
reviewedFiles: Set<string>;
commentCountsByFile: Map<string, number>;
onFileClick: (path: string) => void;
}

function splitPath(path: string): { dir: string; name: string } {
const idx = path.lastIndexOf('/');
if (idx === -1) {
return { dir: '', name: path };
}
return { dir: path.slice(0, idx), name: path.slice(idx + 1) };
}

export function ReviewOrderList(props: ReviewOrderListProps) {
const { files, stops, activeFile, reviewedFiles, commentCountsByFile, onFileClick } = props;

const firstUnvisited = files.findIndex(file => !stops.has(getFilePath(file)));

return (
<ul className="flex-1 overflow-y-auto py-1">
{files.map((file, index) => {
const path = getFilePath(file);
const stop = stops.get(path);
const { dir, name } = splitPath(path);
const comments = commentCountsByFile.get(path);
const isActive = activeFile === path;

return (
<li key={path}>
{index === firstUnvisited && firstUnvisited > 0 && (
<div className="flex items-center gap-2 px-3 py-2 text-[10px] uppercase tracking-wider text-text-muted">
<span className="h-px flex-1 bg-border" />
Not in the walkthrough
<span className="h-px flex-1 bg-border" />
</div>
)}
<button
className={`w-full text-left flex gap-2 px-3 py-1.5 cursor-pointer ${
isActive ? 'bg-accent/10' : 'hover:bg-hover'
}`}
onClick={() => onFileClick(path)}
title={path}
>
<span
className={`shrink-0 mt-0.5 inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-semibold tabular-nums ${
stop
? 'bg-accent/15 text-accent'
: 'bg-bg-tertiary text-text-muted'
}`}
>
{stop ? stop.position : '·'}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span
className={`truncate text-xs ${
reviewedFiles.has(path) ? 'text-text-muted line-through' : 'text-text'
}`}
>
{name}
</span>
{reviewedFiles.has(path) && (
<CheckIcon className="w-3 h-3 shrink-0 text-text-muted" />
)}
{comments ? (
<span className="ml-auto shrink-0 inline-flex items-center gap-0.5 text-[10px] text-text-muted">
<CommentIcon className="w-3 h-3" />
{comments}
</span>
) : null}
</span>
{dir && <span className="block truncate text-[10px] text-text-muted">{dir}</span>}
{stop?.annotation && (
<span className="block text-[11px] text-text-secondary mt-0.5">{stop.annotation}</span>
)}
</span>
</button>
</li>
);
})}
</ul>
);
}
76 changes: 76 additions & 0 deletions packages/ui/src/components/diff/tour-stepper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useMemo } from 'react';
import type { Tour } from '../../lib/api';
import { CompassIcon } from '../icons/compass-icon';
import { ChevronUpIcon } from '../icons/chevron-up-icon';
import { ChevronDownIcon } from '../icons/chevron-down-icon';
import { Spinner } from '../icons/spinner';
import { MarkdownContent } from '../layout/markdown-content';

interface TourStepperProps {
tour: Tour;
stepIndex: number;
onStepChange: (index: number) => void;
}

export function TourStepper(props: TourStepperProps) {
const { tour, stepIndex, onStepChange } = props;

const steps = useMemo(
() => [...tour.steps].sort((a, b) => a.sortOrder - b.sortOrder),
[tour.steps],
);
const isBuilding = tour.status === 'building';

if (steps.length === 0) {
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-bg-secondary text-xs text-text-secondary">
{isBuilding ? <Spinner className="w-3.5 h-3.5" /> : <CompassIcon className="w-3.5 h-3.5" />}
{isBuilding ? 'Working out a reading order…' : tour.topic}
</div>
);
}

const index = Math.min(stepIndex, steps.length - 1);
const step = steps[index];

return (
<div className="flex items-start gap-3 px-4 py-2 border-b border-border bg-bg-secondary">
<span className="shrink-0 inline-flex items-center gap-1.5 mt-0.5 text-[11px] font-semibold text-accent tabular-nums">
<CompassIcon className="w-3.5 h-3.5" />
{index + 1}/{steps.length}
{isBuilding && <Spinner className="w-3 h-3 text-text-muted" />}
</span>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-text truncate">
{step.filePath}
<span className="ml-1.5 text-text-muted font-normal tabular-nums">
{step.startLine === step.endLine ? step.startLine : `${step.startLine}-${step.endLine}`}
</span>
</div>
{step.body && (
<div className="text-xs text-text-secondary mt-0.5">
<MarkdownContent content={step.body} />
</div>
)}
</div>
<div className="shrink-0 flex items-center gap-0.5">
<button
className="p-1 rounded-md text-text-muted hover:text-text hover:bg-hover cursor-pointer disabled:opacity-40 disabled:cursor-default"
onClick={() => onStepChange(index - 1)}
disabled={index === 0}
title="Previous step"
>
<ChevronUpIcon className="w-3.5 h-3.5" />
</button>
<button
className="p-1 rounded-md text-text-muted hover:text-text hover:bg-hover cursor-pointer disabled:opacity-40 disabled:cursor-default"
onClick={() => onStepChange(index + 1)}
disabled={index >= steps.length - 1}
title="Next step"
>
<ChevronDownIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
18 changes: 18 additions & 0 deletions packages/ui/src/components/icons/list-ordered-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function ListOrderedIcon(props: { className?: string }) {
return (
<svg
className={props.className}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 4h8M6 8h8M6 12h8" />
<path d="M2 3.5h1V6M2 6h2" />
<path d="M2 10h2v2H2v1.5h2" />
</svg>
);
}
Loading