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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
body_hash: b0a39d06b7ed2900
raw_hash: 2b2e4ccf7e3ee6f2
vouched: 2026-10-22T20:29:47Z
body_hash: 543e14e26111c21c
raw_hash: 36221fa4f08e0523
vouched: 2026-10-22T20:42:40Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-22T20:29:16Z
norm: '1'
sig: 063d0f00fea02511
body_hash: b0a39d06b7ed2900
raw_hash: 2b2e4ccf7e3ee6f2
vouched: 2026-10-22T20:29:47Z
body_hash: 543e14e26111c21c
raw_hash: 36221fa4f08e0523
vouched: 2026-10-22T20:42:40Z
lines: 330-1237
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function useAttention
created: 2026-10-22T15:40:01Z
norm: '1'
sig: 764bb872be391a4f
body_hash: eaaf5ba9249c1195
raw_hash: 9ac21a4b149b612a
vouched: 2026-10-22T20:27:44Z
body_hash: e8f4fd56e6b15297
raw_hash: 3466b3d2a2a88367
vouched: 2026-10-22T20:42:40Z
lines: 100-195
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-22T15:40:05Z
norm: '1'
sig: 9d222e7bbdcbbb9b
body_hash: 07a9546c62fc409f
raw_hash: e6e88b5690c07577
vouched: 2026-10-22T20:27:44Z
body_hash: fba094c5942bd35d
raw_hash: 1ba221762e725614
vouched: 2026-10-22T20:42:40Z
lines: 1-195
supersedes: 01M4XQJSWS1YHDS9TRD27C41WG
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-16T23:05:41Z
norm: '1'
sig: ce515648b5bedef2
body_hash: fb0394625d6bac4e
raw_hash: 1a90d920d15ad713
body_hash: a984910f8f8a9cdc
raw_hash: c0f95564f75550a7
vouched: 2026-10-22T20:42:40Z
lines: 1-912
---

