Align chat, settings, and icons with design spec
- Chat: add 3-column layout with thread detail panel and thread reply composer - Settings: branded provider logos, connected/available split, Agents section label - Icons: add refresh icon (RefreshCw) to icon registry
This commit is contained in:
1 parent
5be8160c15
commit
f262fb9385
3 files changed
+216
-30
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import type { ChatAttachmentRecord, ChatSpaceRecord, FileObjectRecord } from "../../../shared/domain";
|
||||
import type { ChatAttachmentRecord, ChatSpaceRecord, ChatThreadRecord, FileObjectRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
@@ -21,9 +21,14 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
const files = snapshot.files.filter((item) => item.orgId === orgId);
|
||||
const [spaceId, setSpaceId] = useState(spaces[0]?.id ?? "");
|
||||
const [spaceOpen, setSpaceOpen] = useState(false);
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
const activeSpace = spaces.find((space) => space.id === spaceId) ?? spaces[0];
|
||||
if (!activeSpace) throw new Error("At least one chat space is required.");
|
||||
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace.id);
|
||||
const rootMessages = messages.filter((m) => m.threadId === null);
|
||||
const threads = snapshot.chatThreads.filter((t) => t.spaceId === activeSpace.id);
|
||||
const selectedThread = threads.find((t) => t.id === selectedThreadId) ?? null;
|
||||
const threadMessages = selectedThread ? messages.filter((m) => m.threadId === selectedThread.id) : [];
|
||||
const sender = employees[0];
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -45,6 +50,24 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
}));
|
||||
};
|
||||
|
||||
const submitReply = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!selectedThread || !sender) return;
|
||||
const form = new FormData(event.currentTarget);
|
||||
const bodyMd = messageBody(form);
|
||||
if (!bodyMd) return;
|
||||
event.currentTarget.reset();
|
||||
void actions.run(() => window.agentCompany.sendMessage({
|
||||
orgId,
|
||||
spaceId: activeSpace.id,
|
||||
threadId: selectedThread.id,
|
||||
senderType: "employee",
|
||||
senderId: sender.id,
|
||||
bodyMd,
|
||||
attachmentFileIds: [],
|
||||
}));
|
||||
};
|
||||
|
||||
const createSpace = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
@@ -84,8 +107,9 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
))}
|
||||
</NavGroup>
|
||||
</aside>
|
||||
|
||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
||||
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
|
||||
<Icon name={activeSpace.visibility === "restricted" ? "lock" : "hash"} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace.name}</div>
|
||||
<div style={{ color: "var(--text-3)", borderLeft: "1px solid var(--border)", paddingLeft: 10 }}>
|
||||
@@ -94,20 +118,26 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
<div style={{ marginLeft: "auto" }}><Icon name="search" /></div>
|
||||
</header>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}>
|
||||
{messages.length === 0 ? (
|
||||
{rootMessages.length === 0 ? (
|
||||
<div style={{ color: "var(--text-3)" }}>No messages</div>
|
||||
) : messages.map((message) => (
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
body={message.bodyMd}
|
||||
time={new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
name={displayName(message.senderType, message.senderId, employees, agents)}
|
||||
bot={message.senderType === "agent"}
|
||||
files={messageFiles(message.id, snapshot.chatAttachments, files)}
|
||||
/>
|
||||
))}
|
||||
) : rootMessages.map((message) => {
|
||||
const thread = threads.find((t) => t.rootMessageId === message.id) ?? null;
|
||||
const replyCount = thread ? messages.filter((m) => m.threadId === thread.id).length : 0;
|
||||
return (
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
body={message.bodyMd}
|
||||
time={new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
name={displayName(message.senderType, message.senderId, employees, agents)}
|
||||
bot={message.senderType === "agent"}
|
||||
files={messageFiles(message.id, snapshot.chatAttachments, files)}
|
||||
threadReplyCount={replyCount}
|
||||
onOpenThread={thread ? () => setSelectedThreadId(thread.id) : null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<form onSubmit={submit} style={{ padding: 14, paddingTop: 4 }}>
|
||||
<form onSubmit={submit} style={{ padding: 14, paddingTop: 4, flexShrink: 0 }}>
|
||||
<div className="card" style={{ padding: "10px 12px" }}>
|
||||
<textarea className="input" name="message" rows={2} aria-label="Message" />
|
||||
<div className="row" style={{ paddingTop: 8, borderTop: "1px solid var(--border-soft)" }}>
|
||||
@@ -123,6 +153,22 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{selectedThread && (
|
||||
<ThreadPanel
|
||||
thread={selectedThread}
|
||||
threadMessages={threadMessages}
|
||||
rootMessage={messages.find((m) => m.id === selectedThread.rootMessageId) ?? null}
|
||||
employees={employees}
|
||||
agents={agents}
|
||||
files={files}
|
||||
attachments={snapshot.chatAttachments}
|
||||
senderDisabled={!sender}
|
||||
onClose={() => setSelectedThreadId(null)}
|
||||
onSubmit={submitReply}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog title="New space" open={spaceOpen} submitLabel="Create" onClose={() => setSpaceOpen(false)} onSubmit={createSpace}>
|
||||
<Field label="Name"><input className="input" name="name" required /></Field>
|
||||
<Field label="Topic"><input className="input" name="topic" required /></Field>
|
||||
@@ -145,6 +191,74 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadPanel({
|
||||
thread,
|
||||
threadMessages,
|
||||
rootMessage,
|
||||
employees,
|
||||
agents,
|
||||
files,
|
||||
attachments,
|
||||
senderDisabled,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
thread: ChatThreadRecord;
|
||||
threadMessages: Array<{ id: string; bodyMd: string; createdAt: string; senderType: string; senderId: string | null; threadId: string | null }>;
|
||||
rootMessage: { id: string; bodyMd: string; createdAt: string; senderType: string; senderId: string | null } | null;
|
||||
employees: Array<{ id: string; displayName: string }>;
|
||||
agents: Array<{ id: string; name: string }>;
|
||||
files: FileObjectRecord[];
|
||||
attachments: ChatAttachmentRecord[];
|
||||
senderDisabled: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<aside style={{ width: 320, flexShrink: 0, borderLeft: "1px solid var(--border-soft)", background: "var(--bg)", display: "flex", flexDirection: "column" }}>
|
||||
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
|
||||
<span style={{ fontWeight: 600 }}>Thread</span>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<IconButton icon="x" label="Close thread" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: 16 }}>
|
||||
{rootMessage && (
|
||||
<div style={{ border: "1px solid var(--border)", borderRadius: 10, padding: 12, marginBottom: 16, background: "var(--panel)" }}>
|
||||
<MessageRow
|
||||
body={rootMessage.bodyMd}
|
||||
time={new Date(rootMessage.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
name={displayName(rootMessage.senderType, rootMessage.senderId, employees, agents)}
|
||||
bot={rootMessage.senderType === "agent"}
|
||||
files={messageFiles(rootMessage.id, attachments, files)}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{threadMessages.map((message) => (
|
||||
<MessageRow
|
||||
key={message.id}
|
||||
body={message.bodyMd}
|
||||
time={new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
name={displayName(message.senderType, message.senderId, employees, agents)}
|
||||
bot={message.senderType === "agent"}
|
||||
files={messageFiles(message.id, attachments, files)}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={onSubmit} style={{ padding: 12, flexShrink: 0 }}>
|
||||
<div className="card" style={{ padding: "8px 10px" }}>
|
||||
<textarea className="input" name="message" rows={2} aria-label="Thread reply" />
|
||||
<div style={{ paddingTop: 6, display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" size="small" type="submit" disabled={senderDisabled}><Icon name="send" /> Reply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavGroup({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
@@ -180,16 +294,22 @@ function MessageRow({
|
||||
time,
|
||||
bot,
|
||||
files,
|
||||
threadReplyCount = 0,
|
||||
onOpenThread,
|
||||
compact = false,
|
||||
}: {
|
||||
name: string;
|
||||
body: string;
|
||||
time: string;
|
||||
bot: boolean;
|
||||
files: FileObjectRecord[];
|
||||
threadReplyCount?: number;
|
||||
onOpenThread?: (() => void) | null;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
|
||||
<Avatar name={name} bot={bot} />
|
||||
<div style={{ display: "flex", gap: compact ? 10 : 12, marginBottom: compact ? 12 : 18 }}>
|
||||
<Avatar name={name} bot={bot} size={compact ? "sm" : ""} />
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="row" style={{ alignItems: "baseline", marginBottom: 2 }}>
|
||||
<span style={{ fontWeight: 540 }}>{name}</span>
|
||||
@@ -205,6 +325,20 @@ function MessageRow({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{threadReplyCount > 0 && onOpenThread && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenThread}
|
||||
style={{
|
||||
marginTop: 8, display: "inline-flex", alignItems: "center", gap: 6,
|
||||
fontSize: 11.5, color: "var(--accent)", cursor: "pointer",
|
||||
padding: "3px 8px", borderRadius: 6, background: "var(--accent-soft)",
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
<Icon name="chat" size={11} /> {threadReplyCount} {threadReplyCount === 1 ? "reply" : "replies"} →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -59,6 +59,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
<div className="split-main" style={{ height: "calc(100vh - 112px)", margin: "-28px -32px -40px" }}>
|
||||
<aside className="secondary-pane">
|
||||
<div style={{ fontWeight: 600, padding: "4px 8px 14px" }}>Settings</div>
|
||||
<div className="section-label" style={{ padding: "0 8px", marginBottom: 4, marginTop: 8 }}>Agents</div>
|
||||
<SettingsNav label="Providers" active={tab === "providers"} onClick={() => setTab("providers")} />
|
||||
<SettingsNav label="Skills" active={tab === "skills"} onClick={() => setTab("skills")} />
|
||||
<SettingsNav label="Policies" active={tab === "policies"} onClick={() => setTab("policies")} />
|
||||
@@ -106,23 +107,58 @@ function SettingsNav({ label, active, onClick }: { label: string; active: boolea
|
||||
}
|
||||
|
||||
function ProvidersPanel({ providers, onManage }: { providers: ProviderRecord[]; onManage: (provider: ProviderRecord) => void }) {
|
||||
const connected = providers.filter((p) => p.enabled);
|
||||
const available = providers.filter((p) => !p.enabled);
|
||||
return (
|
||||
<div className="page">
|
||||
<h1 className="page-h1">Providers</h1>
|
||||
<div className="section-label">Configured providers</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{providers.map((provider) => (
|
||||
<div key={provider.id} className="card row">
|
||||
<ProviderLogo label={provider.label} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 540 }}>{provider.label}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{provider.authType.replaceAll("_", " ")}</div>
|
||||
</div>
|
||||
<Pill tone={provider.enabled ? "green" : undefined}>{provider.enabled ? "Enabled" : "Unconfigured"}</Pill>
|
||||
<Button onClick={() => onManage(provider)}>Manage</Button>
|
||||
<p className="page-sub" style={{ marginBottom: 0 }}>Connect model providers — keys, accounts, OAuth.</p>
|
||||
{connected.length > 0 && (
|
||||
<>
|
||||
<div className="section-label" style={{ marginTop: 24, marginBottom: 10 }}>Connected</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 28 }}>
|
||||
{connected.map((provider) => (
|
||||
<ProviderConnectedRow key={provider.id} provider={provider} onManage={() => onManage(provider)} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{available.length > 0 && (
|
||||
<>
|
||||
<div className="section-label" style={{ marginTop: connected.length > 0 ? 0 : 24, marginBottom: 10 }}>Available</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 10 }}>
|
||||
{available.map((provider) => (
|
||||
<ProviderAvailableCard key={provider.id} provider={provider} onConnect={() => onManage(provider)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderConnectedRow({ provider, onManage }: { provider: ProviderRecord; onManage: () => void }) {
|
||||
return (
|
||||
<div className="card row">
|
||||
<ProviderLogo label={provider.label} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 540 }}>{provider.label}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{provider.authType.replaceAll("_", " ")}</div>
|
||||
</div>
|
||||
<Button onClick={onManage}>Manage</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderAvailableCard({ provider, onConnect }: { provider: ProviderRecord; onConnect: () => void }) {
|
||||
return (
|
||||
<div className="card row">
|
||||
<ProviderLogo label={provider.label} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{provider.label}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{provider.authType.replaceAll("_", " ")}</div>
|
||||
</div>
|
||||
<Button size="small" onClick={onConnect}>Connect</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -282,10 +318,24 @@ function requireRevision(
|
||||
return revision;
|
||||
}
|
||||
|
||||
const PROVIDER_BRAND: Record<string, [string, string]> = {
|
||||
"Anthropic": ["#cb9a5b", "A"],
|
||||
"OpenAI": ["#0a0a0b", "O"],
|
||||
"xAI Grok": ["#0a0a0b", "X"],
|
||||
"GitHub Copilot": ["#2da44e", "G"],
|
||||
"Google Antigravity": ["#3a7bd5", "G"],
|
||||
"GPT account": ["#10a37f", "G"],
|
||||
"Azure OpenAI": ["#0078d4", "Az"],
|
||||
"AWS Bedrock": ["#e87722", "A"],
|
||||
"Vertex AI": ["#4285f4", "V"],
|
||||
"Groq": ["#f55036", "Q"],
|
||||
};
|
||||
|
||||
function ProviderLogo({ label }: { label: string }) {
|
||||
const [bg, ch] = PROVIDER_BRAND[label] ?? ["#a1a1aa", label.slice(0, 2)];
|
||||
return (
|
||||
<div style={{ width: 30, height: 30, borderRadius: 7, display: "grid", placeItems: "center", background: "var(--text-1)", color: "#fff", fontWeight: 700 }}>
|
||||
{label.slice(0, 2)}
|
||||
<div style={{ width: 30, height: 30, borderRadius: 7, display: "grid", placeItems: "center", background: bg, color: "#fff", fontSize: 13, fontWeight: 600, flexShrink: 0 }}>
|
||||
{ch}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
@@ -47,6 +48,7 @@ const icons: Record<string, LucideIcon> = {
|
||||
minus: Minus,
|
||||
more: MoreHorizontal,
|
||||
plus: Plus,
|
||||
refresh: RefreshCw,
|
||||
search: Search,
|
||||
send: Send,
|
||||
settings: Settings,
|
||||
|
||||
Reference in new issue
Block a user