diff --git a/docs/design.md b/docs/design.md index 9b4f721f..c4ecb70b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -40,9 +40,11 @@ headings stay neutral on hover; expansion, selection and focus remain distinct. raised cards; preserve unsaved guards, deep links and return paths. At 320×740, the chrome must leave useful space for the setting being changed. - Center the empty Chat/Work composer in its remaining pane, then dock it at the - bottom once conversation begins. Both start compact and grow to roughly four - text lines before internal scrolling. Keep attachments, model and send controls - outside that scroll region; respect enlarged text and short windows. + bottom once conversation begins. The Assistant keeps its scope picker centered + while the composer stays docked, so choosing a scope never moves the input. Both + composers start compact and grow to roughly four text lines before internal + scrolling. Keep attachments, model and send controls outside that scroll + region; respect enlarged text and short windows. - Work chooses a project in its conversation workspace. Preserve saved history, project visibility, pinning and existing sweep/pinned exemptions. Paired panes retain independent drafts and scroll state. Switch panes at narrow widths. @@ -114,7 +116,7 @@ identify personal/team scope. Compact Study metrics stay paired. New desktop sessions start with labelled navigation at 224px, preserving remembered widths (including an explicitly selected icon rail). Navigation fits the available window without overwriting the remembered width. Phone task checkboxes and create controls retain 44px targets. Assistant -starter prompts wrap in two columns and multiline composers stay bounded. +starter prompts wrap as compact pills above the composer and scroll horizontally on phones; multiline composers stay bounded. Browser inspection, source/component tests, native exports and installed-device evidence remain separate. The [redesign work record](work/ui-ux-polish.md) records diff --git a/web/src/screens/assistant/Activity.tsx b/web/src/screens/assistant/Activity.tsx new file mode 100644 index 00000000..1d9b8ec8 --- /dev/null +++ b/web/src/screens/assistant/Activity.tsx @@ -0,0 +1,133 @@ +import type { ChatTraceEntry } from "@orbyn/core"; +import { ChevronDown, ChevronRight, LoaderCircle } from "lucide-react"; +import { useEffect, useLayoutEffect, useState } from "react"; +import { traceWords } from "../agents-shared/words"; + +export function duration(ms: number) { + const secs = Math.max(1, Math.round(ms / 1000)); + return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`; +} + +/** Recorded steps of a finished turn, folded under how long it worked. */ +export function TurnTrace({ + trace, + status, +}: { + trace: readonly ChatTraceEntry[]; + status?: string; +}) { + const [open, setOpen] = useState(false); + const lines = traceWords([...trace]); + if (!lines.length && !status) return null; + const times = trace + .map((entry) => Date.parse(entry.at)) + .filter(Number.isFinite); + const span = + times.length > 1 ? Math.max(...times) - Math.min(...times) : null; + const label = [ + span !== null ? `Worked for ${duration(span)}` : "Steps", + status, + ] + .filter(Boolean) + .join(" · "); + return ( +
+ {lines.length > 0 ? ( + + ) : ( + {label} + )} + {open && ( +
    + {lines.map((line, index) => ( +
  1. {line}
  2. + ))} +
+ )} +
+ ); +} + +/** The run in progress: elapsed time and each progress label as it arrives. */ +export function LiveWork({ + started, + steps, + waiting, +}: { + started: number; + steps: string[]; + waiting: boolean; +}) { + const [now, setNow] = useState(Date.now); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + const latest = steps[steps.length - 1]; + return ( +
+ + + + {waiting ? "Waiting for you" : (latest ?? "Working")} + + {steps.length > 0 && ( +
    + {steps.map((step, index) => ( +
  1. + {step} +
  2. + ))} +
+ )} +
+ ); +} + +const still = () => + typeof matchMedia === "function" && + matchMedia("(prefers-reduced-motion: reduce)").matches; + +/** Shows a just-arrived answer a few words at a time; history renders whole. */ +export function useReveal(text: string | null) { + const [shown, setShown] = useState(0); + const words = text?.match(/\S+\s*/g) ?? []; + const total = words.length; + useLayoutEffect(() => { + setShown(0); + if (!text || still()) { + setShown(Number.MAX_SAFE_INTEGER); + return; + } + // Long answers speed up so none takes much more than five seconds. + const step = Math.max(1, Math.ceil(total / 140)); + const timer = setInterval( + () => + setShown((value) => { + if (value + step >= total) clearInterval(timer); + return value + step; + }), + 35, + ); + return () => clearInterval(timer); + }, [text, total]); + return { + done: !text || shown >= total, + text: text && shown < total ? words.slice(0, shown).join("") : text, + }; +} diff --git a/web/src/screens/assistant/ChatRail.tsx b/web/src/screens/assistant/ChatRail.tsx new file mode 100644 index 00000000..4983dfbc --- /dev/null +++ b/web/src/screens/assistant/ChatRail.tsx @@ -0,0 +1,116 @@ +import { groupAssistantChats, type AiChatSummary } from "@orbyn/core"; +import { X } from "lucide-react"; +import { useEffect } from "react"; +import { client } from "../../lib/session"; +import { Button, ErrorNotice, IconButton } from "../../ui/kit"; +import { useOwned, onForeground } from "../agents-shared/hooks"; +import { useRead } from "./state"; + +/** Compact saved-chat list beside the conversation; managing chats stays in ChatList. */ +export function ChatRail({ + personal, + active, + disabled, + onOpen, + onManage, + onHide, +}: { + personal: string | null; + active: string | null; + disabled: boolean; + onOpen: (id: string) => void; + onManage: () => void; + onHide: () => void; +}) { + const owned = useOwned(); + const read = useRead( + () => client.aiChats({ search: "", kind: "all", limit: 50 }), + [active], + { retain: true }, + ); + useEffect( + () => + onForeground(() => { + if (owned()) void read.reload(); + }), + [owned, read.reload], + ); + const group = groupAssistantChats(read.data ?? [], personal); + const row = (chat: AiChatSummary) => ( +
  • + +
  • + ); + const section = (title: string, chats: AiChatSummary[], key = title) => + chats.length > 0 && ( +
    +

    {title}

    + +
    + ); + return ( + + ); +} diff --git a/web/src/screens/assistant/Transcript.tsx b/web/src/screens/assistant/Transcript.tsx index db4c02a2..6cbad552 100644 --- a/web/src/screens/assistant/Transcript.tsx +++ b/web/src/screens/assistant/Transcript.tsx @@ -4,32 +4,57 @@ import { assistantExcerptMatches, type AssistantConversationEntry, } from "@orbyn/core"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { Button, ErrorNotice } from "../../ui/kit"; +import { Bot, Check, Copy, Info, Reply } from "lucide-react"; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import { Button, ErrorNotice, IconButton } from "../../ui/kit"; import { useOwned } from "../agents-shared/hooks"; -import { Evidence, More } from "../agents-shared/parts"; +import { Evidence } from "../agents-shared/parts"; import { ProposalReview, useProposalLinks, } from "../agents-shared/ProposalReview"; import { ReplyText } from "../agents-shared/ReplyText"; -import { replyWords, traceWords, updateWords } from "../agents-shared/words"; +import { replyWords, updateWords } from "../agents-shared/words"; +import { LiveWork, TurnTrace, useReveal } from "./Activity"; import { MessageDetails } from "./History"; import { Reminder } from "./Reminder"; import { TurnChanges } from "./TurnChanges"; import type { useAssistant } from "./useAssistant"; type Assistant = ReturnType; + +function latestAnswer(display: Assistant["display"]) { + for (let index = display.length - 1; index >= 0; index--) { + const item = display[index]; + if ( + item.kind === "turn" && + item.turn.role === "assistant" && + !item.turn.proposal && + item.turn.text + ) + return item; + } + return null; +} + export function Transcript({ assistant, timezone, onOpenChat, covered, + emptyState, }: { assistant: Assistant; timezone: string; onOpenChat: (id: string) => void; covered: boolean; + emptyState: ReactNode; }) { const inputAllowed = useRouteInput(), activity = useRouteActivity(); @@ -47,12 +72,45 @@ export function Transcript({ const [copyDone, setCopyDone] = useState(null); const entries = assistant.conversation.entries; const currentDetails = entries.find((entry) => entry.id === details); + const [work, setWork] = useState<{ started: number; steps: string[] }>({ + started: 0, + steps: [], + }), + [fresh, setFresh] = useState<{ id: string; text: string } | null>(null), + wasSending = useRef(false), + previousAnswer = useRef(null); + const reveal = useReveal(fresh?.text ?? null); useEffect(() => { setDetails(null); setSelection(null); setReading(false); + setFresh(null); + previousAnswer.current = null; follow.current = true; }, [assistant.chat?.id]); + useEffect(() => { + if (assistant.sending && !wasSending.current) { + // Remember the answer that was already on screen, so a failed send + // cannot replay it when `sending` falls back to false. + previousAnswer.current = latestAnswer(assistant.display)?.id ?? null; + setWork({ started: Date.now(), steps: [] }); + } + if (!assistant.sending && wasSending.current) { + const last = latestAnswer(assistant.display); + if (last && last.id !== previousAnswer.current) + setFresh({ id: last.id, text: last.turn.text }); + } + wasSending.current = assistant.sending; + }, [assistant.sending, assistant.display]); + useEffect(() => { + const label = assistant.progress?.label?.trim(); + if (!label) return; + setWork((old) => + old.steps[old.steps.length - 1] === label + ? old + : { ...old, steps: [...old.steps, label] }, + ); + }, [assistant.progress?.label]); useLayoutEffect(() => { const el = node.current; if (!activity.visible || !el) return; @@ -61,7 +119,13 @@ export function Transcript({ prepend.current.top + el.scrollHeight - prepend.current.height; prepend.current = null; } else if (follow.current) el.scrollTop = el.scrollHeight; - }, [assistant.display, assistant.progress, activity.visible]); + }, [ + assistant.display, + assistant.progress, + activity.visible, + reveal.text, + work.steps.length, + ]); useEffect(() => { const el = node.current; if ( @@ -247,27 +311,7 @@ export function Transcript({ Earlier messages )} - {!assistant.display.length && !assistant.loading && ( -
    -

    What would you like to work on?

    -

    Ask a question or draft work to review.

    -
    - {[ - "What needs my attention today?", - "Help me plan this week", - "What should I study next?", - ].map((text) => ( - - ))} -
    -
    - )} + {!assistant.display.length && !assistant.loading && emptyState} {assistant.display.map((item, index) => { const entry = entries.find((one) => one.id === item.id), date = entry?.created_at, @@ -276,135 +320,179 @@ export function Transcript({ const turn = item.kind === "turn" ? item.turn : null, text = item.kind === "update" ? item.entry.text : (turn?.text ?? ""); + if (turn?.role === "user") + return ( +
    + {label &&

    {label}

    } +
    + +
    +
    + ); + const status = turn + ? replyWords({ + state: turn.state ?? "info", + trace: turn.trace ?? [], + executionOutcome: + turn.executionOutcome ?? turn.saved?.execution_outcome, + execution: turn.execution, + }) + : undefined; + const revealing = + !!turn && !turn.proposal && !reveal.done && item.id === fresh?.id; return ( -
    +
    {label &&

    {label}

    }
    -
    - - {item.kind === "update" - ? updateWords(item.entry) - : turn?.role === "user" - ? "You" - : "Assistant"} - - {turn?.role === "assistant" && ( - - {replyWords({ - state: turn.state ?? "info", - trace: turn.trace ?? [], - executionOutcome: - turn.executionOutcome ?? - turn.saved?.execution_outcome, - execution: turn.execution, - })} - + +
    + {item.kind === "update" && ( +

    + {updateWords(item.entry)} +

    )} -
    - {turn?.proposal ? ( - - assistant.decide(turn, true, deadlines) - } - onDismiss={() => assistant.decide(turn, false)} - onFollowUp={ - index === assistant.display.length - 1 - ? (text) => assistant.send(text) - : undefined - } - {...links} - /> - ) : ( - - )} - {(turn?.executionOutcome ?? turn?.saved?.execution_outcome) && ( - - )} - {turn?.saved?.nudge && assistant.chat && turn.turnId && ( - - )} - {(turn?.changesJob ?? turn?.saved?.changes_job) && ( - - )} - {!!turn?.trace?.length && ( - -
      - {turn.trace.map((trace, i) => ( -
    1. - {traceWords([trace]).join(" · ")} ·{" "} - {new Date(trace.at).toLocaleTimeString()} -
    2. - ))} -
    -
    - )} - {item.kind === "update" && item.entry.execution_chat_id && ( - - )} -
    - - {entry && ( - <> - - + )} + {!revealing && ( +
    + void copy(item.id, text)} > - Details - - + {copyDone === item.id ? ( + + {entry && ( + <> + assistant.setReply({ id: entry.id })} + > + + setDetails(entry.id)} + > + + + )} +
    )}
    ); })} + {assistant.sending && work.started > 0 && ( +
    +
    + +
    + +
    +
    +
    + )}
    {selection && (
    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 ( + + ); + if (item.kind === "goal") + return item.goal.status === "active" ? ( + + ) : null; + if (item.failure) + return ( + + ); + return ( + + ); + }; + 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" ? ( + <> + + + + {item.goal.plan_doc_id && ( + + )} + + + ) : ( + <> + {item.failure && ( + + )} + + + + + ) + } + + ); + }; + 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

    - - -
    - {goals.loading && } - {goals.error && ( - void goals.reload()} - /> - )} - {!goals.loading && goals.data?.length === 0 &&

    No goals yet.

    } -
      - {goals.data?.map((goal) => ( -
    • -
      - {goal.title} - {goal.status} -
      -

      {goal.target}

      -

      - {goal.project_name ?? "Personal"} - {goal.target_date ? ` · Target ${goal.target_date}` : ""} -

      - {goal.plan_doc_id && ( + ) : ( + <> + ( )} -
      - - - - -
      -
    • - ))} -
    -
    -
    -
    -

    Routines

    - - -
    - {prefs.error && ( - void prefs.reload()} - /> + > + {(closeMenu) => ( + <> + + + + )} + + + )} - {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.

      -
      - - - - -
      -
    • - ))} -
    - - - {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] && ( - + {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 ( +
    • + + + { + 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.", + }, + ); + }} + > + + + {value === "scoped" && ( + + )} + +
    • + ); + })} +
    )} + +
    +

    Admission controls

    + + +
    + + ) : ( + <> +
    + + +
    - ))} - - - - - - {projects.error && ( - void projects.reload()} - /> - )} - {docs.error && ( - void docs.reload()} /> - )} +
    + {FILTERS.map(([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}` : ""} + +

    +
      {rows.map(row)}
    +
    + ) + ); + })} + {quiet.length > 0 && ( + +
      {quiet.map(row)}
    +
    + )} + + )} +
    {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?.kind === "task" && ( + + + )} + {projects.data?.map((project) => ( + + ))} +
    +

    + {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"} -

    -
    -
    - - + {rail ? ( +
    -
    -
    - - scopedProject(e.target.value)} + - - {projects.data?.map((project) => ( - - ))} - - - {assistant.scope?.kind === "task" && ( - - )} - -
    - - - - - {assistant.canonicalEnabled && - assistant.chat?.has_legacy_history && - assistant.chat.legacy_history_destination_id && ( - - )} -
    +