feat(pet): cleared alerts stay cleared, contextual bubbles, i18n, moments, chat window
- Fix: an errored session alerted on every update, and Clear alerts only dropped it in memory, so its next update (or a reload) brought the alert straight back. An error now alerts on the transition into error only; cleared sessions are remembered (agenthub.pet.dismissedErrors) and stay quiet until they recover and fail anew. - Quips, greetings, notifications and Ask answers move to i18n (EN/VI); Ask understands Vietnamese keywords and names the sessions it counts. - The pet goes by the worn pet's name; click it in the panel to rename (agenthub.pet.names, synced to the account). - Bubbles name the session they are about, with an Open button (never a resume); a burst of the same event coalesces into one count. - Moments: a new session waves, a finish hops, a subagent peeks, a wait taps, an error flinches — on Sagi, Codex sprites and still images. Idle fidgets, a once-per-session greeting, a "Needs you" list, an amber waiting dot. - Pat (heart button, Ask, or stroking the pet) reacts on the pet itself, so it shows while the panel or chat is open. - Panel drops Run Claude / Activity / Sessions / Errored. - Ask hands off to a chat window (PetChat) on the default AI tool (Claude/Codex): transcript as bubbles, follow-ups and CLI menus through an off-screen attached terminal; no new server route. Verified on HEAD + this diff in a clean worktree: tsc -b, the full client suite (1334/1335; the one failure, ConversationView.follow, is flaky under load and passes 3/3 alone) and server me-prefs.test.js. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -137,14 +137,7 @@ import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from
|
||||
|
||||
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
|
||||
export type Mood =
|
||||
| "disconnected"
|
||||
| "worried"
|
||||
| "stuck"
|
||||
| "happy"
|
||||
| "thinking"
|
||||
| "watching"
|
||||
| "sleeping"
|
||||
| "idle";
|
||||
"disconnected" | "worried" | "stuck" | "happy" | "thinking" | "watching" | "sleeping" | "idle";
|
||||
|
||||
/**
|
||||
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
||||
@@ -152,13 +145,21 @@ export type Mood =
|
||||
* was irrelevant or non-notable.
|
||||
*/
|
||||
export type SagiPulse =
|
||||
| "session_done"
|
||||
| "session_start"
|
||||
| "subagent_spawn"
|
||||
| "waiting"
|
||||
| "error"
|
||||
| "run_done"
|
||||
| null;
|
||||
"session_done" | "session_start" | "subagent_spawn" | "waiting" | "error" | "run_done" | null;
|
||||
|
||||
/** A short animation the pet plays on an event or a pat, over its mood. */
|
||||
export type PetMoment = "wave" | "jump" | "fail" | "wait" | "review";
|
||||
|
||||
/** Which moment each pulse plays: greet a new session, hop at a finish,
|
||||
* inspect a new subagent, tap a foot while you are awaited, flinch at an error. */
|
||||
export const MOMENT_OF_PULSE: Record<NonNullable<SagiPulse>, PetMoment> = {
|
||||
session_start: "wave",
|
||||
session_done: "jump",
|
||||
run_done: "jump",
|
||||
subagent_spawn: "review",
|
||||
waiting: "wait",
|
||||
error: "fail",
|
||||
};
|
||||
|
||||
export interface SagiStatus {
|
||||
/** Active + waiting sessions (everything not finished/errored). */
|
||||
@@ -182,6 +183,47 @@ export interface SagiState {
|
||||
worriedUntil: number;
|
||||
/** True while an Ask request is in flight (panel). */
|
||||
thinking: boolean;
|
||||
/** Errored sessions the user cleared ("clear alerts"). Their further
|
||||
* updates, agent errors and failure events stay silent until the session
|
||||
* goes active again or ends — otherwise every update of a still-errored
|
||||
* session re-raised the alert the user had just dismissed. */
|
||||
dismissed: Record<string, true>;
|
||||
/** Name and owning workspace of each tracked session, so a bubble or the
|
||||
* panel can say WHICH session it means and open it. */
|
||||
info: Record<string, SessionInfo>;
|
||||
}
|
||||
|
||||
/** What Sagi knows about a session beyond its status. */
|
||||
export interface SessionInfo {
|
||||
name: string | null;
|
||||
cwd: string | null;
|
||||
lane_id: number | null;
|
||||
}
|
||||
|
||||
/** The session a pulse is about, when it is about one. */
|
||||
export interface SagiSubject extends SessionInfo {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** A session that needs the user: errored, or waiting on their input. */
|
||||
export interface AttentionItem extends SagiSubject {
|
||||
kind: "error" | "waiting";
|
||||
}
|
||||
|
||||
const infoOf = (s: {
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
lane_id?: number | null;
|
||||
}): SessionInfo => ({ name: s.name ?? null, cwd: s.cwd ?? null, lane_id: s.lane_id ?? null });
|
||||
|
||||
/** A session's label for a bubble: its name, else its folder, else a short id. */
|
||||
export function subjectLabel(s: SagiSubject): string {
|
||||
if (s.name && s.name.trim()) return s.name.trim();
|
||||
const base = s.cwd
|
||||
?.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop();
|
||||
return base || s.id.slice(0, 8);
|
||||
}
|
||||
|
||||
// Tunable timing constants (ms).
|
||||
@@ -211,6 +253,8 @@ export function initialSagiState(now: number): SagiState {
|
||||
happyUntil: 0,
|
||||
worriedUntil: 0,
|
||||
thinking: false,
|
||||
dismissed: {},
|
||||
info: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,16 +300,28 @@ export function reduceSagi(
|
||||
state: SagiState,
|
||||
msg: WSMessage,
|
||||
now: number
|
||||
): { state: SagiState; pulse: SagiPulse } {
|
||||
): { state: SagiState; pulse: SagiPulse; subject?: SagiSubject } {
|
||||
// The tracked session an agent/event message belongs to, when known.
|
||||
const known = (id: string | undefined): SagiSubject | undefined =>
|
||||
id ? { id, ...(state.info[id] ?? { name: null, cwd: null, lane_id: null }) } : undefined;
|
||||
switch (msg.type) {
|
||||
case "session_created":
|
||||
case "session_updated": {
|
||||
const s = msg.data as Session;
|
||||
if (!s || !s.id) return { state, pulse: null };
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info, [s.id]: infoOf(s) };
|
||||
const subject: SagiSubject = { id: s.id, ...info[s.id]! };
|
||||
let dismissed = state.dismissed;
|
||||
let pulse: SagiPulse = null;
|
||||
let happyUntil = state.happyUntil;
|
||||
let worriedUntil = state.worriedUntil;
|
||||
// Anything but a (still) errored status ends a dismissal: a later error
|
||||
// of that session is a new one and must alert again.
|
||||
if (s.status !== "error" && s.id in dismissed) {
|
||||
dismissed = { ...dismissed };
|
||||
delete dismissed[s.id];
|
||||
}
|
||||
|
||||
if (s.status === "active") {
|
||||
// "waiting" = active session blocked on user input (permission prompt
|
||||
@@ -280,12 +336,20 @@ export function reduceSagi(
|
||||
if (prev === undefined) pulse = "session_start";
|
||||
}
|
||||
} else if (s.status === "error") {
|
||||
if (s.id in dismissed) {
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
// Alert on the transition INTO error only; a session that stays
|
||||
// errored keeps getting updates and must not re-alert on each one.
|
||||
if (sessions[s.id] !== "error") {
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
}
|
||||
sessions[s.id] = "error";
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||
const wasTracked = s.id in sessions;
|
||||
delete sessions[s.id];
|
||||
delete info[s.id];
|
||||
if (s.status === "completed") {
|
||||
happyUntil = now + HAPPY_MS;
|
||||
if (wasTracked) pulse = "session_done";
|
||||
@@ -293,31 +357,46 @@ export function reduceSagi(
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
||||
state: {
|
||||
...state,
|
||||
sessions,
|
||||
dismissed,
|
||||
info,
|
||||
happyUntil,
|
||||
worriedUntil,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
pulse,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_created": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||
// is already covered by session_start.
|
||||
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
||||
return { state: { ...state, lastActivityAt: now }, pulse };
|
||||
return {
|
||||
state: { ...state, lastActivityAt: now },
|
||||
pulse,
|
||||
subject: pulse ? known(a.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_updated": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
@@ -325,7 +404,8 @@ export function reduceSagi(
|
||||
|
||||
case "new_event": {
|
||||
const e = msg.data as DashboardEvent;
|
||||
const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type);
|
||||
const isFailure =
|
||||
!!e && FAILURE_EVENT_TYPES.has(e.event_type) && !(e.session_id in state.dismissed);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
@@ -333,6 +413,7 @@ export function reduceSagi(
|
||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||
},
|
||||
pulse: isFailure ? "error" : null,
|
||||
subject: isFailure ? known(e.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,24 +442,60 @@ export function reduceSagi(
|
||||
*/
|
||||
export function seedSessions(
|
||||
state: SagiState,
|
||||
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
||||
rows: ReadonlyArray<{
|
||||
id: string;
|
||||
status: string;
|
||||
awaiting_input_since?: string | null;
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
lane_id?: number | null;
|
||||
}>,
|
||||
now: number
|
||||
): SagiState {
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info };
|
||||
for (const r of rows) {
|
||||
if (!r || !r.id) continue;
|
||||
if (r.status === "error") sessions[r.id] = "error";
|
||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
// completed / abandoned: leave untracked.
|
||||
if (r.status === "error") {
|
||||
if (r.id in state.dismissed) continue;
|
||||
sessions[r.id] = "error";
|
||||
} else if (r.status === "active") {
|
||||
sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
} else continue; // completed / abandoned: leave untracked.
|
||||
info[r.id] = infoOf(r);
|
||||
}
|
||||
return { ...state, sessions, lastActivityAt: now };
|
||||
return { ...state, sessions, info, lastActivityAt: now };
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
||||
/** Errored sessions first, then the ones waiting on the user — the panel's list. */
|
||||
export function attentionOf(state: SagiState): AttentionItem[] {
|
||||
const out: AttentionItem[] = [];
|
||||
for (const kind of ["error", "waiting"] as const) {
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== kind) continue;
|
||||
out.push({
|
||||
id,
|
||||
kind,
|
||||
...(state.info[id] ?? { name: null, cwd: null, lane_id: null }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts") and
|
||||
* remember them as dismissed, so their next update does not bring the alert
|
||||
* straight back. */
|
||||
export function clearErrors(state: SagiState): SagiState {
|
||||
const sessions: SagiState["sessions"] = {};
|
||||
const dismissed = { ...state.dismissed };
|
||||
const info = { ...state.info };
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== "error") sessions[id] = s;
|
||||
else {
|
||||
dismissed[id] = true;
|
||||
delete info[id];
|
||||
}
|
||||
}
|
||||
return { ...state, sessions, worriedUntil: 0 };
|
||||
return { ...state, sessions, dismissed, info, worriedUntil: 0 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user