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:
1 parent
fb81505cb1
commit
64d4476f5a
9 files changed
+94
-126
No files matched your search
@@ -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": "仅人类",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user