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: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A channel a Bot has spoken in unseen shows a dot

The sidebar marks a channel when a Bot has said something since you last had it open: a dot beside
the preview, the name a touch heavier. Opening the channel clears it, your own messages never set
it, and the channel you are looking at never shows it. The marker is yours alone — per member, on
the membership row like the pin — so one person reading does not clear anybody else's dot.

The deployment gains one nullable column, via migration `0019`.


### A finished turn shows the page it opened, not the one open now

Reopening a conversation made every past turn fetch the screen as it is now, so an answer about
Expand Down
39 changes: 38 additions & 1 deletion app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Link, type LinkOptions, useNavigate } from "@tanstack/react-router";
import {
Link,
type LinkOptions,
useNavigate,
useParams,
} from "@tanstack/react-router";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type * as React from "react";
import { useState } from "react";
Expand Down Expand Up @@ -123,6 +128,30 @@ export function pinnedFirst(channels: ChannelSummary[]): ChannelSummary[] {
return [...channels].sort((a, b) => Number(b.pinned) - Number(a.pinned));
}

/**
* Whether a Bot has said something this member has not had on screen yet.
*
* A Bot's message, and only a Bot's: your own message carries a null agent id and reading your own
* words needs no marker. ISO-8601 strings compare correctly as strings, which is the same bet the
* server's recency sort already makes.
*/
export function hasUnseenActivity(channel: ChannelSummary): boolean {
if (channel.lastMessageAgentId === null || channel.lastMessageAt === null) {
return false;
}
return (
channel.lastReadAt === null || channel.lastMessageAt > channel.lastReadAt
);
}

/** Unseen activity somewhere you are not looking. The open channel never shows the dot. */
export function isUnread(
channel: ChannelSummary,
openChannelId: string | undefined,
): boolean {
return channel.id !== openChannelId && hasUnseenActivity(channel);
}

/**
* A roster row that can animate.
*
Expand All @@ -138,6 +167,13 @@ function ChannelRow({
animateOrder: boolean;
}) {
const shouldReduceMotion = useReducedMotion();
// Whether this row is unread, as a boolean, for the same reason `Channel` computes `isOpen`
// that way: navigating re-renders the rows whose answer changed, not the whole roster.
const unread = useParams({
strict: false,
select: (params) =>
isUnread(channel, (params as { channelId?: string }).channelId),
});
return (
<motion.div
animate={{ opacity: 1, transform: "translateY(0px)" }}
Expand All @@ -160,6 +196,7 @@ function ChannelRow({
: undefined
}
pinned={channel.pinned}
unread={unread}
/>
</motion.div>
);
Expand Down
12 changes: 11 additions & 1 deletion app/src/components/app-sidebar/channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ export const Channel = memo(function Channel({
lastMessage,
lastMessageAt,
pinned,
unread,
}: {
channelId: string;
participantIds: string[];
name: string;
lastMessage?: string;
lastMessageAt?: string;
pinned: boolean;
unread: boolean;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
Expand Down Expand Up @@ -112,7 +114,11 @@ export const Channel = memo(function Channel({
</div>
<div className="flex-col min-w-0 flex-1">
<div className="flex flex-row items-center justify-between gap-2">
<span className="text-[14px] tracking-[-1%] truncate">
<span
className={`text-[14px] tracking-[-1%] truncate ${
unread ? "font-medium" : ""
}`}
>
{name}
</span>
<div className="text-[12px] text-muted-foreground/70">
Expand All @@ -123,6 +129,10 @@ export const Channel = memo(function Channel({
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-muted-foreground">
{lastMessage}
</span>
{unread ? (
/* State about the message beats state about the row, so it sits first. */
<span className="size-2 shrink-0 rounded-full bg-primary" />
) : null}
{pinned ? (
<IconPinFilled className="size-3 shrink-0 text-muted-foreground/70" />
) : null}
Expand Down
56 changes: 54 additions & 2 deletions app/src/lib/channels/mutations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { mutationOptions, type QueryClient } from "@tanstack/react-query";
import {
mutationOptions,
type InfiniteData,
type QueryClient,
} from "@tanstack/react-query";
import { client, tryClient } from "@/lib/client";
import { type AgentChannel, channelKeys } from "./queries";
import { type AgentChannel, type ChannelPage, channelKeys } from "./queries";

/**
* Start a new channel with one or more coworkers.
Expand Down Expand Up @@ -66,6 +70,54 @@ export function setChannelPinnedMutationOptions(queryClient: QueryClient) {
});
}

/**
* Stamp a channel read for this member, patching the cache before the wire answers.
*
* Patched in onMutate rather than refetched on success: the dot must clear the instant the channel
* opens, not a round-trip later. No rollback on failure and no invalidation — a mark-read that did
* not land is a dot that returns on the next refetch, which is the truth reasserting itself, and a
* refetch here would race the socket's own patches for nothing.
*/
export function markChannelReadMutationOptions(queryClient: QueryClient) {
return mutationOptions({
mutationFn: async (channelId: string) => {
await client(`/api/channels/${channelId}/read`, {
method: "PUT",
fallback: "Could not mark this channel read",
});
},
onMutate: (channelId) => {
const now = new Date().toISOString();
queryClient.setQueryData(
channelKeys.list(),
(data: InfiniteData<ChannelPage> | undefined) =>
data && {
...data,
pages: data.pages.map((page) => ({
...page,
channels: page.channels.map((row) =>
row.id === channelId
? {
...row,
/*
* The later of now and the row's own lastMessageAt: lastMessageAt comes from
* another clock, and a marker stamped "now" by a clock running behind it
* would leave the row still reading as unseen — and the dot still lit.
*/
lastReadAt:
row.lastMessageAt && row.lastMessageAt > now
? row.lastMessageAt
: now,
}
: row,
),
})),
},
);
},
});
}

