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
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Archive, ArchiveRestore, LockKeyhole } from "lucide-react";
import { Archive, ArchiveRestore } from "lucide-react";
import { Link } from "react-router";

import {
Expand All @@ -9,14 +9,14 @@ import {
slackLocationLabel,
visualStatusForConversation,
} from "../format";
import { ActiveIndicator } from "../components/ActiveIndicator";
import { EmptyTelemetry } from "../components/EmptyTelemetry";
import { Skeleton } from "../components/Skeleton";
import { cn } from "../styles";
import type { Conversation } from "../types";
import { ConversationSidebarAnnotations } from "./ConversationMeta";
import { formatConversationActivityPreview } from "./conversationActivityPreview";
import { ConversationArchiveNotices } from "./ConversationArchiveNotices";
import { ConversationListStatusIcon } from "./ConversationListStatusIcon";
import { conversationPath } from "./conversationRoutes";
import { useArchiveConversation } from "./queries";
import {
Expand All @@ -28,6 +28,7 @@ import {
export function ConversationHomeList(props: {
conversations: Conversation[];
emptyLabel?: string;
finishedConversationIds: ReadonlySet<string>;
loading?: boolean;
timeZone: string;
}) {
Expand Down Expand Up @@ -85,6 +86,7 @@ export function ConversationHomeList(props: {
<div aria-label="Your conversations" className="grid gap-5" role="list">
{sections.map((section) => (
<ConversationCardSection
finishedConversationIds={props.finishedConversationIds}
key={section.key}
onArchiveError={handleArchiveError}
onArchived={handleArchived}
Expand Down Expand Up @@ -159,6 +161,7 @@ function ConversationCardLoading(props: { index: number }) {
}

function ConversationCardSection(props: {
finishedConversationIds: ReadonlySet<string>;
onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
onArchived(conversation: Conversation): void;
section: ConversationSection;
Expand All @@ -175,6 +178,9 @@ function ConversationCardSection(props: {
{props.section.conversations.map((conversation) => (
<ConversationCard
conversation={conversation}
finishedSinceSeen={props.finishedConversationIds.has(
conversation.id,
)}
key={conversation.id}
onArchiveError={props.onArchiveError}
onArchived={props.onArchived}
Expand All @@ -187,6 +193,7 @@ function ConversationCardSection(props: {

function ConversationCard(props: {
conversation: Conversation;
finishedSinceSeen: boolean;
onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
onArchived(conversation: Conversation): void;
}) {
Expand Down Expand Up @@ -216,24 +223,11 @@ function ConversationCard(props: {
<div className="relative z-[1] grid min-w-0 grid-cols-[minmax(0,1fr)_max-content] items-start gap-3 pointer-events-none">
<div className="flex min-w-0 items-start gap-2.5">
<span className="mt-1.5 grid size-3 shrink-0 place-items-center">
{isPrivate ? (
<LockKeyhole
aria-label="Private conversation"
className="size-3 text-dashboard-text-muted"
/>
) : status === "active" ? (
<ActiveIndicator className="size-1.5" />
) : (
<span
aria-hidden="true"
className={cn(
"size-1.5 rounded-full",
status === "failed"
? "bg-rose-300"
: "bg-dashboard-text-muted/40",
)}
/>
)}
<ConversationListStatusIcon
finishedSinceSeen={props.finishedSinceSeen}
isPrivate={isPrivate}
status={status}
/>
</span>
<h4 className="m-0 truncate font-display text-base font-medium leading-snug text-dashboard-text">
{title}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { LockKeyhole } from "lucide-react";

import { ActiveIndicator } from "../components/ActiveIndicator";
import { cn } from "../styles";
import type { VisualStatus } from "../types";

/** Render a conversation status marker, with new completions taking priority. */
export function ConversationListStatusIcon(props: {
finishedSinceSeen: boolean;
isPrivate: boolean;
status: VisualStatus;
}) {
if (props.finishedSinceSeen) {
return (
<span
aria-label="Finished since last viewed"
className="size-1.5 shrink-0 rounded-full bg-orange-300"
role="img"
/>
);
}

if (props.isPrivate) {
return (
<LockKeyhole
aria-label="Private conversation"
className={cn(
"size-3 shrink-0",
props.status === "active" &&
"animate-[junior-active-indicator_1.8s_ease-in-out_infinite] text-emerald-300 drop-shadow-[0_0_6px_rgba(110,231,183,0.55)] motion-reduce:animate-none",
props.status === "failed" && "text-rose-300",
props.status === "idle" && "text-dashboard-text-muted",
)}
/>
);
}

if (props.status === "active") {
return <ActiveIndicator className="size-1.5" />;
}

return (
<span
aria-hidden="true"
className={cn(
"size-1.5 shrink-0 rounded-full",
props.status === "failed" && "bg-rose-300",
props.status === "idle" && "bg-white/25",
)}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export { liveModelId } from "./ConversationMeta";
export function ConversationPage(props: {
conversationId: string;
data?: { conversations: ConversationFeed };
onRead?(conversationId: string, lastReadAt: string): void;
pendingArchiveUpdate?: PendingArchiveConversationUpdate;
}) {
const [subagentTarget, setSubagentTarget] =
Expand All @@ -74,6 +75,7 @@ export function ConversationPage(props: {
const [search, setSearch] = useState("");
const [pinRequestVersion, setPinRequestVersion] = useState(0);
const conversationId = props.conversationId;
const onRead = props.onRead;
const summaries = props.data?.conversations.conversations ?? [];
const conversations = buildConversations(summaries);
const detail = useConversationData(conversationId);
Expand All @@ -86,6 +88,10 @@ export function ConversationPage(props: {
props.pendingArchiveUpdate,
);
const conversationDetail = detail.data;
useEffect(() => {
if (!conversation) return;
onRead?.(conversation.id, conversation.lastSeenAt);
}, [conversation, onRead]);
// Live polls can rebuild a large transcript tree every 2s. Defer that paint so
// composer keystrokes stay urgent without changing visible transcript content.
// Fall back to the latest detail on first load so the body is never blank while
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import {
Archive,
ArchiveRestore,
CircleAlert,
LockKeyhole,
SquarePen,
} from "lucide-react";
import { Archive, ArchiveRestore, CircleAlert, SquarePen } from "lucide-react";
import { Link } from "react-router";

import { useArchiveConversation } from "./queries";
Expand All @@ -16,8 +10,7 @@ import {
visualStatusForConversation,
} from "../format";
import { cn } from "../styles";
import type { Conversation, VisualStatus } from "../types";
import { ActiveIndicator } from "../components/ActiveIndicator";
import type { Conversation } from "../types";
import { Notice, NoticeAction } from "../components/Notice";
import { AnimatedList } from "./AnimatedList";
import {
Expand All @@ -28,6 +21,7 @@ import { EmptyTelemetry } from "../components/EmptyTelemetry";
import { SearchInput } from "../components/SearchInput";
import { Skeleton } from "../components/Skeleton";
import { ConversationSidebarAnnotations } from "./ConversationMeta";
import { ConversationListStatusIcon } from "./ConversationListStatusIcon";

type ConversationSidebarEntry =
| { first: boolean; key: string; kind: "section"; label: string }
Expand All @@ -43,6 +37,7 @@ const conversationEntryKey = (entry: ConversationSidebarEntry) => entry.key;
export function ConversationSidebar(props: {
conversations: Conversation[];
error?: string;
finishedConversationIds: ReadonlySet<string>;
loading: boolean;
query: string;
selectedId?: string;
Expand Down Expand Up @@ -173,6 +168,9 @@ export function ConversationSidebar(props: {
) : (
<ConversationSidebarRow
conversation={entry.conversation}
finishedSinceSeen={props.finishedConversationIds.has(
entry.conversation.id,
)}
onArchiveError={handleArchiveError}
onArchived={handleArchived}
selected={entry.conversation.id === props.selectedId}
Expand Down Expand Up @@ -255,44 +253,9 @@ function conversationSidebarEntries(
]);
}

/** Status glyph for a sidebar row; private rows use the lock instead of a dot. */
function ConversationListStatusIcon(props: {
isPrivate: boolean;
status: VisualStatus;
}) {
if (props.isPrivate) {
return (
<LockKeyhole
aria-label="Private conversation"
className={cn(
"size-3 shrink-0",
props.status === "active" &&
"animate-[junior-active-indicator_1.8s_ease-in-out_infinite] text-emerald-300 drop-shadow-[0_0_6px_rgba(110,231,183,0.55)] motion-reduce:animate-none",
props.status === "failed" && "text-rose-300",
props.status === "idle" && "text-dashboard-text-muted",
)}
/>
);
}

if (props.status === "active") {
return <ActiveIndicator className="size-1.5" />;
}

return (
<span
aria-hidden="true"
className={cn(
"size-1.5 shrink-0 rounded-full",
props.status === "failed" && "bg-rose-300",
props.status === "idle" && "bg-white/25",
)}
/>
);
}

const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
conversation: Conversation;
finishedSinceSeen: boolean;
onArchiveError(conversation: Conversation, wasArchiving: boolean): void;
onArchived(conversation: Conversation): void;
selected: boolean;
Expand Down Expand Up @@ -337,7 +300,11 @@ const ConversationSidebarRow = memo(function ConversationSidebarRow(props: {
>
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-start gap-x-1.5">
<div className="col-start-1 row-start-1 mt-[0.3rem] grid size-3 shrink-0 place-items-center">
<ConversationListStatusIcon isPrivate={isPrivate} status={status} />
<ConversationListStatusIcon
finishedSinceSeen={props.finishedSinceSeen}
isPrivate={isPrivate}
status={status}
/>
</div>
<div className="col-start-2 row-start-1 min-w-0 truncate font-display text-sm font-medium leading-snug text-dashboard-text">
{title}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { buildConversations, getDashboardTimeZone } from "../format";
import type { Conversation } from "../types";
import { cn, dashboardContainerClass } from "../styles";
import { ConversationPage } from "./ConversationPage";
import { useConversationFinishedIndicators } from "./useConversationFinishedIndicators";

const CONVERSATION_PAGE_SIZE = 20;

Expand Down Expand Up @@ -59,6 +60,13 @@ export function ConversationWorkspace() {
useEffect(() => {
if (page > totalPages) setPage(totalPages);
}, [page, totalPages]);
const { finishedConversationIds, markRead } =
useConversationFinishedIndicators(
conversations,
selectedId,
Boolean(feed.data),
!search,
);

const createView = (
<NewConversationView
Expand Down Expand Up @@ -99,6 +107,7 @@ export function ConversationWorkspace() {
<ConversationHomeList
conversations={pagedConversations}
emptyLabel={feed.error?.message}
finishedConversationIds={finishedConversationIds}
loading={feed.isPending}
timeZone={getDashboardTimeZone()}
/>
Expand Down Expand Up @@ -127,6 +136,7 @@ export function ConversationWorkspace() {
<ConversationSidebar
conversations={conversations}
error={feed.error?.message}
finishedConversationIds={finishedConversationIds}
loading={feed.isPending}
onNewConversation={openCreate}
onQueryChange={setQuery}
Expand All @@ -149,6 +159,7 @@ export function ConversationWorkspace() {
}
: undefined
}
onRead={markRead}
pendingArchiveUpdate={pendingArchiveUpdates.find(
(update) => update.conversationId === selectedId,
)}
Expand Down
Loading
Loading