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
2 changes: 2 additions & 0 deletions apps/web/src/aldo/AldoSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
ChevronRightIcon,
CircleAlertIcon,
CircleCheckBigIcon,
CirclePauseIcon,
ClipboardListIcon,
EllipsisIcon,
FolderIcon,
Expand Down Expand Up @@ -714,6 +715,7 @@ const KIND_ICON: Partial<Record<AldoSidebarKind, ReactNode>> = {
waiting: <span className="size-2 rounded-full bg-warning" />,
failed: <CircleAlertIcon className="size-3.5 text-destructive-foreground" />,
merge: <GitMergeIcon className="size-3.5 text-success-foreground" />,
paused: <CirclePauseIcon className="size-3.5 text-warning-foreground" />,
landing: <GitMergeIcon className="size-3.5 text-primary" />,
unread: <CheckCircle2Icon className="size-3.5 text-success-foreground" />,
done: <span className="size-1.5 rounded-full bg-sidebar-muted-foreground/40" />,
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/aldo/decisions.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,17 @@ describe("aldoDecisions", () => {
conversation({ ref: "z", thread: at("z"), state: "failed", snoozedUntil: ago(-60) }),
// Snoozed, but it asks: it raised its hand.
{ ...question, ref: "s", thread: at("s"), snoozedUntil: ago(-60) },
// Paused mid-turn: opening it carries on, nothing to decide (its pull request still can be).
conversation({ ref: "p", thread: at("p"), state: "paused" }),
],
pullRequests: [
pullRequest({ thread: at("m"), mergesAt: ago(-5) }),
pullRequest({ thread: at("p"), number: 9 }),
],
pullRequests: [pullRequest({ thread: at("m"), mergesAt: ago(-5) })],
}),
NOW,
);
expect(decisions.map((d) => d.key)).toEqual(["question:s:r1"]);
expect(decisions.map((d) => d.key)).toEqual(["question:s:r1", "merge:acme/shop#9"]);
});

