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 @@ -10,9 +10,39 @@ await build({ configFile: false, logLevel: "silent", build: {
lib: { entry: resolve("src/data/workspace-progressive-status.ts"), formats: ["es"], fileName: () => "loader.mjs" },
rolldownOptions: { external: ["zod"] },
} });
const { loadWorkspaceGoalSnapshots, directoryStatusPayload } = await import(pathToFileURL(resolve(outDir, "loader.mjs")).href);
const { loadWorkspaceGoalSnapshots, directoryStatusPayload, reusableGoalSnapshots } = await import(pathToFileURL(resolve(outDir, "loader.mjs")).href);
const directory = { ok: true, schema_version: "loopx_workspace_directory_v1", registry_revision: "r1",
goals: [{ id: "alpha", display_name: "Alpha", activation_state: "active", registry_member: true }] };

// What a same-source refresh may keep: a paused Goal must not send its peers
// back to the loading lane, and the touched Goal must not survive as a stale
// snapshot.
const entry = (id, activation_state = "active", display_name = id) =>
({ id, display_name, activation_state, registry_member: true });
const snapshots = { alpha: { alpha: true }, beta: { beta: true }, gamma: { gamma: true } };
const earlier = {
directory: { ok: true, schema_version: "loopx_workspace_directory_v1", registry_revision: "r1",
goals: [entry("alpha"), entry("beta"), entry("gamma")] },
snapshots,
};
const paused = { ok: true, schema_version: "loopx_workspace_directory_v1", registry_revision: "r2",
goals: [entry("alpha"), entry("beta", "stopped"), entry("gamma")] };
assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused)), ["alpha", "gamma"],
"a Goal whose lifecycle moved is re-read instead of reused");
assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused, { invalidateGoalIds: ["gamma"] })), ["alpha"],
"a Goal the action touched is re-read even when its directory entry held");
assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused, { invalidateGoalIds: ["beta"] })), ["alpha", "gamma"],
"invalidating the Goal that already changed keeps its peers");
assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, {
...paused, goals: [entry("alpha"), entry("gamma"), entry("delta")],
})), ["alpha", "gamma"], "a Goal that left the directory loses its snapshot");
assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, {
...paused, goals: [entry("alpha"), entry("beta", "active", "Renamed"), entry("gamma")],
})), ["alpha", "gamma"], "a renamed Goal is re-read rather than reused under its new title");
assert.deepEqual(reusableGoalSnapshots(null, paused), {}, "a first read has nothing to reuse");
assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused, { invalidateGoalIds: ["alpha", "gamma"] })), [],
"invalidating every Goal is a full re-read");
const retentionChecks = 7;
const access = { error_code: "workspace_status_access_denied" };
const original = { fetch, setTimeout, clearTimeout };
const deadline = {};
Expand Down Expand Up @@ -79,7 +109,7 @@ try {
const payload = { ...directoryStatusPayload(directory), workspace_registry_revision: "r1" };
await check("success", () => json(payload, 200), ["success"], 1);
await check("wrong Goal", () => json({ ...payload, run_history: { ...payload.run_history, goals: [{ id: "beta" }] } }, 200), ["scope"], 1);
console.log(JSON.stringify({ ok: true, checks }));
console.log(JSON.stringify({ ok: true, checks: checks + retentionChecks }));
} finally {
globalThis.fetch = original.fetch;
globalThis.setTimeout = original.setTimeout;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@ export type WorkspaceProgress = {
errors: Record<string, WorkspaceLoadError>;
};

/**
* Snapshots a same-source refresh may keep instead of re-reading.
*
* A directory entry is the cheap authoritative signal for "this Goal's
* lifecycle did not move here": when the entry is unchanged, its snapshot still
* describes the Goal, and re-reading it costs one full status collection per
* Goal. Goals the caller just acted on are never reused, and a Goal that left
* the directory loses its snapshot with it.
*/
export function reusableGoalSnapshots(
previous: Pick<WorkspaceProgress, "directory" | "snapshots"> | null,
directory: WorkspaceDirectory,
options: { invalidateGoalIds?: Iterable<string> } = {},
): Record<string, StatusPayload> {
if (!previous) return {};
const invalidated = new Set(options.invalidateGoalIds ?? []);
const before = new Map(previous.directory.goals.map((goal) => [goal.id, goal]));
return Object.fromEntries(directory.goals.flatMap((goal) => {
const earlier = before.get(goal.id);
const snapshot = previous.snapshots[goal.id];
if (!earlier || !snapshot || invalidated.has(goal.id)) return [];
if (earlier.display_name !== goal.display_name
|| earlier.activation_state !== goal.activation_state) return [];
return [[goal.id, snapshot]];
}));
}

function queryUrl(url: string, fields: Record<string, string>, base: string) {
const parsed = new URL(url, base);
parsed.searchParams.delete("goal_activation");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,9 @@ export type PersonalWorkspaceCallbacks = {
projectionVerified: boolean;
}>;
onGoalDeleted?: (goalId: string) => void;
onReconcileStatus?: () => void | Promise<void>;
/** Re-read the workspace projection after an applied action. `invalidateGoalIds`
* names the Goals the action touched, so a peer's snapshot is not re-read for it. */
onReconcileStatus?: (options?: { invalidateGoalIds?: string[] }) => void | Promise<void>;
onRefresh?: () => void | Promise<void>;
onRetryGoalArchive?: () => void | Promise<void>;
onPreviewAction?: (request: WorkspaceActionPreviewRequest) => WorkspaceActionPreview | Promise<WorkspaceActionPreview>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1194,7 +1194,7 @@ export function PersonalWorkspacePage({
callbacks.onGoalActivationStateChange?.(goal.goalId, result.activationState);
setActionFeedback(t("feedback.completed", { title: summaryByOperation[operation] }));
if (operation === "stop") selectGoal(null);
await (callbacks.onReconcileStatus ?? callbacks.onRefresh)?.();
await reconcileStatus([goal.goalId]);
return;
}
const proposal = await createPreview({
Expand Down Expand Up @@ -1320,6 +1320,22 @@ export function PersonalWorkspacePage({
}
}

/**
* Reconcile the projection after an applied action. The touched Goal is the
* only one whose snapshot is dropped; a peer keeps the snapshot it already
* had, so one Goal's pause does not send the rest of the workspace back to
* its loading lane.
*/
function reconcileStatus(invalidateGoalIds?: string[]) {
const reconcile = callbacks.onReconcileStatus;
const request = reconcile
? reconcile({ invalidateGoalIds })
: callbacks.onRefresh?.();
return Promise.resolve(request).catch(() => {
setActionFeedback(t("feedback.goalRefreshFailed"));
});
}

async function applyProposal(
proposal: WorkspaceActionPreview,
options: {
Expand Down Expand Up @@ -1362,8 +1378,7 @@ export function PersonalWorkspacePage({
if (proposal.lifecycleOperation === "delete" && proposal.goalId) {
callbacks.onGoalDeleted?.(proposal.goalId);
}
const reconcile = callbacks.onReconcileStatus ?? callbacks.onRefresh;
void Promise.resolve().then(() => reconcile?.()).catch(() => undefined);
void reconcileStatus(proposal.goalId ? [proposal.goalId] : undefined);
}
return;
}
Expand Down Expand Up @@ -1404,8 +1419,7 @@ export function PersonalWorkspacePage({
callbacks.onGoalDeleted?.(applied.goalId);
}
if (applied.actionKind === "goal.lifecycle") {
const reconcile = callbacks.onReconcileStatus ?? callbacks.onRefresh;
void Promise.resolve().then(() => reconcile?.()).catch(() => undefined);
void reconcileStatus(applied.goalId ? [applied.goalId] : undefined);
}
} catch (error) {
if (lifecycleChange) {
Expand Down Expand Up @@ -1460,10 +1474,7 @@ export function PersonalWorkspacePage({
},
onOpenGoal: (goalId) => {
selectGoal(goalId);
const reconcile = callbacks.onReconcileStatus ?? callbacks.onRefresh;
void Promise.resolve().then(() => reconcile?.()).catch(() => {
setActionFeedback(t("feedback.goalRefreshFailed"));
});
void reconcileStatus([goalId]);
},
onOpenGoalView: (tab) => {
setSelectedGoalTab(tab);
Expand Down
20 changes: 14 additions & 6 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { GoalAcceptanceObservation } from "../data/goal-acceptance-observation";
import { attentionDetails, sourceAttention } from "../features/personal-workspace/attention-details";
import type { AttentionDetails } from "../features/personal-workspace/attention-details";
import { directoryStatusPayload, fetchWorkspaceDirectory, loadWorkspaceGoalSnapshots, type WorkspaceProgress, type WorkspaceLoadError } from "../data/workspace-progressive-status";
import { directoryStatusPayload, fetchWorkspaceDirectory, loadWorkspaceGoalSnapshots, reusableGoalSnapshots, type WorkspaceProgress, type WorkspaceLoadError } from "../data/workspace-progressive-status";
import { useEffect, useMemo, useRef, useState } from "react";
import { CircleAlert, Moon, RefreshCw, Sun } from "lucide-react";

Expand Down Expand Up @@ -1369,7 +1369,7 @@ function PersonalGoalHome({
onGoalActivationStateChange: (goalId: string, activationState: "active" | "stopped") => void;
onGoalDeleted: (goalId: string) => void;
onSelectGoal: (goalId: string) => void;
onReconcileStatus: () => void | Promise<void>;
onReconcileStatus: (options?: { invalidateGoalIds?: string[] }) => void | Promise<void>;
onRefresh: () => void | Promise<void>;
onRetryGoalArchive: () => void | Promise<void>;
payload: StatusPayload;
Expand Down Expand Up @@ -3088,6 +3088,8 @@ export function DashboardPage() {
options: {
background?: boolean;
retryOnly?: boolean;
reuseSnapshots?: boolean;
invalidateGoalIds?: string[];
resyncAttempt?: number;
selectionRevision?: number;
} = {},
Expand Down Expand Up @@ -3118,8 +3120,14 @@ export function DashboardPage() {
const directory = await fetchWorkspaceDirectory(trimmed, window.location.href).catch(() => null);
if (!statusRequestCanCommit(statusRequestFenceRef.current, request)) return;
if (directory) {
const retained = options.retryOnly && source.kind === "url" && source.label === trimmed
&& progress?.directory.registry_revision === directory.registry_revision ? progress.snapshots : {};
// A refresh that keeps the same source only re-reads the Goals whose
// directory entry moved or that the caller just acted on. Dropping every
// snapshot here would send the whole workspace back to its loading lane
// after one Goal's pause, resume or open.
const retained = (options.retryOnly || options.reuseSnapshots)
&& source.kind === "url" && source.label === trimmed
? reusableGoalSnapshots(progress, directory, { invalidateGoalIds: options.invalidateGoalIds })
: {};
setProgress({ directory, snapshots: retained, errors: {} });
const requestedDirectory = { ...directory, goals: directory.goals.filter((goal) => !retained[goal.id]) };
let directoryChanged = false;
Expand Down Expand Up @@ -3351,9 +3359,9 @@ export function DashboardPage() {
) } : current);
}}
onSelectGoal={selectGoal}
onReconcileStatus={() => loadFromUrl(
onReconcileStatus={(options) => loadFromUrl(
source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl),
{ background: true },
{ background: true, invalidateGoalIds: options?.invalidateGoalIds, reuseSnapshots: true },
)}
onRetryGoalArchive={retryGoalArchive}
onRefresh={() => loadFromUrl(source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { retryOnly: Boolean(progress && Object.keys(progress.errors).length) })}
Expand Down
3 changes: 2 additions & 1 deletion examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ import {
startServer,
} from "./personal-workspace-browser/fixture.mjs";
import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs";
import { progressiveLoadingScenario } from "./personal-workspace-browser/progressive-loading.mjs";
import { teamPlanScenario } from "./personal-workspace-browser/team-plan.mjs";
import { typedActionsScenario } from "./personal-workspace-browser/typed-actions.mjs";

const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, executionChipScenario];
const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, executionChipScenario, progressiveLoadingScenario];
const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO;
const scenarios = requestedScenario
? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario)
Expand Down
Loading
Loading