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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: CI

on:
workflow_dispatch:
pull_request:
push:
branches:
Expand Down Expand Up @@ -469,7 +470,8 @@ jobs:
name: Mobile Native Static Analysis
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.mobile_native != 'false' }}
runs-on: ${{ github.repository == 'pingdotgg/t3code' && 'blacksmith-6vcpu-macos-26' || 'macos-26' }}
# Static analysis supports both architectures; Intel avoids the constrained hosted ARM pool.
runs-on: ${{ github.repository == 'pingdotgg/t3code' && 'blacksmith-6vcpu-macos-26' || 'macos-26-intel' }}
timeout-minutes: 10
steps:
- name: Checkout
Expand Down
156 changes: 156 additions & 0 deletions apps/web/src/components/home/HomeActivity.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,163 @@ const completed = () => {
latestRun: { ...thread.latestRun, status: "completed" as const, completedAt: now },
};
};
const limited = () => {
const thread = completed();
return {
...thread,
runtime: {
...running().runtime,
status: "failed" as const,
activeRunId: null,
lastErrorClass: "usage_limit" as const,
},
latestRun: { ...thread.latestRun, status: "failed" as const },
};
};
describe("Home activity", () => {
it("keeps an old child failure from masking a parent waiting for new agents", () => {
const parent = {
...completed(),
lastVisitedAt: now,
latestRun: {
...completed().latestRun,
requestedAt: "2026-10-23T11:55:00.000Z",
startedAt: "2026-10-23T11:55:00.000Z",
},
runtime: { ...running().runtime, status: "idle" as const, activeRunId: null },
pendingBackgroundTasks: [
{ kind: "subagent" as const, taskId: "new-review", childThreadId: ThreadId.make("review") },
],
};
const oldFailure = {
...limited(),
id: ThreadId.make("old-failure"),
latestRun: { ...limited().latestRun, completedAt: start },
lineage: {
rootThreadId: parent.id,
parentThreadId: parent.id,
relationshipToParent: "subagent" as const,
},
};
const reviewer = { ...running(), id: ThreadId.make("review"), lineage: oldFailure.lineage };
expect(buildHomeActivity([parent, oldFailure, reviewer], now)[0]).toMatchObject({
kind: "working",
label: "Waiting for agents",
});
expect(
buildHomeActivity([{ ...parent, lastVisitedAt: null }, oldFailure, reviewer], now)[0]?.kind,
).toBe("working");
});
it("acknowledges child failures when either the child or its parent has been read", () => {
const parent = completed();
const child = {
...limited(),
id: ThreadId.make("failed-child"),
lineage: {
rootThreadId: parent.id,
parentThreadId: parent.id,
relationshipToParent: "subagent" as const,
},
};
expect(buildHomeActivity([parent, { ...child, lastVisitedAt: now }], now)[0]?.kind).toBe(
"unread",
);
expect(buildHomeActivity([{ ...parent, lastVisitedAt: now }, child], now)[0]?.kind).toBe(
"done",
);
expect(
buildHomeActivity(
[
{ ...parent, lastVisitedAt: now },
{ ...child, hasPendingApprovals: true },
],
now,
)[0]?.label,
).toBe("Approval needed");
});
it("keeps new failures visible after an earlier parent visit", () => {
const parent = { ...running(), lastVisitedAt: start };
const child = {
...limited(),
id: ThreadId.make("failed-child"),
lastVisitedAt: start,
lineage: {
rootThreadId: parent.id,
parentThreadId: parent.id,
relationshipToParent: "subagent" as const,
},
};
expect(buildHomeActivity([parent, child], now)[0]).toMatchObject({
kind: "attention",
label: "Usage limit",
});
});
it("hides an acknowledged usage limit while the thread is snoozed", () => {
const thread = {
...limited(),
lastVisitedAt: now,
snoozedAt: now,
snoozedUntil: "2026-10-24T12:00:00.000Z",
};
expect(buildHomeActivity([thread], now)).toEqual([]);
});
it("keeps a snoozed child usage limit out of its parent's attention status", () => {
const parent = { ...completed(), lastVisitedAt: now };
const child = {
...limited(),
id: ThreadId.make("limited-child"),
lastVisitedAt: now,
snoozedAt: now,
snoozedUntil: "2026-10-24T12:00:00.000Z",
lineage: {
rootThreadId: parent.id,
parentThreadId: parent.id,
relationshipToParent: "subagent" as const,
},
};
expect(buildHomeActivity([parent, child], now)[0]).toMatchObject({
kind: "done",
label: "Done",
});
expect(buildHomeActivity([running(), child], now)[0]).toMatchObject({
kind: "working",
label: "Working",
});
const freshFailure = { ...child, snoozedAt: start, lastVisitedAt: start };
expect(
buildHomeActivity([{ ...parent, lastVisitedAt: start }, freshFailure], now)[0],
).toMatchObject({
kind: "attention",
label: "Usage limit",
});
const expired = { ...child, snoozedUntil: now };
expect(buildHomeActivity([parent, expired], now)[0]?.kind).toBe("done");
});
it("honors a parent snooze for old child failures and surfaces new requests", () => {
const parent = {
...completed(),
lastVisitedAt: start,
snoozedAt: now,
snoozedUntil: "2026-10-24T12:00:00.000Z",
};
const child = {
...limited(),
id: ThreadId.make("limited-child"),
lineage: {
rootThreadId: parent.id,
parentThreadId: parent.id,
relationshipToParent: "subagent" as const,
},
};
expect(buildHomeActivity([parent, child], now)).toEqual([]);
expect(buildHomeActivity([{ ...parent, snoozedAt: start }, child], now)[0]).toMatchObject({
kind: "attention",
label: "Usage limit",
});
expect(
buildHomeActivity([parent, { ...child, hasPendingApprovals: true }], now)[0],
).toMatchObject({ kind: "attention", label: "Approval needed" });
});
it("lets completed threads leave Working while persistent commands keep running", () => {
const thread = {
...completed(),
Expand Down
37 changes: 28 additions & 9 deletions apps/web/src/components/home/HomeActivity.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ export interface HomeActivity {
readonly nativeAgentCount: number;
}
const keyOf = (thread: Thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
const needsAttention = (thread: Thread) =>
["approval", "input", "failed", "limited"].includes(resolveSidebarThreadStatus(thread));
const needsAttention = (thread: Thread, acknowledgedAt: readonly (string | null | undefined)[]) => {
const status = resolveSidebarThreadStatus(thread);
if (status === "approval" || status === "input") return true;
if (status !== "failed" && status !== "limited") return false;
const failedAt = Date.parse(thread.latestRun?.completedAt ?? thread.runtime?.updatedAt ?? "");
if (!Number.isFinite(failedAt)) return true;
return !acknowledgedAt.some((at) => at && Date.parse(at) >= failedAt);
};
const doingWork = (thread: Thread) =>
threadRuntimeIsActive(thread.runtime) ||
backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks);
Expand Down Expand Up @@ -70,14 +76,24 @@ export function buildHomeActivity(
const rows: HomeActivity[] = [];
for (const [key, { thread, members }] of groups) {
if (thread.settledOverride === "settled") continue;
const active = members.filter(
const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localVisits[key]);
const attention = members.find(
(member) =>
doingWork(member) || (member.settledOverride !== "settled" && needsAttention(member)),
member.settledOverride !== "settled" &&
needsAttention(member, [
resolveThreadLastVisitedAt(member.lastVisitedAt, localVisits[keyOf(member)]),
lastVisitedAt,
// Continuing the parent handles results from older child tasks.
member !== thread ? (thread.latestRun?.startedAt ?? thread.latestRun?.requestedAt) : null,
]) &&
!effectiveSnoozed(member, { now }) &&
!effectiveSnoozed(
{ ...member, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil },
{ now },
),
);
if (effectiveSnoozed(thread, { now }) && active.length === 0) continue;
const attention = active.find(needsAttention);
const working = active.filter(doingWork);
const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localVisits[key]);
const working = members.filter(doingWork);
if (effectiveSnoozed(thread, { now }) && !attention && working.length === 0) continue;
const unread =
Number.isFinite(Date.parse(thread.latestRun?.completedAt ?? "")) &&
(!lastVisitedAt || hasUnseenCompletion({ ...thread, lastVisitedAt }));
Expand All @@ -99,7 +115,10 @@ export function buildHomeActivity(
: attentionStatus === "limited"
? "Usage limit"
: kind === "working"
? thread.blockedOnThreadWait
? thread.blockedOnThreadWait ||
(!threadRuntimeIsActive(thread.runtime) &&
(working.some((member) => member !== thread) ||
thread.pendingBackgroundTasks.some((task) => task.kind === "subagent")))
? "Waiting for agents"
: "Working"
: kind === "unread"
Expand Down
Loading
Loading