diff --git a/web/src/screens/assistant/Upcoming.tsx b/web/src/screens/assistant/Upcoming.tsx
index 6424ab0c..62e1a0ed 100644
--- a/web/src/screens/assistant/Upcoming.tsx
+++ b/web/src/screens/assistant/Upcoming.tsx
@@ -7,6 +7,23 @@ import {
type AssistantChangeKind,
type Goal,
} from "@orbyn/core";
+import {
+ ArrowLeft,
+ Brain,
+ CalendarClock,
+ Ellipsis,
+ FileText,
+ FolderOpen,
+ GraduationCap,
+ ListChecks,
+ MessageCircleQuestion,
+ Plus,
+ Repeat,
+ Settings2,
+ Shapes,
+ Target,
+ type LucideIcon,
+} from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { client } from "../../lib/session";
import { messageOf } from "../../lib/resource";
@@ -15,8 +32,10 @@ import {
Button,
Dialog,
ErrorNotice,
+ IconButton,
Loading,
- Pill,
+ Menu,
+ SelectBox,
useConfirm,
} from "../../ui/kit";
import { useOwned } from "../agents-shared/hooks";
@@ -31,8 +50,36 @@ import { useTasks } from "../shared-task";
import { GoalForm } from "./GoalForm";
import { GoalWork } from "./GoalWork";
import { RoutineForm } from "./RoutineForm";
+import { upcomingItems, type Bucket, type UpcomingItem } from "./priority";
import { timezone, useRead } from "./state";
+const SECTIONS: { bucket: Bucket; title: string; hint?: string }[] = [
+ {
+ bucket: "now",
+ title: "Need you now",
+ hint: "Overdue, failed or waiting on your answer",
+ },
+ { bucket: "today", title: "Today" },
+ { bucket: "week", title: "This week" },
+ { bucket: "later", title: "Later" },
+];
+const KIND_ICONS: Record
= {
+ tasks: ListChecks,
+ sessions: CalendarClock,
+ pages: FileText,
+ projects: FolderOpen,
+ memory: Brain,
+ study: GraduationCap,
+ other: Shapes,
+};
+const FILTERS = [
+ ["all", "All"],
+ ["goal", "Goals"],
+ ["routine", "Routines"],
+ ["chat", "Waiting chats"],
+] as const;
+
+/** What needs attention across goals, routines and waiting chats, most urgent first. */
export function Upcoming({
onClose,
onOpenChat,
@@ -43,9 +90,13 @@ export function Upcoming({
const navigate = useNavigate();
const owned = useOwned(),
confirm = useConfirm(),
- lock = useRef(false);
+ lock = useRef(false),
+ panel = useRef(null);
const goals = useRead(() => client.listGoals()),
routines = useRead(() => client.listAgentRoutines()),
+ chats = useRead(() =>
+ client.aiChats({ search: "", kind: "all", limit: 100 }),
+ ),
projects = useRead(() => client.listProjects()),
docs = useRead(() => client.listDocs({ kind: "agent" })),
prefs = useRead(() => client.getPlannerPrefs()),
@@ -55,6 +106,8 @@ export function Upcoming({
[work, setWork] = useState(null),
[goalDetail, setGoalDetail] = useState(null),
[routineDetail, setRoutineDetail] = useState(null),
+ [filter, setFilter] = useState<(typeof FILTERS)[number][0]>("all"),
+ [settings, setSettings] = useState(false),
[busy, setBusy] = useState(false),
[error, setError] = useState("");
const childDirty = useRef({ quiet: false, retry: false });
@@ -138,9 +191,250 @@ export function Upcoming({
});
}, routines.reload);
const editable = !busy && !!projects.data && !!docs.data;
+ const reload = () => {
+ void goals.reload();
+ void routines.reload();
+ void chats.reload();
+ };
+ const all = upcomingItems(
+ {
+ goals: goals.data ?? [],
+ routines: routines.data ?? [],
+ chats: chats.data ?? [],
+ },
+ prefs.data?.timezone ?? timezone(),
+ );
+ const items = all.filter((item) => filter === "all" || item.kind === filter);
+ const filterName =
+ FILTERS.find(([value]) => value === filter)?.[1].toLowerCase() ?? "items";
+ const needsAttention = items.some((item) => item.bucket !== "quiet");
+ const count = (bucket: Bucket) =>
+ items.filter((item) => item.bucket === bucket).length;
+ const jump = (bucket: Bucket) =>
+ panel.current
+ ?.querySelector(`[data-bucket="${bucket}"]`)
+ ?.scrollIntoView({ block: "start", behavior: "smooth" });
+ const loading = goals.loading || routines.loading || chats.loading;
+ const quiet = items.filter((item) => item.bucket === "quiet");
+
+ const primary = (item: UpcomingItem) => {
+ if (item.kind === "chat")
+ return (
+ owned() && onOpenChat(item.chat.id)}>
+ Answer
+
+ );
+ if (item.kind === "goal")
+ return item.goal.status === "active" ? (
+ setWork(item.goal)}>
+ Plan next step
+
+ ) : null;
+ if (item.failure)
+ return (
+ {
+ childDirty.current = { quiet: false, retry: false };
+ setRoutineDetail(item.routine);
+ }}
+ >
+ Check history
+
+ );
+ return (
+ toggle(item.routine)}>
+ {item.routine.paused ? "Resume" : "Pause"}
+
+ );
+ };
+ const more = (item: UpcomingItem) => {
+ if (item.kind === "chat") return null;
+ const title =
+ item.kind === "goal" ? item.goal.title : item.routine.instruction;
+ return (
+ (
+
+
+
+ )}
+ >
+ {(closeMenu) =>
+ item.kind === "goal" ? (
+ <>
+ {
+ closeMenu();
+ setGoalForm(item.goal);
+ }}
+ >
+ Edit goal
+
+ {
+ closeMenu();
+ setGoalDetail(item.goal);
+ }}
+ >
+ Check-ins and tasks
+
+ {
+ closeMenu();
+ setWork(item.goal);
+ }}
+ >
+ Goal work
+
+ {item.goal.plan_doc_id && (
+ {
+ closeMenu();
+ if (owned())
+ navigate(
+ `/app/doc/${encodeURIComponent(item.goal.plan_doc_id!)}`,
+ );
+ }}
+ >
+ Open plan page
+
+ )}
+ {
+ closeMenu();
+ void mutate(
+ () => client.deleteGoal(item.goal.id),
+ goals.reload,
+ {
+ title: "Delete this goal?",
+ body: "This removes the goal and its check-in history.",
+ },
+ );
+ }}
+ >
+ Delete goal
+
+ >
+ ) : (
+ <>
+ {item.failure && (
+ {
+ closeMenu();
+ toggle(item.routine);
+ }}
+ >
+ {item.routine.paused ? "Resume" : "Pause"}
+
+ )}
+ {
+ closeMenu();
+ setRoutineForm(item.routine);
+ }}
+ >
+ Edit schedule
+
+ {
+ closeMenu();
+ childDirty.current = { quiet: false, retry: false };
+ setRoutineDetail(item.routine);
+ }}
+ >
+ History and policies
+
+ {
+ closeMenu();
+ void mutate(
+ () => client.deleteAgentRoutine(item.routine.id),
+ routines.reload,
+ {
+ title: "Delete this routine?",
+ body: "This removes its schedule, history and upcoming work. Already applied changes remain.",
+ },
+ );
+ }}
+ >
+ Delete routine
+
+ >
+ )
+ }
+
+ );
+ };
+ const row = (item: UpcomingItem) => {
+ const Icon =
+ item.kind === "goal"
+ ? Target
+ : item.kind === "routine"
+ ? Repeat
+ : MessageCircleQuestion;
+ const title =
+ item.kind === "goal"
+ ? item.goal.title
+ : item.kind === "routine"
+ ? item.routine.instruction
+ : item.chat.title || "Untitled chat";
+ const detail =
+ item.kind === "goal"
+ ? [item.goal.project_name ?? "Personal", item.goal.target]
+ : item.kind === "routine"
+ ? [item.failure ?? describeRrule(item.routine.rrule)]
+ : ["The assistant is waiting for your answer"];
+ return (
+
+
+
+
+
+
+
+ {item.kind === "goal"
+ ? "Goal: "
+ : item.kind === "routine"
+ ? "Routine: "
+ : "Chat: "}
+
+ {title}
+
+
+ {detail.filter(Boolean).join(" · ")}
+
+
+ {item.when}
+
+ {primary(item)}
+ {more(item)}
+
+
+ );
+ };
+
return (
void close()}
actions={
@@ -152,229 +446,227 @@ export function Upcoming({
}
>
- {error && (
- {
- void goals.reload();
- void routines.reload();
- void scopes.reload();
- }}
- />
- )}
-
-
- Goals
- void goals.reload()}
- >
- Refresh goals
+
+ {settings ? (
+
setSettings(false)}>
+
+ Back to Upcoming
-
owned() && setGoalForm("new")}
- >
- New goal
-
-
- {goals.loading &&
}
- {goals.error && (
-
void goals.reload()}
- />
- )}
- {!goals.loading && goals.data?.length === 0 && No goals yet.
}
-
-
-
-
- Routines
- void routines.reload()}
- >
- Refresh routines
-
- owned() && setRoutineForm("new")}
- >
- New routine
-
-
- {prefs.error && (
- void prefs.reload()}
- />
+ >
+ {(closeMenu) => (
+ <>
+ {
+ closeMenu();
+ if (owned()) setGoalForm("new");
+ }}
+ >
+ New goal
+
+ {
+ closeMenu();
+ if (owned()) setRoutineForm("new");
+ }}
+ >
+ New routine
+
+ >
+ )}
+
+ setSettings(true)}>
+
+ Permissions and admission
+
+ >
)}
- {routines.loading && }
- {routines.error && (
- void routines.reload()}
- />
+
+
+ {error &&
}
+ {[goals, routines, chats, prefs, projects, docs].map(
+ (read, index) =>
+ read.error && (
+
void read.reload()}
+ />
+ ),
)}
- {routines.data?.length === 0 && No routines yet.
}
-
- {routines.data?.map((routine) => (
-
-
- {routine.instruction}
-
+ {settings ? (
+ <>
+
+ Saved automatic-change permissions
+ {scopes.loading && }
+ {scopes.error && (
+ void scopes.reload()}
+ />
+ )}
- {describeRrule(routine.rrule)} · {routine.timezone} ·{" "}
- {routine.paused
- ? "Paused"
- : `Next ${new Date(routine.next_run_at).toLocaleString(undefined, { timeZone: routine.timezone })}`}
+ Choose, for each kind of change, whether the assistant asks
+ first. This affects future work only. Pause admission and stop
+ running work separately.
-
- toggle(routine)}>
- {routine.paused ? "Resume" : "Pause"}
-
- setRoutineForm(routine)}>
- Edit schedule
-
- {
- childDirty.current = { quiet: false, retry: false };
- setRoutineDetail(routine);
- }}
- >
- History and policies
-
-
- void mutate(
- () => client.deleteAgentRoutine(routine.id),
- routines.reload,
- {
- title: "Delete this routine?",
- body: "This removes its schedule, history and upcoming work. Already applied changes remain.",
- },
- )
- }
- >
- Delete routine
-
-
-
- ))}
-
-
-
- {scopes.loading && }
- {scopes.error && (
- void scopes.reload()}
- />
- )}
-
- Revoking permission affects future work. Pause admission and stop
- running work separately.
-
- {scopes.data &&
- ASSISTANT_CHANGE_KINDS.map((kind) => (
-
-
- {CHANGE_KINDS[kind]} · {ruleLabel(scopes.data?.[kind])}
-
- {scopes.data?.[kind] && (
-
- void mutate(
- () => {
- const next = { ...scopes.data };
- delete next[kind];
- return client.setAssistantApprovalScopes(next);
- },
- scopes.reload,
- {
- title: `Revoke ${CHANGE_KINDS[kind].toLowerCase()} permission?`,
- body: "Future changes in this category will ask again.",
- },
- )
- }
- >
- Revoke
-
+ {scopes.data && (
+
+ {ASSISTANT_CHANGE_KINDS.map((kind) => {
+ const rule = scopes.data?.[kind];
+ const value = !rule
+ ? "ask"
+ : rule === "always"
+ ? "always"
+ : "scoped";
+ const name = CHANGE_KINDS[kind].toLowerCase();
+ const Icon = KIND_ICONS[kind];
+ return (
+
+
+
+
+
+ {CHANGE_KINDS[kind]}
+
+ {
+ const choice = e.target.value;
+ if (choice === value) return;
+ void mutate(
+ () => {
+ const next = { ...scopes.data };
+ if (choice === "always") next[kind] = "always";
+ else delete next[kind];
+ return client.setAssistantApprovalScopes(next);
+ },
+ scopes.reload,
+ choice === "always"
+ ? {
+ title: `Allow ${name} changes without asking?`,
+ body: `Future ${name} changes from the assistant may be applied without asking. You can switch back to Ask each time here.`,
+ }
+ : {
+ title: `Ask before ${name} changes?`,
+ body: "Future changes in this category will ask again.",
+ },
+ );
+ }}
+ >
+ Ask each time
+ Always allow
+ {value === "scoped" && (
+
+ {`Allowed for ${ruleLabel(rule)}`}
+
+ )}
+
+
+ );
+ })}
+
)}
+
+
+ Admission controls
+
+
+
+ >
+ ) : (
+ <>
+
+ jump("now")}
+ >
+ {count("now")}
+ Need you now
+
+ jump("today")}
+ >
+ {count("today")}
+ Today
+
+ jump("week")}>
+ {count("week")}
+ This week
+
- ))}
-
-
-
-
-
- {projects.error && (
-
void projects.reload()}
- />
- )}
- {docs.error && (
- void docs.reload()} />
- )}
+
+ {FILTERS.map(([value, label]) => (
+ setFilter(value)}
+ >
+ {label}
+
+ ))}
+
+ {loading && !items.length && }
+ {!loading && !needsAttention && filter === "all" && (
+
+ Nothing is coming up. You're caught up.
+
+ )}
+ {!loading && !items.length && filter !== "all" && (
+ No {filterName} right now.
+ )}
+ {SECTIONS.map(({ bucket, title, hint }) => {
+ const rows = items.filter((item) => item.bucket === bucket);
+ return (
+ rows.length > 0 && (
+
+
+ {title}
+
+ {rows.length}
+ {hint ? ` · ${hint}` : ""}
+
+
+
+
+ )
+ );
+ })}
+ {quiet.length > 0 && (
+
+
+
+ )}
+ >
+ )}
+
{goalForm && (
span {
+ flex: 1;
+}
+.assistant-rail-close {
+ display: none;
+}
+.assistant-rail-list {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 0 8px 16px;
+}
+.assistant-rail-list ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.assistant-rail-group h2 {
+ margin: 12px 8px 4px;
+ font-size: 12px;
+ line-height: 16px;
+ font-weight: 500;
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.assistant-rail-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ min-height: 34px;
+ padding: 6px 8px;
+ border: 0;
+ border-radius: var(--r-sm);
+ background: transparent;
+ color: var(--text-soft);
+ font: inherit;
+ font-size: 13px;
+ text-align: start;
+ cursor: pointer;
+}
+.assistant-rail-row > span:first-child {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.assistant-rail-row:hover {
+ background: var(--hover);
+ color: var(--text);
+}
+.assistant-rail-row[aria-current="page"] {
+ background: var(--accent-soft);
+ color: var(--accent-text);
+}
+.assistant-rail-row:disabled {
+ cursor: default;
+ opacity: 0.6;
+}
+.assistant-rail-dot {
+ flex: 0 0 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--accent);
+}
+.assistant-rail-dot.is-warn {
+ background: var(--warn);
+}
+
+.assistant-main {
+ flex: 1;
min-width: 0;
- width: min(100%, 1080px);
- margin-inline: auto;
- padding: 28px 32px;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
}
.assistant-header {
display: flex;
align-items: center;
- gap: 16px;
- flex-wrap: wrap;
+ gap: 8px;
+ padding: 8px 16px 8px 8px;
+ border-bottom: 1px solid var(--line);
}
.assistant-title {
- flex: 1 1 180px;
+ flex: 1 1 120px;
min-width: 0;
}
.assistant-title h1 {
- font-size: 28px;
- line-height: 36px;
+ font-size: 15px;
+ line-height: 20px;
}
.assistant-title p {
- overflow-wrap: anywhere;
+ font-size: 12px;
+ line-height: 16px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.assistant-toolbar {
- display: flex;
- align-items: flex-start;
- gap: 16px;
- flex-wrap: wrap;
+.assistant-scope-lock {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ max-width: 260px;
+ height: 28px;
+ padding: 0 10px;
+ border-radius: 999px;
+ background: var(--chip);
+ color: var(--text-soft);
+ font-size: 12px;
}
-.assistant-toolbar > .field {
- flex: 1 1 220px;
- max-width: 420px;
+.assistant-scope-lock svg {
+ flex: 0 0 auto;
+ width: 13px;
+ height: 13px;
}
+.assistant-scope-lock span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.assistant-notices {
+ display: grid;
+ gap: 8px;
+ padding-inline: 16px;
+}
+.assistant-notices:not(:empty) {
+ padding-block: 8px;
+}
+
+/* Conversation. */
.assistant-transcript-wrap {
position: relative;
- min-width: 0;
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
}
.assistant-transcript {
+ flex: 1;
+ min-height: 0;
display: flex;
flex-direction: column;
gap: 20px;
- max-height: calc(100dvh - 390px);
- min-height: 260px;
overflow-y: auto;
overscroll-behavior: contain;
- scrollbar-gutter: stable;
- padding: 8px 12px 24px;
+ padding: 24px max(16px, calc((100% - 760px) / 2)) 24px;
}
-.assistant-message {
+.assistant-transcript > .btn {
+ align-self: center;
+}
+.assistant-turn {
display: flex;
flex-direction: column;
+ min-width: 0;
+}
+.assistant-message {
+ display: flex;
gap: 12px;
+ align-items: flex-start;
min-width: 0;
- padding: 20px 0;
- border-bottom: 1px solid var(--line);
overflow-wrap: anywhere;
}
-.assistant-message-user {
- background: var(--surface);
- border: 0;
- border-radius: var(--r-lg);
- padding: 16px 20px;
- margin-left: min(10%, 72px);
-}
.assistant-message:focus {
outline: 2px solid var(--accent);
- outline-offset: 3px;
+ outline-offset: 4px;
+ border-radius: var(--r-sm);
+}
+.assistant-message-user {
+ align-self: flex-end;
+ display: block;
+ max-width: min(78%, 600px);
+ padding: 10px 16px;
+ border-radius: 18px 18px 6px 18px;
+ background: var(--accent);
+ color: var(--on-ink, #fff);
+}
+:root[data-theme="dark"] .assistant-message-user {
+ color: #0d0e10;
+}
+.assistant-message-user .agents-reply > :first-child {
+ margin-top: 0;
+}
+.assistant-message-user .agents-reply > :last-child {
+ margin-bottom: 0;
+}
+.assistant-avatar {
+ flex: 0 0 28px;
+ height: 28px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--accent-soft);
+ color: var(--accent-text);
+}
+.assistant-avatar svg {
+ width: 16px;
+ height: 16px;
+}
+.assistant-message-body {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.assistant-update-label {
+ font-size: 13px;
+ font-weight: 600;
+}
+.assistant-actions {
+ display: flex;
+ gap: 2px;
+ margin-inline-start: -8px;
+ color: var(--text-muted);
+}
+.assistant-actions .btn-icon {
+ width: 30px;
+ height: 30px;
+ min-height: 30px;
+ color: var(--text-muted);
+}
+.assistant-actions .btn-icon svg {
+ width: 15px;
+ height: 15px;
}
.assistant-date {
color: var(--text-muted);
- font-size: 13px;
+ font-size: 12px;
text-align: center;
- margin-block: 8px;
+ margin-block: 4px 12px;
+}
+
+/* How long it worked and what it did: quieter than the answer. */
+.assistant-trace {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 13px;
+ line-height: 20px;
+ color: var(--text-muted);
+}
+.assistant-trace-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ width: fit-content;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ font-weight: 400;
}
+button.assistant-trace-toggle {
+ cursor: pointer;
+}
+button.assistant-trace-toggle:hover {
+ color: var(--text-soft);
+}
+.assistant-trace-toggle svg {
+ width: 14px;
+ height: 14px;
+}
+.assistant-trace-steps {
+ list-style: none;
+ margin: 2px 0 0 6px;
+ padding: 0 0 0 12px;
+ border-inline-start: 1.5px solid var(--line);
+ display: grid;
+ gap: 2px;
+ font-size: 12.5px;
+ font-weight: 400;
+}
+.assistant-spin {
+ animation: assistant-spin 1s linear infinite;
+}
+@keyframes assistant-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.assistant-in {
+ animation: assistant-in var(--base) var(--ease) both;
+}
+@keyframes assistant-in {
+ from {
+ opacity: 0;
+ transform: translateY(3px);
+ }
+}
+.assistant-revealing {
+ position: relative;
+}
+.assistant-revealing .agents-reply > :last-child {
+ display: inline;
+}
+.assistant-caret {
+ display: inline-block;
+ width: 7px;
+ height: 16px;
+ margin-inline-start: 2px;
+ vertical-align: -2px;
+ border-radius: 2px;
+ background: var(--accent);
+ animation: assistant-blink 0.9s steps(2) infinite;
+}
+@keyframes assistant-blink {
+ 50% {
+ opacity: 0;
+ }
+}
+
+/* A new chat: choose what it can see. */
.assistant-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ margin: auto 0;
padding: 24px 0;
- display: grid;
- gap: 16px;
+ text-align: center;
}
.assistant-empty h2 {
- font-size: 24px;
- line-height: 32px;
+ font-size: 22px;
+ line-height: 30px;
+}
+.assistant-scopes {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 8px;
+ margin-top: 12px;
+ max-width: 640px;
+}
+.assistant-scopes button,
+.assistant-scope-current {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ max-width: 240px;
+ min-height: var(--control);
+ padding: 0 14px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--surface);
+ color: var(--text-soft);
+ font: inherit;
+ font-size: 13px;
+}
+.assistant-scopes button {
+ cursor: pointer;
+}
+.assistant-scopes button span,
+.assistant-scope-current span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.assistant-scopes button svg,
+.assistant-scope-current svg {
+ flex: 0 0 auto;
+ width: 15px;
+ height: 15px;
+}
+.assistant-scopes button:hover:not(:disabled) {
+ background: var(--hover);
+}
+.assistant-scopes button[aria-pressed="true"],
+.assistant-scope-current {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+ color: var(--accent-text);
+}
+.assistant-scopes button:disabled {
+ cursor: default;
+ opacity: 0.55;
+}
+.assistant-scope-current {
+ cursor: default;
+}
+
+/* Composer and what sits on it. */
+.assistant-dock {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 8px max(16px, calc((100% - 760px) / 2)) 12px;
+}
+.assistant-suggestions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.assistant-suggestions button {
+ max-width: 100%;
+ min-height: var(--control);
+ padding: 4px 14px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--surface);
+ color: var(--text);
+ font: inherit;
+ font-size: 13px;
+ text-align: start;
+ cursor: pointer;
+}
+.assistant-suggestions button:hover:not(:disabled) {
+ background: var(--hover);
+}
+.assistant-suggestions button:disabled {
+ cursor: default;
+ opacity: 0.55;
}
.assistant-composer {
display: grid;
- gap: 12px;
- padding: 20px;
+ gap: 4px;
+ padding: 10px 10px 8px 16px;
+ border: 1px solid var(--line);
+ border-radius: 20px;
background: var(--surface);
- border-radius: var(--r-lg);
box-shadow: var(--raised);
}
+.assistant-composer:focus-within {
+ border-color: var(--accent);
+}
+.assistant-composer textarea {
+ min-height: 44px;
+ max-height: 200px;
+ padding: 4px 0;
+ border: 0;
+ background: transparent;
+ box-shadow: none;
+ resize: none;
+ field-sizing: content;
+}
+.assistant-composer textarea:focus {
+ outline: none;
+ box-shadow: none;
+}
.assistant-compose-actions {
display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
- gap: 12px;
align-items: center;
+ gap: 8px;
}
-.assistant-composer textarea {
- min-height: 92px;
- background: var(--bg);
+.assistant-compose-gap {
+ flex: 1;
+}
+.assistant-over {
+ color: var(--danger);
+}
+.assistant-send.btn-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+}
+.assistant-footnote {
+ font-size: 12px;
+ line-height: 16px;
+ color: var(--text-muted);
+ text-align: center;
}
.assistant-reply-target {
display: grid;
gap: 8px;
- border-left: 3px solid var(--accent);
- padding-left: 12px;
+ padding: 8px 0 8px 12px;
+ border-inline-start: 3px solid var(--accent);
+}
+.assistant-reply-target .btn {
+ justify-self: start;
}
.assistant-reply-target blockquote {
max-height: 140px;
@@ -129,6 +523,7 @@
gap: 12px;
align-items: center;
flex-wrap: wrap;
+ margin: 0 16px 8px;
padding: 12px;
background: var(--accent-soft);
border-radius: var(--r-md);
@@ -136,9 +531,11 @@
.assistant-latest {
position: absolute;
right: 20px;
- bottom: 8px;
+ bottom: 12px;
box-shadow: var(--raised);
}
+
+/* Saved chats dialog and history. */
.assistant-records {
list-style: none;
padding: 0;
@@ -174,43 +571,282 @@
max-width: 100%;
overflow: auto;
}
-@media (max-width: 650px) {
+
+@media (max-width: 760px) {
.assistant-workspace {
- padding: 20px 12px;
- gap: 16px;
+ position: relative;
}
- .assistant-header {
- gap: 12px;
+ .assistant-rail {
+ position: absolute;
+ inset: 0 auto 0 0;
+ z-index: 3;
+ width: min(280px, 85%);
+ box-shadow: var(--float);
}
- .assistant-header > .agents-buttons {
- width: 100%;
+ .assistant-rail-close {
+ display: inline-flex;
}
- .assistant-header > .agents-buttons > .btn {
- flex: 1 1 100px;
+ .assistant-suggestions {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ scrollbar-width: none;
}
- .assistant-header > .agents-buttons > .btn svg {
+ .assistant-suggestions button {
+ flex: 0 0 auto;
+ white-space: nowrap;
+ }
+ .assistant-header > .btn:not(.btn-icon) svg {
display: none;
}
- .assistant-transcript {
- min-height: 240px;
- max-height: 52dvh;
- padding-inline: 4px;
+ .assistant-scope-lock {
+ display: none;
}
.assistant-message-user {
- margin-left: 12px;
- padding: 14px;
+ max-width: 88%;
}
- .assistant-composer {
- padding: 14px;
- }
- .assistant-workspace .input {
+ .assistant-workspace .input,
+ .assistant-composer textarea {
font-size: 16px;
}
- .assistant-compose-actions > .agents-buttons {
- flex: 1 1 100%;
- justify-content: flex-end;
+ .assistant-header .btn,
+ .assistant-actions .btn-icon,
+ .assistant-suggestions button,
+ .assistant-scopes button,
+ .assistant-scope-current {
+ min-height: 44px;
+ }
+ .assistant-actions .btn-icon {
+ width: 44px;
}
- .assistant-workspace .btn {
+ .assistant-send.btn-icon {
+ width: 44px;
+ height: 44px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .assistant-spin,
+ .assistant-in,
+ .assistant-caret {
+ animation: none;
+ }
+}
+
+/* Upcoming: what needs attention, most urgent first. */
+.upcoming-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+.upcoming-body {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+}
+.upcoming-counts {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+.upcoming-counts button {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ padding: 10px 12px;
+ border: 0;
+ border-radius: var(--r-md);
+ background: var(--sunken);
+ color: var(--text-soft);
+ font: inherit;
+ font-size: 12px;
+ text-align: start;
+ cursor: pointer;
+}
+.upcoming-counts strong {
+ font-size: 22px;
+ line-height: 28px;
+ color: var(--text);
+}
+.upcoming-counts .is-now {
+ background: var(--danger-soft);
+ color: var(--danger);
+}
+.upcoming-counts .is-now strong {
+ color: var(--danger);
+}
+.upcoming-counts .is-today {
+ background: var(--warn-soft);
+ color: var(--warn);
+}
+.upcoming-counts .is-today strong {
+ color: var(--warn);
+}
+.upcoming-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.upcoming-filters button {
+ min-height: var(--control);
+ padding: 0 12px;
+ border: 1px solid var(--line);
+ border-radius: 999px;
+ background: var(--surface);
+ color: var(--text-soft);
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+}
+.upcoming-filters button[aria-pressed="true"] {
+ border-color: var(--accent);
+ background: var(--accent-soft);
+ color: var(--accent-text);
+}
+.upcoming-section h3 {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ margin-bottom: 4px;
+ font-size: 13px;
+ line-height: 20px;
+}
+.upcoming-section h3 span {
+ font-weight: 400;
+ font-size: 12px;
+ color: var(--text-muted);
+}
+.upcoming-section ul,
+.upcoming-quiet {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+.upcoming-row {
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr) auto;
+ grid-template-areas:
+ "kind text when"
+ "kind actions actions";
+ align-items: center;
+ column-gap: 10px;
+ row-gap: 6px;
+ padding: 10px 0;
+ border-top: 1px solid var(--line-soft);
+}
+.upcoming-kind {
+ grid-area: kind;
+ align-self: start;
+ width: 28px;
+ height: 28px;
+ border-radius: var(--r-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--sunken);
+ color: var(--text-soft);
+}
+.upcoming-kind svg {
+ width: 15px;
+ height: 15px;
+}
+.upcoming-text {
+ grid-area: text;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+.upcoming-title {
+ font-size: 14px;
+ line-height: 20px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.upcoming-detail {
+ font-size: 12px;
+ line-height: 16px;
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.upcoming-when {
+ grid-area: when;
+ align-self: start;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--chip);
+ color: var(--text-soft);
+ font-size: 11px;
+ line-height: 18px;
+ white-space: nowrap;
+}
+.upcoming-when.is-now {
+ background: var(--danger-soft);
+ color: var(--danger);
+}
+.upcoming-when.is-today {
+ background: var(--warn-soft);
+ color: var(--warn);
+}
+.upcoming-actions {
+ grid-area: actions;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.upcoming-actions .btn:not(.btn-icon) {
+ min-height: var(--control);
+ font-size: 12px;
+}
+.upcoming-clear {
+ padding: 24px 0;
+ color: var(--text-muted);
+ text-align: center;
+}
+.upcoming-settings {
+ display: grid;
+ gap: 10px;
+}
+.upcoming-settings h3 {
+ font-size: 13px;
+}
+@media (max-width: 760px) {
+ .upcoming-actions .btn:not(.btn-icon),
+ .upcoming-filters button,
+ .upcoming-actions .btn-icon {
min-height: 44px;
}
}
+.upcoming-permissions {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: var(--r-md);
+}
+.upcoming-permissions li {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 8px 8px 8px 12px;
+}
+.upcoming-permissions .upcoming-kind {
+ align-self: center;
+}
+.upcoming-permissions li + li {
+ border-top: 1px solid var(--line-soft);
+}
+.upcoming-permissions label {
+ flex: 1;
+ min-width: 0;
+ font-size: 14px;
+}
+.upcoming-permissions select {
+ flex: 0 1 220px;
+ min-width: 0;
+}
diff --git a/web/src/screens/assistant/index.tsx b/web/src/screens/assistant/index.tsx
index 32ab4fb1..93adf316 100644
--- a/web/src/screens/assistant/index.tsx
+++ b/web/src/screens/assistant/index.tsx
@@ -1,22 +1,34 @@
import { newId } from "@orbyn/core";
-import { CalendarDays, History, Plus, Settings2 } from "lucide-react";
+import {
+ ArrowUp,
+ CalendarDays,
+ Ellipsis,
+ FolderOpen,
+ ListChecks,
+ Lock,
+ PanelLeftClose,
+ PanelLeftOpen,
+ Plus,
+ Square,
+ User,
+} from "lucide-react";
import { useEffect, useState } from "react";
import { client } from "../../lib/session";
import { useNavigate, useSearch, useTitle } from "../../lib/router";
import {
Button,
ErrorNotice,
- Field,
- Input,
+ IconButton,
Loading,
- SelectBox,
+ Menu,
TextArea,
+ useConfirm,
} from "../../ui/kit";
import { Character } from "../agents-shared/Character";
import { useOwner, useOwned, useControls } from "../agents-shared/hooks";
-import { More } from "../agents-shared/parts";
import { useTasks } from "../shared-task";
import { ChatList } from "./ChatList";
+import { ChatRail } from "./ChatRail";
import { SavedHistory } from "./History";
import { PersonalIdentity } from "./PersonalIdentity";
import { Transcript } from "./Transcript";
@@ -26,6 +38,40 @@ import { useAssistant } from "./useAssistant";
import { timezone, useRead } from "./state";
import "./assistant.css";
+const RAIL_KEY = "orbyn.assistant.rail";
+// On a narrow screen the rail covers the chat, so it starts closed there.
+const narrow = () =>
+ typeof matchMedia === "function" && matchMedia("(max-width: 760px)").matches;
+function savedRail() {
+ if (narrow()) return false;
+ try {
+ return localStorage.getItem(RAIL_KEY) !== "hidden";
+ } catch {
+ return true;
+ }
+}
+
+/** Starters follow the chat's scope, so they never ask beyond what it can see. */
+function suggestionsFor(scope: { kind: string; name: string } | null) {
+ if (!scope)
+ return [
+ "What needs my attention today?",
+ "Help me plan this week",
+ "What should I study next?",
+ ];
+ if (scope.kind === "task")
+ return [
+ `Help me finish ${scope.name}`,
+ `Break ${scope.name} into steps`,
+ `What is blocking ${scope.name}?`,
+ ];
+ return [
+ `What's blocking ${scope.name}?`,
+ `What should I do next in ${scope.name}?`,
+ `Summarize progress on ${scope.name}`,
+ ];
+}
+
export default function AssistantScreen() {
const owner = useOwner();
useTitle("Assistant");
@@ -34,6 +80,7 @@ export default function AssistantScreen() {
function AssistantWorkspace() {
const tasks = useTasks();
const navigate = useNavigate();
+ const confirm = useConfirm();
const owned = useOwned(),
query = useSearch(),
assistant = useAssistant(query),
@@ -52,12 +99,23 @@ function AssistantWorkspace() {
const [panel, setPanel] = useState<"chats" | "history" | "identity" | null>(
null,
),
+ [rail, setRail] = useState(savedRail),
[upcoming, setUpcoming] = useState(
() => new URLSearchParams(query).get("panel") === "upcoming",
);
useEffect(() => {
setUpcoming(new URLSearchParams(query).get("panel") === "upcoming");
}, [query]);
+ const toggleRail = () => {
+ const next = !rail;
+ setRail(next);
+ if (narrow()) return;
+ try {
+ localStorage.setItem(RAIL_KEY, next ? "shown" : "hidden");
+ } catch {
+ // The choice still applies for this visit.
+ }
+ };
const openChat = (id: string) => {
if (!owned()) return;
if (assistant.sending) {
@@ -68,6 +126,7 @@ function AssistantWorkspace() {
}
setPanel(null);
setUpcoming(false);
+ if (narrow()) setRail(false);
navigate(`/app/assistant?chat=${encodeURIComponent(id)}`);
};
const closeUpcoming = () => {
@@ -86,18 +145,35 @@ function AssistantWorkspace() {
};
const newChat = () => {
if (!owned() || assistant.sending) return;
- const next = new URLSearchParams({ fresh: newId() });
- if (assistant.scope) next.set(assistant.scope.kind, assistant.scope.id);
- navigate(`/app/assistant?${next}`);
+ navigate(`/app/assistant?${new URLSearchParams({ fresh: newId() })}`);
};
- const scopedProject = (id: string) => {
- if (!owned() || assistant.sending) return;
+ // A scope is chosen only before the first message; each choice opens a new chat.
+ const chooseScope = (id: string) => {
+ if (!owned() || assistant.sending || assistant.display.length) return;
navigate(
id
? `/app/assistant?project=${encodeURIComponent(id)}`
- : "/app/assistant",
+ : `/app/assistant?${new URLSearchParams({ fresh: newId() })}`,
);
};
+ const stopRun = async () => {
+ if (!owned() || !assistant.progress) return;
+ if (
+ !(await confirm({
+ title: "Stop this run?",
+ body: "Cancellation is requested; already applied changes remain.",
+ action: "Request stop",
+ danger: true,
+ }))
+ )
+ return;
+ try {
+ await assistant.runAction("stop");
+ } catch {
+ if (owned())
+ assistant.setError("Couldn't request a stop. Retry in a moment.");
+ }
+ };
const overLimit = assistant.message.trim().length > 4000;
const disabled =
overLimit ||
@@ -108,97 +184,240 @@ function AssistantWorkspace() {
!!assistant.replyError ||
(!!assistant.reply && !assistant.replyEntry);
const zone = prefs.data?.timezone ?? timezone();
+ const started = assistant.display.length > 0;
+ const scopeName = assistant.scope
+ ? `${assistant.scope.kind === "task" ? "Task" : "Project"} · ${assistant.scope.name}`
+ : "Personal";
+ const suggestions = suggestionsFor(assistant.scope);
+ const personal = assistant.conversation.destination?.personal
+ ? (assistant.chat?.id ?? null)
+ : null;
+ const scopePicker = (
+
+ What would you like to work on?
+
+ Choose what this chat can see. It stays fixed once you send.
+
+
+ assistant.scope && chooseScope("")}
+ >
+
+ Personal
+
+ {assistant.scope?.kind === "task" && (
+
+
+ {assistant.scope.name}
+
+ )}
+ {projects.data?.map((project) => (
+
+ !(
+ assistant.scope?.kind === "project" &&
+ assistant.scope.id === project.id
+ ) && chooseScope(project.id)
+ }
+ >
+
+ {project.name}
+
+ ))}
+
+
+ {assistant.scope
+ ? `Only ${assistant.scope.name} and its linked work.`
+ : "Your own tasks, calendar, notes and study."}
+
+
+ );
return (
-
-
-
+ {rail && (
+ setPanel("chats")}
+ onHide={toggleRail}
/>
-
-
Assistant
-
- {assistant.scope?.name ?? "Personal conversation"}
-
-
-
-
-
- New chat
-
-
setPanel("chats")}
+ )}
+
-
-
-
- scopedProject(e.target.value)}
+
- Personal
- {projects.data?.map((project) => (
-
- {project.name}
- {project.assistant_off ? " · Assistant off" : ""}
-
- ))}
-
-
- {assistant.scope?.kind === "task" && (
-
owned() && tasks.open(assistant.scope!.id)}>
- Open task
-
- )}
-
-
- owned() && navigate("/app/background")}>
- Background
-
- owned() && navigate("/app/overnight")}>
- Overnight
-
- owned() && navigate("/app/review")}>
- Review
-
- setPanel("identity")}>
-
- Name and character
-
- {assistant.canonicalEnabled &&
- assistant.chat?.has_legacy_history &&
- assistant.chat.legacy_history_destination_id && (
- setPanel("history")}>
- Previous saved history
-
- )}
-
+
+
+ (
+
+
+
+ )}
+ >
+ {(close) => (
+ <>
+ {
+ close();
+ setPanel("chats");
+ }}
+ >
+ Search and manage chats
+
+ {
+ close();
+ setPanel("identity");
+ }}
+ >
+ Name and character
+
+ {assistant.canonicalEnabled &&
+ assistant.chat?.has_legacy_history &&
+ assistant.chat.legacy_history_destination_id && (
+ {
+ close();
+ setPanel("history");
+ }}
+ >
+ Previous saved history
+
+ )}
+ {
+ close();
+ if (owned()) navigate("/app/background");
+ }}
+ >
+ Background
+
+ {
+ close();
+ if (owned()) navigate("/app/overnight");
+ }}
+ >
+ Overnight
+
+ {
+ close();
+ if (owned()) navigate("/app/review");
+ }}
+ >
+ Review
+
+ {controls.data && deliveryAvailable === true && (
+ void control("muted", !controls.data!.muted)}
+ >
+ Mute delivery notifications
+
+ )}
+ >
+ )}
+
+
+
{controls.error && (
void refreshControls()}
/>
- )}{" "}
+ )}
{deliveryError && (
)}
{deliveryAvailable === false && (
-
+
Conversation notifications are unavailable on this server.
)}
- {controls.data && deliveryAvailable === true && (
-
- void control("muted", e.target.checked)}
- />
- Mute Assistant delivery notifications
-
+ {projects.error && (
+ void projects.reload()}
+ />
)}
-
-
- {projects.error && (
- void projects.reload()}
- />
- )}{" "}
- {assistant.error && (
-
- )}{" "}
- {assistant.loading && }
- {assistant.projectUncertain && (
-
- A project draft is awaiting confirmation. Drafting another project is
- blocked.{" "}
- owned() && navigate("/app/review")}>
- Check Review
-
-
- )}
- {assistant.scope?.disabled && (
-
- Assistant access is off for this project. Choose another scope or
- change its project settings.
-
- )}
-
- {assistant.progress && (
-
- )}
- {assistant.failedAnswers.map((failed) => (
-
-
- An answer could not be confirmed. Your original text is retained.
-
- assistant.waiting.restore(failed.id)}
- >
- Restore failed answer
-
-
- ))}
-
+
+
+ {assistant.progress?.state === "waiting" && (
+
+ )}
+ {assistant.failedAnswers.map((failed) => (
+
+
+ An answer could not be confirmed. Your original text is
+ retained.
+
+ assistant.waiting.restore(failed.id)}
+ >
+ Restore failed answer
+
+
+ ))}
+ {!assistant.sending && !assistant.message && (
+
+ {suggestions.map((text) => (
+ void assistant.send(text)}
+ >
+ {text}
+
+ ))}
+
+ )}
+
{
+ e.preventDefault();
+ if (!disabled) void assistant.send();
+ }}
+ >
+ {assistant.reply && (
+
+
+ {assistant.replyEntry?.kind === "update" &&
+ assistant.replyEntry.event === "question"
+ ? "Answering a retained question"
+ : "Replying to a message"}
+
+ {assistant.canonicalEnabled && assistant.reply.excerpt && (
+ {assistant.reply.excerpt}
+ )}
+ {assistant.replyError && (
+
+ )}
+ {!assistant.replyEntry && !assistant.replyError && (
+ Checking reply access…
+ )}
+ assistant.setReply(null)}
+ >
+ Cancel reply
+
+
+ )}
+
+ {assistant.scope
+ ? `Message about ${assistant.scope.name}`
+ : "Message your assistant"}
+
+ assistant.setMessage(e.target.value)}
+ onKeyDown={(e) => {
+ if (
+ e.key === "Enter" &&
+ !e.shiftKey &&
+ !e.nativeEvent.isComposing
+ ) {
+ e.preventDefault();
+ if (!disabled) void assistant.send();
+ }
+ }}
+ />
+
+ {!assistant.scope && (
+
void assistant.send(undefined, true)}
+ >
+ Draft project
+
+ )}
+ {assistant.message.length > 3500 && (
+
+ {overLimit
+ ? "Keep the message within 4,000 characters. All your text is retained."
+ : `${assistant.message.length}/4,000`}
+
+ )}
+
+ {assistant.sending ? (
+
void stopRun()}
+ >
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ Proposed changes wait for your review or your existing permissions.
-
-
+
+
{panel === "chats" && (
owned() && setPanel(null)}
diff --git a/web/src/screens/assistant/priority.ts b/web/src/screens/assistant/priority.ts
new file mode 100644
index 00000000..1d2b63f4
--- /dev/null
+++ b/web/src/screens/assistant/priority.ts
@@ -0,0 +1,148 @@
+import type { AgentRoutine, AiChatSummary, Goal } from "@orbyn/core";
+
+export type Bucket = "now" | "today" | "week" | "later" | "quiet";
+export type UpcomingItem =
+ | {
+ kind: "goal";
+ id: string;
+ bucket: Bucket;
+ at: number;
+ when: string;
+ goal: Goal;
+ }
+ | {
+ kind: "routine";
+ id: string;
+ bucket: Bucket;
+ at: number;
+ when: string;
+ routine: AgentRoutine;
+ failure: string | null;
+ }
+ | {
+ kind: "chat";
+ id: string;
+ bucket: Bucket;
+ at: number;
+ when: string;
+ chat: AiChatSummary;
+ };
+
+const DAY = 86_400_000;
+/** The calendar day of an instant in a zone, as YYYY-MM-DD. */
+const dayIn = (at: Date, zone: string) =>
+ new Intl.DateTimeFormat("en-CA", {
+ timeZone: zone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).format(at);
+const dayNumber = (day: string) => Date.parse(`${day}T00:00:00Z`) / DAY;
+
+function goalItem(goal: Goal, today: number): UpcomingItem {
+ const base = { kind: "goal" as const, id: goal.id, goal };
+ if (goal.status !== "active")
+ return {
+ ...base,
+ bucket: "quiet",
+ at: 0,
+ when: goal.status === "done" ? "Done" : "Paused",
+ };
+ if (!goal.target_date)
+ return { ...base, bucket: "later", at: Infinity, when: "No target date" };
+ const target = dayNumber(goal.target_date),
+ gap = target - today,
+ label = new Date(`${goal.target_date}T12:00:00Z`).toLocaleDateString(
+ undefined,
+ gap < 7
+ ? { weekday: "short", timeZone: "UTC" }
+ : { day: "numeric", month: "short", timeZone: "UTC" },
+ );
+ if (gap < 0)
+ return {
+ ...base,
+ bucket: "now",
+ at: target * DAY,
+ when: `Overdue ${-gap} ${gap === -1 ? "day" : "days"}`,
+ };
+ if (gap === 0)
+ return { ...base, bucket: "today", at: target * DAY, when: "Due today" };
+ return {
+ ...base,
+ bucket: gap < 7 ? "week" : "later",
+ at: target * DAY,
+ when: `Target ${label}`,
+ };
+}
+
+function routineItem(routine: AgentRoutine, now: Date): UpcomingItem {
+ const error = routine.last_result?.error;
+ const failure = typeof error === "string" && error.trim() ? error : null;
+ const base = { kind: "routine" as const, id: routine.id, routine, failure };
+ if (failure)
+ return { ...base, bucket: "now", at: 0, when: "Last run failed" };
+ if (routine.paused)
+ return { ...base, bucket: "quiet", at: 0, when: "Paused" };
+ const next = new Date(routine.next_run_at),
+ zone = routine.timezone,
+ gap = dayNumber(dayIn(next, zone)) - dayNumber(dayIn(now, zone)),
+ time = next.toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ timeZone: zone,
+ });
+ if (next.getTime() <= now.getTime())
+ return {
+ ...base,
+ bucket: "now",
+ at: next.getTime(),
+ when: "Overdue",
+ };
+ if (gap === 0)
+ return {
+ ...base,
+ bucket: "today",
+ at: next.getTime(),
+ when: `Today ${time}`,
+ };
+ return {
+ ...base,
+ bucket: gap < 7 ? "week" : "later",
+ at: next.getTime(),
+ when: `${next.toLocaleDateString(undefined, gap < 7 ? { weekday: "short", timeZone: zone } : { day: "numeric", month: "short", timeZone: zone })} ${time}`,
+ };
+}
+
+/** Goals, routines and chats waiting on the person, most urgent first. */
+export function upcomingItems(
+ input: {
+ goals: readonly Goal[];
+ routines: readonly AgentRoutine[];
+ chats: readonly AiChatSummary[];
+ },
+ zone: string,
+ now = new Date(),
+): UpcomingItem[] {
+ const today = dayNumber(dayIn(now, zone));
+ const order: Record = {
+ now: 0,
+ today: 1,
+ week: 2,
+ later: 3,
+ quiet: 4,
+ };
+ return [
+ ...input.chats
+ .filter((chat) => chat.active === "needs_you")
+ .map((chat): UpcomingItem => ({
+ kind: "chat",
+ id: chat.id,
+ bucket: "now",
+ at: -1,
+ when: "Waiting on you",
+ chat,
+ })),
+ ...input.goals.map((goal) => goalItem(goal, today)),
+ ...input.routines.map((routine) => routineItem(routine, now)),
+ ].sort((a, b) => order[a.bucket] - order[b.bucket] || a.at - b.at);
+}
diff --git a/web/src/screens/assistant/state.ts b/web/src/screens/assistant/state.ts
index e05e3d88..2980cd6f 100644
--- a/web/src/screens/assistant/state.ts
+++ b/web/src/screens/assistant/state.ts
@@ -3,11 +3,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { messageOf } from "../../lib/resource";
import { useOwned } from "../agents-shared/hooks";
-/** A view-owned read. Failed permission checks remove the old authorized payload. */
+/**
+ * A view-owned read. Failed permission checks remove the old authorized payload.
+ * `retain` keeps the last payload while an explicit refresh is loading.
+ */
export function useRead(
read: () => Promise,
deps: readonly unknown[] = [],
+ options: { retain?: boolean } = {},
) {
+ const retain = options.retain === true;
const owned = useOwned();
const loader = useRef(read);
loader.current = read;
@@ -35,7 +40,7 @@ export function useRead(
}
}, [owned]);
useEffect(() => {
- setData(undefined);
+ if (!retain) setData(undefined);
void reload();
return () => {
sequence.current++;