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
12 changes: 6 additions & 6 deletions apps/desktop/src/components/attention/away-summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? o
*/
export function AwaySummaryCard({ agents }: { agents: Agent[] }) {
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const since = useUi((s) => s.awaySince);
const setAwaySince = useUi((s) => s.setAwaySince);
const { data } = useAway(since);
if (!since || !data || (data.finished === 0 && data.delivered === 0)) return null;
const away = useUi((s) => s.away);
const setAway = useUi((s) => s.setAway);
const { data } = useAway(away);
if (!away || !data || (data.finished === 0 && data.delivered === 0)) return null;
const facts = [
plural(data.finished, "run") + " finished",
data.failed ? plural(data.failed, "problem") : null,
Expand All @@ -42,9 +42,9 @@ export function AwaySummaryCard({ agents }: { agents: Agent[] }) {
>
<div className="mb-3 flex items-center gap-2">
<h2 id="away-title" className="eyebrow">
While you were away · {formatDistanceStrict(new Date(since), new Date())}
While you were away · {formatDistanceStrict(new Date(away.since), new Date(away.until))}
</h2>
<Button variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground" aria-label="Close the summary" onClick={() => setAwaySince(null)}>
<Button variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground" aria-label="Close the summary" onClick={() => setAway(null)}>
<X />
</Button>
</div>
Expand Down
57 changes: 36 additions & 21 deletions apps/desktop/src/components/layout/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,19 @@ const SETTINGS: { id: string; label: string; icon: LucideIcon; words: string }[]
{ id: "about", label: "About", icon: Info, words: "version updates" },
];

/** Matches ignore case and accents: "cafe" finds "Café", "istanbul" finds "İstanbul". */
const fold = (s: string) => s.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
/** Scripts written without spaces between words: there a word may start anywhere. */
const UNSPACED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Thai}]/u;
/** Keeps two items with the same text apart (two automations may share a name); never searched. */
const ID_MARK = "\u2063";
const unique = (text: string, id: string) => `${text}${ID_MARK}${id}`;

/** Every word typed starts a word somewhere — no stray letters: "invoice" doesn't find "Voice", "sign in" not "redesign". */
function wordFilter(value: string, search: string, keywords?: string[]): number {
const words = search.toLowerCase().split(/\s+/).filter(Boolean);
const text = `${value} ${keywords?.join(" ") ?? ""}`.toLowerCase();
return words.every((w) => new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "u").test(text)) ? 1 : 0;
const words = fold(search).split(/\s+/).filter(Boolean);
const text = fold(`${value.split(ID_MARK)[0]} ${keywords?.join(" ") ?? ""}`);
return words.every((w) => (UNSPACED.test(w) ? text.includes(w) : new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "u").test(text))) ? 1 : 0;
}