Expand Down
3 changes: 2 additions & 1 deletion apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ export function App() {
];
const github = people[hello.user];
const names = useMemo(() => github ? [github.toLowerCase()] : [], [github]);
const { attention, pulse } = useAttention(
const { attention, pulse, seen } = useAttention(
channels,
hello.user,
names,
Expand Down Expand Up @@ -1054,6 +1054,7 @@ export function App() {
channels={channels}
user={hello.user}
remote={channel.host !== hello.host}
seen={seen(channel)}
draft={draft}
onDraftLoaded={() => setDraft(undefined)}
onProviders={() => setSettings("providers")}
Expand Down
5 changes: 4 additions & 1 deletion apps/app/src/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,5 +204,8 @@ export function useAttention(
return found;
}, [channels, user, names, state]);

return { attention, pulse };
/** When this person last saw a channel; undefined before this profile's first visit. */
const seen = (channel: Listing) => state ? seenAt(channel, state) : undefined;

return { attention, pulse, seen };
}
7 changes: 6 additions & 1 deletion apps/app/src/channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ type Props = {
channels: Listing[];
user: string;
remote: boolean;
/** When this person last saw the channel, before opening it now. */
seen?: number;
draft?: ChannelDraft;
onDraftLoaded: () => void;
onProviders?: () => void;
Expand Down Expand Up @@ -301,9 +303,11 @@ export function ChannelDetails({ channel, user }: { channel: Listing; user: stri

/** A channel's tabs: its chat, plus Diff and Terminal views of that chat's lane. */
export function Channel(
{ channel, channels, user, remote, draft, onDraftLoaded, onProviders, onOpen }: Props,
{ channel, channels, user, remote, seen, draft, onDraftLoaded, onProviders, onOpen }: Props,
) {
const place = placement(channel);
// Taken as the channel opens: opening it marks everything seen, and later messages arrive in view.
const [unread] = useState(() => ({ since: seen, until: channel.said?.at }));
const [changed, setChanged] = useState<boolean>();
const check = useRef({ busy: false, again: false });

Expand Down Expand Up @@ -367,6 +371,7 @@ export function Channel(
channels={channels}
chat={chat}
user={user}
unread={unread}
draft={draft?.channel === channel.id ? draft.text : undefined}
onDraftLoaded={onDraftLoaded}
onProviders={!remote && !channel.hosted ? onProviders : undefined}
Expand Down
40 changes: 39 additions & 1 deletion apps/app/src/conversation.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";

import { MENTION } from "@ace/channel/protocol";
import { Button, ChatStatus, Timeline, toast } from "@ace/ui";
import { channelProject, type Listing } from "@ace/host/protocol";

Expand All @@ -26,6 +27,8 @@ type Props = {
channels: Listing[];
chat: number;
user: string;
/** Others' messages after `since` and up to `until` arrived while this person was away. */
unread?: { since?: number; until?: number };
draft?: string;
onDraftLoaded: () => void;
onProviders?: () => void;
Expand All @@ -36,7 +39,18 @@ type Props = {

/** One chat's timeline and composer over its transcript; the content of a Chat tab. */
export function Conversation(
{ channel, channels, chat: id, user, draft, onDraftLoaded, onProviders, onOpen, onWork }: Props,
{
channel,
channels,
chat: id,
user,
unread,
draft,
onDraftLoaded,
onProviders,
onOpen,
onWork,
}: Props,
) {
const offline = channel.state === "offline";
const placement = channel.hosted || channel.host;
Expand Down Expand Up @@ -116,6 +130,28 @@ export function Conversation(
() => new Map(transcript.items.map((item) => [item.key, item])),
[transcript.items],
);
const { since, until = 0 } = unread || {};
const first = since === undefined
? undefined
: transcript.items.find((item) =>
item.kind === "message" && item.author !== user && item.at > since && item.at <= until
)?.key;
// Mentions name people by GitHub login, as the channel's host counts them.
const self = people[user]?.toLowerCase();
const mentioned = useMemo(
() =>
new Set(
self
? transcript.items.flatMap((item) =>
item.kind === "message" && item.author !== user && !item.deleted
&& [...item.text.matchAll(MENTION)].some((match) => match[1]!.toLowerCase() === self)
? [item.key]
: []
)
: [],
),
[transcript.items, user, self],
);
// Reactions and deletions target stored entries; the streamed draft and tool-only replies have none.
const actions = (uid: string) => {
const item = byKey.get(uid);
Expand Down Expand Up @@ -219,6 +255,8 @@ export function Conversation(
events={events}
working={transcript.busy || undefined}
currentUser={{ login: user, own: [user] }}
unread={first}
mentioned={mentioned}
actions={actions}
onReact={(uid, emoji) => {
const entry = byKey.get(uid)!.entry!;
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/components/timeline/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ async function parseBlockMap(
};

for (let item of timeline) {
if (item.kind === "day") continue;
if (item.kind === "day" || item.kind === "new") continue;
if (item.kind === "event") {
jobs.push(convert(item.row.chunks, item.row.uid, eventTextCompact(item.row.chunks)));
continue;
Expand Down Expand Up @@ -666,7 +666,7 @@ function useBlockMap(
};

for (let item of timeline) {
if (item.kind === "day") continue;
if (item.kind === "day" || item.kind === "new") continue;
if (item.kind === "event") {
map.set(
item.row.uid,
Expand Down Expand Up @@ -742,7 +742,7 @@ function itemHeight(
open: Set<string> = new Set(),
openWorking: Set<string> = new Set(),
): number {
if (item.kind === "day") return DAY_HEIGHT;
if (item.kind === "day" || item.kind === "new") return DAY_HEIGHT;
if (item.kind === "event") {
let b = blocks.get(item.row.uid);
let content = width - PAD * 2 - AVATAR - AVATAR_GAP;
Expand Down
52 changes: 48 additions & 4 deletions packages/ui/src/components/timeline/timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,7 @@ type GroupProps = {
editing?: string;
unsent?: ReadonlyMap<string, Unsent>;
editable?: ReadonlySet<string>;
mentioned?: ReadonlySet<string>;
openGroups: Set<string>;
openWorking: Set<string>;
settlingWorking: Set<string>;
Expand Down Expand Up @@ -960,8 +961,18 @@ function GroupTurn({ props, state, toolbar }: GroupRender & { toolbar: RowToolba
}

function GroupRows({ props, state, toolbar }: GroupRender & { toolbar: RowToolbar }) {
let { group, blocks, editing, unsent, target, onRowEnter, currentUser, onReact, onOpenChange } =
props;
let {
group,
blocks,
editing,
unsent,
mentioned,
target,
onRowEnter,
currentUser,
onReact,
onOpenChange,
} = props;
let { content, mine } = state;

let isUser = group.role === "user";
Expand Down Expand Up @@ -1032,9 +1043,15 @@ function GroupRows({ props, state, toolbar }: GroupRender & { toolbar: RowToolba
background: isBubble
? target === uid && isUser
? "color-mix(in oklch, var(--accent) 75%, transparent)"
: mentioned?.has(uid)
? "color-mix(in oklch, var(--accent) 25%, var(--muted))"
: "var(--muted)"
: "transparent",
boxShadow: tone ? `inset 3px 0 0 ${tone}` : undefined,
boxShadow: tone
? `inset 3px 0 0 ${tone}`
: mentioned?.has(uid)
? "inset 3px 0 0 var(--accent)"
: undefined,
// Outlines never affect measured layout.
outline: state === "failed" ? "1px solid var(--destructive)" : undefined,
padding: isBubble ? `${BUBBLE_PAD_Y}px ${BUBBLE_PAD_X}px` : 0,
Expand Down Expand Up @@ -1113,6 +1130,10 @@ type TimelineProps =
onReact?: (uid: string, emoji: string) => void;
/** Message UID to scroll into view. */
target?: string;
/** The first message the viewer hasn't seen, which a New divider sits above. */
unread?: string;
/** Messages that mention the viewer, which are highlighted. */
mentioned?: ReadonlySet<string>;
/** Called when the user clicks a rendered message link. */
onLink?: (href: string) => void;
/** How to open a sender whose name links somewhere, such as the channel that sent it. */
Expand Down Expand Up @@ -1163,9 +1184,11 @@ function useTimeline(
alignment = "left",
editing,
unsent,
mentioned,
editable,
onReact,
target,
unread,
onLink,
openSender,
onDocumentOpen,
Expand Down Expand Up @@ -1202,7 +1225,12 @@ function useTimeline(
let seenFinalWorking = useRef<Set<string> | null>(null);
let point = useRef<{ x: number; y: number } | null>(null);
let suppress = useRef(false);
let timeline = useMemo(() => compile(events, working, project), [events, working, project]);
let timeline = useMemo(() => compile(events, working, project, unread), [
events,
working,
project,
unread,
]);
let finalWorkingKey = useMemo(() => finalWorkingIds(timeline).join("\0"), [timeline]);
let visibleWorking = useMemo(() => {
let next = new Set(openWorking);
Expand Down Expand Up @@ -1524,6 +1552,7 @@ function useTimeline(
target,
editing,
unsent,
mentioned,
editable,
openWorking,
settlingWorking,
Expand Down Expand Up @@ -1573,6 +1602,7 @@ function Timeline(input: TimelineProps) {
target,
editing,
unsent,
mentioned,
editable,
openWorking,
settlingWorking,
Expand Down Expand Up @@ -1636,6 +1666,19 @@ function Timeline(input: TimelineProps) {
</div>
);
}
if (item.kind === "new") {
return (
<div
role="separator"
aria-label="New messages"
className="utils:max-width flex items-center gap-2 text-[0.6875rem] leading-4 font-medium"
style={{ paddingInline: PAD, blockSize: DAY_HEIGHT }}
>
<div className="h-px min-w-2 flex-1 bg-accent" />
<span className="rounded-full bg-accent px-1.5 text-accent-foreground">New</span>
</div>
);
}
if (item.kind === "event") {
let content = lane - PAD * 2 - AVATAR - AVATAR_GAP;
return (
Expand Down Expand Up @@ -1675,6 +1718,7 @@ function Timeline(input: TimelineProps) {
editing={editing}
unsent={unsent}
editable={editable}
mentioned={mentioned}
openGroups={openGroups}
openWorking={openWorking}
settlingWorking={settlingWorking}
Expand Down
10 changes: 9 additions & 1 deletion packages/ui/src/lib/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,9 @@ type AgentTurn = {
type TimelineItem =
| { kind: "group"; group: Group; turn?: AgentTurn }
| { kind: "event"; row: Row }
| { kind: "day"; ts: number };
| { kind: "day"; ts: number }
/** Where messages the viewer hasn't seen start. */
| { kind: "new" };

// -- Constants --

Expand Down Expand Up @@ -1084,6 +1086,7 @@ function compile(
events: Event[],
working: TimelineWorking = false,
project?: REPO,
unread?: string,
): TimelineItem[] {
let items: TimelineItem[] = [];
let current: { group: Group; ts: number } | null = null;
Expand Down Expand Up @@ -1118,6 +1121,11 @@ function compile(
lastDay = day;
}

if (row.uid === unread) {
flush(false);
items.push({ kind: "new" });
}

// System events always break the current group and stand alone.
if (row.role === "system") {
flush(false);
Expand Down