diff --git a/apps/mobile/src/features/assistant/AssistantQuickActionsPreview.tsx b/apps/mobile/src/features/assistant/AssistantQuickActionsPreview.tsx index f080bc926569a..a7eeb4e26cb3c 100644 --- a/apps/mobile/src/features/assistant/AssistantQuickActionsPreview.tsx +++ b/apps/mobile/src/features/assistant/AssistantQuickActionsPreview.tsx @@ -23,6 +23,8 @@ import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSe import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; import { buildAssistantAttentionSnapshot, + filterAssistantAttentionItems, + type AssistantAttentionFilter, type AssistantAttentionKind, type AssistantAttentionItem, } from "./attentionSnapshot"; @@ -48,8 +50,6 @@ const liveShellsAtom = Atom.make((get) => { return readiness; }).pipe(Atom.withLabel("mobile:assistant:live-shell-readiness")); -type AttentionFilter = "all" | "needs-me" | "working"; - const LABELS: Readonly> = { approval: "Approval needed", question: "Answer needed", @@ -58,10 +58,6 @@ const LABELS: Readonly> = { working: "Working", }; -function isNeedsMe(kind: AssistantAttentionKind): boolean { - return kind === "approval" || kind === "question" || kind === "plan" || kind === "failed"; -} - /** Opt-in downstream text experiment, not a voice or autonomous executor. */ export function AssistantQuickActionsPreview() { const insets = useSafeAreaInsets(); @@ -110,7 +106,7 @@ function AssistantQuickActionsContent(props: { readonly onClose: () => void }) { const [input, setInput] = useState(""); const [notice, setNotice] = useState(null); const [matches, setMatches] = useState>([]); - const [filter, setFilter] = useState("all"); + const [filter, setFilter] = useState("all"); const threads = useNavigationThreadShells(); const { connectedEnvironments } = useRemoteConnectionStatus(); const shellLive = useAtomValue(liveShellsAtom); @@ -211,7 +207,7 @@ function AssistantQuickActionsContent(props: { readonly onClose: () => void }) { return; } if (result.action.kind === "inspect") { - setFilter(result.action.queue === "working" ? "working" : "needs-me"); + setFilter(result.action.queue); setNotice(null); setInput(""); return; @@ -236,9 +232,7 @@ function AssistantQuickActionsContent(props: { readonly onClose: () => void }) { setNotice("No live thread has that exact title. Search Chats for other titles."); }, [input, liveIds, navigate, openThread, threads]); - const visibleItems = attention.items.filter((item) => - filter === "all" ? true : filter === "working" ? item.kind === "working" : isNeedsMe(item.kind), - ); + const visibleItems = filterAssistantAttentionItems(attention.items, filter); const shownItems = visibleItems.slice(0, 24); const needsMeCount = attention.counts.approval + @@ -282,10 +276,11 @@ function AssistantQuickActionsContent(props: { readonly onClose: () => void }) { {notice} ) : null} - + {([ ["all", "All", attention.items.length], ["needs-me", "Needs me", needsMeCount], + ["approvals", "Approvals", attention.counts.approval], ["working", "Working", attention.counts.working], ] as const).map(([nextFilter, label, count]) => ( void }) { ) : null} {shownItems.length === 0 ? ( - No current {filter === "working" ? "working threads" : "attention items"} from live environments. + No current{" "} + {filter === "working" + ? "working threads" + : filter === "approvals" + ? "approvals" + : "attention items"}{" "} + from live environments. ) : ( shownItems.map((item: AssistantAttentionItem) => ( diff --git a/apps/mobile/src/features/assistant/attentionSnapshot.test.ts b/apps/mobile/src/features/assistant/attentionSnapshot.test.ts index 09469091fc9cc..43337c82e9f61 100644 --- a/apps/mobile/src/features/assistant/attentionSnapshot.test.ts +++ b/apps/mobile/src/features/assistant/attentionSnapshot.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { buildAssistantAttentionSnapshot, + filterAssistantAttentionItems, type AssistantAttentionEnvironment, type AssistantAttentionFacts, } from "./attentionSnapshot"; @@ -31,6 +32,26 @@ function thread( } describe("assistant attention snapshot", () => { + it("keeps approvals exact while needs-me also includes questions, plans and failures", () => { + const snapshot = buildAssistantAttentionSnapshot( + [live], + [ + thread({ threadId: "approval", needsApproval: true }), + thread({ threadId: "question", needsAnswer: true }), + thread({ threadId: "plan", needsPlanReview: true }), + thread({ threadId: "failure", failed: true }), + thread({ threadId: "working", working: true }), + ], + ); + const ids = (filter: "approvals" | "needs-me" | "working" | "all") => + filterAssistantAttentionItems(snapshot.items, filter).map((item) => item.threadId); + + expect(ids("approvals")).toEqual(["approval"]); + expect(ids("needs-me")).toEqual(["approval", "question", "plan", "failure"]); + expect(ids("working")).toEqual(["working"]); + expect(ids("all")).toEqual(["approval", "question", "plan", "failure", "working"]); + }); + it("ranks human decisions above failures and working; chooses one state per thread", () => { const snapshot = buildAssistantAttentionSnapshot( [live], diff --git a/apps/mobile/src/features/assistant/attentionSnapshot.ts b/apps/mobile/src/features/assistant/attentionSnapshot.ts index 43a2e2411caf6..aa45b8394f04a 100644 --- a/apps/mobile/src/features/assistant/attentionSnapshot.ts +++ b/apps/mobile/src/features/assistant/attentionSnapshot.ts @@ -48,6 +48,19 @@ export interface AssistantAttentionSnapshot { readonly unavailableEnvironments: ReadonlyArray; } +export type AssistantAttentionFilter = "all" | "needs-me" | "approvals" | "working"; + +/** Exact read-only filtering; approvals must never include other blockers. */ +export function filterAssistantAttentionItems( + items: ReadonlyArray, + filter: AssistantAttentionFilter, +): ReadonlyArray { + if (filter === "all") return items; + if (filter === "approvals") return items.filter((item) => item.kind === "approval"); + if (filter === "working") return items.filter((item) => item.kind === "working"); + return items.filter((item) => item.kind !== "working"); +} + const PRIORITY: Readonly> = { approval: 0, question: 1, diff --git a/apps/mobile/src/features/assistant/fastActions.test.ts b/apps/mobile/src/features/assistant/fastActions.test.ts index 608a75b440c38..c43b21888036b 100644 --- a/apps/mobile/src/features/assistant/fastActions.test.ts +++ b/apps/mobile/src/features/assistant/fastActions.test.ts @@ -27,11 +27,16 @@ describe("app assistant fast actions", () => { }); it("keeps inspection read-only and sends complex requests to the agent", () => { - expect(resolveAssistantFastAction("What's waiting on me?")).toEqual({ + expect(resolveAssistantFastAction("Show approvals")).toEqual({ kind: "action", source: "rule", action: { kind: "inspect", queue: "approvals" }, }); + expect(resolveAssistantFastAction("What's waiting on me?")).toEqual({ + kind: "action", + source: "rule", + action: { kind: "inspect", queue: "needs-me" }, + }); expect(resolveAssistantFastAction("show working agents")).toEqual({ kind: "action", source: "rule", diff --git a/apps/mobile/src/features/assistant/fastActions.ts b/apps/mobile/src/features/assistant/fastActions.ts index 80e8729556f5d..cfcddce6c150d 100644 --- a/apps/mobile/src/features/assistant/fastActions.ts +++ b/apps/mobile/src/features/assistant/fastActions.ts @@ -10,7 +10,7 @@ export type AssistantDestination = "chats" | "pulls" | "environments" | "usage"; export type AssistantFastAction = | { readonly kind: "navigate"; readonly destination: AssistantDestination } - | { readonly kind: "inspect"; readonly queue: "approvals" | "working" } + | { readonly kind: "inspect"; readonly queue: "approvals" | "needs-me" | "working" } | { readonly kind: "find-thread"; readonly title: string }; export type AssistantFastResolution = @@ -62,9 +62,12 @@ export function resolveAssistantFastAction(utterance: string): AssistantFastReso return { kind: "action", source: "rule", action: { kind: "navigate", destination } }; } - if (["show approvals", "what needs my approval", "what's waiting on me"].includes(phrase)) { + if (["show approvals", "what needs my approval"].includes(phrase)) { return { kind: "action", source: "rule", action: { kind: "inspect", queue: "approvals" } }; } + if (["what's waiting on me", "what needs my attention", "show what needs me"].includes(phrase)) { + return { kind: "action", source: "rule", action: { kind: "inspect", queue: "needs-me" } }; + } if (["show working agents", "who is working", "what's running"].includes(phrase)) { return { kind: "action", source: "rule", action: { kind: "inspect", queue: "working" } }; }