Harden PRD page coverage and migration audit
This commit is contained in:
1 parent
83a51c6881
commit
f107ecba31
12 files changed
+235
-25
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 { ChatSpaceRecord } from "../../../shared/domain";
|
||||
import type { ChatAttachmentRecord, ChatSpaceRecord, FileObjectRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
@@ -18,10 +18,12 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
const spaces = snapshot.chatSpaces.filter((item) => item.orgId === orgId);
|
||||
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||
const files = snapshot.files.filter((item) => item.orgId === orgId);
|
||||
const [spaceId, setSpaceId] = useState(spaces[0]?.id ?? "");
|
||||
const [spaceOpen, setSpaceOpen] = useState(false);
|
||||
const activeSpace = spaces.find((space) => space.id === spaceId) ?? spaces[0];
|
||||
const messages = snapshot.chatMessages.filter((item) => item.orgId === orgId && item.spaceId === activeSpace?.id);
|
||||
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 sender = employees[0];
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -31,6 +33,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
const bodyMd = messageBody(form);
|
||||
if (!bodyMd) return;
|
||||
event.currentTarget.reset();
|
||||
const attachmentFileId = value(form, "attachmentFileId");
|
||||
void actions.run(() => window.agentCompany.sendMessage({
|
||||
orgId,
|
||||
spaceId: activeSpace.id,
|
||||
@@ -38,6 +41,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
senderType: "employee",
|
||||
senderId: sender.id,
|
||||
bodyMd,
|
||||
attachmentFileIds: attachmentFileId ? [attachmentFileId] : [],
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -82,10 +86,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
</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)" }}>
|
||||
<Icon name={activeSpace?.visibility === "restricted" ? "lock" : "hash"} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{activeSpace?.name ?? "Messages"}</div>
|
||||
<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 }}>
|
||||
{activeSpace?.topic ?? ""}
|
||||
{activeSpace.topic}
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}><Icon name="search" /></div>
|
||||
</header>
|
||||
@@ -99,6 +103,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -107,7 +112,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
<textarea className="input" name="message" rows={2} aria-label="Message" />
|
||||
<div className="row" style={{ paddingTop: 8, borderTop: "1px solid var(--border-soft)" }}>
|
||||
<Icon name="attach" />
|
||||
<Icon name="social" />
|
||||
<select className="input" name="attachmentFileId" aria-label="File attachment" style={{ maxWidth: 220 }}>
|
||||
<option value="">File</option>
|
||||
{files.map((file) => <option key={file.id} value={file.id}>{file.name}</option>)}
|
||||
</select>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" size="small" type="submit" disabled={!activeSpace || !sender}><Icon name="send" /> Send</Button>
|
||||
</div>
|
||||
@@ -158,7 +166,7 @@ function SpaceItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||
<button type="button" aria-label={space.name} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} />
|
||||
<span>{space.name}</span>
|
||||
{count > 0 && <span className="nav-badge">{count}</span>}
|
||||
@@ -166,7 +174,19 @@ function SpaceItem({
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({ name, body, time, bot }: { name: string; body: string; time: string; bot: boolean }) {
|
||||
function MessageRow({
|
||||
name,
|
||||
body,
|
||||
time,
|
||||
bot,
|
||||
files,
|
||||
}: {
|
||||
name: string;
|
||||
body: string;
|
||||
time: string;
|
||||
bot: boolean;
|
||||
files: FileObjectRecord[];
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
|
||||
<Avatar name={name} bot={bot} />
|
||||
@@ -176,11 +196,34 @@ function MessageRow({ name, body, time, bot }: { name: string; body: string; tim
|
||||
<span className="num" style={{ color: "var(--text-3)", fontSize: 11 }}>{time}</span>
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.5, whiteSpace: "pre-wrap" }}>{body}</div>
|
||||
{files.length > 0 && (
|
||||
<div className="row" style={{ marginTop: 8, flexWrap: "wrap" }}>
|
||||
{files.map((file) => (
|
||||
<span key={file.id} className="attachment-pill">
|
||||
<Icon name="file" size={13} /> {file.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function messageFiles(
|
||||
messageId: string,
|
||||
attachments: ChatAttachmentRecord[],
|
||||
files: FileObjectRecord[],
|
||||
): FileObjectRecord[] {
|
||||
return attachments
|
||||
.filter((attachment) => attachment.messageId === messageId)
|
||||
.map((attachment) => {
|
||||
const file = files.find((item) => item.id === attachment.fileId);
|
||||
if (!file) throw new Error("Chat attachment file is missing.");
|
||||
return file;
|
||||
});
|
||||
}
|
||||
|
||||
function displayName(
|
||||
senderType: string,
|
||||
senderId: string | null,
|
||||
|
||||
@@ -67,7 +67,7 @@ function TreeItem({
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||
<button type="button" aria-label={label} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>
|
||||
<Icon name={icon} />
|
||||
<span>{label}</span>
|
||||
{typeof count === "number" && <span className="nav-badge">{count}</span>}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { activeOrgId, money } from "./types";
|
||||
|
||||
export function HomePage({ snapshot }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const organization = snapshot.organizations.find((item) => item.id === orgId)!;
|
||||
const organization = snapshot.organizations.find((item) => item.id === orgId);
|
||||
if (!organization) throw new Error("Active organization is missing.");
|
||||
const tasks = snapshot.tasks.filter((item) => item.orgId === orgId);
|
||||
const approvals = snapshot.approvals.filter((item) => item.orgId === orgId);
|
||||
const agents = snapshot.agents.filter((item) => item.orgId === orgId);
|
||||
|
||||
@@ -102,7 +102,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) {
|
||||
}
|
||||
|
||||
function SettingsNav({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) {
|
||||
return <button type="button" className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
|
||||
return <button type="button" aria-label={label} className={`nav-item ${active ? "active" : ""}`} onClick={onClick}>{label}</button>;
|
||||
}
|
||||
|
||||
function ProvidersPanel({ providers, onManage }: { providers: ProviderRecord[]; onManage: (provider: ProviderRecord) => void }) {
|
||||
@@ -231,8 +231,9 @@ function MigrationPanel({ reports, onCreate }: { reports: MigrationReportRecord[
|
||||
|
||||
function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
|
||||
const body = parseMigrationReport(report.reportJson);
|
||||
const idCount = Object.values(body.idMaps).reduce((sum, ids) => sum + ids.length, 0);
|
||||
return (
|
||||
<div className="table-row" style={{ gridTemplateColumns: "1.4fr 1fr 1fr" }}>
|
||||
<div className="table-row" style={{ gridTemplateColumns: "1.4fr 1fr 1fr 1fr" }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={report.sourceRef}>{report.sourceRef}</div>
|
||||
<div style={{ color: "var(--text-3)", fontSize: 11 }}>{new Date(report.createdAt).toLocaleString()}</div>
|
||||
@@ -240,15 +241,36 @@ function MigrationReportRow({ report }: { report: MigrationReportRecord }) {
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
{Object.entries(body.tableCounts).map(([table, count]) => <Pill key={table}>{table}: {count}</Pill>)}
|
||||
</div>
|
||||
<Pill>{body.missingTables.length} missing</Pill>
|
||||
<Pill>{Object.keys(body.tableChecksums).length} checksums</Pill>
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
<Pill>{idCount} ids</Pill>
|
||||
<Pill>{body.failures.length} failures</Pill>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseMigrationReport(reportJson: string): { tableCounts: Record<string, number>; missingTables: string[] } {
|
||||
const parsed = JSON.parse(reportJson) as { tableCounts?: Record<string, number>; missingTables?: string[] };
|
||||
if (!parsed.tableCounts || !Array.isArray(parsed.missingTables)) throw new Error("Migration report is invalid.");
|
||||
return { tableCounts: parsed.tableCounts, missingTables: parsed.missingTables };
|
||||
function parseMigrationReport(reportJson: string): {
|
||||
tableCounts: Record<string, number>;
|
||||
tableChecksums: Record<string, string>;
|
||||
idMaps: Record<string, string[]>;
|
||||
failures: Array<{ table: string; reason: string }>;
|
||||
} {
|
||||
const parsed = JSON.parse(reportJson) as {
|
||||
tableCounts?: Record<string, number>;
|
||||
tableChecksums?: Record<string, string>;
|
||||
idMaps?: Record<string, string[]>;
|
||||
failures?: Array<{ table: string; reason: string }>;
|
||||
};
|
||||
if (!parsed.tableCounts || !parsed.tableChecksums || !parsed.idMaps || !Array.isArray(parsed.failures)) {
|
||||
throw new Error("Migration report is invalid.");
|
||||
}
|
||||
return {
|
||||
tableCounts: parsed.tableCounts,
|
||||
tableChecksums: parsed.tableChecksums,
|
||||
idMaps: parsed.idMaps,
|
||||
failures: parsed.failures,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRevision(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Avatar, Button, Field, Icon } from "../shared/ui";
|
||||
import { Dialog } from "../shared/Dialog";
|
||||
import { SOCIAL_PLATFORM, type SocialAccountRecord, type SocialPlatform } from "../../../shared/domain";
|
||||
import {
|
||||
SOCIAL_PLATFORM,
|
||||
type SocialAccountGrantRecord,
|
||||
type SocialAccountRecord,
|
||||
type SocialPlatform,
|
||||
} from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
|
||||
@@ -13,6 +18,8 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const [open, setOpen] = useState(false);
|
||||
const accounts = snapshot.socialAccounts.filter((item) => item.orgId === orgId);
|
||||
const grants = snapshot.socialAccountGrants.filter((item) => item.orgId === orgId);
|
||||
const actionsByAccount = snapshot.browserActions.filter((item) => item.orgId === orgId);
|
||||
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -47,6 +54,8 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
||||
key={account.id}
|
||||
account={account}
|
||||
employeeName={authorizedEmployeeName(account.authorizedEmployeeId, employees)}
|
||||
grants={grants.filter((grant) => grant.socialAccountId === account.id)}
|
||||
actionCount={actionsByAccount.filter((action) => action.socialAccountId === account.id).length}
|
||||
onLogin={() => void actions.run(() => window.agentCompany.launchSocialLogin({ socialAccountId: account.id }))}
|
||||
/>
|
||||
))}
|
||||
@@ -76,10 +85,14 @@ export function SocialPage({ snapshot, actions }: PageProps) {
|
||||
function AccountCard({
|
||||
account,
|
||||
employeeName,
|
||||
grants,
|
||||
actionCount,
|
||||
onLogin,
|
||||
}: {
|
||||
account: SocialAccountRecord;
|
||||
employeeName: string | null;
|
||||
grants: SocialAccountGrantRecord[];
|
||||
actionCount: number;
|
||||
onLogin: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -97,6 +110,10 @@ function AccountCard({
|
||||
<span>{employeeName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="row" style={{ color: "var(--text-3)", fontSize: 11 }}>
|
||||
<span>{grants.length} grants</span>
|
||||
<span>{actionCount} actions</span>
|
||||
</div>
|
||||
<Button size="small" onClick={onLogin}>Sign in</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,10 @@ export function Shell({
|
||||
}) {
|
||||
const organization = snapshot.organizations.find((item) => item.id === snapshot.activeOrgId);
|
||||
const employee = snapshot.employees.find((item) => item.orgId === snapshot.activeOrgId);
|
||||
const activeRoute = navItems.find((item) => item.id === route);
|
||||
if (!organization) throw new Error("Active organization is missing.");
|
||||
if (!employee) throw new Error("Active employee is missing.");
|
||||
if (!activeRoute) throw new Error("Active route is missing.");
|
||||
const badges = {
|
||||
work: snapshot.tasks.filter((item) => item.orgId === snapshot.activeOrgId && item.status !== "done").length,
|
||||
chat: snapshot.chatMessages.filter((item) => item.orgId === snapshot.activeOrgId).length,
|
||||
@@ -41,7 +45,7 @@ export function Shell({
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">A</div>
|
||||
<div className="brand-name">{organization?.name ?? "AgentCompany"}</div>
|
||||
<div className="brand-name">{organization.name}</div>
|
||||
<Icon name="chevronDown" size={12} />
|
||||
</div>
|
||||
<nav className="nav">
|
||||
@@ -49,6 +53,7 @@ export function Shell({
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
className={`nav-item ${route === item.id ? "active" : ""}`}
|
||||
onClick={() => setRoute(item.id)}
|
||||
>
|
||||
@@ -59,16 +64,16 @@ export function Shell({
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-foot">
|
||||
<Avatar name={employee?.displayName ?? "User"} size="sm" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{employee?.displayName ?? "User"}</div>
|
||||
<Avatar name={employee.displayName} size="sm" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{employee.displayName}</div>
|
||||
<Icon name="bell" />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">
|
||||
<header className="topbar">
|
||||
<span className="crumb-sub">{organization?.name ?? "Workspace"}</span>
|
||||
<span className="crumb-sub">{organization.name}</span>
|
||||
<span style={{ color: "var(--text-4)" }}>/</span>
|
||||
<span className="crumb">{navItems.find((item) => item.id === route)?.label}</span>
|
||||
<span className="crumb">{activeRoute.label}</span>
|
||||
<div className="topbar-actions">
|
||||
{actions}
|
||||
{window.agentCompany.platform !== "darwin" && (
|
||||
|
||||
@@ -177,6 +177,20 @@ button { cursor: pointer; }
|
||||
.pill.amber { background: var(--amber-soft); color: var(--amber); border-color: transparent; }
|
||||
.pill.green { background: var(--green-soft); color: var(--green); border-color: transparent; }
|
||||
.pill.violet { background: var(--accent-soft); color: var(--accent); border-color: transparent; }
|
||||
.attachment-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 260px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--panel-2);
|
||||
color: var(--text-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.avatar {
|
||||
width: 28px;
|
||||
|
||||
Reference in new issue
Block a user