Five shell fixes: pinned Work header, honest close targets, a draggable sidebar top

- Work page is now a flush route: the title, project overview, and column
  headers stay put and each board column scrolls its own cards (List and
  Timeline scroll inside their own region). The page no longer scrolls its
  header away under 300 tasks.
- Team page drops the hiring-pipeline block (and its orphaned component and
  i18n key) — four columns of mostly-zero counters said nothing the roster
  doesn't.
- Clicking outside the search overlay closes it; the Close button stops
  being the only exit.
- The sidebar's top strip is a window drag region now (the topbar only
  covers the main pane). Interactive children opt out of dragging, and the
  org button shrinks to its content so the strip actually has drag area.
- The operator's name hugs their avatar in the sidebar foot: buttons
  center text by UA default and the flexed name span inherited it;
  text-align: left ends that.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-09 21:07:56 -04:00
1 parent fb81505cb1
commit 64d4476f5a
9 files changed
+94 -126

No files matched your search

-1
View File
@@ -179,7 +179,6 @@ export const zh: Record<string, string> = {
"Hire agent": "招聘智能体",
"Hires need your stamp; deliverables route through review before shipping.": "招聘需要你签字确认;交付物先经审核再发布。",
"Hires require approval": "招聘需审批",
"Hiring pipeline": "招聘进展",
"Home": "首页",
"How autonomous should the CEO be?": "CEO 的自主程度?",
"Humans only": "仅人类",
-3
View File
@@ -4,7 +4,6 @@ import { AvatarField } from "../shared/AvatarField";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
import { OrgChartTree } from "./team/OrgChartTree";
import { HiringPipeline } from "./team/HiringPipeline";
import type { AgentRecord, EmployeeRecord, ProviderRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId, money } from "./types";
@@ -119,8 +118,6 @@ export function TeamPage({ snapshot, actions }: PageProps) {
<OrgChartTree boardChair={employees[0] ?? null} agents={agents} onSelectAgent={setEditAgentId} />
</section>
<HiringPipeline agents={agents} approvals={snapshot.approvals.filter((a) => a.orgId === orgId)} />
<div className="section-label" style={{ marginBottom: 10 }}>{t("Roster")}</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div
+51 -41
View File
@@ -83,7 +83,7 @@ export function WorkPage({ snapshot, actions }: PageProps) {
const detailTask = detailTaskId ? allTasks.find((task) => task.id === detailTaskId) ?? null : null;
return (
<div className="page-wide">
<div className="page-wide work-page">
<TopbarActions>
<Segmented value={view} onChange={setView} options={VIEW_OPTIONS} />
<WorkFilter
@@ -104,30 +104,38 @@ export function WorkPage({ snapshot, actions }: PageProps) {
<ProjectOverview projects={projects} tasks={allTasks} agents={agents} workspaces={workspaces} chatSpaces={chatSpaces} />
{view === "board" && (
<BoardView
tasks={tasks}
agents={agents}
projects={projects}
providers={providers}
taskRuns={taskRuns}
onAdvance={advance}
onRun={runTask}
onCreate={() => setNewTaskOpen(true)}
onOpen={(task) => setDetailTaskId(task.id)}
/>
)}
{view === "list" && (
<ListView
tasks={tasks}
agents={agents}
projects={projects}
providers={providers}
onRun={runTask}
onOpen={(task) => setDetailTaskId(task.id)}
/>
)}
{view === "timeline" && <TimelineView tasks={tasks} goals={goals} projects={projects} />}
<div className="work-page-body">
{view === "board" && (
<BoardView
tasks={tasks}
agents={agents}
projects={projects}
providers={providers}
taskRuns={taskRuns}
onAdvance={advance}
onRun={runTask}
onCreate={() => setNewTaskOpen(true)}
onOpen={(task) => setDetailTaskId(task.id)}
/>
)}
{view === "list" && (
<div className="work-scroll">
<ListView
tasks={tasks}
agents={agents}
projects={projects}
providers={providers}
onRun={runTask}
onOpen={(task) => setDetailTaskId(task.id)}
/>
</div>
)}
{view === "timeline" && (
<div className="work-scroll">
<TimelineView tasks={tasks} goals={goals} projects={projects} />
</div>
)}
</div>
<GoalDialog open={newGoalOpen} orgId={orgId} agents={agents} actions={actions} onClose={() => setNewGoalOpen(false)} />
<ProjectDialog open={newProjectOpen} orgId={orgId} goals={goals} agents={agents} actions={actions} onClose={() => setNewProjectOpen(false)} />
@@ -230,30 +238,32 @@ function BoardView({
onOpen: (task: TaskRecord) => void;
}) {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12, minWidth: 0 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12, minWidth: 0, flex: 1, minHeight: 0 }}>
{COLUMNS.map((column) => {
const columnTasks = tasks.filter((task) => task.status === column.id);
return (
<div key={column.id} style={{ display: "flex", flexDirection: "column", gap: 8, minWidth: 0 }}>
<div className="row" style={{ padding: "4px 6px 8px" }}>
<div key={column.id} style={{ display: "flex", flexDirection: "column", minWidth: 0, minHeight: 0 }}>
<div className="row" style={{ padding: "4px 6px 8px", flexShrink: 0 }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: column.tone, flexShrink: 0 }} />
<span style={{ fontSize: 12, fontWeight: 540 }}>{column.label}</span>
<span className="num" style={{ fontSize: 12, color: "var(--text-3)" }}>{columnTasks.length}</span>
<IconButton icon="plus" label={t("Add task")} onClick={onCreate} />
</div>
{columnTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
assignee={agents.find((agent) => agent.id === task.assigneeAgentId) ?? null}
project={projects.find((project) => project.id === task.projectId) ?? null}
provider={providerFor(agents.find((agent) => agent.id === task.assigneeAgentId) ?? null, providers)}
latestRun={latestTaskRun(task.id, taskRuns)}
onAdvance={() => onAdvance(task)}
onRun={() => onRun(task)}
onOpen={() => onOpen(task)}
/>
))}
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", gap: 8, paddingBottom: 24 }}>
{columnTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
assignee={agents.find((agent) => agent.id === task.assigneeAgentId) ?? null}
project={projects.find((project) => project.id === task.projectId) ?? null}
provider={providerFor(agents.find((agent) => agent.id === task.assigneeAgentId) ?? null, providers)}
latestRun={latestTaskRun(task.id, taskRuns)}
onAdvance={() => onAdvance(task)}
onRun={() => onRun(task)}
onOpen={() => onOpen(task)}
/>
))}
</div>
</div>
);
})}
@@ -1,76 +0,0 @@
import { Avatar } from "../../shared/ui";
import type { AgentRecord, ApprovalRecord } from "../../../../shared/domain";
import { formatRelative } from "../types";
import { t } from "../../i18n/i18n";
interface PipelineStage {
id: string;
label: string;
color: string;
}
const STAGES: PipelineStage[] = [
{ id: "pending_approval", label: t("Pending approval"), color: "var(--amber)" },
{ id: "approved", label: t("Approved"), color: "var(--green)" },
{ id: "active", label: t("Active"), color: "var(--accent)" },
{ id: "rejected", label: t("Rejected"), color: "var(--red)" },
];
interface PipelineEntry {
agent: AgentRecord;
stage: string;
approval: ApprovalRecord | null;
}
function resolveStage(agent: AgentRecord, approvals: ApprovalRecord[]): PipelineEntry {
const hireApproval = approvals.find(
(a) => a.sourceEntityType === "agent" && a.sourceEntityId === agent.id && a.kind === "agent_hire",
) ?? null;
if (hireApproval?.state === "pending") return { agent, stage: "pending_approval", approval: hireApproval };
if (hireApproval?.state === "declined") return { agent, stage: "rejected", approval: hireApproval };
if (agent.lifecycle === "active") return { agent, stage: "active", approval: hireApproval };
return { agent, stage: "approved", approval: hireApproval };
}
export function HiringPipeline({ agents, approvals }: { agents: AgentRecord[]; approvals: ApprovalRecord[] }) {
const entries = agents.map((a) => resolveStage(a, approvals));
return (
<section style={{ marginBottom: 24 }}>
<div className="section-label" style={{ marginBottom: 12 }}>{t("Hiring pipeline")}</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12 }}>
{STAGES.map((stage) => {
const stageEntries = entries.filter((e) => e.stage === stage.id);
return (
<div key={stage.id}>
<div className="row" style={{ gap: 6, marginBottom: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: stage.color }} />
<span style={{ fontSize: 12, fontWeight: 540 }}>{stage.label}</span>
<span className="num" style={{ fontSize: 12, color: "var(--text-3)" }}>{stageEntries.length}</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{stageEntries.map((entry) => (
<div key={entry.agent.id} className="card" style={{ padding: 10 }}>
<div className="row" style={{ gap: 8 }}>
<Avatar name={entry.agent.name} size="sm" bot />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 500, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{entry.agent.name}</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{entry.agent.role} · {entry.agent.department}</div>
</div>
</div>
{entry.approval && (
<div style={{ marginTop: 6, fontSize: 12, color: "var(--text-3)" }}>
{formatRelative(entry.approval.createdAt)}
</div>
)}
</div>
))}
</div>
</div>
);
})}
</div>
</section>
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function OrgSwitcher({ snapshot, activeOrgId, onSelect, onNew, onDelete }
};
return (
<div ref={containerRef} style={{ position: "relative" }}>
<div ref={containerRef} className="sidebar-top">
<button
type="button"
className="sidebar-org"
+7 -1
View File
@@ -81,7 +81,13 @@ export function SearchDialog({
};
return (
<div role="dialog" aria-modal="true" aria-label={t("Search")} style={overlayStyle}>
<div
role="dialog"
aria-modal="true"
aria-label={t("Search")}
style={overlayStyle}
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<form onSubmit={submit} style={panelStyle}>
<div className="row" style={{ padding: "10px 12px", borderBottom: "1px solid var(--border-soft)" }}>
<Icon name="search" size={14} color="var(--text-3)" />
+1 -1
View File
@@ -28,7 +28,7 @@ interface NavSpec {
/** Routes whose pages own a full-height, multi-pane layout with their own
* internal scroll regions, so the content area must not scroll as one block. */
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "runs", "office"]);
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "runs", "office", "work"]);
const allNav: NavSpec[] = [
{ id: "home", label: t("Home") },
+33 -1
View File
@@ -146,6 +146,16 @@ a { color: inherit; }
}
/* Clear the native macOS traffic lights that overlap the sidebar top. */
.app-shell.is-mac .sidebar { padding-top: 36px; }
/* The sidebar's top strip doubles as a window drag handle (the topbar only
spans the main pane). Interactive children opt back out of dragging, and
the org button shrinks to its content so the rest of the strip drags. */
.sidebar-top {
position: relative;
-webkit-app-region: drag;
margin-bottom: var(--sp-4);
}
.sidebar-top button,
.sidebar-top [role="menu"] { -webkit-app-region: no-drag; }
.sidebar-org {
display: flex;
align-items: center;
@@ -156,7 +166,8 @@ a { color: inherit; }
text-align: left;
border-radius: var(--r-sm);
cursor: pointer;
margin-bottom: var(--sp-4);
width: fit-content;
max-width: 100%;
transition: background var(--motion-fast) var(--ease-out);
}
.sidebar-org:hover { background: var(--hover); }
@@ -509,6 +520,27 @@ a { color: inherit; }
}
.page { max-width: 1100px; }
.page-wide { width: 100%; }
/* The Work page is flush: its header (title + projects) stays put and only
the view below it scrolls — on the board, each column scrolls its own cards. */
.work-page {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: var(--sp-7) var(--sp-8) 0;
}
.work-page-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.work-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-bottom: var(--sp-8);
}
.page-h1 {
margin: 0 0 var(--sp-1);
font-family: var(--font-display);
+1 -1
View File
@@ -251,7 +251,7 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
.avatar-swatch { width: 18px; height: 18px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; }
.avatar-swatch.selected { border-color: var(--accent); }
.avatar-swatch.auto { background: conic-gradient(#5e6ad2, #e8b339, #26b69a, #5e6ad2); }
.sidebar-foot-button { display: flex; align-items: center; gap: 8px; width: 100%; background: none; border: none; padding: 0; cursor: pointer; color: inherit; font: inherit; border-radius: var(--r-sm); }
.sidebar-foot-button { display: flex; align-items: center; gap: 8px; width: 100%; background: none; border: none; padding: 0; cursor: pointer; color: inherit; font: inherit; text-align: left; border-radius: var(--r-sm); }
.sidebar-foot-button:hover .sidebar-foot-name { text-decoration: underline; }
.form-error { color: var(--red); font-size: var(--fs-caption); margin-top: 4px; }