update
This commit is contained in:
1 parent
66158e222e
commit
67c757f481
314 files changed
+37945
-22962
No files matched your search
@@ -37,10 +37,10 @@ export function ChannelHeader({
|
||||
activeChannel,
|
||||
showSidebar,
|
||||
onShowSidebar,
|
||||
channelMessages,
|
||||
taskDiscussionComments,
|
||||
channelMessages: _channelMessages,
|
||||
taskDiscussionComments: _taskDiscussionComments,
|
||||
activeTask,
|
||||
unreadByChannel,
|
||||
unreadByChannel: _unreadByChannel,
|
||||
onNavigateToTask,
|
||||
filterBar,
|
||||
}: ChannelHeaderProps) {
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ChannelSidebar({
|
||||
unreadByChannel,
|
||||
agents,
|
||||
totalChannels,
|
||||
onHideSidebar,
|
||||
onHideSidebar: _onHideSidebar,
|
||||
}: ChannelSidebarProps) {
|
||||
const t = useT()
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import type { AgentMessageRecord, AgentRecord, CommentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types"
|
||||
import type { AgentMessageRecord, AgentRecord, CommentRecord, DocumentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types"
|
||||
import { useAppStore } from "../../state/store"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
import { useT } from "../../i18n"
|
||||
import { ChannelSidebar, FilterBar, type FilterState } from "./ChannelSidebar"
|
||||
import type { ChannelDescriptor, TaskDiscussionDescriptor, AnyChannel } from "./ChannelHeader"
|
||||
import type { ChannelDescriptor, AnyChannel } from "./ChannelHeader"
|
||||
import { ChannelHeader } from "./ChannelHeader"
|
||||
import { MessageList } from "./MessageList"
|
||||
import { MessageComposer, TaskDiscussionComposer, NoChannelComposer } from "./MessageComposer"
|
||||
import { SearchResults } from "./SearchResults"
|
||||
import ThreadPanel from "./ThreadPanel"
|
||||
import { deriveChannels, deriveTaskDiscussionChannels, getChannelMessages } from "./channel-utils"
|
||||
|
||||
|
||||
interface CommunicationHubProps {
|
||||
messages: AgentMessageRecord[]; comments: CommentRecord[]; tasks: TaskRecord[]; agents: AgentRecord[]
|
||||
projects?: ProjectRecord[]; companyId: string; selectedEntityId?: string | null
|
||||
projects?: ProjectRecord[]; documents?: DocumentRecord[]; companyId: string; selectedEntityId?: string | null
|
||||
onEntityConsumed?: () => void; onNavigateToTask: (taskId: string) => void; onRefresh: () => void
|
||||
}
|
||||
|
||||
@@ -58,8 +58,7 @@ function SearchScopeToggle({ scope, onChangeScope }: { scope: "global" | "channe
|
||||
)
|
||||
}
|
||||
|
||||
export function CommunicationHub({ messages, comments, tasks, agents, projects, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) {
|
||||
const t = useT()
|
||||
export function CommunicationHub({ messages, comments, tasks, agents, projects, documents, companyId, selectedEntityId, onEntityConsumed, onNavigateToTask, onRefresh }: CommunicationHubProps) {
|
||||
const [selectedChannelKey, setSelectedChannelKey] = useState<string | null>(null)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [replyTo, setReplyTo] = useState<AgentMessageRecord | null>(null)
|
||||
@@ -67,6 +66,10 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
const pendingReadIdsRef = useRef<Set<string>>(new Set())
|
||||
const [filters, setFilters] = useState<FilterState>({ search: "", channelType: "all", priority: "all", unreadOnly: false })
|
||||
|
||||
// Thread panel state from Zustand store
|
||||
const activeThreadId = useAppStore((s) => s.activeThreadId)
|
||||
const setActiveThreadId = useAppStore((s) => s.setActiveThreadId)
|
||||
|
||||
// Search state from Zustand store
|
||||
const searchQuery = useAppStore((s) => s.searchQuery)
|
||||
const searchScope = useAppStore((s) => s.searchScope)
|
||||
@@ -269,8 +272,19 @@ export function CommunicationHub({ messages, comments, tasks, agents, projects,
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{activeChannel ? (activeChannel.kind === "message" ? <MessageComposer agents={agents} companyId={companyId} channel={activeChannel.channel} channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId} directParticipantIds={activeChannel.channel === "direct" ? activeChannel.participantIds : []} replyTo={replyTo} onSent={() => { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} /> : <TaskDiscussionComposer companyId={companyId} taskId={activeChannel.taskId} onSent={onRefresh} />) : <NoChannelComposer />}
|
||||
{activeChannel ? (activeChannel.kind === "message" ? <MessageComposer agents={agents} companyId={companyId} channel={activeChannel.channel} channelTargetId={activeChannel.channel === "direct" ? null : activeChannel.targetId} directParticipantIds={activeChannel.channel === "direct" ? activeChannel.participantIds : []} replyTo={replyTo} onSent={() => { setReplyTo(null); onRefresh() }} onCancelReply={() => setReplyTo(null)} documents={documents} /> : <TaskDiscussionComposer companyId={companyId} taskId={activeChannel.taskId} onSent={onRefresh} />) : <NoChannelComposer />}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{activeThreadId && (
|
||||
<ThreadPanel
|
||||
threadId={activeThreadId}
|
||||
companyId={companyId}
|
||||
agents={agents}
|
||||
documents={documents ?? []}
|
||||
onClose={() => setActiveThreadId(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ArrowBendUpLeft, PaperPlaneTilt, X, ChatCircle } from "@phosphor-icons/react"
|
||||
import type { AgentMessageRecord, AgentRecord, MessageChannel } from "@shared/types"
|
||||
import { ArrowBendUpLeft, PaperPlaneTilt, X, ChatCircle, UserCircle, Paperclip, FileText } from "@phosphor-icons/react"
|
||||
import type { AgentMessageRecord, AgentRecord, DocumentRecord, MessageChannel } from "@shared/types"
|
||||
import { unwrap } from "../../lib/desktop"
|
||||
import { useAppStore } from "../../state/store"
|
||||
import { InlineNotice } from "../ui"
|
||||
import { AgentAvatar } from "./MessageItem"
|
||||
import { useT } from "../../i18n"
|
||||
|
||||
const HUMAN_SENDER_ID = "__board__";
|
||||
|
||||
function SendButton({ sending, disabled, onClick, label }: { sending: boolean; disabled: boolean; onClick: () => void; label: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={disabled || sending} className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition hover:brightness-90 active:translate-y-px disabled:opacity-40 disabled:pointer-events-none" aria-label={label}>
|
||||
@@ -15,41 +17,265 @@ function SendButton({ sending, disabled, onClick, label }: { sending: boolean; d
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Attachment Picker ──────────────────────────────────────────────────────
|
||||
|
||||
interface AttachmentItem { type: string; id: string; title: string }
|
||||
|
||||
function AttachmentPicker({ documents, attachments, onAdd, onRemove }: {
|
||||
documents: DocumentRecord[];
|
||||
attachments: AttachmentItem[];
|
||||
onAdd: (doc: DocumentRecord) => void;
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filter, setFilter] = useState("")
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const attachedIds = useMemo(() => new Set(attachments.map((a) => a.id)), [attachments])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.toLowerCase()
|
||||
return documents.filter((d) => !attachedIds.has(d.id) && (d.title.toLowerCase().includes(q) || d.type.toLowerCase().includes(q)))
|
||||
}, [documents, attachedIds, filter])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener("mousedown", handler)
|
||||
return () => document.removeEventListener("mousedown", handler)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
aria-label="Attach document"
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 z-50 mb-1 w-72 rounded-xl border border-border bg-card shadow-xl">
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Search documents..."
|
||||
className="w-full rounded-lg border border-border bg-muted px-2.5 py-1.5 text-[12px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground">
|
||||
{documents.length === 0 ? "No documents in this company" : "No matching documents"}
|
||||
</div>
|
||||
) : (
|
||||
filtered.slice(0, 20).map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
type="button"
|
||||
onClick={() => { onAdd(doc); setFilter(""); setOpen(false) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left transition hover:bg-muted"
|
||||
>
|
||||
<FileText size={14} className="shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] font-medium text-foreground">{doc.title}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{doc.type.replaceAll("_", " ")} · {doc.status}</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="absolute bottom-full left-10 mb-1 flex flex-wrap gap-1">
|
||||
{attachments.map((att) => (
|
||||
<span key={att.id} className="inline-flex items-center gap-1 rounded-lg bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary">
|
||||
<FileText size={10} />
|
||||
<span className="max-w-[120px] truncate">{att.title}</span>
|
||||
<button type="button" onClick={() => onRemove(att.id)} className="ml-0.5 rounded-sm p-0.5 transition hover:bg-primary/20"><X size={8} /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── @Mention Autocomplete ──────────────────────────────────────────────────
|
||||
|
||||
function MentionDropdown({ agents, query, onSelect, position }: {
|
||||
agents: AgentRecord[];
|
||||
query: string;
|
||||
onSelect: (agent: AgentRecord) => void;
|
||||
position: { top: number; left: number };
|
||||
}) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.toLowerCase()
|
||||
return agents.filter((a) => a.status !== "terminated" && (
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
(a.title?.toLowerCase().includes(q)) ||
|
||||
(a.department?.toLowerCase().includes(q))
|
||||
)).slice(0, 8)
|
||||
}, [agents, query])
|
||||
|
||||
useEffect(() => { setSelectedIndex(0) }, [query])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)) }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)) }
|
||||
else if (e.key === "Enter" || e.key === "Tab") {
|
||||
if (filtered[selectedIndex]) { e.preventDefault(); onSelect(filtered[selectedIndex]) }
|
||||
}
|
||||
else if (e.key === "Escape") { onSelect(null as unknown as AgentRecord) }
|
||||
}
|
||||
document.addEventListener("keydown", handler, true)
|
||||
return () => document.removeEventListener("keydown", handler, true)
|
||||
}, [filtered, selectedIndex, onSelect])
|
||||
|
||||
if (filtered.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-50 w-64 rounded-xl border border-border bg-card shadow-xl"
|
||||
style={{ bottom: position.top, left: position.left }}
|
||||
>
|
||||
{filtered.map((agent, i) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(agent)}
|
||||
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left transition ${
|
||||
i === selectedIndex ? "bg-primary/10" : "hover:bg-muted"
|
||||
} ${i === 0 ? "rounded-t-xl" : ""} ${i === filtered.length - 1 ? "rounded-b-xl" : ""}`}
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-bold text-muted-foreground">
|
||||
{agent.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[12px] font-semibold text-foreground">{agent.name}</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">{agent.title}{agent.department ? ` · ${agent.department}` : ""}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Composer ──────────────────────────────────────────────────────────
|
||||
|
||||
interface MessageComposerProps {
|
||||
agents: AgentRecord[]; companyId: string; channel: MessageChannel; channelTargetId: string | null
|
||||
directParticipantIds?: string[]; replyTo: AgentMessageRecord | null; onSent: () => void; onCancelReply: () => void
|
||||
documents?: DocumentRecord[]
|
||||
/** Thread ID for precise checkpoint resolution. */
|
||||
threadId?: string | null
|
||||
/** Wait state ID for precise checkpoint resolution. */
|
||||
waitStateId?: string | null
|
||||
}
|
||||
|
||||
export function MessageComposer({ agents, companyId, channel, channelTargetId, directParticipantIds = [], replyTo, onSent, onCancelReply }: MessageComposerProps) {
|
||||
const [fromAgentId, setFromAgentId] = useState(agents[0]?.id ?? "")
|
||||
export function MessageComposer({ agents, companyId, channel, channelTargetId, directParticipantIds = [], replyTo, onSent, onCancelReply, documents = [], threadId = null, waitStateId = null }: MessageComposerProps) {
|
||||
const [body, setBody] = useState(""); const [subject, setSubject] = useState("")
|
||||
const [sending, setSending] = useState(false); const [sendError, setSendError] = useState<string | null>(null)
|
||||
const [attachments, setAttachments] = useState<AttachmentItem[]>([])
|
||||
const [mentionState, setMentionState] = useState<{ active: boolean; query: string; startIndex: number }>({ active: false, query: "", startIndex: -1 })
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const addToast = useAppStore((s) => s.addToast)
|
||||
|
||||
useEffect(() => { if (replyTo) textareaRef.current?.focus() }, [replyTo])
|
||||
useEffect(() => { if (agents.length === 0) { if (fromAgentId) setFromAgentId(""); return }; if (!agents.some((a) => a.id === fromAgentId)) setFromAgentId(agents[0]?.id ?? "") }, [agents, fromAgentId])
|
||||
|
||||
const directRecipientId = useMemo(() => {
|
||||
if (channel !== "direct") return null
|
||||
if (replyTo?.fromAgentId) return replyTo.fromAgentId
|
||||
if (directParticipantIds.length === 0) return null
|
||||
if (directParticipantIds.length === 1) return directParticipantIds[0] ?? null
|
||||
if (fromAgentId && directParticipantIds.includes(fromAgentId)) return directParticipantIds.find((id) => id !== fromAgentId) ?? null
|
||||
return directParticipantIds[0] ?? null
|
||||
}, [channel, replyTo, directParticipantIds, fromAgentId])
|
||||
const nonBoardParticipant = directParticipantIds.find((id) => id !== HUMAN_SENDER_ID)
|
||||
return nonBoardParticipant ?? directParticipantIds[0] ?? null
|
||||
}, [channel, replyTo, directParticipantIds])
|
||||
|
||||
const handleAddAttachment = useCallback((doc: DocumentRecord) => {
|
||||
setAttachments((prev) => [...prev, { type: "document", id: doc.id, title: doc.title }])
|
||||
}, [])
|
||||
|
||||
const handleRemoveAttachment = useCallback((id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleMentionSelect = useCallback((agent: AgentRecord | null) => {
|
||||
if (!agent) { setMentionState({ active: false, query: "", startIndex: -1 }); return }
|
||||
setBody((prev) => {
|
||||
const before = prev.slice(0, mentionState.startIndex)
|
||||
const after = prev.slice(mentionState.startIndex + mentionState.query.length + 1)
|
||||
return `${before}@${agent.name} ${after}`
|
||||
})
|
||||
setMentionState({ active: false, query: "", startIndex: -1 })
|
||||
textareaRef.current?.focus()
|
||||
}, [mentionState])
|
||||
|
||||
const handleBodyChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
setBody(value)
|
||||
|
||||
const cursorPos = e.target.selectionStart
|
||||
const textBeforeCursor = value.slice(0, cursorPos)
|
||||
const atIndex = textBeforeCursor.lastIndexOf("@")
|
||||
|
||||
if (atIndex >= 0) {
|
||||
const charBefore = atIndex > 0 ? textBeforeCursor[atIndex - 1] : " "
|
||||
if (charBefore === " " || charBefore === "\n" || atIndex === 0) {
|
||||
const query = textBeforeCursor.slice(atIndex + 1)
|
||||
if (!query.includes(" ") && !query.includes("\n") && query.length <= 30) {
|
||||
setMentionState({ active: true, query, startIndex: atIndex })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
setMentionState({ active: false, query: "", startIndex: -1 })
|
||||
}, [])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!body.trim() || !fromAgentId) return; if (channel === "direct" && !directRecipientId) return
|
||||
if (!agents.some((a) => a.id === fromAgentId)) { setSendError("Select a valid sender."); return }
|
||||
if (!body.trim()) return; if (channel === "direct" && !directRecipientId) return
|
||||
setSending(true); setSendError(null)
|
||||
try { unwrap(await window.agentCompany.sendAgentMessage({ companyId, fromAgentId, toAgentId: channel === "direct" ? directRecipientId : null, channel, channelTargetId, subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""), body: body.trim(), priority: "normal", parentMessageId: replyTo?.id ?? null, attachmentsJson: "[]" })); setBody(""); setSubject(""); onSent() } catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send message.") } finally { setSending(false) }
|
||||
}, [agents, body, channel, channelTargetId, companyId, directRecipientId, fromAgentId, onSent, replyTo, subject])
|
||||
const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : "[]"
|
||||
try {
|
||||
const result = unwrap(await window.agentCompany.sendAgentMessage({
|
||||
companyId, fromAgentId: HUMAN_SENDER_ID,
|
||||
toAgentId: channel === "direct" ? directRecipientId : null,
|
||||
channel, channelTargetId,
|
||||
subject: subject.trim() || (replyTo ? `Re: ${replyTo.subject}` : ""),
|
||||
body: body.trim(), priority: "normal",
|
||||
parentMessageId: replyTo?.id ?? null, attachmentsJson,
|
||||
threadId: threadId ?? undefined,
|
||||
waitStateId: waitStateId ?? undefined,
|
||||
}))
|
||||
if (result.mentionResults && result.mentionResults.length > 0) {
|
||||
const woken = result.mentionResults.filter((m) => m.outcome === "woken")
|
||||
const queued = result.mentionResults.filter((m) => m.outcome === "queued")
|
||||
const busy = result.mentionResults.filter((m) => m.outcome === "busy")
|
||||
if (woken.length > 0) addToast(`Notified: ${woken.map((m) => m.agentName).join(", ")}`, "success")
|
||||
if (queued.length > 0) addToast(`Queued: ${queued.map((m) => m.agentName).join(", ")}`, "warn")
|
||||
if (busy.length > 0) addToast(`Busy: ${busy.map((m) => m.agentName).join(", ")}`, "warn")
|
||||
}
|
||||
setBody(""); setSubject(""); setAttachments([]); onSent()
|
||||
} catch (e) { setSendError(e instanceof Error ? e.message : "Failed to send message.") }
|
||||
finally { setSending(false) }
|
||||
}, [body, channel, channelTargetId, companyId, directRecipientId, onSent, replyTo, subject, attachments, threadId, waitStateId, addToast])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void handleSend() } }, [handleSend])
|
||||
const fromAgent = agents.find((a) => a.id === fromAgentId)
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (mentionState.active) return // let MentionDropdown handle keys
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); void handleSend() }
|
||||
}, [handleSend, mentionState.active])
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card px-4 py-3">
|
||||
<div className="relative border-t border-border bg-card px-4 py-3">
|
||||
{sendError ? <div className="mb-3"><InlineNotice message={sendError} /></div> : null}
|
||||
{channel === "direct" && !directRecipientId ? <div className="mb-2"><InlineNotice message="Select a direct conversation partner before sending a DM." /></div> : null}
|
||||
<AnimatePresence>
|
||||
@@ -62,16 +288,38 @@ export function MessageComposer({ agents, companyId, channel, channelTargetId, d
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AgentAvatar agent={fromAgent} size="sm" />
|
||||
<select value={fromAgentId} onChange={(e) => setFromAgentId(e.target.value)} className="min-w-0 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium text-foreground">
|
||||
{agents.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.role})</option>)}
|
||||
</select>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary"><UserCircle size={16} weight="fill" /></div>
|
||||
<span className="text-[11px] font-medium text-foreground">You (Board)</span>
|
||||
{!replyTo ? <input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject (optional)" aria-label="Message subject" className="min-w-0 flex-1 rounded-lg border border-border bg-card px-2.5 py-1 text-[11px] text-foreground placeholder:text-muted-foreground" /> : null}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea ref={textareaRef} value={body} onChange={(e) => setBody(e.target.value)} onKeyDown={handleKeyDown} placeholder="Type a message..." aria-label="Message body" rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} />
|
||||
<SendButton sending={sending} disabled={!body.trim() || !fromAgentId || (channel === "direct" && !directRecipientId)} onClick={() => void handleSend()} label="Send message" />
|
||||
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((att) => (
|
||||
<span key={att.id} className="inline-flex items-center gap-1 rounded-lg bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary">
|
||||
<FileText size={10} />
|
||||
<span className="max-w-[160px] truncate">{att.title}</span>
|
||||
<button type="button" onClick={() => handleRemoveAttachment(att.id)} className="ml-0.5 rounded-sm p-0.5 transition hover:bg-primary/20"><X size={8} /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-end gap-2">
|
||||
<AttachmentPicker documents={documents} attachments={attachments} onAdd={handleAddAttachment} onRemove={handleRemoveAttachment} />
|
||||
<textarea ref={textareaRef} value={body} onChange={handleBodyChange} onKeyDown={handleKeyDown} placeholder="Type a message... (use @ to mention agents)" aria-label="Message body" rows={2} className="min-h-[40px] min-w-0 flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-[13px] text-foreground placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-2" style={{ focusRingColor: "var(--primary)" } as React.CSSProperties} />
|
||||
<SendButton sending={sending} disabled={!body.trim() || (channel === "direct" && !directRecipientId)} onClick={() => void handleSend()} label="Send message" />
|
||||
</div>
|
||||
|
||||
{mentionState.active && (
|
||||
<MentionDropdown
|
||||
agents={agents}
|
||||
query={mentionState.query}
|
||||
onSelect={handleMentionSelect}
|
||||
position={{ top: 60, left: 48 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">Press {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+Enter to send</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
CheckCircle,
|
||||
WarningCircle,
|
||||
ArrowBendUpLeft,
|
||||
UserCircle,
|
||||
} from "@phosphor-icons/react"
|
||||
import type { AgentMessageRecord, AgentRecord, CommentRecord, DepartmentId } from "@shared/types"
|
||||
import { getAgentInitials, formatMessageTime } from "../../lib/formatters"
|
||||
import { DEPARTMENT_COLORS } from "@shared/constants"
|
||||
|
||||
const HUMAN_SENDER_ID = "__board__";
|
||||
|
||||
|
||||
const priorityConfig: Record<string, { label: string; className: string }> = {
|
||||
urgent: { label: "Urgent", className: "bg-destructive-muted text-destructive" },
|
||||
@@ -59,8 +62,10 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
onReply: () => void
|
||||
}) {
|
||||
const isUnread = !message.readAt
|
||||
const isUrgent = message.priority === "urgent"
|
||||
const isHuman = message.fromAgentId === HUMAN_SENDER_ID
|
||||
const deptColor = agent ? getDepartmentColor(agent.department) : "var(--muted-foreground)"
|
||||
const senderColor = isHuman ? "var(--primary)" : deptColor
|
||||
const senderName = isHuman ? "You" : (agent?.name ?? "Unknown Agent")
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -75,12 +80,14 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
: "hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<AgentAvatar agent={agent} />
|
||||
{isHuman
|
||||
? <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/15 text-primary"><UserCircle size={20} weight="fill" /></div>
|
||||
: <AgentAvatar agent={agent} />}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px] font-semibold" style={{ color: deptColor }}>
|
||||
{agent?.name ?? "Unknown Agent"}
|
||||
<span className="truncate text-[13px] font-semibold" style={{ color: senderColor }}>
|
||||
{senderName}
|
||||
</span>
|
||||
{agent?.title ? (
|
||||
<span className="truncate text-[11px] text-muted-foreground">{agent.title}</span>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function MessageList({
|
||||
activeChannel,
|
||||
filteredMessages,
|
||||
taskDiscussionComments,
|
||||
activeTask,
|
||||
activeTask: _activeTask,
|
||||
agentMap,
|
||||
filters,
|
||||
onReply,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Thread Panel
|
||||
*
|
||||
* Renders a thread event timeline with wait-state indicators and a
|
||||
* message composer for scoped replies. Designed to slide in as a
|
||||
* right-side panel alongside the main message area.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { X, ChatCircleDots, ArrowBendUpLeft } from "@phosphor-icons/react"
|
||||
import { motion } from "framer-motion"
|
||||
import type { AgentRecord, ConversationEventRecord, ThreadWaitStateRecord, ConversationThreadRecord, DocumentRecord } from "@shared/types"
|
||||
import WaitStateIndicator, { type WaitStateInfo } from "./WaitStateIndicator"
|
||||
import { MessageComposer } from "./MessageComposer"
|
||||
|
||||
interface ThreadPanelProps {
|
||||
threadId: string
|
||||
companyId: string
|
||||
agents: AgentRecord[]
|
||||
documents: DocumentRecord[]
|
||||
onClose: () => void
|
||||
onNavigateApproval?: (approvalId: string) => void
|
||||
}
|
||||
|
||||
function EventItem({ event, agents }: { event: ConversationEventRecord; agents: AgentRecord[] }) {
|
||||
const actorName = event.actorType === "user"
|
||||
? "You"
|
||||
: agents.find((a) => a.id === event.actorId)?.name ?? event.actorId
|
||||
|
||||
const time = new Date(event.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
|
||||
const iconClass = event.eventType === "status_change"
|
||||
? "text-blue-500"
|
||||
: event.eventType === "approval_request"
|
||||
? "text-amber-500"
|
||||
: event.eventType === "approval_decision"
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground"
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 px-3 py-2">
|
||||
<div className={`mt-0.5 shrink-0 ${iconClass}`}>
|
||||
{event.actorType === "user"
|
||||
? <ArrowBendUpLeft size={14} />
|
||||
: <ChatCircleDots size={14} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-foreground">{actorName}</span>
|
||||
<span className="text-xs text-muted-foreground">{time}</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/80">{event.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ThreadPanel({ threadId, companyId, agents, documents, onClose, onNavigateApproval }: ThreadPanelProps) {
|
||||
const [thread, setThread] = useState<ConversationThreadRecord | null>(null)
|
||||
const [events, setEvents] = useState<ConversationEventRecord[]>([])
|
||||
const [waitStates, setWaitStates] = useState<ThreadWaitStateRecord[]>([])
|
||||
const [replyWaitStateId, setReplyWaitStateId] = useState<string | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadThread = useCallback(async () => {
|
||||
const threadResult = await window.agentCompany.getThread({ threadId, companyId })
|
||||
if (threadResult.ok) setThread(threadResult.data)
|
||||
|
||||
const eventsResult = await window.agentCompany.listThreadEvents({ threadId, companyId, limit: 100, offset: 0 })
|
||||
if (eventsResult.ok) setEvents(eventsResult.data)
|
||||
|
||||
const wsResult = await window.agentCompany.listThreadWaitStates({ companyId, resolvedOnly: false })
|
||||
if (wsResult.ok) {
|
||||
setWaitStates(wsResult.data.filter((ws) => ws.threadId === threadId && !ws.resolvedAt))
|
||||
}
|
||||
}, [threadId, companyId])
|
||||
|
||||
useEffect(() => { void loadThread() }, [loadThread])
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [events])
|
||||
|
||||
const handleReply = useCallback((waitStateId: string, _threadId: string) => {
|
||||
setReplyWaitStateId(waitStateId)
|
||||
}, [])
|
||||
|
||||
const handleSent = useCallback(() => {
|
||||
setReplyWaitStateId(null)
|
||||
void loadThread()
|
||||
}, [loadThread])
|
||||
|
||||
const activeReplyWaitState = waitStates.find((waitState) => waitState.waitingFor !== "user_approval") ?? null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ x: 380, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 380, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex h-full w-[380px] shrink-0 flex-col border-l border-border bg-background"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<ChatCircleDots size={18} className="text-muted-foreground" />
|
||||
<h3 className="flex-1 truncate text-sm font-medium text-foreground">
|
||||
{thread?.title ?? "Thread"}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Close thread"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Wait State Indicators */}
|
||||
{waitStates.length > 0 && (
|
||||
<div className="space-y-1 border-b border-border p-2">
|
||||
{waitStates.map((ws) => (
|
||||
<WaitStateIndicator
|
||||
key={ws.id}
|
||||
waitState={ws as WaitStateInfo}
|
||||
agents={agents}
|
||||
onReply={handleReply}
|
||||
onNavigateApproval={onNavigateApproval}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Timeline */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{events.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground">
|
||||
No events yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50 py-1">
|
||||
{events.map((event) => (
|
||||
<EventItem key={event.id} event={event} agents={agents} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div className="border-t border-border">
|
||||
<MessageComposer
|
||||
agents={agents}
|
||||
companyId={companyId}
|
||||
channel="direct"
|
||||
channelTargetId={null}
|
||||
replyTo={null}
|
||||
onSent={handleSent}
|
||||
onCancelReply={() => setReplyWaitStateId(null)}
|
||||
documents={documents}
|
||||
threadId={threadId}
|
||||
waitStateId={replyWaitStateId ?? activeReplyWaitState?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Wait State Indicator
|
||||
*
|
||||
* Renders an inline banner within a thread or channel when an agent
|
||||
* is waiting for human input (reply, review, or approval).
|
||||
* Provides a primary action button to reply or navigate to the approval.
|
||||
*/
|
||||
|
||||
import { Hourglass, ChatCircle, CheckCircle } from "@phosphor-icons/react"
|
||||
import type { AgentRecord } from "@shared/types"
|
||||
|
||||
export interface WaitStateInfo {
|
||||
id: string
|
||||
threadId: string
|
||||
companyId: string
|
||||
waitingFor: "user_reply" | "user_review" | "user_approval"
|
||||
reason: string
|
||||
createdAt: string
|
||||
relatedApprovalId: string | null
|
||||
}
|
||||
|
||||
interface WaitStateIndicatorProps {
|
||||
waitState: WaitStateInfo
|
||||
agents: AgentRecord[]
|
||||
onReply: (waitStateId: string, threadId: string) => void
|
||||
onNavigateApproval?: (approvalId: string) => void
|
||||
}
|
||||
|
||||
function formatWaitDuration(createdAt: string): string {
|
||||
const diff = Date.now() - new Date(createdAt).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return "just now"
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export default function WaitStateIndicator({ waitState, onReply, onNavigateApproval }: WaitStateIndicatorProps) {
|
||||
const isApproval = waitState.waitingFor === "user_approval" && waitState.relatedApprovalId
|
||||
const isReview = waitState.waitingFor === "user_review"
|
||||
const duration = formatWaitDuration(waitState.createdAt)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border-l-2 border-amber-500 bg-amber-50 px-3 py-2 dark:border-amber-400 dark:bg-amber-950/30">
|
||||
<Hourglass size={18} weight="fill" className="shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{waitState.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">Waiting since {duration}</p>
|
||||
</div>
|
||||
{isApproval && onNavigateApproval ? (
|
||||
<button
|
||||
onClick={() => onNavigateApproval(waitState.relatedApprovalId!)}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600"
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
Review
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onReply(waitState.id, waitState.threadId)}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600"
|
||||
>
|
||||
<ChatCircle size={14} />
|
||||
{isReview ? "Review" : "Reply"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { AgentMessageRecord, AgentRecord, CommentRecord, MessageChannel, ProjectRecord, TaskRecord } from "@shared/types"
|
||||
import type { ChannelDescriptor, TaskDiscussionDescriptor } from "./ChannelHeader"
|
||||
|
||||
const BOARD_ID = "__board__"
|
||||
|
||||
function labelForParticipant(id: string | undefined, agentMap: Map<string, AgentRecord>): string {
|
||||
if (!id) return "Unknown"
|
||||
if (id === BOARD_ID) return "Board"
|
||||
return agentMap.get(id)?.name ?? id.slice(0, 8)
|
||||
}
|
||||
|
||||
export function deriveChannels(messages: AgentMessageRecord[], agents: AgentRecord[], projects?: ProjectRecord[]): ChannelDescriptor[] {
|
||||
const channelMap = new Map<string, ChannelDescriptor>()
|
||||
const agentMap = new Map(agents.map((a) => [a.id, a]))
|
||||
@@ -21,7 +29,12 @@ export function deriveChannels(messages: AgentMessageRecord[], agents: AgentReco
|
||||
case "direct": {
|
||||
const otherId = msg.toAgentId
|
||||
if (!otherId) { key = "direct-broadcast"; label = "Broadcast DMs" }
|
||||
else { const ids = [msg.fromAgentId, otherId].sort(); key = `direct-${ids.join("-")}`; const f = agentMap.get(ids[0] ?? ""); const s = agentMap.get(ids[1] ?? ""); label = `${f?.name ?? (ids[0] ? ids[0].slice(0, 8) : "Unknown")} \u2194 ${s?.name ?? (ids[1] ? ids[1].slice(0, 8) : "Unknown")}`; agentId = ids[1] ?? null }
|
||||
else {
|
||||
const ids = [msg.fromAgentId, otherId].sort()
|
||||
key = `direct-${ids.join("-")}`
|
||||
label = `${labelForParticipant(ids[0], agentMap)} \u2194 ${labelForParticipant(ids[1], agentMap)}`
|
||||
agentId = ids.find((id) => id !== BOARD_ID) ?? ids[1] ?? null
|
||||
}
|
||||
break }
|
||||
case "department": { const d = msg.channelTargetId ?? "general"; key = `department-${d}`; label = `#${d.replace(/_/g, "-")}`; break }
|
||||
case "company": { key = "company-all"; label = "#company-wide"; break }
|
||||
|
||||
Reference in new issue
Block a user