export function CommandPalette() {
Expand All @@ -98,16 +106,16 @@ export function CommandPalette() {
// What the core finds in chats (message text too), a moment after typing stops.
const [deferred, setDeferred] = useState("");
useEffect(() => {
const t = setTimeout(() => setDeferred(q.trim()), 200);
const t = setTimeout(() => setDeferred(q.trim()), 250);
return () => clearTimeout(t);
}, [q]);
useEffect(() => {
if (!open) setQ("");
}, [open]);
const { data: found } = useQuery({
const { data: found, isPlaceholderData: foundBefore } = useQuery({
queryKey: qk.conversations("all", deferred),
queryFn: () => api.conversations.list({ search: deferred, limit: 20 }),
enabled: open && deferred.length >= 2,
enabled: open && deferred.length >= 3,
placeholderData: keepPreviousData,
});
const { data: waiting = [] } = useQuery({ queryKey: qk.attention, queryFn: api.attention, enabled: open, staleTime: 2_000 });
Expand All @@ -122,10 +130,15 @@ export function CommandPalette() {
// Tickets by number ("#12", "12") or title; automations by name or what they do. Capped: the list stays short.
const number = /^#?(\d+)$/.exec(query)?.[1];
const ticketHits = searching
? tasks.filter((t) => (number ? String(t.number).startsWith(number) : wordFilter(`${t.title} ${t.description ?? ""}`, q) > 0)).slice(0, 8)
? number
? tasks.filter((t) => String(t.number).startsWith(number)).sort((a, b) => a.number - b.number).slice(0, 8)
: tasks.filter((t) => wordFilter(`${t.title} ${t.description ?? ""}`, q) > 0).slice(0, 8)
: [];
const routineHits = searching ? routines.filter((r) => wordFilter(`${r.name} ${r.prompt}`, q) > 0).slice(0, 6) : [];
const chats = searching && deferred.length >= 2 && found ? found : conversations.slice(0, 50);
// Chats whose title or preview match here, then what the core found in their messages (for this very search).
const chats = searching ? conversations.filter((c) => wordFilter(`${c.title} ${c.preview ?? ""}`, q) > 0) : conversations.slice(0, 50);
const fresh = searching && deferred === q.trim() && !foundBefore ? (found ?? []) : [];
const inMessages = fresh.filter((c) => !chats.some((l) => l.id === c.id));

return (
<CommandDialog open={open} onOpenChange={setOpen} filter={wordFilter} title="Search" description="Find what waits for you, chats, tickets, automations, agents, settings and actions">
Expand All @@ -138,7 +151,7 @@ export function CommandPalette() {
{(searching ? waiting : waiting.slice(0, 5)).map((item) => {
const Icon = ATTENTION_ICON[item.kind];
return (
<CommandItem key={item.id} value={`waiting ${item.title} ${item.detail}`} onSelect={() => go(item.link)}>
<CommandItem key={item.id} value={unique(`waiting ${item.title} ${item.detail}`, item.id)} onSelect={() => go(item.link)}>
<Icon className="text-warning" />
<span className="truncate">{item.title}</span>
{item.detail && <span className="ml-2 min-w-0 flex-1 truncate text-xs text-muted-foreground">{item.detail}</span>}
Expand All @@ -153,7 +166,8 @@ export function CommandPalette() {
{ticketHits.length > 0 && (
<CommandGroup heading="Tickets">
{ticketHits.map((t) => (
<CommandItem key={t.id} value={`ticket #${t.number} ${t.title}`} keywords={[q]} onSelect={() => go(`/tasks?task=${t.id}`)}>
// Keyed by the search: cmdk reads `keywords` only when an item mounts, and these were matched here already.
<CommandItem key={`${t.id}:${q}`} value={unique(`ticket #${t.number} ${t.title}`, t.id)} keywords={[q]} onSelect={() => go(`/tasks?task=${t.id}`)}>
<SquareKanban className="opacity-60" />
<span className="shrink-0 font-mono text-xs text-muted-foreground">#{t.number}</span>
<span className="truncate">{t.title}</span>
Expand All @@ -164,7 +178,7 @@ export function CommandPalette() {
{routineHits.length > 0 && (
<CommandGroup heading="Automations">
{routineHits.map((r) => (
<CommandItem key={r.id} value={`automation ${r.name}`} keywords={[q]} onSelect={() => go(`/automations?edit=${r.id}`)}>
<CommandItem key={`${r.id}:${q}`} value={unique(`automation ${r.name}`, r.id)} keywords={[q]} onSelect={() => go(`/automations?edit=${r.id}`)}>
<Workflow className="opacity-60" />
<span className="truncate">{r.name}</span>
{!r.enabled && <span className="ml-2 text-xs text-muted-foreground">off</span>}
Expand Down Expand Up @@ -211,7 +225,7 @@ export function CommandPalette() {
agents.map((a) => (
<CommandItem
key={`chat-${a.id}`}
value={`new chat with ${a.name} talk to ${a.role ?? ""}`}
value={unique(`new chat with ${a.name} talk to ${a.role ?? ""}`, a.id)}
onSelect={() => {
setOpen(false);
startChat.mutate(a);
Expand Down Expand Up @@ -305,7 +319,7 @@ export function CommandPalette() {
<CommandSeparator />
<CommandGroup heading="Agents">
{agents.map((a) => (
<CommandItem key={a.id} value={`agent ${a.name} ${a.description}`} onSelect={() => go(`/agents/${a.id}`)}>
<CommandItem key={a.id} value={unique(`agent ${a.name} ${a.description}`, a.id)} onSelect={() => go(`/agents/${a.id}`)}>
<AgentAvatar agent={a} size="sm" still className="size-5" />
{a.name}
<span className="ml-2 truncate text-xs text-muted-foreground">{a.description}</span>
Expand All @@ -314,22 +328,23 @@ export function CommandPalette() {
</CommandGroup>
</>
)}
{chats.length > 0 && (
{chats.length + inMessages.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Chats">
{chats.map((c) => (
<CommandItem
key={c.id}
value={`chat ${c.title} ${c.preview ?? ""}`}
// The core found it, maybe in a message: keep it whatever the title says.
keywords={chats === found ? [q] : undefined}
onSelect={() => go(`/chat/${c.id}`)}
>
<CommandItem key={c.id} value={unique(`chat ${c.title} ${c.preview ?? ""}`, c.id)} onSelect={() => go(`/chat/${c.id}`)}>
<MessageCircle className="opacity-50" /> <span className="truncate">{c.title}</span>
{c.preview && <span className="ml-2 min-w-0 flex-1 truncate text-xs text-muted-foreground">{c.preview}</span>}
</CommandItem>
))}
{inMessages.map((c) => (
// Found by the core in its messages: kept whatever the title says (keyed by the search, see Tickets).
<CommandItem key={`${c.id}:${q}`} value={unique(`chat ${c.title}`, c.id)} keywords={[q]} onSelect={() => go(`/chat/${c.id}`)}>
<MessageCircle className="opacity-50" /> <span className="truncate">{c.title}</span>
<span className="ml-2 min-w-0 flex-1 truncate text-xs text-muted-foreground">mentioned in a message</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ export const api = {
/** Everything that waits for the human ("Needs you"), from live state. */
attention: () => get<AttentionItem[]>("/api/attention"),
/** What the team did since the human was last here. */
away: (since: string) => get<AwaySummary>("/api/away", { since }),
away: (since: string, until: string) => get<AwaySummary>("/api/away", { since, until }),

budgets: {
get: () => get<BudgetOverview>("/api/budgets"),
Expand Down
11 changes: 8 additions & 3 deletions apps/desktop/src/lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,14 @@ export function useAttention() {
return useQuery({ queryKey: qk.attention, queryFn: api.attention, staleTime: 2_000 });
}

/** What the team did since `since` (null = not asked). */
export function useAway(since: string | null) {
return useQuery({ queryKey: qk.away(since ?? ""), queryFn: () => api.away(since!), enabled: !!since, staleTime: 60_000 });
/** What the team did while the human was away (null = not asked). */
export function useAway(away: { since: string; until: string } | null) {
return useQuery({
queryKey: qk.away(away?.since ?? "", away?.until ?? ""),
queryFn: () => api.away(away!.since, away!.until),
enabled: !!away,
staleTime: 60_000,
});
}

export function useBudgets() {
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/lib/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function startPresence(): () => void {
const now = Date.now();
// Another window may have seen the human since.
if (last !== null && now - last >= AWAY_MS) last = Math.max(last, stored() ?? 0);
if (last !== null && now - last >= AWAY_MS) useUi.getState().setAwaySince(new Date(last).toISOString());
if (last !== null && now - last >= AWAY_MS) useUi.getState().setAway({ since: new Date(last).toISOString(), until: new Date(now).toISOString() });
last = now;
if (now - written < WRITE_MS) return;
written = now;
Expand All @@ -38,7 +38,8 @@ export function startPresence(): () => void {
/* private window: only this session notices */
}
};
const events = ["pointerdown", "keydown", "wheel"] as const;
// On release, not press: the summary appearing mid-click would move what the click was meant for.
const events = ["pointerup", "keyup", "wheel"] as const;
for (const e of events) window.addEventListener(e, onInput, { passive: true, capture: true });
return () => {
for (const e of events) window.removeEventListener(e, onInput, { capture: true });
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export const qk = {
bootstrap: ["bootstrap"] as unknown[],
/** "Needs you" — under `bootstrap`, so whatever refreshes the counts refreshes the list. */
attention: ["bootstrap", "attention"] as unknown[],
away: (since: string) => ["away", since],
away: (since: string, until: string) => ["away", since, until],
authStatus: ["auth-status"] as unknown[],
settings: ["settings"] as unknown[],
notifications: ["notifications"] as unknown[],
Expand Down
10 changes: 5 additions & 5 deletions apps/desktop/src/stores/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ interface UiState {
collapsedColumns: string[];
/** The Agents page shows cards or the org chart. */
agentsView: "grid" | "chart";
/** The human came back after a while: since when they were away (Home sums up what happened). Not kept. */
awaySince: string | null;
/** The human came back after a while: from when to when they were away (Home sums up what happened). Not kept. */
away: { since: string; until: string } | null;
setWorkspace: (id: string) => void;
setCommandOpen: (open: boolean) => void;
setVoiceMode: (on: boolean) => void;
Expand All @@ -32,7 +32,7 @@ interface UiState {
skipClaudeVersion: (version: string | null) => void;
toggleColumn: (status: string) => void;
setAgentsView: (v: "grid" | "chart") => void;
setAwaySince: (since: string | null) => void;
setAway: (away: { since: string; until: string } | null) => void;
}

export const useUi = create<UiState>()(
Expand All @@ -48,7 +48,7 @@ export const useUi = create<UiState>()(
skippedClaudeVersion: null,
collapsedColumns: ["cancelled"],
agentsView: "grid",
awaySince: null,
away: null,
setWorkspace: (workspace) => set({ workspace }),
setCommandOpen: (commandOpen) => set({ commandOpen }),
setVoiceMode: (voiceMode) => set({ voiceMode }),
Expand All @@ -62,7 +62,7 @@ export const useUi = create<UiState>()(
collapsedColumns: s.collapsedColumns.includes(status) ? s.collapsedColumns.filter((c) => c !== status) : [...s.collapsedColumns, status],
})),
setAgentsView: (agentsView) => set({ agentsView }),
setAwaySince: (awaySince) => set({ awaySince }),
setAway: (away) => set({ away }),
}),
{
// Per computer in cloud mode: the selected workspace id belongs to one computer.
Expand Down
12 changes: 7 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,11 +369,13 @@ An agent that needs the human asks and waits, instead of ending its turn with a
socket); showing it, or `POST /api/conversations/read` (`ids` or `"all"`),
reads it — and reading a chat whose run failed clears the agent's "Last run failed". Automation, task, delegation and
platform chats are never unread.
* **While you were away** (`services/away.ts`, `GET /api/away?since=<ISO>`, Cloud: allowed; phone: closed). After at
least two hours without the human's input (pointer, keys, wheel in a focused window; the last time is kept per computer
in `localStorage`, `lib/presence.ts`), Home shows what the team did since: runs that ended (checks left out) and how
many failed, tickets delivered, what the work cost (the spend ledger), who worked, and up to six things worth a look —
delivered tickets, problems (chats and automations), then replies — one line per chat or automation. Closed with ×.
* **While you were away** (`services/away.ts`, `GET /api/away?since=<ISO>&until=<ISO>`, Cloud: allowed; phone: closed).
After at least two hours without the human's input (released pointer or key, wheel, in a focused window; the last time
is kept per computer in `localStorage`, `lib/presence.ts`), Home shows what the team did from then until they came
back (at most 31 days): runs that ended (checks left out) and how many failed, tickets delivered (by whom), what the
work cost (the spend ledger), who worked, and up to six things worth a look — delivered tickets, problems (chats still
unread, and automations), then replies in chats still unread — one line per chat or automation. Runs are found
through `idx_runs_finished` (migration 58). Closed with ×.
* **Notices** (`services/runNotices.ts`). Such a run that nobody watched notifies once — "Mia replied in “Q4 plan”" /
"Mia ran into a problem in “…”" — unless the agent called `notify_user` itself (the tool call, not the word) or the
run reported a missing login (that has its own notice). Only the computer the human uses tells: a runner's runs
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,15 @@ ALTER TABLE paused_runs ADD COLUMN exempt INTEGER NOT NULL DEFAULT 0;
-- A sub-ticket: part of a bigger ticket (its parent waits until its sub-tickets are done, then continues with them).
ALTER TABLE tasks ADD COLUMN parent_id TEXT;
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id) WHERE parent_id IS NOT NULL;
`,
},
{
id: 58,
name: "away_indexes",
sql: /* sql */ `
-- "While you were away" reads what ended, and what was delivered, in a window of time.
CREATE INDEX IF NOT EXISTS idx_runs_finished ON runs(finished_at) WHERE finished_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_task_events_kind ON task_events(kind, created_at);
`,
},
];
2 changes: 1 addition & 1 deletion packages/core/src/server/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function registerChatRoutes(app: Hono): void {
app.get("/api/attention", (c) => c.json(listAttention()));

// What the team did since the human was last here (Home's "while you were away").
app.get("/api/away", (c) => c.json(awaySummary(c.req.query("since") ?? "")));
app.get("/api/away", (c) => c.json(awaySummary(c.req.query("since") ?? "", c.req.query("until") || undefined)));

app.patch("/api/conversations/:id", async (c) => {
const patch = await body(
Expand Down
Loading
Loading