Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 5 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,11 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) {
return { kind: "compacting" };
}
if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
return { kind: "working", startedAt: props.activeWorkStartedAt };
return {
kind: "working",
environmentId: props.environmentId,
startedAt: props.activeWorkStartedAt,
};
}
if (pendingBackgroundWork !== null && contentPresentationKind === "ready") {
return {
Expand Down
19 changes: 15 additions & 4 deletions apps/mobile/src/features/threads/floating-working-control.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { toServerClock } from "@t3tools/client-runtime/clock";
import type { SubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
import type { EnvironmentId } from "@t3tools/contracts";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import { GlassContainer, GlassView } from "expo-glass-effect";
import { type ReactNode, useEffect, useRef, useState } from "react";
Expand Down Expand Up @@ -511,7 +513,12 @@ function FloatingStatusLabel(props: {
);
}
return (
<WorkingDuration key="working" startedAt={props.status.startedAt} onLayout={props.onLayout} />
<WorkingDuration
key="working"
environmentId={props.status.environmentId}
startedAt={props.status.startedAt}
onLayout={props.onLayout}
/>
);
}

Expand Down Expand Up @@ -550,25 +557,29 @@ function StatusLabelRow(props: {
}

function WorkingDuration(props: {
readonly environmentId: EnvironmentId;
readonly startedAt: string;
readonly onLayout: (event: LayoutChangeEvent) => void;
}) {
return (
<StatusLabelRow onLayout={props.onLayout}>
<WorkingTimer startedAt={props.startedAt} />
<WorkingTimer environmentId={props.environmentId} startedAt={props.startedAt} />
</StatusLabelRow>
);
}

export function WorkingTimer(props: { readonly startedAt: string }) {
export function WorkingTimer(props: {
readonly environmentId: EnvironmentId;
readonly startedAt: string;
}) {
const nowMs = useVisibleSecondClock(true);
return (
<SystemText
className="shrink text-xs text-foreground"
numberOfLines={1}
style={{ fontVariant: ["tabular-nums"], fontWeight: "500" }}
>
Working {formatWorkingDuration(props.startedAt, nowMs)}
Working {formatWorkingDuration(props.startedAt, toServerClock(props.environmentId, nowMs))}
</SystemText>
);
}
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src/features/threads/floating-working-status.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
import type { EnvironmentId } from "@t3tools/contracts";

export interface FloatingStatusAction {
readonly label: string;
Expand All @@ -8,7 +9,7 @@ export interface FloatingStatusAction {
}

export type FloatingWorkingStatus =
| { readonly kind: "working"; readonly startedAt: string }
| { readonly kind: "working"; readonly environmentId: EnvironmentId; readonly startedAt: string }
| { readonly kind: "syncing"; readonly label: string }
| { readonly kind: "compacting" }
| {
Expand Down
24 changes: 19 additions & 5 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ import {
formatWorkingDurationLabel,
resolveThreadWorkingStartedAt,
} from "@t3tools/client-runtime/state/models";
import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts";
import {
AuthOrchestrationOperateScope,
type EnvironmentId,
type EnvironmentMachineKind,
} from "@t3tools/contracts";
import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
import { toServerClock } from "@t3tools/client-runtime/clock";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
Expand Down Expand Up @@ -131,11 +136,14 @@ const DONE_STATUS_LABEL: StatusLabel = {
iconTintClassName: "accent-adaptive-emerald-700-300",
};

function ThreadListV2Duration(props: { readonly startedAt: string | null }) {
function ThreadListV2Duration(props: {
readonly environmentId: EnvironmentId;
readonly startedAt: string | null;
}) {
const startedAtMs = Date.parse(props.startedAt ?? "");
const nowMs = useVisibleSecondClock(Number.isFinite(startedAtMs));
if (!Number.isFinite(startedAtMs)) return null;
return ` ${formatWorkingDurationLabel(nowMs - startedAtMs)}`;
return ` ${formatWorkingDurationLabel(toServerClock(props.environmentId, nowMs) - startedAtMs)}`;
}

const CARD_MENU_ACTIONS: MenuAction[] = [
Expand Down Expand Up @@ -1090,9 +1098,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
>
{statusLabel.label}
{status === "working" ? (
<ThreadListV2Duration startedAt={resolveThreadWorkingStartedAt(thread)} />
<ThreadListV2Duration
environmentId={thread.environmentId}
startedAt={resolveThreadWorkingStartedAt(thread)}
/>
) : status === "waiting" ? (
<ThreadListV2Duration startedAt={thread.latestRun?.completedAt ?? null} />
<ThreadListV2Duration
environmentId={thread.environmentId}
startedAt={thread.latestRun?.completedAt ?? null}
/>
) : null}
</Text>
</View>
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1802,6 +1802,8 @@ export const layerWsRpc = (
}),
newProjectsRoot: managedFolders.namedProjectsRoot,
...(lastEventLoopStall === undefined ? {} : { lastEventLoopStall }),
// Read last so the stamp is as close to sending as this config gets.
serverTime: DateTime.formatIso(yield* DateTime.now),
};
});

Expand Down
16 changes: 9 additions & 7 deletions apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { toServerClock } from "@t3tools/client-runtime/clock";
import type { EnvironmentId } from "@t3tools/contracts";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";

function formatWorkingTimer(startIso: string, endIso: string): string | null {
function formatWorkingTimer(startIso: string, nowMs: number): string {
const startedAtMs = Date.parse(startIso);
const endedAtMs = Date.parse(endIso);
if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) {
return null;
if (!Number.isFinite(startedAtMs)) {
return "0s";
}

const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000));
const elapsedSeconds = Math.max(0, Math.floor((nowMs - startedAtMs) / 1000));
if (elapsedSeconds < 60) {
return `${elapsedSeconds}s`;
}

return formatDuration(elapsedSeconds * 1_000);
}

export function formatWorkingTimerNow(startIso: string): string {
return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
/** Counts on the thread's host clock, since `startIso` is a host timestamp. */
export function formatWorkingTimerNow(startIso: string, environmentId: EnvironmentId): string {
return formatWorkingTimer(startIso, toServerClock(environmentId, Date.now()));
}
7 changes: 4 additions & 3 deletions apps/web/src/components/chat/TimelineWorkGroupRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,19 +109,20 @@ export const WorkGroupSection = memo(function WorkGroupSection({
});

function WorkingTimer({ createdAt }: { createdAt: string }) {
const { activeThreadEnvironmentId } = use(TimelineRowCtx);
const textRef = useRef<HTMLSpanElement>(null);
const initialText = formatWorkingTimerNow(createdAt);
const initialText = formatWorkingTimerNow(createdAt, activeThreadEnvironmentId);

useEffect(() => {
const updateText = () => {
if (textRef.current) {
textRef.current.textContent = formatWorkingTimerNow(createdAt);
textRef.current.textContent = formatWorkingTimerNow(createdAt, activeThreadEnvironmentId);
}
};
updateText();
const id = setInterval(updateText, 1000);
return () => clearInterval(id);
}, [createdAt]);
}, [activeThreadEnvironmentId, createdAt]);

return (
<span ref={textRef} className="tabular-nums">
Expand Down
22 changes: 16 additions & 6 deletions apps/web/src/components/sidebar/SidebarThreadRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import {
resolveThreadProviderStack,
} from "@t3tools/client-runtime/state/models";
import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
import { toServerClock } from "@t3tools/client-runtime/clock";
import {
AuthOrchestrationOperateScope,
type EnvironmentId,
type EnvironmentMachineKind,
type ScopedThreadRef,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -158,18 +160,20 @@ function JumpHintBadge(props: { label: string }) {
);
}

function WorkingDuration(props: { startedAt: string | null }) {
function WorkingDuration(props: { environmentId: EnvironmentId; startedAt: string | null }) {
const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN;
if (Number.isNaN(startedMs)) return null;
return <WorkingDurationLabel startedMs={startedMs} />;
return <WorkingDurationLabel environmentId={props.environmentId} startedMs={startedMs} />;
}

// Self-ticking so only this span re-renders each second, not the whole row;
// the shared tick lands every working row's update in the same frame.
function WorkingDurationLabel(props: { startedMs: number }) {
function WorkingDurationLabel(props: { environmentId: EnvironmentId; startedMs: number }) {
const nowMs = useNowSecond();
return (
<span className="tabular-nums">{formatWorkingDurationLabel(nowMs - props.startedMs)}</span>
<span className="tabular-nums">
{formatWorkingDurationLabel(toServerClock(props.environmentId, nowMs) - props.startedMs)}
</span>
);
}

Expand Down Expand Up @@ -1386,11 +1390,17 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
{topStatus.label}
{status === "working" ? (
<span aria-hidden>
<WorkingDuration startedAt={resolveWorkingStartedAt(thread)} />
<WorkingDuration
environmentId={thread.environmentId}
startedAt={resolveWorkingStartedAt(thread)}
/>
</span>
) : status === "waiting" ? (
<span aria-hidden>
<WorkingDuration startedAt={thread.latestRun?.completedAt ?? null} />
<WorkingDuration
environmentId={thread.environmentId}
startedAt={thread.latestRun?.completedAt ?? null}
/>
</span>
) : null}
</span>
Expand Down
46 changes: 45 additions & 1 deletion packages/client-runtime/src/clock.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EnvironmentId } from "@t3tools/contracts";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { deadlineStore, secondTick } from "./clock.ts";
import { deadlineStore, recordServerClock, secondTick, toServerClock } from "./clock.ts";

describe("deadlineStore", () => {
afterEach(() => {
Expand Down Expand Up @@ -138,3 +139,46 @@ describe("secondTick", () => {
expect(vi.getTimerCount()).toBe(0);
});
});

describe("server clock", () => {
const HOUR = 3_600_000;

it("counts from a host clock running ahead, minus only the reply's latency", () => {
const environmentId = EnvironmentId.make("ahead");
// Stamped at the host's 1h + 1_000 while this clock read about 1_000.
recordServerClock(environmentId, {
serverTimeMs: HOUR + 1_000,
requestedAtMs: 900,
receivedAtMs: 1_050,
});
expect(toServerClock(environmentId, 2_050)).toBe(HOUR + 2_000);
});

it("counts from a host clock running behind, minus only the request's latency", () => {
const environmentId = EnvironmentId.make("behind");
// Stamped at the host's 1_000 while this clock read about 1h + 1_000.
recordServerClock(environmentId, {
serverTimeMs: 1_000,
requestedAtMs: HOUR + 900,
receivedAtMs: HOUR + 1_050,
});
expect(toServerClock(environmentId, HOUR + 1_900)).toBe(2_000);
});

it("leaves clocks that agree within the round trip unshifted", () => {
const environmentId = EnvironmentId.make("synced");
recordServerClock(environmentId, {
serverTimeMs: 5_000,
requestedAtMs: 1_000,
receivedAtMs: 9_000,
});
expect(toServerClock(environmentId, 12_345)).toBe(12_345);
});

it("replaces an environment's reading on reconnect", () => {
const environmentId = EnvironmentId.make("reconnected");
recordServerClock(environmentId, { serverTimeMs: HOUR, requestedAtMs: 0, receivedAtMs: 0 });
recordServerClock(environmentId, { serverTimeMs: 0, requestedAtMs: 0, receivedAtMs: 0 });
expect(toServerClock(environmentId, 500)).toBe(500);
});
});
40 changes: 40 additions & 0 deletions packages/client-runtime/src/clock.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @effect-diagnostics globalTimers:off globalDate:off - Render clocks for React hooks, outside an Effect runtime.

import type { EnvironmentId } from "@t3tools/contracts";

/**
* A `useSyncExternalStore`-shaped clock. Snapshots stay cached between
* notifications while subscribed, as `useSyncExternalStore` requires.
Expand Down Expand Up @@ -70,6 +72,44 @@ export const minuteTick: ClockStore<string> = makeTickStore(
60_000,
);

// How far each environment's clock runs ahead of this one. Timers read it on
// every tick, so a reconnect's new reading lands without a re-render.
const serverClockOffsets = new Map<EnvironmentId, number>();

/**
* The host stamped `serverTimeMs` between the request and its reply, so the
* offset lies within that window. Keep the one nearest 0, so clocks that agree
* read as 0 instead of shifting every timer by network latency.
*/
export function recordServerClock(
environmentId: EnvironmentId,
sample: {
readonly serverTimeMs: number;
readonly requestedAtMs: number;
readonly receivedAtMs: number;
},
): void {
const { serverTimeMs, requestedAtMs, receivedAtMs } = sample;
if (!Number.isFinite(serverTimeMs)) return;
serverClockOffsets.set(
environmentId,
serverTimeMs > receivedAtMs
? serverTimeMs - receivedAtMs
: serverTimeMs < requestedAtMs
? serverTimeMs - requestedAtMs
: 0,
);
}

/**
* `clientMs` on `environmentId`'s clock. Live elapsed timers subtract that
* environment's timestamps from this, not from `Date.now()`, so a skewed host
* clock does not freeze them at 0 or inflate them.
*/
export function toServerClock(environmentId: EnvironmentId, clientMs: number): number {
return clientMs + (serverClockOffsets.get(environmentId) ?? 0);
}

export interface DeadlineStore extends ClockStore<boolean> {
/**
* Re-checks `Date.now()` and notifies if the deadline has passed. For callers
Expand Down
Loading
Loading