Skip to content

Commit d2b5c60

Browse files
committed
See a Slack conversation, and finish one, in OpenBot
The Slack side of a conversation was only in Slack: a person could not read what their coworker had done, and the account link and the secure prompt a Slack turn sends somebody to had nowhere to land. Three surfaces, all behind the existing session guard. Confirming a Slack account is theirs happens on a page that reads the signed link token and binds only to the OpenBot user completing the flow, with a sign-in return that comes back to the same confirmation rather than the roster. Taking the wheel or answering a secure prompt happens on the coworker's own screen, reached from the expiring link in the thread. And a Slack thread appears in the conversation sidebar, labelled, next to the channels it already lists, opening a read-only transcript of the turns as they were stored. The computer tools a Slack turn calls are declared once, in shared, so the browser and the channel offer the same contract rather than two drifting copies of it.
1 parent a9f180e commit d2b5c60

21 files changed

Lines changed: 2164 additions & 254 deletions

app/src/components/app-sidebar/app-sidebar.tsx

Lines changed: 119 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,39 @@ import {
5151
channelListQueryOptions,
5252
} from "@/lib/channels/queries";
5353
import { useChannelEvents } from "@/lib/channels/use-channel-events";
54+
import { externalThreadListQueryOptions } from "@/lib/external/queries";
5455
import { appConfig } from "@/lib/generated/application-config";
5556
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
5657
import { relativeTime } from "@/lib/relative-time";
5758
import { Button } from "../ui/button";
5859
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty";
5960
import { Channel } from "./channel";
61+
import {
62+
conversationRoster,
63+
matchingRoster,
64+
type RosterSourceStatus,
65+
rosterKey,
66+
shouldShowEmptyRoster,
67+
shouldShowSearchEmpty,
68+
type SidebarRosterRow,
69+
} from "./roster";
70+
import { SlackChannel, SlackRosterProblem } from "./slack-channel";
6071

6172
const appLinkOptions = { to: "/" } satisfies LinkOptions;
6273
const adminLinkOptions = { to: "/admin" } satisfies LinkOptions;
6374
const settingsLinkOptions = { to: "/settings" } satisfies LinkOptions;
6475

6576
const userMenuItemClassName = "gap-2 px-2 py-1.5";
6677