it("puts away a thread's pull request with it, and wakes a snooze that has run out", () => {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/aldo/decisions.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ function putAway(c: AldoHomeConversation, now: number): boolean {
* own. Aldo's brief picks from the same (src/lib/assistant/brief.ts).
*/
export function aldoDecisions(home: AldoHome, now: number): ReadonlyArray<AldoDecision> {
const needsYou = boardFor(home.conversations, now).needsYou.filter((c) => !putAway(c, now));
// A thread paused mid-turn needs opening, not a decision (Aldo's brief leaves it out too).
const needsYou = boardFor(home.conversations, now).needsYou.filter(
(c) => !putAway(c, now) && needsYouKind(c) !== "paused",
);
Comment on lines +88 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include paused threads in phone waiting counts

On the phone layout, excluding paused conversations from aldoDecisions also excludes them from the Agents-tab badge and header summary, because AldoHome.tsx:337 and AldoAgentsTab.tsx:75 use aldoDecisions(...).length as their waiting count. Thus a paused thread appears inside the “Waiting on you” section while the tab reports no waiting items; keep it out of the decision walkthrough, but calculate those counts from all needs-you items.

AGENTS.md reference: AGENTS.md:L67-L70

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cfc7cf7. A new aldoWaitingCount adds the decisions and the paused threads that aren't put away. The phone's badge (AldoHome) and the Agents tab summary use it. The walkthrough still goes through decisions only.

const fromThreads = needsYou.flatMap((c) => {
const decision = conversationDecision(c);
return decision ? [decision] : [];
Expand Down
35 changes: 28 additions & 7 deletions apps/web/src/aldo/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

import { setOrchestrationCommandDispatchOverride } from "@t3tools/client-runtime/operations";
import type { EnvironmentId } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { AsyncResult } from "effect/unstable/reactivity";

import { environmentCatalog } from "../connection/catalog";
import { keepAldoCommand } from "./threadCommands";
Expand Down Expand Up @@ -73,6 +75,23 @@ export function isAldoConnected(environmentId: string): boolean {
return presentation?.connection.phase === "connected";
}

/**
* Whether to tell a machine's connection to try again now: only while it
* waits to (backing off, or not trying). An attempt under way has a deadline
* of its own, and a nudge would cut it off, so a slow one (a big thread's
* first sync on a phone, say) would start over every few seconds; a nudge to
* one connected drops it.
*/
function wantsNudge(environmentId: string): boolean {
const state = AsyncResult.value(
appAtomRegistry.get(environmentCatalog.stateAtom(environmentId as EnvironmentId)),
);
return (
Option.isNone(state) ||
(state.value.phase !== "connecting" && state.value.phase !== "connected")
);
}

/**
* What the thread says while its message waits for the cloud agent: where
* Aldo says the message it holds stands (waiting for room, trying again),
Expand Down Expand Up @@ -119,7 +138,7 @@ export function ensureAldoConnected(environmentId: string): Promise<void> {
aldoMachineConnected(environmentId);
return;
}
if (Date.now() >= nextNudge) {
if (Date.now() >= nextNudge && wantsNudge(environmentId)) {
nextNudge = Date.now() + NUDGE_EVERY_MS;
void runAtomCommand(
appAtomRegistry,
Expand Down Expand Up @@ -236,12 +255,14 @@ function followAldoMachine(environmentId: string): void {
const deadline = Date.now() + FOLLOW_MS;
while (Date.now() < deadline && !isAldoConnected(environmentId)) {
requestAldoDirectoryRefresh();
void runAtomCommand(
appAtomRegistry,
environmentCatalog.retryNow,
environmentId as EnvironmentId,
{ reportFailure: false },
);
if (wantsNudge(environmentId)) {
void runAtomCommand(
appAtomRegistry,
environmentCatalog.retryNow,
environmentId as EnvironmentId,
{ reportFailure: false },
);
}
await sleep(NUDGE_EVERY_MS);
}
if (isAldoConnected(environmentId)) aldoMachineConnected(environmentId);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/aldo/home.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe("needsYouKind", () => {
expect(needsYouKind(conversation({ state: "working" }))).toBeNull();
expect(needsYouKind(conversation({ state: "failed" }))).toBe("failed");
expect(needsYouKind(conversation({ state: "waiting" }))).toBe("waiting");
expect(needsYouKind(conversation({ state: "paused" }))).toBe("paused");
expect(
needsYouKind(
conversation({
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/aldo/home.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,22 @@ const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;

export type NeedsYouKind = "question" | "approval" | "plan" | "waiting" | "failed";
export type NeedsYouKind = "question" | "approval" | "plan" | "waiting" | "paused" | "failed";

export const NEEDS_YOU_LABEL: Record<NeedsYouKind, string> = {
question: "Question",
approval: "Approval",
plan: "Plan to approve",
waiting: "Waiting on you",
paused: "Paused mid-turn",
failed: "Stopped with an error",
};

/** Why a conversation needs the user, or null if it doesn't. */
export function needsYouKind(c: AldoHomeConversation): NeedsYouKind | null {
if (c.state === "failed") return "failed";
// Its machine went to sleep mid-turn: opening the thread wakes it to carry on.
if (c.state === "paused") return "paused";
Comment on lines +39 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render paused state at pending-form call sites

Returning paused as a needs-you kind sends it through UI branches that assume every truthy kind has a PendingForm: AldoPeek.tsx:208-214 renders a warning callout whose only content can be that form, while PendingForm returns null for paused, producing an empty bordered panel when the conversation has no summary; AldoLiveCard.tsx:163-166 similarly renders an empty container. Add paused-specific content/action or restrict those callouts to the form-backed kinds.

AGENTS.md reference: AGENTS.md:L67-L69

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cfc7cf7. For a paused thread, PendingForm now shows 'Its machine went to sleep mid-turn.' with an 'Open to carry on' button. The callouts in the Peek and the live card are no longer empty.

Comment on lines +39 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the asleep-machine fallback on the Home board

This recognizes only the newer literal state: "paused", not the state: "working", machine: "asleep" payload that the new sidebar test explicitly handles for older/stale Aldo reads. For that payload boardFor still places the conversation in working, so the Home board continues to show “Working now” indefinitely while the sidebar correctly says “Paused mid-turn”; use the same asleep/failed-machine predicate, with the same live-connection override, for Home classification.

AGENTS.md reference: AGENTS.md:L67-L70

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cfc7cf7. A new isPausedMidTurn covers both Aldo's paused and an older read's working on a machine asleep or failed. needsYouKind, boardFor (paused leaves the working list), isStuck and aldoDecisions all use it. I left out the live-connection override on the home board: Aldo's read follows the directory within seconds of a wake, and threading the connection through every needsYouKind caller isn't worth it. The sidebar keeps the override, since its shells are live.

if (c.state !== "waiting") return null;
if (c.pending?.kind === "question") return "question";
if (c.pending?.kind === "approval") return "approval";
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/aldo/sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,33 @@ describe("aldoSidebarList", () => {
expect(byKey["aldo-r:r"]).toMatchObject({ kind: "starting", detail: "Retrying" });
});

it("says a thread whose machine went to sleep mid-turn is paused, waiting on the user", () => {
const running = { session: { status: "running", activeTurnId: "t1" } };
const result = aldoSidebarList({
shells: [shell("kept", running), shell("said"), shell("up", running), shell("on", running)],
home: home({
conversations: [
// Its kept shell still says working; Aldo knows its machine is asleep.
conversation("kept", { state: "working", machine: "asleep" }),
// Aldo says paused (a newer Aldo).
conversation("said", { state: "paused", machine: "asleep" }),
// Aldo's read is behind: this page is connected to it, so it's up.
conversation("up", { state: "paused", machine: "asleep" }),
conversation("on", { state: "working", machine: "running" }),
],
}),
lastVisitedAt: () => undefined,
repoOf: () => "shop",
connected: (s) => s.id === "up",
now: NOW,
});
expect(result.waiting.map((row) => [row.key, row.kind, row.detail])).toEqual([
["aldo-kept:kept", "paused", "Paused mid-turn: open it to carry on"],
["aldo-said:said", "paused", "Paused mid-turn: open it to carry on"],
]);
expect(keys(result.working).toSorted()).toEqual(["aldo-on:on", "aldo-up:up"]);
});

it("keeps pinned threads in their own group, and snoozed ones out of sight", () => {
const result = list([
shell("p", { pinnedAt: ago(100), hasPendingUserInput: true }),
Expand Down
30 changes: 26 additions & 4 deletions apps/web/src/aldo/sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type AldoSidebarKind =
| "failed"
| "waiting"
| "merge"
| "paused"
| "working"
| "monitoring"
| "starting"
Expand All @@ -53,6 +54,7 @@ const WAITING_KINDS: ReadonlySet<AldoSidebarKind> = new Set([
"failed",
"waiting",
"merge",
"paused",
]);
const WORKING_KINDS: ReadonlySet<AldoSidebarKind> = new Set(["working", "monitoring", "starting"]);

Expand Down Expand Up @@ -110,24 +112,33 @@ export function aldoSidebarKind(input: {
readonly lastVisitedAt: string | undefined;
/** The user settled it (on a machine that supports settling). */
readonly settled: boolean;
/**
* Its machine is asleep: what its shell last said (working, say) is from
* before it slept, and its turn waits for the user to open it again.
*/
readonly asleep?: boolean;
readonly now: string;
}): AldoSidebarKind {
const { shell, conversation: c } = input;
const status = resolveSidebarThreadStatus(shell);
// Settled: done with, unless it's running again (T3 unsettles it when it can).
if (input.settled) {
if (status === "working" || status === "monitoring") return status;
if (status === "working" || status === "monitoring") return input.asleep ? "paused" : status;
return unseen(shell, input.lastVisitedAt, input.now) ? "unread" : "done";
}
if (status === "approval" || c?.pending?.kind === "approval") return "approval";
if (status === "input" || c?.pending?.kind === "question") return "question";
if (status === "failed" || c?.state === "failed") return "failed";
// A thread Aldo is starting (or queued, or trying again) shows connecting until its machine has it.
// One whose machine is asleep is paused mid-turn, whatever it said before it slept.
if (status === "working") {
return c && c.state !== "working" && WORKING_STATES.has(c.state) ? "starting" : "working";
if (c && c.state !== "working" && WORKING_STATES.has(c.state)) return "starting";
return input.asleep ? "paused" : "working";
}
if (status === "monitoring") return "monitoring";
if (c && WORKING_STATES.has(c.state)) return c.state === "working" ? "working" : "starting";
if (status === "monitoring") return input.asleep ? "paused" : "monitoring";
// Paused: Aldo's word for working on a machine that went to sleep (stale once this page is connected to it).
if (c?.state === "working" || c?.state === "paused") return input.asleep ? "paused" : "working";
if (c && WORKING_STATES.has(c.state)) return "starting";
if (c?.plan || shell.hasActionableProposedPlan) return "plan";
if (input.ready) return "merge";
if (input.landing) return "landing";
Expand Down Expand Up @@ -180,6 +191,8 @@ function detailFor(
return summary || "Waiting on you";
case "merge":
return pr ? `#${pr.number} ready to merge` : "Ready to merge";
case "paused":
return "Paused mid-turn: open it to carry on";
Comment on lines +194 to +195

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resume paused turns when opened

When the user has disabled Settings → preloading opened threads, this instruction is false: useAldoPreload computes enabled from settings.openedThreads and exits at preload.ts:110-117, so opening a cached paused thread never calls ensureAldoConnected and the turn remains paused. Paused mid-turn threads need to bypass that preload preference or offer an explicit resume action.

AGENTS.md reference: AGENTS.md:L73-L73

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cfc7cf7. useAldoPreload wakes a thread whose machine went to sleep mid-turn when it's opened, whatever the preload setting, the same way it wakes a thread with nothing to show. Paused here means the directory has the machine asleep while a kept shell still says working or monitoring. So 'open it to carry on' holds with preloading off.

case "landing":
return pr ? `#${pr.number} merged · deploying` : "Merged · deploying";
case "working":
Expand Down Expand Up @@ -214,6 +227,11 @@ export function aldoSidebarList(input: {
readonly repoOf: (shell: EnvironmentThreadShell) => string;
/** Narrows threads to a project (approvals and what's scheduled stay). */
readonly inScope?: (shell: EnvironmentThreadShell) => boolean;
/**
* Whether this page is connected to the thread's machine: it's up then,
* whatever Aldo's last read said (not connected, when not given).
*/
readonly connected?: (shell: EnvironmentThreadShell) => boolean;
/** Whether the thread's machine takes settling and snoozing (both, when not given). */
readonly supports?: (shell: EnvironmentThreadShell) => {
readonly settlement: boolean;
Expand Down Expand Up @@ -243,13 +261,17 @@ export function aldoSidebarList(input: {
const lastVisitedAt = input.lastVisitedAt(key);
const supports = input.supports?.(shell) ?? { settlement: true, snooze: true };
const settled = supports.settlement && shell.settledOverride === "settled";
const asleep =
!(input.connected?.(shell) ?? false) &&
(conversation?.machine === "asleep" || conversation?.machine === "failed");
const kind = aldoSidebarKind({
shell,
conversation,
ready,
landing,
lastVisitedAt,
settled,
asleep,
now: input.now,
});
const pullRequest = kind === "merge" ? ready : kind === "landing" ? landing : null;
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/aldo/useAldoThreadList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { selectProjectGroupingSettings } from "../logicalProject";
import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping";
import { useProjects, useThreadShells } from "../state/entities";
import { usePrimaryEnvironmentId } from "../state/environments";
import { environmentPresentations } from "../state/presentation";
import { environmentServerConfigsAtom } from "../state/server";
import { useUiStateStore } from "../uiStateStore";
import { isAldoEnvironmentId, type AldoHome } from "./cloud";
Expand All @@ -27,6 +28,7 @@ export function useAldoThreadList(input: {
const projects = useProjects();
const lastVisited = useUiStateStore((s) => s.threadLastVisitedAtById);
const serverConfigs = useAtomValue(environmentServerConfigsAtom);
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
const groupingSettings = useClientSettings(selectProjectGroupingSettings);
const primaryEnvironmentId = usePrimaryEnvironmentId();
const { home, now, scope } = input;
Expand Down Expand Up @@ -62,6 +64,8 @@ export function useAldoThreadList(input: {
lastVisitedAt: (key) => lastVisited[key],
repoOf: (shell) => repoLabel(groupOf(shell)?.displayName),
...(scope === null ? {} : { inScope: (shell) => groupOf(shell)?.projectKey === scope }),
connected: (shell) =>
presentations.get(shell.environmentId)?.connection.phase === "connected",
supports: (shell) => {
const capabilities = serverConfigs.get(shell.environmentId)?.environment.capabilities;
return {
Expand All @@ -71,7 +75,7 @@ export function useAldoThreadList(input: {
},
now: new Date(now).toISOString(),
}),
[groupOf, home, lastVisited, now, scope, serverConfigs, shells],
[groupOf, home, lastVisited, now, presentations, scope, serverConfigs, shells],
);

const shellCount = useMemo(
Expand Down