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
10 changes: 6 additions & 4 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions web/src/screens/assistant/Activity.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="assistant-trace">
{lines.length > 0 ? (
<button
type="button"
className="assistant-trace-toggle"
aria-expanded={open}
onClick={() => setOpen(!open)}
>
{open ? (
<ChevronDown aria-hidden="true" />
) : (
<ChevronRight aria-hidden="true" />
)}
{label}
</button>
) : (
<span className="assistant-trace-toggle">{label}</span>
)}
{open && (
<ol className="assistant-trace-steps">
{lines.map((line, index) => (
<li key={index}>{line}</li>
))}
</ol>
)}
</div>
);
}

/** 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 (
<div className="assistant-trace">
<span className="assistant-trace-toggle">
<LoaderCircle className="assistant-spin" aria-hidden="true" />
{waiting ? "Waiting for you" : "Working"} ·{" "}
{duration(Math.max(1000, now - started))}
</span>
<span className="sr-only" role="status" aria-live="polite">
{waiting ? "Waiting for you" : (latest ?? "Working")}
</span>
{steps.length > 0 && (
<ol className="assistant-trace-steps" aria-live="off">
{steps.map((step, index) => (
<li key={`${index}:${step}`} className="assistant-in">
{step}
</li>
))}
</ol>
)}
</div>
);
}

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,
};
}
116 changes: 116 additions & 0 deletions web/src/screens/assistant/ChatRail.tsx
Original file line number Diff line number Diff line change
@@ -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],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useRead clears its data and sets loading whenever a dependency changes. Passing [active] means every chat switch blanks the rail and refetches up to 50 chats even though only the highlight changed. Remove active from the dependencies, or add a retaining reload mode so the rail stays visible while it refreshes.

{ retain: true },
);
useEffect(
() =>
onForeground(() => {
if (owned()) void read.reload();
}),
[owned, read.reload],
);
const group = groupAssistantChats(read.data ?? [], personal);
const row = (chat: AiChatSummary) => (
<li key={chat.id}>
<button
type="button"
className="assistant-rail-row"
aria-current={active === chat.id ? "page" : undefined}
disabled={disabled}
onClick={() => owned() && onOpen(chat.id)}
>
<span dir="auto">{chat.title || "Untitled chat"}</span>
{chat.active && (
<span
className={`assistant-rail-dot${chat.active === "needs_you" ? " is-warn" : ""}`}
title={chat.active === "needs_you" ? "Needs you" : "Working"}
>
<span className="sr-only">
{chat.active === "needs_you" ? "Needs you" : "Working"}
</span>
</span>
)}
</button>
</li>
);
const section = (title: string, chats: AiChatSummary[], key = title) =>
chats.length > 0 && (
<section key={key} className="assistant-rail-group">
<h2 dir="auto">{title}</h2>
<ul>{chats.map(row)}</ul>
</section>
);
return (
<nav className="assistant-rail" aria-label="Saved chats">
<div className="assistant-rail-head">
<span>Chats</span>
<Button variant="ghost" onClick={onManage}>
Search and manage
</Button>
<IconButton
label="Hide saved chats"
className="assistant-rail-close"
onClick={onHide}
>
<X aria-hidden="true" />
</IconButton>
</div>
{read.error && (
<ErrorNotice message={read.error} onRetry={() => void read.reload()} />
)}
<div className="assistant-rail-list">
{personal && (
<ul>
<li>
<button
type="button"
className="assistant-rail-row"
aria-current={active === personal ? "page" : undefined}
disabled={disabled}
onClick={() => owned() && onOpen(personal)}
>
<span>Personal conversation</span>
</button>
</li>
</ul>
)}
{section("Pinned", group.pinned)}
{group.projects.map((project) =>
section(project.name, project.chats, project.id),
)}
{section("Recent", group.recent)}
{read.loading && !read.data && (
<p className="small muted" role="status">
Loading chats…
</p>
)}
</div>
</nav>
);
}
Loading
Loading