Skip to content

Commit 630de74

Browse files
committed
Added the Thread List
1 parent dd828d4 commit 630de74

8 files changed

Lines changed: 274 additions & 94 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ A component appearing here doesn't mean it's actively used in the default repo
137137
| `PrivateDisclaimer` | Lock icon + "Only you can see this conversation" strip. | `text?` (override the default copy) | Used inside private message bubbles and above the agents-rail chat thread. |
138138
| `TypingIndicator` | Canonical "X is typing" UI — avatar + 3 bouncing dots. **Whenever a task says "typing indicator", it means this.** | `contact`, `avatarSize = 32`, `dotSize = 5`, `className?` | Component owns only the avatar + dots visual; positioning is the caller's job. Main canvas: floats above `Compose` with the avatar's bottom ~10% tucked behind the compose box top edge (see `.chat-compose-area` / `.chat-compose-typing` in `ChatView.css`). Agents rail: in-flow above the rail compose. Add new surface-specific CSS rather than forking the component. |
139139
| `DemoArrow` | Red pulsing arrow that cues the viewer to the next click target during a prototype walkthrough. **Whenever a task says "red arrow" / "demo arrow" / "hint arrow" to guide the viewer, it means this.** | `direction: 'left' \| 'right' \| 'up' \| 'down'`, `size = 24`, `className?` | Owns only the visual + pulse animation (uses the `translate` CSS property so caller `transform` doesn't collide). Caller handles absolute positioning near the target and should set `pointer-events: none` on the wrapper so clicks still land on the target. **Ephemeral**: track show/hide state wherever the target's click handler lives and unmount once the target is clicked. See `DESIGN_GUIDE.md` → "Demo Hint Arrows". |
140-
| `Icon` library | Shared SVG icons exported from `components/common/Icon.jsx`: `Close`, `Plus`, `ChevronDown`, `ChevronLeft`, `ChevronRight`, `Send`, `Clock`, `Search`, `Dots`, `EmojiAdd`, `Edit`, `Lock`. | Each takes `size`; stroked icons (`Clock`, `EmojiAdd`, `Edit`) also take `stroke`. | When adding a new icon, start at [fluenticons.co](https://fluenticons.co/) (Fluent UI — matches Teams). Add it here instead of inlining SVG in a feature component. |
140+
| `Icon` library | Shared SVG icons exported from `components/common/Icon.jsx`: `Close`, `Plus`, `ChevronDown`, `ChevronLeft`, `ChevronRight`, `Send`, `Clock`, `Search`, `Dots`, `EmojiAdd`, `Edit`, `ChatMultiple`, `Lock`. | Each takes `size`; stroked icons (`Clock`, `EmojiAdd`, `Edit`, `ChatMultiple`) also take `stroke`. | When adding a new icon, start at [fluenticons.co](https://fluenticons.co/) (Fluent UI — matches Teams). Add it here instead of inlining SVG in a feature component. |
141141

142142
## Key Conventions
143143

src/components/ChannelThreadRail.css

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,24 @@
2424
flex-shrink: 0;
2525
}
2626

27-
/* Single-line "Threads › [initial message]" with ellipsis on overflow. The
28-
subject span is the only flex child that shrinks, so the "Threads ›"
29-
prefix always stays fully visible. */
27+
/* Title group holds the optional back button + the title itself with a tight
28+
internal gap so they read as one unit; the larger header gap separates the
29+
group from the right-side action buttons. */
30+
.channel-thread-rail-title-group {
31+
display: flex;
32+
align-items: center;
33+
gap: 4px;
34+
min-width: 0;
35+
flex: 1;
36+
}
37+
38+
.channel-thread-rail-back {
39+
margin-left: -4px;
40+
}
41+
3042
.channel-thread-rail-title {
3143
display: flex;
32-
align-items: baseline;
33-
gap: 6px;
44+
align-items: center;
3445
min-width: 0;
3546
flex: 1;
3647
font-size: 14px;
@@ -39,17 +50,6 @@
3950
color: #242424;
4051
}
4152

42-
.channel-thread-rail-title-prefix {
43-
flex-shrink: 0;
44-
color: #616161;
45-
}
46-
47-
.channel-thread-rail-title-chevron {
48-
flex-shrink: 0;
49-
color: #616161;
50-
font-weight: 400;
51-
}
52-
5353
.channel-thread-rail-title-subject {
5454
min-width: 0;
5555
flex: 1;
@@ -98,16 +98,90 @@
9898
render identically. `.messages-container`'s max-width/padding is meant for
9999
the full canvas; we override padding to fit the narrower rail. */
100100
/* MessageRow applies margin-left: -40px / margin-right: -40px to reach the
101-
canvas's visual edge. Pad by 56px on both sides here so the effective
102-
spacing is 16px after those negative margins — avatars and mine-bubbles
103-
breathe the same on both sides of the narrower rail. */
101+
canvas's visual edge. Pad by 51px on both sides so rows have ~10px more
102+
horizontal room than before; effective spacing after the negative margins
103+
is 11px — avatars and mine-bubbles still breathe on both sides. */
104104
.channel-thread-rail-messages {
105-
padding: 12px 56px 16px 56px;
105+
padding: 12px 51px 16px 51px;
106106
display: flex;
107107
flex-direction: column;
108108
gap: 4px;
109109
}
110110

111+
/* Empty state for the threads list — shown when no posts/threads exist in
112+
the current chat scope. Center-aligned title + subtitle. */
113+
.channel-thread-rail-body-empty {
114+
display: flex;
115+
align-items: center;
116+
justify-content: center;
117+
}
118+
119+
.channel-thread-rail-empty {
120+
text-align: center;
121+
padding: 0 32px;
122+
max-width: 320px;
123+
}
124+
125+
.channel-thread-rail-empty-title {
126+
font-size: 15px;
127+
font-weight: 600;
128+
color: #242424;
129+
margin-bottom: 6px;
130+
}
131+
132+
.channel-thread-rail-empty-subtitle {
133+
font-size: 13px;
134+
line-height: 1.45;
135+
color: #616161;
136+
}
137+
138+
.channel-thread-rail-empty-subtitle em {
139+
font-style: italic;
140+
}
141+
142+
/* List view — shown when no thread is selected. One row per channel post:
143+
subject (or first line) + a meta line with timestamp and reply count.
144+
Click a row to open that thread's detail view. */
145+
.channel-thread-rail-list {
146+
list-style: none;
147+
margin: 0;
148+
padding: 8px 0;
149+
display: flex;
150+
flex-direction: column;
151+
}
152+
153+
.channel-thread-rail-list-item {
154+
width: 100%;
155+
background: none;
156+
border: none;
157+
text-align: left;
158+
cursor: pointer;
159+
padding: 10px 16px;
160+
display: flex;
161+
flex-direction: column;
162+
gap: 2px;
163+
font-family: inherit;
164+
color: #242424;
165+
}
166+
167+
.channel-thread-rail-list-item:hover {
168+
background: #F5F5F5;
169+
}
170+
171+
.channel-thread-rail-list-subject {
172+
font-size: 14px;
173+
font-weight: 600;
174+
line-height: 1.4;
175+
white-space: nowrap;
176+
overflow: hidden;
177+
text-overflow: ellipsis;
178+
}
179+
180+
.channel-thread-rail-list-meta {
181+
font-size: 12px;
182+
color: #616161;
183+
}
184+
111185
.channel-thread-rail-compose {
112186
padding: 8px 16px 16px;
113187
background: #FFFFFF;
Lines changed: 118 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,58 @@
11
import { useState, useRef, useEffect } from 'react'
22
import MessageRow from './MessageRow'
3-
import { IconButton, Close, Dots, Send } from './common'
3+
import { IconButton, ChevronLeft, Close, Dots, Send } from './common'
44
import './ChannelThreadRail.css'
55

6-
// Right-pane thread view for a channel post. Shows the root post, all replies,
7-
// and a compose input at the bottom. Replies added here are ephemeral
8-
// (prototype state only) — the root post data in channelPosts.js is not
9-
// mutated.
10-
export default function ChannelThreadRail({ post, activeContact, onClose }) {
11-
const [extraReplies, setExtraReplies] = useState([])
6+
// Right-pane thread view for a channel. Has two modes:
7+
// • Detail — root post + replies + compose. Header shows a back arrow and
8+
// the thread title.
9+
// • List — all threads in the current channel, click an item to open its
10+
// detail. Header reads "Threads".
11+
// The back arrow on detail toggles to list; the X button closes the rail
12+
// entirely. `initialPostId` opens the rail in detail mode for that post; if
13+
// it changes from the outside (e.g. an Activity event re-targets the rail),
14+
// the rail re-syncs to the new thread.
15+
export default function ChannelThreadRail({ posts, initialPostId, activeContact, onClose }) {
16+
const [initialPostIdCursor, setInitialPostIdCursor] = useState(initialPostId)
17+
const [viewPostId, setViewPostId] = useState(initialPostId)
18+
if (initialPostIdCursor !== initialPostId) {
19+
setInitialPostIdCursor(initialPostId)
20+
setViewPostId(initialPostId)
21+
}
22+
23+
const [extraRepliesByPost, setExtraRepliesByPost] = useState({})
1224
const [input, setInput] = useState('')
1325
const endRef = useRef(null)
1426

27+
const post = viewPostId ? posts.find((p) => p.id === viewPostId) : null
28+
const extraReplies = post ? extraRepliesByPost[post.id] || [] : []
29+
const allReplies = post ? [...(post.replies || []), ...extraReplies] : []
30+
1531
useEffect(() => {
16-
endRef.current?.scrollIntoView({ behavior: 'smooth' })
32+
if (post) endRef.current?.scrollIntoView({ behavior: 'smooth' })
1733
}, [extraReplies, post?.id])
1834

19-
const allReplies = [...(post.replies || []), ...extraReplies]
20-
21-
// Title for the rail: "Threads > [initial message]". Prefer the post's
22-
// subject when it has one; otherwise use the flattened post text.
23-
const firstLine = (() => {
24-
if (post.subject) return post.subject
25-
if (Array.isArray(post.text)) {
26-
return post.text.map((p) => (typeof p === 'string' ? p : p.name)).join('')
35+
const flattenSubject = (p) => {
36+
if (p.subject) return p.subject
37+
if (Array.isArray(p.text)) {
38+
return p.text.map((seg) => (typeof seg === 'string' ? seg : seg.name)).join('')
2739
}
28-
return post.text || ''
29-
})().replace(/\s+/g, ' ').trim()
40+
return p.text || ''
41+
}
42+
const titleText = post ? flattenSubject(post).replace(/\s+/g, ' ').trim() : 'Threads'
3043

3144
const send = () => {
3245
const text = input.trim()
33-
if (!text) return
46+
if (!text || !post) return
3447
const now = new Date()
3548
const time = now.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
36-
setExtraReplies((prev) => [
49+
setExtraRepliesByPost((prev) => ({
3750
...prev,
38-
{ id: `reply-${Date.now()}`, senderId: 'me', text, time },
39-
])
51+
[post.id]: [
52+
...(prev[post.id] || []),
53+
{ id: `reply-${Date.now()}`, senderId: 'me', text, time },
54+
],
55+
}))
4056
setInput('')
4157
}
4258

@@ -50,10 +66,19 @@ export default function ChannelThreadRail({ post, activeContact, onClose }) {
5066
return (
5167
<div className="channel-thread-rail">
5268
<div className="channel-thread-rail-header">
53-
<div className="channel-thread-rail-title" title={`Threads > ${firstLine}`}>
54-
<span className="channel-thread-rail-title-prefix">Threads</span>
55-
<span className="channel-thread-rail-title-chevron" aria-hidden="true"></span>
56-
<span className="channel-thread-rail-title-subject">{firstLine}</span>
69+
<div className="channel-thread-rail-title-group">
70+
{post && (
71+
<IconButton
72+
label="Back to threads"
73+
className="channel-thread-rail-action channel-thread-rail-back"
74+
onClick={() => setViewPostId(null)}
75+
>
76+
<ChevronLeft size={18} />
77+
</IconButton>
78+
)}
79+
<div className="channel-thread-rail-title" title={titleText}>
80+
<span className="channel-thread-rail-title-subject">{titleText}</span>
81+
</div>
5782
</div>
5883
<div className="channel-thread-rail-actions">
5984
<IconButton label="More options" className="channel-thread-rail-action">
@@ -65,38 +90,75 @@ export default function ChannelThreadRail({ post, activeContact, onClose }) {
6590
</div>
6691
</div>
6792

68-
<div className="channel-thread-rail-body">
69-
<div className="channel-thread-rail-messages">
70-
<MessageRow
71-
message={{ ...post, threadReply: undefined }}
72-
activeContact={activeContact}
73-
/>
74-
{allReplies.map((reply) => (
75-
<MessageRow
76-
key={reply.id}
77-
message={reply}
78-
activeContact={activeContact}
79-
/>
80-
))}
81-
<div ref={endRef} />
82-
</div>
83-
</div>
93+
{post ? (
94+
<>
95+
<div className="channel-thread-rail-body">
96+
<div className="channel-thread-rail-messages">
97+
<MessageRow
98+
message={{ ...post, threadReply: undefined }}
99+
activeContact={activeContact}
100+
/>
101+
{allReplies.map((reply) => (
102+
<MessageRow
103+
key={reply.id}
104+
message={reply}
105+
activeContact={activeContact}
106+
/>
107+
))}
108+
<div ref={endRef} />
109+
</div>
110+
</div>
84111

85-
<div className="channel-thread-rail-compose">
86-
<div className="channel-thread-rail-compose-box">
87-
<input
88-
type="text"
89-
className="channel-thread-rail-input"
90-
placeholder="Reply"
91-
value={input}
92-
onChange={(e) => setInput(e.target.value)}
93-
onKeyDown={onKeyDown}
94-
/>
95-
<IconButton label="Send" className="channel-thread-rail-send" onClick={send}>
96-
<Send />
97-
</IconButton>
112+
<div className="channel-thread-rail-compose">
113+
<div className="channel-thread-rail-compose-box">
114+
<input
115+
type="text"
116+
className="channel-thread-rail-input"
117+
placeholder="Reply"
118+
value={input}
119+
onChange={(e) => setInput(e.target.value)}
120+
onKeyDown={onKeyDown}
121+
/>
122+
<IconButton label="Send" className="channel-thread-rail-send" onClick={send}>
123+
<Send />
124+
</IconButton>
125+
</div>
126+
</div>
127+
</>
128+
) : posts.length === 0 ? (
129+
<div className="channel-thread-rail-body channel-thread-rail-body-empty">
130+
<div className="channel-thread-rail-empty">
131+
<div className="channel-thread-rail-empty-title">No threads yet</div>
132+
<div className="channel-thread-rail-empty-subtitle">
133+
Start a side conversation by selecting <em>Reply in thread</em> from any message
134+
</div>
135+
</div>
98136
</div>
99-
</div>
137+
) : (
138+
<div className="channel-thread-rail-body">
139+
<ul className="channel-thread-rail-list">
140+
{posts.map((p) => {
141+
const subject = flattenSubject(p).replace(/\s+/g, ' ').trim()
142+
const replyCount = (p.replies || []).length + (extraRepliesByPost[p.id]?.length || 0)
143+
return (
144+
<li key={p.id}>
145+
<button
146+
type="button"
147+
className="channel-thread-rail-list-item"
148+
onClick={() => setViewPostId(p.id)}
149+
>
150+
<span className="channel-thread-rail-list-subject">{subject}</span>
151+
<span className="channel-thread-rail-list-meta">
152+
{p.time}
153+
{replyCount > 0 && ` · ${replyCount} ${replyCount === 1 ? 'reply' : 'replies'}`}
154+
</span>
155+
</button>
156+
</li>
157+
)
158+
})}
159+
</ul>
160+
</div>
161+
)}
100162
</div>
101163
)
102164
}

src/components/ChatHeader.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@
105105
color: #242424;
106106
}
107107

108+
.meet-now-label {
109+
font-weight: 600;
110+
}
111+
108112
.meet-chevron {
109113
margin-left: -2px;
110114
}

0 commit comments

Comments
 (0)