Skip to content
Open
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
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 @@ -652,7 +652,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, useMemo, useState, type ComponentProps } from "react";
import { Pressable, useWindowDimensions, View } from "react-native";
Expand Down Expand Up @@ -132,11 +137,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 @@ -1084,9 +1092,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
31 changes: 31 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { recordServerClock, toServerClock } from "@t3tools/client-runtime/clock";
import {
boundAttachmentIds,
findRecordedWorktreeSetup,
Expand Down Expand Up @@ -129,6 +130,36 @@ describe("deriveComposerSendState", () => {
});
});

it.each([true, false])(
"counts optimistic sends on the host clock with an existing thread: %s",
(hasThread) => {
const thread = makeThread();
vi.useFakeTimers();
vi.setSystemTime(now);
recordServerClock(thread.environmentId, {
serverTimeMs: Date.parse(now) + 3_600_000,
requestedAtMs: Date.parse(now),
receivedAtMs: Date.parse(now),
});
try {
const dispatch = createLocalDispatchSnapshot(hasThread ? thread : undefined, {
environmentId: thread.environmentId,
});
expect(
toServerClock(thread.environmentId, Date.parse(now) + 2_000) -
Date.parse(dispatch.startedAt),
).toBe(2_000);
} finally {
recordServerClock(thread.environmentId, {
serverTimeMs: undefined,
requestedAtMs: 0,
receivedAtMs: 0,
});
vi.useRealTimers();
}
},
);

describe("hasServerAcknowledgedLocalDispatch", () => {
it("acknowledges a settled newer background turn", () => {
const localDispatch = createLocalDispatchSnapshot(
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { toServerClock } from "@t3tools/client-runtime/clock";
import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts";
import {
deriveLatestThreadRun,
Expand Down Expand Up @@ -1113,15 +1114,19 @@ export interface LocalDispatchSnapshot {
export function createLocalDispatchSnapshot(
activeThread: Thread | undefined,
options?: {
environmentId?: EnvironmentId;
preparingWorktree?: boolean;
latestUserMessageId?: ChatMessage["id"] | null;
submissionIntent?: ComposerSubmissionIntent;
},
): LocalDispatchSnapshot {
const environmentId = options?.environmentId ?? activeThread?.environmentId;
const latestRun = activeThread?.latestRun ?? null;
const runtime = activeThread?.runtime ?? null;
return {
startedAt: new Date().toISOString(),
startedAt: new Date(
environmentId === undefined ? Date.now() : toServerClock(environmentId, Date.now()),
).toISOString(),
preparingWorktree: Boolean(options?.preparingWorktree),
submissionIntent: options?.submissionIntent ?? "foreground",
latestUserMessageId: options?.latestUserMessageId ?? null,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,7 @@ export default function ChatView(props: ChatViewProps) {
isSendBusy,
backgroundSubmissionPending,
} = useLocalDispatchState({
environmentId,
activeThread,
activeLatestRun,
latestUserMessageId:
Expand Down
18 changes: 9 additions & 9 deletions apps/web/src/components/chat/TimelineWorkGroupRows.logic.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
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 {
export function formatWorkingTimerNow(startIso: string, environmentId: EnvironmentId): 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((toServerClock(environmentId, Date.now()) - 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";
}
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/TimelineWorkGroupRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,11 @@ export const WorkGroupSection = memo(function WorkGroupSection({
});

function WorkingTimer({ createdAt }: { createdAt: string }) {
const { activeThreadEnvironmentId } = use(TimelineRowCtx);

return (
<span
ref={tickTextContent(() => formatWorkingTimerNow(createdAt), true)}
ref={tickTextContent(() => formatWorkingTimerNow(createdAt, activeThreadEnvironmentId), true)}
className="tabular-nums"
/>
);
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/useLocalDispatchState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type MessageId, type RuntimeRequestId } from "@t3tools/contracts";
import { type EnvironmentId, type MessageId, type RuntimeRequestId } from "@t3tools/contracts";
import { useState } from "react";
import { type ComposerSubmissionIntent } from "../../composer-logic";
import { type SessionPhase, type Thread } from "../../types";
Expand All @@ -9,6 +9,7 @@ import {
} from "../ChatView.logic";

export function useLocalDispatchState(input: {
environmentId: EnvironmentId;
activeThread: Thread | undefined;
activeLatestRun: Thread["latestRun"] | null;
latestUserMessageId: MessageId | null;
Expand Down Expand Up @@ -50,6 +51,7 @@ export function useLocalDispatchState(input: {
}
return createLocalDispatchSnapshot(input.activeThread, {
...options,
environmentId: input.environmentId,
latestUserMessageId: input.latestUserMessageId,
});
});
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
Loading
Loading