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
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,7 @@ function useStableList<T>(
* 3000 of them cost more than 3000 memo compares.
*/
const CountedTurn = React.memo(function CountedTurn(props: React.ComponentProps<typeof ChatTurn>) {
// oxlint-disable-next-line react/globals -- counting renders is this probe's measurement
turnRenders += 1;
return <ChatTurn {...props} />;
});
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/renderer/lab/scratches/host-connection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ export default function HostConnectionScratch() {
const retryRef = React.useRef(retrySucceeds);
retryRef.current = retrySucceeds;
const timers = React.useRef<number[]>([]);
// The seeded hosts went offline relative to when the scratch opened.
const [openedAt] = React.useState(() => Date.now());

const source = React.useMemo<FakeHostSource>(() => {
const later = (ms: number, act: () => void) => {
Expand Down Expand Up @@ -225,17 +227,17 @@ export default function HostConnectionScratch() {
return createFakeHostSource(
hostSnapshot(
[
remoteHost(HETZNER, "hetzner-1", hetzner("offline", Date.now())),
remoteHost(HETZNER, "hetzner-1", hetzner("offline", openedAt)),
remoteHost(MINI, "mac-mini", {
os: "macos",
link: { status: "offline", since: Date.now() - 2 * 3_600_000, retryAt: null },
link: { status: "offline", since: openedAt - 2 * 3_600_000, retryAt: null },
}),
],
{ "prj-voltaic": HETZNER, "prj-harbor": MINI },
),
onCall,
);
}, []);
}, [openedAt]);

React.useEffect(() => {
const local = createFakeHostSource(
Expand Down
13 changes: 10 additions & 3 deletions apps/desktop/src/renderer/lab/scratches/model-tiers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,14 +303,21 @@ function labModelAccess(): ModelAccessClient {
/* ------------------------------------------------------------------ scratch */

export default function ModelTiersScratch() {
const [generation, setGeneration] = React.useState(0);
const client = React.useMemo(labModelAccess, [generation]);
// Reset swaps in a fresh client and remounts the provider around it.
const [{ generation, client }, setLab] = React.useState(() => ({
generation: 0,
client: labModelAccess(),
}));

return (
<ModelAccessProvider key={generation} client={client}>
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => setGeneration((n) => n + 1)}>
<Button
variant="secondary"
size="sm"
onClick={() => setLab({ generation: generation + 1, client: labModelAccess() })}
>
Reset defaults
</Button>
<span className="text-ui text-muted-foreground">
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/lab/scratches/split-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ function PaneBody({
onFocus,
}: PaneBodyProps): React.JSX.Element {
const token = React.useRef<object>({});
const started = React.useRef<number>(Date.now());
const [started] = React.useState(() => Date.now());

React.useEffect(() => {
onInstance(pane.id, token.current);
Expand Down Expand Up @@ -477,7 +477,7 @@ function PaneBody({
className="font-mono text-label tabular-nums text-muted-foreground"
title="How long this pane has been running. Back to 00:00 means it was killed and rebuilt."
>
{elapsed(now - started.current)}
{elapsed(now - started)}
</span>
<span
className="h-0.5 w-10 shrink-0 overflow-hidden rounded-full bg-muted"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/lab/scratches/ticket-kickoff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ function labModelAccess(): ModelAccessClient {
export default function TicketKickoffScratch({
initialTickets = tickets,
}: { initialTickets?: typeof tickets } = {}) {
const client = React.useMemo(labModelAccess, []);
const [client] = React.useState(labModelAccess);
const [empty, setEmpty] = React.useState(false);

// The board's own store, emptied and refilled in place: `BoardEmpty` keys off
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/renderer/src/components/chat/chat-plane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,10 @@ export function ChatPlane({
.map((message) => message.id),
);
for (const [id, label] of deliveryLabels) if (label === "Pending") ids.add(id);
// Each id is stamped with the time it was first seen pending and keeps
// that stamp on every later pass, so re-running this memo cannot move a
// clock; only a new id reads the time.
// oxlint-disable-next-line react/purity -- first-seen stamp, idempotent per id
const now = Date.now();
const next = new Map([...ids].map((id) => [id, waitTimes.current.get(id) ?? now]));
waitTimes.current = next;
Expand Down Expand Up @@ -3007,7 +3011,13 @@ export const ChatTurn = React.memo(function ChatTurn({
* Refusals remain immediate; ordinary round trips never show waiting copy. */
function MessageDelivery({ label, since }: { label: string; since: number | undefined }) {
const failed = label === "Not delivered" || label === "Delivery unconfirmed";
const remaining = since === undefined ? 600 : Math.max(0, 600 - (Date.now() - since));
// The clock is read once, at mount, so the wait does not restart per render.
// A stamp present at mount ends the wait at `since + 600`. A stamp that
// arrives later can end it before `since + 600`, but never sooner than
// 600ms after the tab mounted.
const [mountedAt] = React.useState(() => Date.now());
const remaining =
since === undefined ? 600 : Math.min(600, Math.max(0, 600 - (mountedAt - since)));
const waited = useDelayedPresence(!failed, remaining, remaining === 0);
if (!failed && !waited) return null;
const Icon = failed ? WarningIcon : ClockIcon;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2522,6 +2522,74 @@ describe("VC-769 one progress indicator through the live ChatPlane projection",
);
});

const submitted = (sequence: number, commandId: string, message: UIMessage) =>
frame(
sequence,
{
kind: "command.recorded",
command: {
id: commandId,
sessionId: SESSION,
createdAt: 0,
route: null,
intent: {
kind: "message.submit",
reference: { id: `artifact-${sequence}`, mediaType: null, digest: null },
},
},
},
message,
);

it("keeps the wait's clock from the hold when ownership hands off part-way through it", async () => {
vi.useFakeTimers();
const store = chatStore([]);
const message: UIMessage = {
id: "handoff-mid-wait",
role: "user",
parts: [{ type: "text", text: "Halfway there" }],
};
useChatDraftsStore
.getState()
.holdMessage(SESSION, { id: message.id, text: "Halfway there", delivery: "send" });
await mountPlane(store);
await advanceProgressTime(300);
await ingest(store, [submitted(1, "held-command", message)]);
expect(container?.querySelectorAll(".is-user")).toHaveLength(1);
await advanceProgressTime(299);
expect(container?.querySelector('[data-slot="message-delivery"]')).toBeNull();
await advanceProgressTime(1);
expect(container?.querySelector('[data-slot="message-delivery"]')?.textContent).toBe(
"Sending…",
);
});

it("never holds a wait longer than 600ms after the tab mounted when its stamp arrives late", async () => {
vi.useFakeTimers();
const store = chatStore([]);
const drafts = useChatDraftsStore.getState();
drafts.holdMessage(SESSION, { id: "late-stamp", text: "Line up", delivery: "queue" });
// Accepted before the tab mounts: "Queued", with no pending stamp yet.
drafts.amendHeldMessage(SESSION, "late-stamp", (held) => ({ ...held, deliveryAccepted: true }));
await mountPlane(store);
expect(container?.querySelector('[data-slot="message-delivery"]')).toBeNull();
await advanceProgressTime(300);
// Pending again, so the message is stamped now — 300ms after the tab mounted.
await act(async () =>
drafts.amendHeldMessage(SESSION, "late-stamp", (held) => {
const next = { ...held };
delete next.deliveryAccepted;
return next;
}),
);
await advanceProgressTime(299);
expect(container?.querySelector('[data-slot="message-delivery"]')).toBeNull();
await advanceProgressTime(1);
expect(container?.querySelector('[data-slot="message-delivery"]')?.textContent).toBe(
"Queuing…",
);
});

it.each(["reasoning", "text", "tool"] as const)(
"restores Working during a transport retry with stale %s output",
async (kind) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function StreakVisual() {
// The window is a fact about now, so it is taken once per mount rather than
// per render — a grid whose first cell slid while the pointer was over it
// would be a different chart under the same tooltip.
const now = React.useMemo(() => Date.now(), []);
const [now] = React.useState(() => Date.now());
React.useEffect(() => {
void refresh(streakWindowStart(now));
}, [now, refresh]);
Expand Down
6 changes: 2 additions & 4 deletions apps/desktop/src/renderer/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -491,10 +491,8 @@ function SidebarMenuSkeleton({
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
// Random width between 50 to 90%, drawn once when the skeleton mounts.
const [width] = React.useState(() => `${Math.floor(Math.random() * 40) + 50}%`);

return (
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export function UsageLimitsPopover({ now }: { now?: number } = {}) {
const requestId = React.useRef(0);
// When a read was last ASKED for, which is what the focus gate below is
// about: one request per hold, whatever the answer turned out to be.
const askedAt = React.useRef(Date.now());
const [mountedAt] = React.useState(() => Date.now());
const askedAt = React.useRef(mountedAt);
React.useEffect(
() => () => {
requestId.current += 1;
Expand Down
26 changes: 19 additions & 7 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ export default defineConfig({

// React Compiler rules remain individually disabled pending adoption
// (VC-216, VC-211). oxlint 1.79 split the inactive compiler rule into
// these ten rules (oxc #25500, #25830, #25840). Keep the React plugin and
// error categories enabled so other rules and newly added rules still
// enforce. Remove each override only with its corresponding behavior
// fixes and tests; `vp lint` reports the current findings.
// ten rules (oxc #25500, #25830, #25840); `globals`, `purity` and
// `use-memo` are adopted, the seven below are not yet (VC-784). Keep the
// React plugin and error categories enabled so other rules and newly
// added rules still enforce. Remove each override only with its
// corresponding behavior fixes and tests; `vp lint` reports the current
// findings.
// Keep the override set scoped to compiler adoption (VC-216); preserve
// existing error categories for unrelated rules (VC-211). Each enabled
// rule needs its behavior regression coverage (VC-216). Remove obsolete
Expand All @@ -94,10 +96,20 @@ export default defineConfig({
"react/immutability": "off",
"react/memo-dependencies": "off",
"react/preserve-manual-memoization": "off",
"react/globals": "off",
"react/purity": "off",
"react/use-memo": "off",
},
overrides: [
{
// A test's probe component exists to hand render-time values to the
// assertions around it: `function Probe() { handle = useThing(); }`
// captures a hook's result, and `renders += 1` counts renders because
// the render count IS what the test measures. Writing an outer
// variable during render is the point of those probes, not a hazard,
// and no test is compiled by React Compiler. Product code keeps the
// rule at error.
files: ["**/*.test.ts", "**/*.test.tsx"],
rules: { "react/globals": "off" },
},
],
},
staged: {
// `vp fmt` (Oxfmt) aborts with "Expected at least one target file" when
Expand Down
Loading