diff --git a/src/components/channel/MessageItem.tsx b/src/components/channel/MessageItem.tsx
index cfe46a7..fec840c 100644
--- a/src/components/channel/MessageItem.tsx
+++ b/src/components/channel/MessageItem.tsx
@@ -21,6 +21,7 @@ interface Props {
onSetEmojiPicker: (msgId: string | null) => void;
onShowEditHistory: (msgId: string) => void;
roomId: string;
+ readOnly?: boolean;
}
const COMMON_EMOJIS = ['👍', '❤️', '😄', '😭', '😡', '🎉', '🚀', '👀'];
@@ -37,14 +38,16 @@ export function MessageItem({
onOpenThread,
onSetEmojiPicker,
onShowEditHistory,
+ readOnly = false,
}: Props) {
const [isHovered, setIsHovered] = useState(false);
const isRevoked = !!msg.revoked;
const { addPin, removePin, pinnedMessages } = useRoom();
const isPinned = pinnedMessages.some((p) => p.message === msg.id);
const handleTogglePin = () => {
- if (isPinned) removePin(msg.id);
- else addPin(msg.id);
+ if (readOnly) return;
+ const action = isPinned ? removePin(msg.id) : addPin(msg.id);
+ action.catch((err) => console.error('[MessageItem] failed to toggle pin:', err));
};
const isEdited = !!msg.edited_at;
const isStreaming = msg.is_streaming === true;
@@ -53,6 +56,7 @@ export function MessageItem({
return (
(
))}
{/* Hover actions — Discord-style: button center aligns with message top edge */}
- {isHovered && !isRevoked && (
+ {isHovered && !isRevoked && !readOnly && (
+
onOpenThread(msg)}
+ title={msg.thread ? 'Open thread' : 'Start thread'}
+ className={MESSAGE_ITEM.actionButton}
+ >
+
+
{msg.sender_type !== 'ai' && (
<>
-
-
-
>
)}
+
+
+
)}
diff --git a/src/components/channel/MessageList.tsx b/src/components/channel/MessageList.tsx
index 59c73bf..d4c91a3 100644
--- a/src/components/channel/MessageList.tsx
+++ b/src/components/channel/MessageList.tsx
@@ -41,6 +41,7 @@ interface Props {
onShowEditHistory: (msgId: string) => void;
onStartReached?: () => void;
roomId: string;
+ readOnly?: boolean;
}
export function MessageList({
@@ -57,6 +58,7 @@ export function MessageList({
onShowEditHistory,
onStartReached,
roomId,
+ readOnly = false,
}: Props) {
const virtuosoRef = useRef(null);
const initialScrollDoneRef = useRef(false);
@@ -208,6 +210,7 @@ export function MessageList({
onSetEmojiPicker={onSetEmojiPicker}
onShowEditHistory={onShowEditHistory}
roomId={roomId}
+ readOnly={readOnly}
/>
);
}}
diff --git a/src/components/channel/PinPanel.tsx b/src/components/channel/PinPanel.tsx
index 28e4763..27f4edd 100644
--- a/src/components/channel/PinPanel.tsx
+++ b/src/components/channel/PinPanel.tsx
@@ -1,14 +1,18 @@
-import { Pin, X } from 'lucide-react';
-import type { PinnedMessage } from '@/contexts/room';
+import { Pin, PinOff, X } from 'lucide-react';
+import type { Message, PinnedMessage } from '@/contexts/room';
import { formatRelativeTime } from '@/contexts/room';
interface Props {
pins: PinnedMessage[];
+ messages: Message[];
onClose: () => void;
onGotoMessage: (messageId: string) => void;
+ onUnpin?: (messageId: string) => void;
}
-export function PinPanel({ pins, onClose, onGotoMessage }: Props) {
+export function PinPanel({ pins, messages, onClose, onGotoMessage, onUnpin }: Props) {
+ const messageById = new Map(messages.map((msg) => [msg.id, msg]));
+
return (
@@ -28,23 +32,60 @@ export function PinPanel({ pins, onClose, onGotoMessage }: Props) {
No pinned messages yet.
) : (
- pins.map((pin) => (
-
onGotoMessage(pin.message)}
- >
-
-
- #{pin.message.slice(0, 8)}
-
-
- {formatRelativeTime(pin.pinned_at)}
-
-
- ))
+ pins.map((pin) => {
+ const msg = messageById.get(pin.message);
+ return (
+
+
onGotoMessage(pin.message)}
+ style={{
+ flex: 1,
+ minWidth: 0,
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 4,
+ background: 'none',
+ border: 'none',
+ padding: 0,
+ textAlign: 'left',
+ cursor: 'pointer',
+ }}
+ >
+
+ {msg?.display_name ?? msg?.sender_id ?? `#${pin.message.slice(0, 8)}`}
+
+
+ {msg?.content ?? 'Pinned message is not loaded in the current history window.'}
+
+
+ Pinned {formatRelativeTime(pin.pinned_at)}
+
+
+ {onUnpin && (
+
onUnpin(pin.message)}
+ title="Unpin"
+ className="thread-close-btn"
+ style={{ flexShrink: 0 }}
+ >
+
+
+ )}
+
+ );
+ })
)}
);
-}
\ No newline at end of file
+}
diff --git a/src/components/channel/ThreadPanel.tsx b/src/components/channel/ThreadPanel.tsx
index 50510c4..241187d 100644
--- a/src/components/channel/ThreadPanel.tsx
+++ b/src/components/channel/ThreadPanel.tsx
@@ -5,6 +5,9 @@ import type { Message } from '@/contexts/room';
import { formatTime } from '@/contexts/room';
import { Avatar } from './Avatar';
import { getWsClient } from '@/ws';
+import { ProjectJoinBanner } from '@/app/project/layout';
+import { IrRenderer } from '@/lib/ir/renderer';
+import { extractIrNodes } from '@/lib/ir/parser';
function safeGetClient() {
try { return getWsClient(); } catch { return null; }
@@ -21,6 +24,7 @@ interface Props {
sendMessage: (content: string, opts?: { thread?: string }) => void;
onTypingStart: () => void;
onTypingStop: () => void;
+ readOnly?: boolean;
}
const COMMON_EMOJIS = ['👍', '❤️', '😄', '😭', '😡', '🎉'];
@@ -32,6 +36,7 @@ export function ThreadPanel({
sendMessage,
onTypingStart,
onTypingStop,
+ readOnly = false,
}: Props) {
const [inputValue, setInputValue] = useState('');
const [showReactionPicker, setShowReactionPicker] = useState(null);
@@ -43,7 +48,7 @@ export function ThreadPanel({
}, [thread.messages]);
const handleSend = () => {
- if (!inputValue.trim()) return;
+ if (readOnly || !inputValue.trim()) return;
sendMessage(inputValue.trim(), { thread: thread.id });
setInputValue('');
onTypingStop();
@@ -51,6 +56,7 @@ export function ThreadPanel({
};
const handleInputChange = (e: React.ChangeEvent) => {
+ if (readOnly) return;
setInputValue(e.target.value);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
onTypingStart();
@@ -58,11 +64,13 @@ export function ThreadPanel({
};
const handleResolve = () => {
+ if (readOnly) return;
const c = safeGetClient();
if (c) c.resolveThread(thread.id);
};
const handleArchive = () => {
+ if (readOnly) return;
const c = safeGetClient();
if (c) c.archiveThread(thread.id);
};
@@ -118,7 +126,7 @@ export function ThreadPanel({
{isRevoked ? (
Message deleted
) : (
- msg.content
+
)}
@@ -136,6 +144,11 @@ export function ThreadPanel({
)}
+ {readOnly ? (
+
+ ) : (
+ )}
);
-}
\ No newline at end of file
+}