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
7 changes: 0 additions & 7 deletions packages/web/src/components/activity-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,6 @@ type Props = {
// The windows the view is about to pass through, zooming in on a bar, in order; none otherwise.
// Rows make room for every one of them as the zoom sets off, so they move with it.
ahead: Window[];
// Where a task's page comes back to: this page, with the window as it is.
backTo: string;
};

// Who is doing what (the layout in lib/activity-timeline.ts): every agent's row over a window of
Expand All @@ -103,7 +101,6 @@ export function ActivityTimeline({
onOpenEntry,
onZoom,
ahead,
backTo,
}: Props) {
const [measure, width] = useChartWidth();
const requestTooltip = useRequestTooltip();
Expand Down Expand Up @@ -249,7 +246,6 @@ export function ActivityTimeline({
top={barTop(laneIndex)}
windowMs={window.to.getTime() - window.from.getTime()}
onZoom={onZoom}
backTo={backTo}
/>
<RequestTargets
requests={bar.requests}
Expand Down Expand Up @@ -500,13 +496,11 @@ function BarTarget({
top,
windowMs,
onZoom,
backTo,
}: {
bar: ActivityBar;
top: number;
windowMs: number;
onZoom: (from: number, to: number) => void;
backTo: string;
}) {
const org = useOrgSlug();
const [task] = bar.tasks;
Expand All @@ -519,7 +513,6 @@ function BarTarget({
const className = cn(MOVES, 'hover:bg-foreground/5');
const pageOf = (one: ActivityTask) => ({
to: `/${org}/activity/tasks/${encodeURIComponent(one.id)}`,
state: {back: backTo, then: null},
});
if (!task) {
return null;
Expand Down
7 changes: 1 addition & 6 deletions packages/web/src/components/agent-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,7 @@ export function AgentTimeline({
connections: Connection[];
}) {
const org = useOrgSlug();
// The window is kept in the address, so coming back to the page, by the browser's back button
// or a task page's, finds it as it was.
// The window is kept in the address, so going back to the page finds it as it was.
const [params, setParams] = useSearchParams();
const location = useLocation();
const [minutes, setMinutes] = useState(() => windowFromParams(params).minutes);
Expand Down Expand Up @@ -296,9 +295,6 @@ export function AgentTimeline({
}, [minutes, end, location.search, setParams]);
// The window's length as read: whole minutes, which it is but for a moment while zooming in.
const wholeMinutes = Math.round(minutes);
// Where a task's page comes back to: this page, with the window as it is now and the rest of the
// address, as the activity log's filters, as it was.
const backTo = `${location.pathname}${windowSearch(location.search, wholeMinutes, end)}`;

// Scrolled or zoomed to where there is nothing, the timeline stays up, its rows going the way
// they do, and only says so once the window has rested a second.
Expand Down Expand Up @@ -393,7 +389,6 @@ export function AgentTimeline({
onOpenEntry={setOpened}
onZoom={zoomTo}
ahead={ahead}
backTo={backTo}
/>
)}
{!inWindow && resting && (
Expand Down
20 changes: 0 additions & 20 deletions packages/web/src/components/back-button.tsx

This file was deleted.

121 changes: 121 additions & 0 deletions packages/web/src/components/desktop-history.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/// <reference types="bun" />

import {afterEach, beforeEach, describe, expect, test} from 'bun:test';
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {createMemoryRouter, Outlet, RouterProvider} from 'react-router';
import {TooltipProvider} from '@proxy/ui/components/tooltip';
import {DesktopHistory} from '@/components/desktop-history';

// The window's history as the Navigation API tells it, which happy-dom has none of.
class FakeNavigation {
canGoBack = false;
canGoForward = false;
listeners = new Set<() => void>();

addEventListener(_type: 'currententrychange', listener: () => void) {
this.listeners.add(listener);
}

removeEventListener(_type: 'currententrychange', listener: () => void) {
this.listeners.delete(listener);
}

set(next: {canGoBack: boolean; canGoForward: boolean}) {
this.canGoBack = next.canGoBack;
this.canGoForward = next.canGoForward;
for (const listener of this.listeners) {
listener();
}
}
}

let history: FakeNavigation;

beforeEach(() => {
history = new FakeNavigation();
Object.assign(globalThis, {navigation: history});
});

afterEach(() => {
cleanup();
Reflect.deleteProperty(globalThis, 'navigation');
});

function renderAt(entries: string[], index: number) {
const router = createMemoryRouter(
[
{
element: (
<>
<Outlet />
<DesktopHistory />
</>
),
children: [
{path: '/connections', element: <p>Connections</p>},
{path: '/connections/c1', element: <p>Gmail</p>},
],
},
],
{initialEntries: entries, initialIndex: index},
);
render(
<TooltipProvider>
<RouterProvider router={router} />
</TooltipProvider>,
);
}

function button(name: string): HTMLElement {
return screen.getByRole('button', {name});
}

function isOff(name: string): boolean {
return button(name).hasAttribute('disabled');
}

describe('DesktopHistory', () => {
test('is off where the history has nowhere to go, and follows it as it changes', () => {
renderAt(['/connections'], 0);

expect(isOff('Back')).toBe(true);
expect(isOff('Forward')).toBe(true);

act(() => history.set({canGoBack: true, canGoForward: false}));

expect(isOff('Back')).toBe(false);
expect(isOff('Forward')).toBe(true);
});

test('goes back and forward', async () => {
history.set({canGoBack: true, canGoForward: true});
renderAt(['/connections', '/connections/c1'], 1);
expect(screen.getByText('Gmail')).toBeTruthy();

fireEvent.click(button('Back'));
expect(await screen.findByText('Connections')).toBeTruthy();

fireEvent.click(button('Forward'));
expect(await screen.findByText('Gmail')).toBeTruthy();
});

test('goes back with ⌘[ and forward with ⌘]', async () => {
renderAt(['/connections', '/connections/c1'], 1);

fireEvent.keyDown(window, {key: '[', metaKey: true});
expect(await screen.findByText('Connections')).toBeTruthy();

fireEvent.keyDown(window, {key: ']', metaKey: true});
expect(await screen.findByText('Gmail')).toBeTruthy();
});

test("goes back and forward with the mouse's side buttons", async () => {
renderAt(['/connections', '/connections/c1'], 1);

fireEvent.mouseUp(window, {button: 3});
expect(await screen.findByText('Connections')).toBeTruthy();

fireEvent.mouseUp(window, {button: 4});
expect(await screen.findByText('Gmail')).toBeTruthy();
});
});
71 changes: 71 additions & 0 deletions packages/web/src/components/desktop-history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {ChevronLeftIcon, ChevronRightIcon} from 'lucide-react';
import {useEffect, useSyncExternalStore} from 'react';
import {useNavigate} from 'react-router';
import {IconButton} from '@/components/icon-button';

// The mouse's side buttons, as `MouseEvent.button` numbers them.
const MOUSE_BACK = 3;
const MOUSE_FORWARD = 4;

function subscribe(onChange: () => void): () => void {
navigation.addEventListener('currententrychange', onChange);
return () => navigation.removeEventListener('currententrychange', onChange);
}

// Back and forward in the desktop app's title bar, beside the traffic lights, where a browser has
// its own: the window has none. They step through the window's history, kept by the Navigation
// API, which counts only the app's own pages, never the splash pages (`data:`) shown before it.
// ⌘[ and ⌘], and a mouse's side buttons, do the same.
export function DesktopHistory() {
const navigate = useNavigate();
const canGoBack = useSyncExternalStore(subscribe, () => navigation.canGoBack);
const canGoForward = useSyncExternalStore(subscribe, () => navigation.canGoForward);

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) {
return;
}
if (event.key === '[') {
event.preventDefault();
void navigate(-1);
return;
}
if (event.key === ']') {
event.preventDefault();
void navigate(1);
}
};
const onMouseUp = (event: MouseEvent) => {
if (event.button === MOUSE_BACK) {
void navigate(-1);
return;
}
if (event.button === MOUSE_FORWARD) {
void navigate(1);
}
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('mouseup', onMouseUp);
};
}, [navigate]);

return (
<div data-desktop-history className="flex items-center gap-0.5">
<IconButton label="Back" size="xs" disabled={!canGoBack} onClick={() => void navigate(-1)}>
<ChevronLeftIcon className="size-4" />
</IconButton>
<IconButton
label="Forward"
size="xs"
disabled={!canGoForward}
onClick={() => void navigate(1)}
>
<ChevronRightIcon className="size-4" />
</IconButton>
</div>
);
}
12 changes: 2 additions & 10 deletions packages/web/src/components/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,9 @@
import {AppShell, PageTitle} from '@/components/app-shell';
import {BackButton} from '@/components/back-button';

// What a detail page shows when its id no longer points at anything, after a delete for one.
export function NotFound({what, back, backLabel}: {what: string; back: string; backLabel: string}) {
export function NotFound({what}: {what: string}) {
return (
<AppShell
title={
<>
<BackButton to={back} label={backLabel} />
<PageTitle>Not found</PageTitle>
</>
}
>
<AppShell title={<PageTitle>Not found</PageTitle>}>
<p className="text-sm text-muted-foreground">This {what} does not exist anymore.</p>
</AppShell>
);
Expand Down
6 changes: 5 additions & 1 deletion packages/web/src/components/root.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import {Outlet} from 'react-router';
import {DesktopHistory} from '@/components/desktop-history';
import {ReleaseNotice} from '@/components/release-notice';
import {hasHiddenDesktopTitleBar} from '@/lib/desktop';

// Around every page, the human side's and the agents' alike: the page, and word of a new release.
// Around every page, the human side's and the agents' alike: the page, word of a new release, and
// in the desktop app's title bar, back and forward.
export function Root() {
return (
<>
<Outlet />
<ReleaseNotice />
{hasHiddenDesktopTitleBar() && <DesktopHistory />}
</>
);
}
10 changes: 2 additions & 8 deletions packages/web/src/components/run-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type {ActivityTask, AuditEntry} from '@proxy/contracts/activity';
import type {Connection} from '@proxy/contracts/connections';
import {ChevronDownIcon, ChevronRightIcon, CornerDownRightIcon} from 'lucide-react';
import {Fragment, useId, useMemo, useState} from 'react';
import {Link, useLocation} from 'react-router';
import {Link} from 'react-router';
import {
AxisLabels,
ChartLegend,
Expand All @@ -20,7 +20,6 @@ import {useRequestTooltip} from '@/hooks/use-request-tooltip';
import {useTitleOf} from '@/hooks/use-title-of';
import {useChartWidth} from '@/hooks/use-chart-width';
import {useOrgSlug} from '@/hooks/use-org';
import {backFrom} from '@/lib/back';
import {formatTime} from '@/lib/format';
import {
buildRunTimeline,
Expand Down Expand Up @@ -264,7 +263,6 @@ function RowLabel({
onHover: (over: boolean) => void;
}) {
const org = useOrgSlug();
const back = backFrom(useLocation());
const [task] = row.tasks;
const own = row.depth === 0;
const foldable = row.kind === 'group' || row.rolledIn > 0 || open;
Expand Down Expand Up @@ -326,7 +324,6 @@ function RowLabel({
{row.kind === 'task' && task && !own && (
<Link
to={`/${org}/activity/tasks/${encodeURIComponent(task.id)}`}
state={back}
preventScrollReset
className="flex h-full min-w-0 flex-1 items-center pr-3 pl-1 hover:text-foreground"
>
Expand All @@ -353,7 +350,6 @@ function RowTarget({
onHover: (over: boolean) => void;
}) {
const org = useOrgSlug();
const back = backFrom(useLocation());
const [task] = row.tasks;
const style = {
left: row.start,
Expand Down Expand Up @@ -383,9 +379,7 @@ function RowTarget({
return (
<ChartTarget
action={
row.depth === 0
? null
: {to: `/${org}/activity/tasks/${encodeURIComponent(task.id)}`, state: back}
row.depth === 0 ? null : {to: `/${org}/activity/tasks/${encodeURIComponent(task.id)}`}
}
label={task.title}
style={style}
Expand Down
Loading
Loading