Q187: a search hit opens the object itself and marks it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 08:10:22 -04:00
1 parent 6f6ed48549
commit 6bedba850c
23 files changed
+213 -68

No files matched your search

+1
View File
@@ -126,6 +126,7 @@ export function App() {
onSwitchOrg={(orgId) => requestLeave(() => void actions.run(() => window.agentCompany.setActiveOrganization({ orgId })))}
onDeleteOrg={(orgId) => requestLeave(() => void actions.run(() => window.agentCompany.deleteOrganization({ orgId })))}
onNewOrg={() => requestLeave(() => setCreatingOrg(true))}
onOpen={navigate}
>
<ErrorBoundary key={pageKey} scope="page">{page}</ErrorBoundary>
</Shell>
+5 -2
View File
@@ -1,3 +1,4 @@
import { useRevealObject } from "../shared/reveal";
import { isIdOrNull, usePersistentState } from "../shared/view-state";
import { useLeaveGuard } from "../app/leave-guard";
import { setDrafts, useDrafts } from "./chat/draft-store";
@@ -23,7 +24,7 @@ function value(form: FormData, key: string): string {
const IDLE_SEND: SendState = { pending: false, error: null };
const EMPTY_DRAFT: Draft = { body: "", attachmentFileIds: [] };
export function ChatPage({ snapshot, actions }: PageProps) {
export function ChatPage({ snapshot, actions, focus }: PageProps) {
const orgId = activeOrgId(snapshot);
const employee = requireActiveEmployee(snapshot);
const spaces = useMemo(() => snapshot.chatSpaces.filter((space) => space.orgId === orgId), [snapshot.chatSpaces, orgId]);
@@ -67,7 +68,8 @@ export function ChatPage({ snapshot, actions }: PageProps) {
}),
[spaces, snapshot.chatMemberships, messagesAll, snapshot.chatReadStates, employees, agents, employee.id, ceoAgentId],
);
const [selectedSpaceId, setSelectedSpaceId] = usePersistentState<string | null>(`${orgId}:chat.space`, null, isIdOrNull);
const [selectedSpaceId, setSelectedSpaceId] = usePersistentState<string | null>(`${orgId}:chat.space`, null, isIdOrNull, focus?.kind === "space" ? focus.id : focus?.kind === "message" ? focus.spaceId : undefined);
useRevealObject(focus?.kind === "message" ? focus.id : null);
const [createOpen, setCreateOpen] = useState(false);
const requestLeave = useLeaveGuard();
// Text and attachment are kept per conversation, outside the page, so switching conversations or pages loses neither.
@@ -236,6 +238,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
<ChatConversation
key={activeSpace?.id ?? "none"}
initialThreadId={focus?.kind === "message" && focus.spaceId === activeSpace?.id ? focus.threadId ?? null : null}
space={activeSpace}
members={members}
messagesInSpace={messagesInSpace}
+6 -3
View File
@@ -1,3 +1,4 @@
import { useRevealObject } from "../shared/reveal";
import { oneOf, usePersistentState } from "../shared/view-state";
import { useMemo } from "react";
import { Button, Icon, Segmented } from "../shared/ui";
@@ -28,7 +29,7 @@ const isSection = (value: unknown): value is Section => {
return (section.kind === "department" || section.kind === "agent") && typeof section.value === "string";
};
export function FilesPage({ snapshot, actions }: PageProps) {
export function FilesPage({ snapshot, actions, focus }: PageProps) {
const orgId = activeOrgId(snapshot);
const employees = snapshot.employees.filter((employee) => employee.orgId === orgId);
const agents = snapshot.agents.filter((agent) => agent.orgId === orgId);
@@ -38,9 +39,11 @@ export function FilesPage({ snapshot, actions }: PageProps) {
() => snapshot.fileAccessGrants.filter((grant) => grant.orgId === orgId),
[snapshot.fileAccessGrants, orgId],
);
const [section, setSection] = usePersistentState<Section>(`${orgId}:files.section`, { kind: "all" }, isSection);
const fileFocus = focus?.kind === "file" ? focus.id : null;
const [section, setSection] = usePersistentState<Section>(`${orgId}:files.section`, { kind: "all" }, isSection, fileFocus ? { kind: "all" } : undefined);
useRevealObject(fileFocus);
const [view, setView] = usePersistentState<ViewMode>(`${orgId}:files.view`, "list", oneOf(["grid", "list"] as const));
const [scanFilter, setScanFilter] = usePersistentState<FileScanState | "all">(`${orgId}:files.scan`, "all", (value): value is FileScanState | "all" => typeof value === "string");
const [scanFilter, setScanFilter] = usePersistentState<FileScanState | "all">(`${orgId}:files.scan`, "all", (value): value is FileScanState | "all" => typeof value === "string", fileFocus ? "all" : undefined);
const departments = collectDepartments(files, agents);
const filesByAgent = collectAgentFiles(files, agents);
+4 -4
View File
@@ -13,10 +13,10 @@ const TABS: Array<{ id: LibraryTab; label: string }> = [
{ id: "knowledge", label: t("Knowledge") },
];
export function LibraryPage({ snapshot, actions }: PageProps) {
export function LibraryPage({ snapshot, actions, focus }: PageProps) {
const orgId = activeOrgId(snapshot);
const employee = requireActiveEmployee(snapshot);
const [tab, setTab] = usePersistentState<LibraryTab>(`${orgId}:library.tab`, "documents", oneOf(["documents", "knowledge"] as const));
const [tab, setTab] = usePersistentState<LibraryTab>(`${orgId}:library.tab`, "documents", oneOf(["documents", "knowledge"] as const), focus?.kind === "document" ? "documents" : focus?.kind === "knowledge" ? "knowledge" : undefined);
const documents = useMemo(() => inOrg(snapshot.documents, orgId), [snapshot.documents, orgId]);
const entries = useMemo(() => inOrg(snapshot.knowledgeEntries, orgId), [snapshot.knowledgeEntries, orgId]);
const tasks = useMemo(() => inOrg(snapshot.tasks, orgId), [snapshot.tasks, orgId]);
@@ -50,8 +50,8 @@ export function LibraryPage({ snapshot, actions }: PageProps) {
<div className="page">
<h1 className="page-h1">{tab === "documents" ? t("Documents") : t("Knowledge")}</h1>
{tab === "documents"
? <DocumentsPanel orgId={orgId} employeeId={employee.id} documents={documents} tasks={tasks} actions={actions} />
: <KnowledgePanel orgId={orgId} employeeId={employee.id} entries={entries} scopes={scopes} actions={actions} />}
? <DocumentsPanel focusId={focus?.kind === "document" ? focus.id : null} orgId={orgId} employeeId={employee.id} documents={documents} tasks={tasks} actions={actions} />
: <KnowledgePanel focusId={focus?.kind === "knowledge" ? focus.id : null} orgId={orgId} employeeId={employee.id} entries={entries} scopes={scopes} actions={actions} />}
</div>
</div>
</div>
+3 -1
View File
@@ -1,3 +1,4 @@
import { useRevealObject } from "../shared/reveal";
import { oneOf, usePersistentState } from "../shared/view-state";
import { useLeaveGuard } from "../app/leave-guard";
import { useMemo } from "react";
@@ -29,7 +30,8 @@ export function SettingsPage({ snapshot, actions, focus }: PageProps) {
const organization = requireActiveOrganization(snapshot);
const employee = requireActiveEmployee(snapshot);
// A workspace link opens General, where workspaces are, whatever section was left open.
const [tab, setTab] = usePersistentState<SettingsTab>(`${orgId}:settings.tab`, "general", oneOf(SETTINGS_TABS), focus?.kind === "workspace" ? "general" : undefined);
const [tab, setTab] = usePersistentState<SettingsTab>(`${orgId}:settings.tab`, "general", oneOf(SETTINGS_TABS), focus?.kind === "workspace" ? "general" : focus?.kind === "provider" ? "providers" : undefined);
useRevealObject(focus?.kind === "provider" ? focus.id : null);
const requestLeave = useLeaveGuard();
const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]);
+5 -2
View File
@@ -1,3 +1,4 @@
import { useRevealObject } from "../shared/reveal";
import { oneOf, usePersistentState } from "../shared/view-state";
import { agentModel } from "../../../shared/agent-model";
import { useMemo, useState, type FormEvent } from "react";
@@ -19,13 +20,14 @@ function optional(form: FormData, key: string): string | null {
return text(form, key) || null;
}
export function TeamPage({ snapshot, actions }: PageProps) {
export function TeamPage({ snapshot, actions, focus }: PageProps) {
const orgId = activeOrgId(snapshot);
const [hireOpen, setHireOpen] = useState(false);
const [hireProviderId, setHireProviderId] = useState("");
const [hireModelId, setHireModelId] = useState("");
const [editAgentId, setEditAgentId] = useState<string | null>(null);
const [filter, setFilter] = usePersistentState<"all" | "human" | "agent">(`${orgId}:team.filter`, "all", oneOf(["all", "human", "agent"] as const));
const [filter, setFilter] = usePersistentState<"all" | "human" | "agent">(`${orgId}:team.filter`, "all", oneOf(["all", "human", "agent"] as const), focus?.kind === "agent" || focus?.kind === "employee" ? "all" : undefined);
useRevealObject(focus?.kind === "agent" || focus?.kind === "employee" ? focus.id : null);
const employees = useMemo(() => snapshot.employees.filter((item) => item.orgId === orgId), [snapshot.employees, orgId]);
const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]);
@@ -291,6 +293,7 @@ function ModelSuggestions({ id, providerId, providers, catalog }: { id: string;
function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onEdit: (() => void) | null }) {
return (
<div
data-object-id={row.id}
className="table-row"
style={{ gridTemplateColumns: "minmax(0, 2fr) minmax(0, 1.2fr) minmax(0, 1.4fr) minmax(0, 1.4fr) 60px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
@@ -24,6 +24,7 @@ import { Popover } from "../../shared/Popover";
import { t } from "../../i18n/i18n";
export function ChatConversation({
initialThreadId,
space,
members,
messagesInSpace,
@@ -52,6 +53,8 @@ export function ChatConversation({
send,
composerRef,
}: {
/** The thread to show at once, as when a search hit opens one of its messages. */
initialThreadId: string | null;
space: ChatSpaceRecord | null;
members: SpaceMember[];
messagesInSpace: ChatMessageRecord[];
@@ -83,7 +86,7 @@ export function ChatConversation({
}) {
const [query, setQuery] = useState("");
// The component is keyed by space, so an open thread closes when the conversation changes.
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
const [openThreadId, setOpenThreadId] = useState<string | null>(initialThreadId);
// A reply typed in one thread is lost by opening another or closing the panel.
const requestLeave = useLeaveGuard();
// The snapshot holds recent chat only; older messages come from the database, paged back from the oldest one loaded.
@@ -80,7 +80,7 @@ export function ChatMessageRow({
});
};
return (
<div className="chat-message" style={{ display: "flex", gap: 12, marginBottom: 18 }}>
<div className="chat-message" data-object-id={message.id} style={{ display: "flex", gap: 12, marginBottom: 18 }}>
<Avatar name={name} bot={bot} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="row" style={{ alignItems: "baseline", gap: 8, marginBottom: 2 }}>
@@ -57,6 +57,7 @@ export function PinnedCard({
const canOpen = file.scanState === "approved";
return (
<div
data-object-id={file.id}
className={canOpen ? "clickable-card" : ""}
role={canOpen ? "button" : undefined}
tabIndex={canOpen ? 0 : undefined}
@@ -66,6 +66,7 @@ function FileRow({
const canOpen = file.scanState === "approved";
return (
<div
data-object-id={file.id}
className={`table-row ${canOpen ? "clickable" : ""}`.trim()}
role={canOpen ? "button" : undefined}
tabIndex={canOpen ? 0 : undefined}
@@ -1,3 +1,4 @@
import { useRevealObject } from "../../shared/reveal";
import { isIdOrNull, usePersistentState } from "../../shared/view-state";
import { DOCUMENT_STATUS_VALUES, documentStatusLabel } from "../../i18n/labels";
import { useEffect, useState, type FormEvent } from "react";
@@ -10,19 +11,23 @@ import { formatTime } from "../types";
import { t } from "../../i18n/i18n";
export function DocumentsPanel({
focusId,
orgId,
employeeId,
documents,
tasks,
actions,
}: {
/** A document opened by a link: selected and shown. */
focusId: string | null;
orgId: string;
employeeId: string;
documents: DocumentRecord[];
tasks: TaskRecord[];
actions: SnapshotActions;
}) {
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${orgId}:library.document`, null, isIdOrNull);
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${orgId}:library.document`, null, isIdOrNull, focusId ?? undefined);
useRevealObject(focusId);
const selected = documents.find((doc) => doc.id === selectedId) ?? null;
const [loaded, setLoaded] = useState<{ documentId: string; versions: DocumentVersionRecord[] } | null>(null);
const [shownVersion, setShownVersion] = useState<number | null>(null);
@@ -83,6 +88,7 @@ export function DocumentsPanel({
{documents.map((doc) => (
<button
key={doc.id}
data-object-id={doc.id}
type="button"
role="listitem"
aria-current={doc.id === selectedId}
@@ -1,3 +1,4 @@
import { useRevealObject } from "../../shared/reveal";
import { isIdOrNull, oneOf, usePersistentState } from "../../shared/view-state";
import { useEffect, useState, type FormEvent } from "react";
import type { AgentRecord, GoalRecord, KnowledgeEntryRecord, ProjectRecord, TaskRecord } from "../../../../shared/domain";
@@ -16,12 +17,15 @@ const FILTER_LABELS: Record<EntryState, string> = {
};
export function KnowledgePanel({
focusId,
orgId,
employeeId,
entries,
scopes,
actions,
}: {
/** An entry opened by a link: selected, shown, and its state's list chosen. */
focusId: string | null;
orgId: string;
employeeId: string;
entries: KnowledgeEntryRecord[];
@@ -29,8 +33,10 @@ export function KnowledgePanel({
actions: SnapshotActions;
}) {
const now = new Date().toISOString();
const [filter, setFilter] = usePersistentState<EntryState>(`${orgId}:library.knowledgeFilter`, "active", oneOf(["active", "superseded", "expired"] as const));
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${orgId}:library.entry`, null, isIdOrNull);
const focused = focusId ? entries.find((entry) => entry.id === focusId) ?? null : null;
const [filter, setFilter] = usePersistentState<EntryState>(`${orgId}:library.knowledgeFilter`, "active", oneOf(["active", "superseded", "expired"] as const), focused ? entryState(focused, now) : undefined);
const [selectedId, setSelectedId] = usePersistentState<string | null>(`${orgId}:library.entry`, null, isIdOrNull, focusId ?? undefined);
useRevealObject(focusId);
const [loaded, setLoaded] = useState<{ entryId: string; revisions: KnowledgeRevisionRecord[] } | null>(null);
const [dialog, setDialog] = useState<"new" | "revise" | "delete" | null>(null);
const [scopeType, setScopeType] = useState<KnowledgeScopeType>("organization");
@@ -116,6 +122,7 @@ export function KnowledgePanel({
{visible.map((entry) => (
<button
key={entry.id}
data-object-id={entry.id}
type="button"
role="listitem"
aria-current={entry.id === selectedId}
@@ -250,6 +250,7 @@ function ProviderRow({
const boundCount = agents.filter((agent) => agent.modelProviderId === provider.id).length;
return (
<div
data-object-id={provider.id}
className="reveal-row"
style={{
display: "flex",
@@ -299,6 +300,7 @@ function ProviderHealth({ provider }: { provider: ProviderRecord }) {
function ProviderCard({ provider, onConnect }: { provider: ProviderRecord; onConnect: () => void }) {
return (
<div
data-object-id={provider.id}
style={{
display: "flex",
alignItems: "center",
+4 -1
View File
@@ -9,8 +9,11 @@ import { t } from "../i18n/i18n";
/** One object a page should open when navigated to: a task, run, approval, workspace, or the goal or project to scope work by. */
export interface Focus {
kind: "task" | "run" | "approval" | "goal" | "project" | "workspace";
kind: "task" | "run" | "approval" | "goal" | "project" | "workspace" | "space" | "message" | "document" | "knowledge" | "file" | "agent" | "employee" | "provider";
id: string;
/** A message's conversation and thread, so it can be shown even outside the loaded window. */
spaceId?: string;
threadId?: string;
}
export interface PageProps {
+34 -19
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
import type { AppSnapshot, SearchEntityType, SearchResultRecord } from "../../../shared/domain";
import { SEARCH_ENTITY_TYPES } from "../../../shared/domain";
import type { RouteId } from "./Shell";
import type { RouteId } from "./routes";
import type { Focus } from "../pages/types";
import { Button, Icon, Pill } from "./ui";
import { t } from "../i18n/i18n";
@@ -19,8 +20,6 @@ const LABELS: Record<SearchEntityType, string> = {
chat_message: t("Message"),
file: t("File"),
provider: t("Provider"),
social_account: t("Social"),
scheduled_post: t("Post"),
document: t("Document"),
knowledge: t("Knowledge"),
};
@@ -34,7 +33,7 @@ export function SearchDialog({
snapshot: AppSnapshot;
open: boolean;
onClose: () => void;
onNavigate: (route: RouteId) => void;
onNavigate: (route: RouteId, focus: Focus) => void;
}) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<SearchFilter>("all");
@@ -73,13 +72,17 @@ export function SearchDialog({
}, [onClose, open]);
if (!open) return null;
const openResult = (result: SearchResultRecord) => {
const target = targetFor(result);
onNavigate(target.route, target.focus);
onClose();
};
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const first = visibleResults[0];
if (!first) return;
onNavigate(routeFor(first.entityType));
onClose();
openResult(first);
};
return (
@@ -117,10 +120,7 @@ export function SearchDialog({
<button
key={`${result.entityType}:${result.entityId}`}
type="button"
onClick={() => {
onNavigate(routeFor(result.entityType));
onClose();
}}
onClick={() => openResult(result)}
style={rowStyle}
>
<Pill>{LABELS[result.entityType]}</Pill>
@@ -137,15 +137,30 @@ export function SearchDialog({
);
}
function routeFor(entityType: SearchEntityType): RouteId {
if (entityType === "employee" || entityType === "agent") return "team";
if (entityType === "approval") return "approvals";
if (entityType === "chat_space" || entityType === "chat_message") return "chat";
if (entityType === "file") return "files";
if (entityType === "provider") return "settings";
if (entityType === "social_account" || entityType === "scheduled_post") return "connectors";
if (entityType === "document" || entityType === "knowledge") return "library";
return "work";
/** The page and the object on it that a hit opens. */
function targetFor(result: SearchResultRecord): { route: RouteId; focus: Focus } {
const id = result.entityId;
switch (result.entityType) {
case "employee": return { route: "team", focus: { kind: "employee", id } };
case "agent": return { route: "team", focus: { kind: "agent", id } };
case "task": return { route: "work", focus: { kind: "task", id } };
case "goal": return { route: "work", focus: { kind: "goal", id } };
case "project": return { route: "work", focus: { kind: "project", id } };
case "workspace": return { route: "settings", focus: { kind: "workspace", id } };
case "approval": return { route: "approvals", focus: { kind: "approval", id } };
case "chat_space": return { route: "chat", focus: { kind: "space", id } };
case "chat_message": return { route: "chat", focus: { kind: "message", id, spaceId: requireFacet(result, "space"), threadId: requireFacet(result, "thread") } };
case "file": return { route: "files", focus: { kind: "file", id } };
case "provider": return { route: "settings", focus: { kind: "provider", id } };
case "document": return { route: "library", focus: { kind: "document", id } };
case "knowledge": return { route: "library", focus: { kind: "knowledge", id } };
}
}
function requireFacet(result: SearchResultRecord, name: string): string {
const value = result.facets[name];
if (!value) throw new Error(`Search result ${result.entityId} has no ${name}.`);
return value;
}
const overlayStyle = {
+5 -1
View File
@@ -1,3 +1,4 @@
import type { Focus } from "../pages/types";
import type { RouteId } from "./routes";
import { shortcutLabel } from "../i18n/labels";
import { isBusinessTask } from "../pages/work/task-helpers";
@@ -45,11 +46,14 @@ export function Shell({
onSwitchOrg,
onDeleteOrg,
onNewOrg,
onOpen,
children,
}: {
snapshot: AppSnapshot;
route: RouteId;
setRoute: (route: RouteId) => void;
/** Opens an object on its page, as a search hit does. */
onOpen: (route: RouteId, focus: Focus) => void;
onSwitchOrg: (orgId: string) => void;
onDeleteOrg: (orgId: string) => void;
onNewOrg: () => void;
@@ -163,7 +167,7 @@ export function Shell({
snapshot={snapshot}
open={searchOpen}
onClose={() => setSearchOpen(false)}
onNavigate={setRoute}
onNavigate={onOpen}
/>
</div>
);
+28
View File
@@ -0,0 +1,28 @@
import { useEffect } from "react";
const TRIES = 30;
const EVERY_MS = 100;
/** Brings the object a link opened into view and marks it, waiting briefly for content that loads after the page. */
export function useRevealObject(id: string | null | undefined): void {
useEffect(() => {
if (!id) return;
let tries = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const find = () => {
const node = document.querySelector(`[data-object-id="${CSS.escape(id)}"]`);
if (node) {
node.scrollIntoView({ block: "center" });
node.setAttribute("data-revealed", "true");
return;
}
tries += 1;
if (tries < TRIES) timer = setTimeout(find, EVERY_MS);
else console.warn(`[reveal] ${id} is not on screen`);
};
find();
return () => {
if (timer) clearTimeout(timer);
};
}, [id]);
}
+3
View File
@@ -670,3 +670,6 @@ a { color: inherit; }
transition-duration: 0.01ms !important;
}
}
/* The object a link or search hit opened. */
[data-revealed="true"] { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }