feat: update workspace channel components (header, sidebar, thread-panel, composer, etc)

This commit is contained in:
zhenyi 2026-05-30 15:08:01 +08:00
parent 04798b5adb
commit b6a4bd0210
7 changed files with 240 additions and 141 deletions

View File

@ -1,45 +1,37 @@
import { Hash, MessageSquare, Settings, Users } from "lucide-react";
import { Hash, Settings, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { Room } from "./channel-sidebar";
import type { Thread } from "./thread-sidebar";
type Props = {
room: Room;
threads: Thread[];
onToggleThreads: () => void;
onToggleSettings: () => void;
memberCount?: number;
};
export function ChannelHeader({
room,
threads,
onToggleThreads,
onToggleSettings,
memberCount,
}: Props) {
const activeThreads = threads.filter((t) => !t.archived && !t.locked).length;
return (
<header className="flex h-[52px] shrink-0 items-center gap-3 border-b border-border/50 bg-background/70 px-5 backdrop-blur-md">
<header className="flex h-[56px] shrink-0 items-center gap-3 border-b border-border/40 bg-card/60 px-5 backdrop-blur-md">
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-primary/10 to-primary/[0.03] ring-1 ring-primary/[0.08]">
<Hash className="size-3.5 text-primary/60" />
<span className="grid size-9 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-primary/15 to-primary/[0.04] ring-1 ring-primary/[0.12] shadow-sm">
<Hash className="size-4 text-primary/70" />
</span>
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[15px] font-semibold text-foreground tracking-tight">
<h3 className="truncate text-[16px] font-bold text-foreground tracking-tight">
{room.name}
</h3>
{room.topic && (
<Tooltip>
<TooltipTrigger className="hidden sm:block">
<span className="max-w-[200px] cursor-default truncate text-[13px] text-muted-foreground/40">
<span className="max-w-[200px] cursor-default truncate text-[13px] text-muted-foreground/35">
{room.topic}
</span>
</TooltipTrigger>
@ -56,44 +48,17 @@ export function ChannelHeader({
<div className="ml-auto flex items-center gap-0.5">
{typeof memberCount === "number" && memberCount > 0 && (
<span className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] text-muted-foreground/60">
<span className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] text-muted-foreground/50 hover:bg-accent/40 hover:text-muted-foreground/70 transition-colors cursor-default">
<Users className="size-3.5" />
<span className="tabular-nums">{memberCount}</span>
<span className="tabular-nums font-medium">{memberCount}</span>
</span>
)}
<Tooltip>
<TooltipTrigger>
<Button
aria-label="Toggle threads panel"
className={cn(
"relative size-8 cursor-pointer rounded-lg transition-all duration-150",
activeThreads > 0
? "text-foreground hover:bg-accent/50"
: "text-muted-foreground/50 hover:bg-accent/40 hover:text-foreground",
)}
onClick={onToggleThreads}
size="icon"
variant="ghost"
>
<MessageSquare className="size-[18px]" />
{activeThreads > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex size-[18px] items-center justify-center rounded-full bg-primary text-[10px] font-semibold text-primary-foreground ring-[2.5px] ring-background shadow-sm">
{activeThreads}
</span>
)}
</Button>
</TooltipTrigger>
<TooltipContent className="text-xs">
Threads{activeThreads > 0 ? ` (${activeThreads} active)` : ""}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
aria-label="Channel settings"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/50 transition-all duration-150 hover:bg-accent/40 hover:text-foreground"
className="size-8 cursor-pointer rounded-lg text-muted-foreground/40 transition-all duration-150 hover:bg-accent/40 hover:text-foreground"
onClick={onToggleSettings}
size="icon"
variant="ghost"

View File

@ -53,17 +53,17 @@ function RoomLink({
return (
<Link
className={cn(
"group flex h-8 items-center gap-2 rounded-md px-2 text-[13px] transition-all duration-150",
"group flex h-8 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-all duration-150",
active
? "bg-primary/[0.08] font-medium text-foreground"
: "text-muted-foreground/80 hover:bg-accent/[0.4] hover:text-foreground",
? "bg-primary/[0.08] font-semibold text-foreground shadow-[inset_0_0_0_1px_rgba(var(--color-primary),0.08)]"
: "text-muted-foreground/70 hover:bg-accent/50 hover:text-foreground",
)}
to={`/${projectName}/channel/${room.id}`}
>
<span
className={cn(
"shrink-0 transition-colors duration-150",
active && "text-primary/70",
active ? "text-primary/70" : "text-muted-foreground/50 group-hover:text-muted-foreground/70",
)}
>
<RoomIcon type={room.room_type} />

View File

@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import ThreadSidebar from "./thread-sidebar";
import { useState, useEffect, useRef } from "react";
import ThreadPane from "./thread-pane";
import type { Thread } from "./thread-sidebar";
@ -7,17 +6,51 @@ type Props = {
open: boolean;
threads: Thread[];
roomId: string;
selectedThreadId?: string | null;
initialSeq?: number;
onClose: () => void;
};
export function ChannelThreadPanel({ open, threads, roomId, onClose }: Props) {
export function ChannelThreadPanel({
open,
threads,
roomId,
selectedThreadId,
initialSeq = 0,
onClose,
}: Props) {
const [selectedThread, setSelectedThread] = useState<Thread | null>(null);
const prevOpen = useRef(false);
// Auto-select the thread when selectedThreadId is provided
useEffect(() => {
if (selectedThreadId) {
const thread = threads.find((t) => t.id === selectedThreadId);
if (thread) {
setSelectedThread(thread);
} else if (initialSeq > 0) {
setSelectedThread({
id: selectedThreadId,
room: roomId,
seq: 0,
parent_seq: initialSeq,
title: "",
created_by: { id: "", display_name: "", username: "" },
archived: false,
locked: false,
last_message_at: null,
created_at: new Date().toISOString(),
});
}
}
}, [selectedThreadId, threads, roomId, initialSeq]);
// Reset selection when panel closes
useEffect(() => {
if (!open) {
if (!open && prevOpen.current) {
setSelectedThread(null);
}
prevOpen.current = open;
}, [open]);
if (!open) return null;
@ -26,31 +59,16 @@ export function ChannelThreadPanel({ open, threads, roomId, onClose }: Props) {
<>
{/* Backdrop */}
<div
className="absolute inset-0 z-20 bg-background/[0.03] backdrop-blur-[2px]"
className="absolute inset-0 z-20 bg-background/50"
onClick={onClose}
/>
{/* Thread Sidebar (list) */}
{/* Thread Pane (detail) — slides from right */}
<div
className={`
absolute inset-y-0 right-0 z-30 w-80 border-l border-border/30 bg-card shadow-2xl
transition-transform duration-300 ease-out
${selectedThread ? "translate-x-full" : "translate-x-0"}
`}
>
<ThreadSidebar
onClose={onClose}
onSelect={setSelectedThread}
threads={threads}
/>
</div>
{/* Thread Pane (detail) */}
<div
className={`
absolute inset-y-0 right-0 z-40 w-96 border-l border-border/30 bg-card shadow-2xl
transition-transform duration-300 ease-out
${selectedThread ? "translate-x-0" : "translate-x-full"}
absolute inset-y-0 right-0 z-40 w-[min(460px,88vw)] border-l border-border/30 bg-card shadow-2xl
transition-[transform,opacity] duration-300 ease-out
${selectedThread ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"}
`}
>
{selectedThread && (

View File

@ -108,16 +108,16 @@ export default function MessageComposer({
<div
className={cn(
"relative rounded-xl border bg-card shadow-sm transition-all duration-200",
"focus-within:border-primary/20 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/[0.06]",
replyTarget ? "border-primary/[0.12]" : "border-border/50",
"relative rounded-2xl border bg-card/80 shadow-sm transition-all duration-200",
"focus-within:border-primary/20 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/[0.06] focus-within:bg-card",
replyTarget ? "border-primary/[0.12]" : "border-border/40",
)}
>
<Textarea
aria-label={`Message #${roomName}`}
className={cn(
"min-h-[48px] max-h-48 resize-none border-0 bg-transparent px-4 py-3 pr-28 text-sm leading-relaxed shadow-none focus-visible:ring-0",
"placeholder:text-muted-foreground/30",
"min-h-[52px] max-h-48 resize-none border-0 bg-transparent px-5 py-3.5 pr-28 text-[13px] leading-relaxed shadow-none focus-visible:ring-0",
"placeholder:text-muted-foreground/35",
)}
disabled={disabled || sending}
onChange={(e) => handleInput(e.target.value)}
@ -125,7 +125,7 @@ export default function MessageComposer({
placeholder={
replyTarget
? `Reply to ${replyAuthorName}`
: `Message #${roomName}`
: `Send a message in #${roomName}`
}
ref={textareaRef}
value={content}

View File

@ -1,5 +1,7 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { useParams } from "react-router";
import { api } from "@/client";
import { MessageSquare } from "lucide-react";
import { useChannelState } from "./use-channel-state";
import { ChannelHeader } from "./channel-header";
import { ChannelThreadPanel } from "./channel-thread-panel";
@ -10,26 +12,62 @@ export default function ChannelPage() {
const { roomId } = useParams();
const { state, actions } = useChannelState(roomId);
const [showThreads, setShowThreads] = useState(false);
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [activeThreadSeq, setActiveThreadSeq] = useState<number>(0);
const [showRoomSettings, setShowRoomSettings] = useState(false);
const handleStartThread = useCallback(
async (_messageId: string, seq: number) => {
if (!roomId) return;
try {
const res = await api.post(`/api/v1/ws/rooms/${roomId}/threads`, {
parent: seq,
});
const payload = res.data as Record<string, unknown> | undefined;
const eventData = payload?.data as Record<string, unknown> | undefined;
const threadId = eventData?.id as string | undefined;
if (threadId) {
setActiveThreadId(threadId);
setShowThreads(true);
}
} catch {
// ignore websocket will sync threads
}
},
[roomId],
);
const handleViewThread = useCallback((threadId: string, seq: number) => {
setActiveThreadId(threadId);
setActiveThreadSeq(seq);
setShowThreads(true);
}, []);
const closeThreadPanel = useCallback(() => {
setShowThreads(false);
setTimeout(() => {
setActiveThreadId(null);
setActiveThreadSeq(0);
}, 320);
}, []);
return (
<div className="relative flex h-full min-w-0 flex-col overflow-hidden">
{state.currentRoom ? (
<ChannelHeader
memberCount={undefined}
onToggleSettings={() => setShowRoomSettings(true)}
onToggleThreads={() => setShowThreads((v) => !v)}
room={state.currentRoom}
threads={state.threads}
/>
) : (
<div className="flex h-[52px] shrink-0 items-center border-b border-border/40 px-4">
{state.loadingRooms ? (
<span className="text-sm text-muted-foreground/40">Loading</span>
) : (
<span className="text-sm text-muted-foreground/40">
Select a channel
</span>
<div className="flex items-center gap-2 text-muted-foreground/40">
<MessageSquare className="size-4" />
<span className="text-sm">Select a channel to start chatting</span>
</div>
)}
</div>
)}
@ -45,19 +83,23 @@ export default function ChannelPage() {
onPinToggle={actions.handlePinToggle}
onReactionToggle={actions.handleReactionToggle}
onSend={actions.handleSend}
onStartThread={actions.handleStartThread}
onStartThread={handleStartThread}
onViewThread={handleViewThread}
onTyping={actions.handleTyping}
roomId={roomId ?? ""}
roomName={state.currentRoom?.name ?? ""}
streamingMessages={state.streamingMessages}
threads={state.threads}
typingText={state.typingText}
/>
{roomId && (
<ChannelThreadPanel
onClose={() => setShowThreads(false)}
initialSeq={activeThreadSeq}
onClose={closeThreadPanel}
open={showThreads}
roomId={roomId}
selectedThreadId={activeThreadId}
threads={state.threads}
/>
)}

View File

@ -25,6 +25,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import type { MessageNewService } from "@/socket";
import type { Thread } from "./thread-sidebar";
export function formatTime(iso: string) {
const d = new Date(iso);
@ -47,12 +48,12 @@ export function formatDate(iso: string) {
export function DateDivider({ date }: { date: string }) {
return (
<div className="flex items-center gap-3 px-4 py-4">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-border/50 to-transparent" />
<span className="shrink-0 rounded-full bg-muted/40 px-3 py-0.5 text-[11px] font-medium text-muted-foreground/50">
<div className="flex items-center gap-3 px-6 py-5">
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-border/30 to-border/30" />
<span className="shrink-0 rounded-lg bg-muted/30 px-3 py-1 text-[11px] font-semibold text-muted-foreground/40 tracking-wide uppercase border border-border/20">
{date}
</span>
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-border/50 to-transparent" />
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-border/30 to-border/30" />
</div>
);
}
@ -69,7 +70,9 @@ type Props = {
onDelete?: (messageId: string) => void;
onEdit?: (messageId: string, content: string) => void;
onStartThread?: (messageId: string, seq: number) => void;
onViewThread?: (threadId: string, seq: number) => void;
onReactionToggle?: (messageId: string, emoji: string, add: boolean) => void;
threads?: Thread[];
};
export default function MessageItem({
@ -84,7 +87,9 @@ export default function MessageItem({
onDelete,
onEdit,
onStartThread,
onViewThread,
onReactionToggle,
threads,
}: Props) {
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content);
@ -99,6 +104,8 @@ export default function MessageItem({
| boolean
| undefined;
const threadForMessage = threads?.find((t) => t.parent_seq === message.seq);
// Look up the replied-to message from the local message list
const repliedMessage = message.in_reply_to
? messages?.find((m) => m.id === message.in_reply_to)
@ -176,27 +183,27 @@ export default function MessageItem({
className={cn(
"group relative flex gap-3 transition-colors duration-150",
isCompact
? "items-start px-4 py-[1px] hover:bg-accent/[0.03]"
: "items-start px-4 py-1.5 hover:bg-accent/[0.03]",
? "items-start px-4 py-0 hover:bg-black/[0.025]"
: "items-start px-4 py-1 hover:bg-black/[0.03]",
)}
>
{isCompact ? (
<div className="w-9 shrink-0 pt-0.5 text-center">
<span className="text-[10px] text-muted-foreground/0 transition-opacity duration-150 group-hover:text-muted-foreground/40">
<div className="w-10 shrink-0 text-center">
<span className="mt-1 inline-block text-[10px] text-muted-foreground/0 transition-opacity duration-150 group-hover:text-muted-foreground/50">
{formatTime(message.send_at)}
</span>
</div>
) : (
<div
className={cn(
"grid size-9 shrink-0 place-items-center rounded-full bg-gradient-to-br text-sm font-semibold text-white shadow-sm",
"grid size-10 shrink-0 place-items-center rounded-[10px] bg-gradient-to-br text-[13px] font-bold text-white shadow-sm ring-1 ring-white/10",
colorClass,
)}
>
{message.sender.avatar_url ? (
<img
alt={name}
className="size-full rounded-full object-cover"
className="size-full rounded-[10px] object-cover"
src={message.sender.avatar_url}
/>
) : (
@ -207,20 +214,20 @@ export default function MessageItem({
<div className="min-w-0 flex-1">
{!isCompact && showHeader && (
<div className="flex items-baseline gap-2">
<span className="cursor-pointer text-sm font-semibold text-foreground transition-colors hover:text-primary">
<div className="flex items-center gap-2">
<span className="cursor-pointer text-[15px] font-bold text-foreground transition-colors hover:text-primary">
{name}
</span>
<span className="text-[11px] text-muted-foreground/40">
<span className="text-[11px] font-medium text-muted-foreground/35">
{formatTime(message.send_at)}
</span>
{message.sender_type !== "user" && (
<span className="rounded-full bg-primary/[0.06] px-1.5 py-[1px] text-[10px] font-medium text-primary/70">
<span className="rounded-full border border-primary/10 bg-primary/[0.05] px-1.5 py-[1px] text-[10px] font-semibold uppercase tracking-wide text-primary/60">
{message.sender_type}
</span>
)}
{isPinned && (
<span className="text-amber-500/70" title="Pinned">
<span className="text-amber-500/60" title="Pinned">
<Pin className="size-3" />
</span>
)}
@ -231,8 +238,8 @@ export default function MessageItem({
{message.in_reply_to && (
<div
className={cn(
"mb-0.5 flex items-center gap-1.5 rounded rounded-r-md border-l-2 border-primary/30 bg-muted/[0.15] px-2 py-0.5 text-left",
repliedMessage && "cursor-pointer transition-colors hover:bg-muted/[0.3]",
"mb-1 flex items-center gap-1.5 rounded-md border border-border/20 bg-muted/[0.08] px-2.5 py-1 text-left",
repliedMessage && "cursor-pointer transition-colors hover:bg-muted/[0.15] hover:border-border/30",
)}
onClick={() => {
if (repliedMessage) onReply?.(repliedMessage);
@ -240,9 +247,9 @@ export default function MessageItem({
title={replyPreview ? `Replying to: ${replyPreview}` : "Replying to a message"}
role={repliedMessage ? "button" : undefined}
>
<CornerDownRight className="size-3 shrink-0 text-primary/40" />
<CornerDownRight className="size-3 shrink-0 text-primary/50" />
{replyAuthor ? (
<span className="truncate text-[11px] font-medium text-primary/60">
<span className="truncate text-[11px] font-semibold text-primary/60">
{replyAuthor}
</span>
) : (
@ -251,7 +258,7 @@ export default function MessageItem({
</span>
)}
{replyPreview && (
<span className="truncate text-[11px] text-muted-foreground/35">
<span className="truncate text-[11px] text-muted-foreground/40">
{replyPreview}
</span>
)}
@ -262,7 +269,7 @@ export default function MessageItem({
<div className="mt-1 space-y-2">
<Textarea
autoFocus
className="min-h-[60px] resize-none rounded-lg text-sm"
className="min-h-[56px] resize-none rounded-lg border-border/60 bg-muted/30 text-sm focus-visible:ring-1 focus-visible:ring-primary/20"
disabled={saving}
onBlur={handleSaveEdit}
onChange={(e) => setEditContent(e.target.value)}
@ -274,38 +281,49 @@ export default function MessageItem({
</div>
</div>
) : message.content_type === "text" || !message.content_type ? (
<p className="whitespace-pre-wrap break-words text-[14px] leading-[1.6] text-foreground/90">
<p className="whitespace-pre-wrap break-words text-[13px] leading-[1.55] text-foreground/85">
{message.content}
</p>
) : (
<p className="whitespace-pre-wrap break-words text-[14px] text-foreground/90">
<span className="rounded bg-muted/50 px-1.5 py-[1px] text-[10px] font-medium uppercase text-muted-foreground/60">
<p className="whitespace-pre-wrap break-words text-[13px] text-foreground/85">
<span className="inline-flex items-center gap-1 rounded bg-muted/40 px-1.5 py-[1px] text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
{message.content_type}
</span>{" "}
{message.content}
</p>
)}
{threadForMessage && !message.thread && (
<button
className="mt-1.5 flex items-center gap-1.5 rounded-lg border border-border/20 bg-muted/[0.05] px-2.5 py-1 text-[12px] font-medium text-primary/60 transition-all duration-150 hover:bg-primary/[0.05] hover:text-primary hover:border-primary/15 hover:shadow-sm"
onClick={() => onViewThread?.(threadForMessage.id, message.seq)}
type="button"
>
<MessageSquarePlus className="size-3.5" />
<span>View thread</span>
</button>
)}
{message.reactions && message.reactions.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
<div className="mt-1.5 flex flex-wrap gap-1.5">
{message.reactions.map((r) => (
<button
className={cn(
"inline-flex cursor-pointer items-center gap-1 rounded-full border px-2 py-[1px] text-xs transition-all duration-150",
"inline-flex cursor-pointer items-center gap-1 rounded-lg border px-2 py-[2px] text-xs transition-all duration-150",
r.reacted_by_me
? "border-primary/20 bg-primary/[0.06] text-primary hover:bg-primary/[0.1]"
: "border-border/40 bg-muted/30 text-muted-foreground/70 hover:border-border/60 hover:bg-accent/40",
? "border-primary/15 bg-primary/[0.06] text-primary hover:bg-primary/[0.1]"
: "border-border/30 bg-muted/20 text-muted-foreground/60 hover:border-border/50 hover:bg-muted/40",
)}
key={r.emoji}
onClick={() => handleReaction(r.emoji)}
title={r.reacted_by_me ? "Click to remove" : "Click to add"}
>
<span>{r.emoji}</span>
<span className="text-[11px] tabular-nums">{r.count}</span>
<span className="text-[13px]">{r.emoji}</span>
<span className="text-[10px] font-semibold tabular-nums">{r.count}</span>
</button>
))}
<ReactionPicker onSelect={(emoji) => handleReaction(emoji)}>
<button className="inline-flex size-6 cursor-pointer items-center justify-center rounded-full border border-dashed border-border/40 text-muted-foreground/30 opacity-0 transition-all duration-150 hover:border-primary/30 hover:text-primary/60 group-hover:opacity-100">
<button className="inline-flex size-6 cursor-pointer items-center justify-center rounded-lg border border-dashed border-border/30 text-muted-foreground/25 opacity-0 transition-all duration-150 hover:border-primary/25 hover:text-primary/50 group-hover:opacity-100">
<SmilePlus className="size-3" />
</button>
</ReactionPicker>
@ -313,10 +331,10 @@ export default function MessageItem({
)}
</div>
<div className="absolute -top-3 right-3 z-10 flex items-center gap-[1px] rounded-lg border border-border/40 bg-background/95 px-0.5 py-0.5 shadow-md backdrop-blur-sm opacity-0 transition-all duration-150 group-hover:opacity-100">
<div className="absolute right-2 top-0 z-10 flex items-center gap-[1px] rounded-lg border border-border/20 bg-card/95 px-1 py-1 shadow-md backdrop-blur-sm opacity-0 transition-all duration-150 group-hover:opacity-100">
<ReactionPicker onSelect={(emoji) => handleReaction(emoji)}>
<Button
className="size-7 cursor-pointer rounded-md"
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
size="icon"
title="Add reaction"
variant="ghost"
@ -326,7 +344,7 @@ export default function MessageItem({
</ReactionPicker>
<Button
className="size-7 cursor-pointer rounded-md"
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
onClick={() => onReply?.(message)}
size="icon"
title="Reply"
@ -336,10 +354,14 @@ export default function MessageItem({
</Button>
<Button
className="size-7 cursor-pointer rounded-md"
onClick={() => onStartThread?.(message.id, message.seq)}
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
onClick={() =>
threadForMessage
? onViewThread?.(threadForMessage.id, message.seq)
: onStartThread?.(message.id, message.seq)
}
size="icon"
title="Start thread"
title={threadForMessage ? "View thread" : "Start thread"}
variant="ghost"
>
<MessageSquarePlus className="size-3.5" />
@ -348,7 +370,7 @@ export default function MessageItem({
<DropdownMenu>
<DropdownMenuTrigger>
<Button
className="size-7 cursor-pointer rounded-md"
className="size-7 cursor-pointer rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-accent/50"
size="icon"
title="More"
variant="ghost"
@ -419,10 +441,14 @@ export default function MessageItem({
<DropdownMenuItem
className="cursor-pointer text-[13px]"
onClick={() => onStartThread?.(message.id, message.seq)}
onClick={() =>
threadForMessage
? onViewThread?.(threadForMessage.id, message.seq)
: onStartThread?.(message.id, message.seq)
}
>
<MessageSquarePlus className="mr-2 size-4" />
Start thread
{threadForMessage ? "View thread" : "Start thread"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -80,6 +80,44 @@ export function useChannelState(roomId: string | undefined) {
const lastSeq = useRef(0);
const prevRoomId = useRef<string | undefined>(undefined);
// Load threads for current room
const { data: threadsResponse } = useQuery({
queryKey: ["threads", roomId],
queryFn: async () => {
const response = await api.get(
`/api/v1/ws/rooms/${roomId}/threads`,
);
// REST returns WsOutEvent::ThreadList { type, data: { threads } }
const payload = response.data as Record<string, unknown> | undefined;
const inner = payload?.data as Record<string, unknown> | undefined;
const list = (inner?.threads ?? []) as Array<Record<string, unknown>>;
// Normalize: room comes back as RoomInfo { id, name } object, need string UUID
return list.map((t) => ({
...t,
room:
typeof t.room === "object" && t.room !== null
? ((t.room as Record<string, unknown>).id as string)
: t.room,
})) as Thread[];
},
enabled: Boolean(roomId),
retry: false,
staleTime: 5000,
});
// Merge fetched threads with websocket events
useEffect(() => {
if (threadsResponse && threadsResponse.length > 0) {
setThreads((prev) => {
const existingIds = new Set(prev.map((t) => t.id));
const newThreads = threadsResponse.filter(
(t: Thread) => !existingIds.has(t.id),
);
return newThreads.length > 0 ? [...prev, ...newThreads] : prev;
});
}
}, [threadsResponse]);
const { data: channelData, isLoading: loadingRooms } = useQuery({
queryKey: ["channel", "rooms"],
queryFn: async () => {
@ -113,16 +151,19 @@ export function useChannelState(roomId: string | undefined) {
const result = (response.data as Record<string, unknown>)?.data as { messages?: MessageNewService[] } | undefined;
if (result?.messages) {
const msgs = result.messages as MessageNewService[];
messageCache.persistBatch(msgs).catch(() => {});
const allMsgs = result.messages as MessageNewService[];
// Persist all messages (including thread) to cache
messageCache.persistBatch(allMsgs).catch(() => {});
// Only show non-thread messages in main channel view
const channelMsgs = allMsgs.filter((m) => !m.thread);
if (beforeSeq) {
setMessages((prev) => [...msgs, ...prev]);
setHasMore(msgs.length >= 50);
setMessages((prev) => [...channelMsgs, ...prev]);
setHasMore(allMsgs.length >= 50);
} else {
setMessages(msgs);
setHasMore(msgs.length >= 50);
if (msgs.length > 0) {
lastSeq.current = msgs[msgs.length - 1].seq;
setMessages(channelMsgs);
setHasMore(allMsgs.length >= 50);
if (allMsgs.length > 0) {
lastSeq.current = allMsgs[allMsgs.length - 1].seq;
}
}
}
@ -167,10 +208,13 @@ export function useChannelState(roomId: string | undefined) {
const msg = event.data as MessageNewService;
messageCache.persistMessage(msg).catch(() => {
});
// Only add to main channel if message is NOT in a thread
if (!msg.thread) {
setMessages((prev) => {
if (prev.some((m) => m.id === msg.id)) return prev;
return [...prev, msg];
});
}
if (msg.seq > lastSeq.current) lastSeq.current = msg.seq;
break;
}
@ -373,7 +417,8 @@ export function useChannelState(roomId: string | undefined) {
{
id: tc.id,
room: tc.room.id,
seq: tc.parent,
seq: 0,
parent_seq: tc.parent,
title: "",
created_by: tc.created_by,
archived: false,
@ -518,11 +563,14 @@ export function useChannelState(roomId: string | undefined) {
try {
const body: Record<string, unknown> = { content, content_type: "text" };
if (inReplyTo) body.in_reply_to = inReplyTo;
const res = await api.post<{ data?: MessageNewService }>(
const res = await api.post(
`/api/v1/ws/rooms/${roomId}/messages`,
body,
);
const msg = res.data?.data;
// res.data = WsOutEvent::MessageNew { type, room, data: MessageNewService }
const msg = (res.data as Record<string, unknown>)?.data as
| MessageNewService
| undefined;
if (msg) {
messageCache.persistMessage(msg).catch(() => {});
setMessages((prev) => {