/** Soft-delete a channel for everyone in it. The server keeps the transcript; the roster forgets. */
export function deleteChannelMutationOptions(queryClient: QueryClient) {
return mutationOptions({
Expand Down
2 changes: 2 additions & 0 deletions app/src/lib/channels/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export type ChannelSummary = AgentChannel & {
createdAt: string;
/** Whether this member pinned the channel. Pinned channels sort first in the roster. */
pinned: boolean;
/** ISO-8601 when this member last had the channel open, or null for never. The caller's, only. */
lastReadAt: string | null;
};

export const channelKeys = {
Expand Down
44 changes: 42 additions & 2 deletions app/src/routes/_authed/_app/channel/$channelId.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import { IconDeviceDesktop, IconSettings } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { z } from "zod";
import { AgentProfile } from "@/components/agents/agent-profile";
import { hasUnseenActivity } from "@/components/app-sidebar/app-sidebar";
import { ChannelAvatar } from "@/components/channels/avatar";
import { ChannelChat } from "@/components/channels/channel-chat";
import { ActivityLog } from "@/components/computer/activity-log";
import { ComputerView } from "@/components/computer/computer-view";
import { useNeedsYou } from "@/components/computer/needs-you";
import { DetailPanel } from "@/components/layout/detail-panel";
import { Button } from "@/components/ui/button";
import { type AgentChannel, channelQueryOptions } from "@/lib/channels/queries";
import { markChannelReadMutationOptions } from "@/lib/channels/mutations";
import {
type AgentChannel,
channelListQueryOptions,
channelQueryOptions,
} from "@/lib/channels/queries";
import { onComputerActivity } from "@/lib/copilot/computer-activity";

const chatSearchSchema = z.object({
Expand Down Expand Up @@ -77,6 +88,35 @@ function RouteComponent() {
/** Only polled while the screen is closed; the screen panel polls control itself. */
const needsYou = useNeedsYou(agentId, !isWatching);

const queryClient = useQueryClient();
const markRead = useMutation(markChannelReadMutationOptions(queryClient));
/*
* This channel's roster summary, read out of the same infinite query the sidebar renders.
* The detail query deliberately knows nothing about activity; the roster is where the socket
* keeps lastMessageAt live, so it is the one honest source for "has something new been said".
*/
const roster = useInfiniteQuery(channelListQueryOptions());
const summary = roster.data?.find((row) => row.id === channelId);

/*
* Opening the channel marks it read; the Bot replying while it is open marks it read again.
* One effect covers both: the dep changes on navigation and on every activity patch, and the
* unseen check keeps it from writing a row per render. No dependency on the mutation object —
* its identity changes per render and the effect must not re-fire for that.
*
* Keyed on primitives, deliberately. The optimistic mark-read patch changes the summary OBJECT's
* identity without changing these values, so an object dep would re-fire the effect on its own
* write — and when lastMessageAt sits ahead of this browser's clock (another device wrote it),
* that re-fire loops into a PUT per render. Primitives hold still under the patch: one PUT.
*/
const unseen = summary !== undefined && hasUnseenActivity(summary);
const markReadMutate = markRead.mutate;
useEffect(() => {
if (unseen) {
markReadMutate(channelId);
}
}, [channelId, unseen, markReadMutate]);

/*
* Needs-you prompts auto-open the screen panel, because the prompt with the reason on it — the
* amber "the assistant needs you" row, and the masked field for a credential — is drawn on the
Expand Down
6 changes: 3 additions & 3 deletions app/src/routes/_authed/admin/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ function RouteComponent() {
return (
<SidebarProvider
/*
* The same 340px the app and Settings use. A rail that changes width as you cross into admin
* makes the whole frame look like it moved.
* The same 300px Settings uses: these two rails hold short nav labels, not the roster's
* two-line previews, so they earn less width than the app shell's 340px.
*/
style={
{
"--sidebar-width": "340px",
"--sidebar-width": "300px",
"--sidebar-width-mobile": "20rem",
} as React.CSSProperties
}
Expand Down
6 changes: 3 additions & 3 deletions app/src/routes/_authed/settings/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ function RouteComponent() {
return (
<SidebarProvider
/*
* The same 340px the app shell uses. Settings is a different screen, not a different product,
* and a rail that changes width on the way in makes the whole frame look like it moved.
* The same 300px admin uses: these two rails hold short nav labels, not the roster's
* two-line previews, so they earn less width than the app shell's 340px.
*/
style={
{
"--sidebar-width": "340px",
"--sidebar-width": "300px",
"--sidebar-width-mobile": "20rem",
} as React.CSSProperties
}
Expand Down
Loading
Loading