78+
function rosterSourceStatus(query: {
79+
isError: boolean;
80+
isSuccess: boolean;
81+
}): RosterSourceStatus {
82+
if (query.isSuccess) return "success";
83+
if (query.isError) return "error";
84+
return "pending";
85+
}
86+
6787
function UserAvatar() {
6888
const { data: currentUser } = useQuery(currentUserQueryOptions());
6989
const initials =
@@ -86,36 +106,6 @@ function UserAvatar() {
86106
*/
87107
const MAX_ANIMATED_ROWS = 60;
88108

89-
/**
90-
* The roster, narrowed to what the person typed.
91-
*
92-
* Matches the channel's name, its summary, and the last message, because those are the things the
93-
* row can actually show — searching against something invisible returns results a person cannot
94-
* account for. The last message is included because it is still what the second line draws until the
95-
* conversation has been named. Message history beyond that line is not here to search: it lives in
96-
* the thread store, and reaching for it is a server endpoint rather than a filter.
97-
*
98-
* An empty query returns the input array unchanged rather than a copy, so typing and clearing does
99-
* not hand `AnimatePresence` a new array identity and restage the whole list.
100-
*/
101-
export function matchingChannels(
102-
channels: ChannelSummary[] | undefined,
103-
query: string,
104-
): ChannelSummary[] {
105-
if (!channels) {
106-
return [];
107-
}
108-
const needle = query.trim().toLowerCase();
109-
if (!needle) {
110-
return channels;
111-
}
112-
return channels.filter((channel) =>
113-
[channel.name, channel.summary, channel.lastMessage].some((field) =>
114-
field?.toLowerCase().includes(needle),
115-
),
116-
);
117-
}
118-
119109
/**
120110
* Pinned channels first, everything else after, newest activity first within each group.
121111
*
@@ -162,9 +152,11 @@ export function isUnread(
162152
* moves under the cursor.
163153
*/
164154
function ChannelRow({
155+
animateVisibility,
165156
channel,
166157
animateOrder,
167158
}: {
159+
animateVisibility: boolean;
168160
channel: ChannelSummary;
169161
animateOrder: boolean;
170162
}) {
@@ -178,12 +170,18 @@ function ChannelRow({
178170
});
179171
return (
180172
<motion.div
181-
animate={{ opacity: 1, transform: "translateY(0px)" }}
182-
initial={{
183-
opacity: 0,
184-
transform: shouldReduceMotion ? "none" : "translateY(-8px)",
185-
}}
186-
exit={{ opacity: 0 }}
173+
animate={
174+
animateVisibility ? { opacity: 1, transform: "translateY(0px)" } : false
175+
}
176+
initial={
177+
animateVisibility
178+
? {
179+
opacity: 0,
180+
transform: shouldReduceMotion ? "none" : "translateY(-8px)",
181+
}
182+
: false
183+
}
184+
exit={animateVisibility ? { opacity: 0 } : undefined}
187185
layout={animateOrder && !shouldReduceMotion ? "position" : false}
188186
transition={{ duration: ENTRANCE_SECONDS, ease: EASE_OUT }}
189187
>
@@ -206,25 +204,68 @@ function ChannelRow({
206204
);
207205
}
208206

207+
function SlackRow({
208+
animateVisibility,
209+
animateOrder,
210+
thread,
211+
}: {
212+
animateVisibility: boolean;
213+
animateOrder: boolean;
214+
thread: SidebarRosterRow & { kind: "slack" };
215+
}) {
216+
const shouldReduceMotion = useReducedMotion();
217+
return (
218+
<motion.div
219+
animate={
220+
animateVisibility ? { opacity: 1, transform: "translateY(0px)" } : false
221+
}
222+
initial={
223+
animateVisibility
224+
? {
225+
opacity: 0,
226+
transform: shouldReduceMotion ? "none" : "translateY(-8px)",
227+
}
228+
: false
229+
}
230+
exit={animateVisibility ? { opacity: 0 } : undefined}
231+
layout={animateOrder && !shouldReduceMotion ? "position" : false}
232+
transition={{ duration: ENTRANCE_SECONDS, ease: EASE_OUT }}
233+
>
234+
<SlackChannel
235+
lastMessageAt={
236+
thread.thread.lastMessageAt
237+
? relativeTime(thread.thread.lastMessageAt)
238+
: undefined
239+
}
240+
thread={thread.thread}
241+
/>
242+
</motion.div>
243+
);
244+
}
245+
209246
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
210247
const { data: currentUser } = useQuery(currentUserQueryOptions());
211248
const queryClient = useQueryClient();
212249
const navigate = useNavigate();
213250
const signOut = useMutation(signOutMutationOptions(queryClient));
214251
const channels = useInfiniteQuery(channelListQueryOptions());
252+
const slackThreads = useInfiniteQuery(externalThreadListQueryOptions());
215253
// One socket for the app, opened where the roster is kept live.
216254
useChannelEvents();
217255
const [search, setSearch] = useState("");
218256
const searching = search.trim().length > 0;
219-
const visibleChannels = pinnedFirst(matchingChannels(channels.data, search));
257+
const roster = conversationRoster(channels.data, slackThreads.data);
258+
const visibleRoster = matchingRoster(roster, search);
259+
const channelStatus = rosterSourceStatus(channels);
260+
const slackThreadStatus = rosterSourceStatus(slackThreads);
220261
/*
221262
* FILTERING DOES NOT ANIMATE. Rows exit and relayout on every keystroke otherwise, which is a
222263
* list thrashing under somebody who is still typing — and the moving target is the very thing
223264
* they are trying to read. Order animation is for a channel that was just spoken in, which is
224265
* occasional; this is not.
225266
*/
226-
const animateOrder =
227-
!searching && (channels.data?.length ?? 0) <= MAX_ANIMATED_ROWS;
267+
const animateOrder = !searching && roster.length <= MAX_ANIMATED_ROWS;
268+
const animateVisibility = !searching;
228269

229270
const handleSignOut = async () => {
230271
await signOut.mutateAsync();
@@ -268,7 +309,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
268309
<SidebarMenuItem>
269310
<InputGroup className="bg-background text-sm rounded-lg h-9">
270311
<InputGroupInput
271-
aria-label="Search channels"
312+
aria-label="Search conversations"
272313
onChange={(event) => setSearch(event.target.value)}
273314
placeholder="Search..."
274315
value={search}
@@ -285,7 +326,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
285326
* the box has to say so and quote it back — told "you don't have channels yet" while
286327
* holding a typo, a person reads their conversations as gone.
287328
*/}
288-
{searching && visibleChannels.length === 0 ? (
329+
{shouldShowSearchEmpty(
330+
visibleRoster,
331+
search,
332+
channelStatus,
333+
slackThreadStatus,
334+
) ? (
289335
<div className="py-4">
290336
<Empty className="border border-dashed min-h-[40dvh]">
291337
<EmptyHeader>
@@ -298,7 +344,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
298344
</Empty>
299345
</div>
300346
) : null}
301-
{!searching && channels.data?.length === 0 ? (
347+
{!searching &&
348+
shouldShowEmptyRoster(
349+
channels.data,
350+
slackThreads.data,
351+
channels.isSuccess,
352+
slackThreads.isSuccess,
353+
) ? (
302354
<div className="py-4">
303355
<Empty className="border border-dashed min-h-[40dvh]">
304356
<EmptyHeader>
@@ -311,14 +363,32 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
311363
</Empty>
312364
</div>
313365
) : null}
366+
{slackThreads.isError ? (
367+
<SlackRosterProblem
368+
isRetrying={slackThreads.isFetching}
369+
onRetry={() => {
370+
void slackThreads.refetch();
371+
}}
372+
/>
373+
) : null}
314374
<AnimatePresence initial={false}>
315-
{visibleChannels.map((channel) => (
316-
<ChannelRow
317-
key={channel.id}
318-
animateOrder={animateOrder}
319-
channel={channel}
320-
/>
321-
))}
375+
{visibleRoster.map((row) =>
376+
row.kind === "openbot" ? (
377+
<ChannelRow
378+
key={rosterKey(row)}
379+
animateVisibility={animateVisibility}
380+
animateOrder={animateOrder}
381+
channel={row.channel}
382+
/>
383+
) : (
384+
<SlackRow
385+
key={rosterKey(row)}
386+
animateVisibility={animateVisibility}
387+
animateOrder={animateOrder}
388+
thread={row}
389+
/>
390+
),
391+
)}
322392
</AnimatePresence>
323393
</SidebarGroup>
324394
</SidebarMenu>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { linkOptions, type LinkOptions } from "@tanstack/react-router";
2+
import type { ChannelSummary } from "@/lib/channels/queries";
3+
import type { ExternalThreadSummary } from "@/lib/external/queries";
4+
5+
export type SidebarRosterRow =
6+
| { kind: "openbot"; channel: ChannelSummary }
7+
| { kind: "slack"; thread: ExternalThreadSummary };
8+
9+
export type RosterSourceStatus = "pending" | "success" | "error";
10+
11+
const openbotChannelRoute = "/channel/$channelId" as const;
12+
const slackThreadRoute = "/slack/thread/$threadId" as const;
13+
14+
export function rosterKey(row: SidebarRosterRow): string {
15+
return row.kind === "openbot"
16+
? `openbot:${row.channel.id}`
17+
: `slack:${row.thread.threadId}`;
18+
}
19+
20+
function activityAt(row: SidebarRosterRow): string {
21+
const source = row.kind === "openbot" ? row.channel : row.thread;
22+
return source.lastMessageAt ?? source.createdAt;
23+
}
24+
25+
export function conversationRoster(
26+
channels: ChannelSummary[] = [],
27+
slackThreads: ExternalThreadSummary[] = [],
28+
): SidebarRosterRow[] {
29+
const nativeRows = channels.map(
30+
(channel): SidebarRosterRow & { kind: "openbot" } => ({
31+
kind: "openbot",
32+
channel,
33+
}),
34+
);
35+
const slackRows = slackThreads.map(
36+
(thread): SidebarRosterRow & { kind: "slack" } => ({
37+
kind: "slack",
38+
thread,
39+
}),
40+
);
41+
const pinned = nativeRows.filter(
42+
(row) => row.kind === "openbot" && row.channel.pinned,
43+
);
44+
const remaining = [
45+
...nativeRows.filter((row) => !row.channel.pinned),
46+
...slackRows,
47+
];
48+
49+
return [
50+
...pinned.sort(byActivityThenKey),
51+
...remaining.sort(byActivityThenKey),
52+
];
53+
}
54+
55+
function byActivityThenKey(a: SidebarRosterRow, b: SidebarRosterRow): number {
56+
const activity = activityAt(b).localeCompare(activityAt(a));
57+
if (activity !== 0) return activity;
58+
return rosterKey(a).localeCompare(rosterKey(b));
59+
}
60+
61+
export function matchingRoster(
62+
rows: SidebarRosterRow[] | undefined,
63+
query: string,
64+
): SidebarRosterRow[] {
65+
if (!rows) {
66+
return [];
67+
}
68+
const needle = query.trim().toLowerCase();
69+
if (!needle) {
70+
return rows;
71+
}
72+
return rows.filter((row) =>
73+
[rosterName(row), rosterSummary(row), rosterLastMessage(row)].some(
74+
(field) => field?.toLowerCase().includes(needle),
75+
),
76+
);
77+
}
78+
79+
export function rosterName(row: SidebarRosterRow): string {
80+
return row.kind === "openbot" ? row.channel.name : row.thread.agentName;
81+
}
82+
83+
/**
84+
* What the row was named, once a conversation has one. A Slack thread has none: it is named by the
85+
* coworker it is pinned to, which {@link rosterName} already returns.
86+
*/
87+
export function rosterSummary(row: SidebarRosterRow): string | null {
88+
return row.kind === "openbot" ? row.channel.summary : null;
89+
}
90+
91+
export function rosterLastMessage(row: SidebarRosterRow): string | null {
92+
return row.kind === "openbot"
93+
? row.channel.lastMessage
94+
: row.thread.lastMessage;
95+
}
96+
97+
export function rosterDestination(row: SidebarRosterRow): LinkOptions {
98+
return row.kind === "openbot"
99+
? linkOptions({
100+
to: openbotChannelRoute,
101+
params: { channelId: row.channel.id },
102+
})
103+
: linkOptions({
104+
to: slackThreadRoute,
105+
params: { threadId: row.thread.threadId },
106+
});
107+
}
108+
109+
export function shouldShowEmptyRoster(
110+
channels: readonly ChannelSummary[] | undefined,
111+
slackThreads: readonly ExternalThreadSummary[] | undefined,
112+
channelsLoaded: boolean,
113+
slackThreadsLoaded: boolean,
114+
): boolean {
115+
return (
116+
channelsLoaded &&
117+
slackThreadsLoaded &&
118+
channels?.length === 0 &&
119+
slackThreads?.length === 0
120+
);
121+
}
122+
123+
export function shouldShowSearchEmpty(
124+
visibleRows: readonly SidebarRosterRow[],
125+
query: string,
126+
channelsStatus: RosterSourceStatus,
127+
slackThreadsStatus: RosterSourceStatus,
128+
): boolean {
129+
return (
130+
query.trim().length > 0 &&
131+
channelsStatus === "success" &&
132+
slackThreadsStatus === "success" &&
133+
visibleRows.length === 0
134+
);
135+
}

0 commit comments

Comments
 (0)