Open a task detail view from the Work board and list

This commit is contained in:
ZacharyZhang-NY committed 2026-05-28 22:21:06 -04:00
1 parent 881747341e
commit ae13111bff
4 files changed
+222 -44

No files matched your search

@@ -16,6 +16,8 @@ import {
type WorkspaceRecord,
} from "../../../shared/domain";
import { NewTaskDialog } from "./NewTaskDialog";
import { TaskDetailDialog } from "./work/TaskDetailDialog";
import { canRun, latestTaskRun, nextStatus, prettyRunState, prettyStatus, providerFor, runTone } from "./work/task-helpers";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
@@ -40,6 +42,7 @@ export function WorkPage({ snapshot, actions }: PageProps) {
const [newTaskOpen, setNewTaskOpen] = useState(false);
const [priorityFilter, setPriorityFilter] = useState<TaskPriority | "all">("all");
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const allTasks = useMemo(() => snapshot.tasks.filter((task) => task.orgId === orgId), [snapshot.tasks, orgId]);
const tasks = useMemo(
@@ -70,6 +73,7 @@ export function WorkPage({ snapshot, actions }: PageProps) {
const runTask = (task: TaskRecord) => {
void actions.run(() => window.agentCompany.runTask({ orgId, taskId: task.id }));
};
const detailTask = detailTaskId ? allTasks.find((task) => task.id === detailTaskId) ?? null : null;
return (
<div className="page-wide">
@@ -101,12 +105,34 @@ export function WorkPage({ snapshot, actions }: PageProps) {
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 === "list" && <ListView tasks={tasks} agents={agents} projects={projects} providers={providers} onRun={runTask} />}
{view === "timeline" && <TimelineView tasks={tasks} goals={goals} />}
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => setNewTaskOpen(false)} />
<TaskDetailDialog
task={detailTask}
agents={agents}
projects={projects}
goals={goals}
workspaces={workspaces}
providers={providers}
taskRuns={taskRuns}
onClose={() => setDetailTaskId(null)}
onAdvance={advance}
onRun={runTask}
/>
</div>
);
}
@@ -177,6 +203,7 @@ function BoardView({
onAdvance,
onRun,
onCreate,
onOpen,
}: {
tasks: TaskRecord[];
agents: AgentRecord[];
@@ -188,6 +215,7 @@ function BoardView({
onAdvance: (task: TaskRecord) => void;
onRun: (task: TaskRecord) => void;
onCreate: () => void;
onOpen: (task: TaskRecord) => void;
}) {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12, minWidth: 0 }}>
@@ -213,6 +241,7 @@ function BoardView({
latestRun={latestTaskRun(task.id, taskRuns)}
onAdvance={() => onAdvance(task)}
onRun={() => onRun(task)}
onOpen={() => onOpen(task)}
/>
))}
</div>
@@ -228,12 +257,14 @@ function ListView({
projects,
providers,
onRun,
onOpen,
}: {
tasks: TaskRecord[];
agents: AgentRecord[];
projects: ProjectRecord[];
providers: ProviderRecord[];
onRun: (task: TaskRecord) => void;
onOpen: (task: TaskRecord) => void;
}) {
return (
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
@@ -251,7 +282,11 @@ function ListView({
return (
<div
key={task.id}
className="table-row"
role="button"
tabIndex={0}
className="table-row clickable"
onClick={() => onOpen(task)}
onKeyDown={(event) => { if (event.key === "Enter") onOpen(task); }}
style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr) 88px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
>
<div className="row" style={{ gap: 8, minWidth: 0 }}>
@@ -271,9 +306,9 @@ function ListView({
<span style={{ color: "var(--text-4)" }}>—</span>
)}
</div>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 4 }}>
<div style={{ display: "flex", justifyContent: "flex-end", gap: 4 }} onClick={(event) => event.stopPropagation()}>
{assignee && <IconButton icon="send" label={`Run ${task.title}`} onClick={() => onRun(task)} disabled={!canRun(provider)} />}
<IconButton icon="more" label="More" />
<IconButton icon="more" label={`Open ${task.title}`} onClick={() => onOpen(task)} />
</div>
</div>
);
@@ -316,6 +351,7 @@ function TaskCard({
latestRun,
onAdvance,
onRun,
onOpen,
}: {
task: TaskRecord;
assignee: AgentRecord | null;
@@ -326,9 +362,14 @@ function TaskCard({
latestRun: TaskRunRecord | null;
onAdvance: () => void;
onRun: () => void;
onOpen: () => void;
}) {
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(event) => { if (event.key === "Enter") onOpen(); }}
style={{
background: "var(--panel)",
border: "1px solid var(--border)",
@@ -337,6 +378,7 @@ function TaskCard({
display: "flex",
flexDirection: "column",
gap: 10,
cursor: "pointer",
opacity: task.status === "done" ? 0.7 : 1,
}}
>
@@ -362,7 +404,7 @@ function TaskCard({
{workspace && <Pill>{workspace.name}</Pill>}
{task.requiresFilesystem && <Pill>workspace</Pill>}
{latestRun && <Pill tone={runTone(latestRun.state)}>{prettyRunState(latestRun.state)}</Pill>}
<div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }}>
<div style={{ marginLeft: "auto", display: "flex", gap: 6, alignItems: "center" }} onClick={(event) => event.stopPropagation()}>
{assignee && <Avatar name={assignee.name} size="sm" bot />}
{assignee && <IconButton icon="send" label={`Run ${task.title}`} onClick={onRun} disabled={!canRun(provider)} />}
{nextStatus(task.status) && <IconButton icon="check" label="Advance" onClick={onAdvance} />}
@@ -372,45 +414,6 @@ function TaskCard({
);
}
function nextStatus(status: TaskStatus): TaskStatus | null {
if (status === TASK_STATUS.BACKLOG) return TASK_STATUS.IN_PROGRESS;
if (status === TASK_STATUS.IN_PROGRESS) return TASK_STATUS.REVIEW;
if (status === TASK_STATUS.REVIEW) return TASK_STATUS.DONE;
return null;
}
function prettyStatus(status: TaskStatus): string {
return status.replaceAll("_", " ");
}
function prettyRunState(state: string): string {
return state.replaceAll("_", " ");
}
function providerFor(assignee: AgentRecord | null, providers: ProviderRecord[]): ProviderRecord | null {
if (!assignee?.modelProviderId) return null;
return providers.find((provider) => provider.id === assignee.modelProviderId) ?? null;
}
function canRun(provider: ProviderRecord | null): boolean {
return provider?.enabled === true && provider.authState === "configured";
}
function latestTaskRun(taskId: string, taskRuns: TaskRunRecord[]): TaskRunRecord | null {
for (let index = taskRuns.length - 1; index >= 0; index -= 1) {
const run = taskRuns[index];
if (run?.taskId === taskId) return run;
}
return null;
}
function runTone(state: string): "amber" | "green" | "red" | undefined {
if (state === "running" || state === "queued") return "amber";
if (state === "completed") return "green";
if (state === "failed") return "red";
return undefined;
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
@@ -0,0 +1,123 @@
import { Avatar, Button, Icon, IconButton, Pill } from "../../shared/ui";
import {
type AgentRecord,
type GoalRecord,
type ProjectRecord,
type ProviderRecord,
type TaskRecord,
type TaskRunRecord,
type WorkspaceRecord,
} from "../../../../shared/domain";
import { formatRelative } from "../types";
import { canRun, nextStatus, prettyRunState, prettyStatus, providerFor, runsForTask, runTone } from "./task-helpers";
export function TaskDetailDialog({
task,
agents,
projects,
goals,
workspaces,
providers,
taskRuns,
onClose,
onAdvance,
onRun,
}: {
task: TaskRecord | null;
agents: AgentRecord[];
projects: ProjectRecord[];
goals: GoalRecord[];
workspaces: WorkspaceRecord[];
providers: ProviderRecord[];
taskRuns: TaskRunRecord[];
onClose: () => void;
onAdvance: (task: TaskRecord) => void;
onRun: (task: TaskRecord) => void;
}) {
if (!task) return null;
const assignee = agents.find((agent) => agent.id === task.assigneeAgentId) ?? null;
const project = projects.find((item) => item.id === task.projectId) ?? null;
const goal = goals.find((item) => item.id === task.goalId) ?? null;
const workspace = workspaces.find((item) => item.id === task.workspaceId) ?? null;
const provider = providerFor(assignee, providers);
const runs = runsForTask(task.id, taskRuns);
const advanceTo = nextStatus(task.status);
return (
<div className="modal-backdrop" role="presentation" onMouseDown={onClose}>
<div className="modal" role="dialog" aria-label={task.title} onMouseDown={(event) => event.stopPropagation()}>
<div className="row" style={{ alignItems: "flex-start", gap: 10, marginBottom: 12 }}>
{task.priority === "high" && <span style={{ width: 3, height: 18, background: "var(--red)", borderRadius: 2, marginTop: 2, flexShrink: 0 }} />}
<h2 className="modal-title" style={{ margin: 0, flex: 1 }}>{task.title}</h2>
<IconButton icon="x" label="Close" onClick={onClose} />
</div>
<div className="row" style={{ flexWrap: "wrap", gap: 6, marginBottom: 14 }}>
<Pill>{prettyStatus(task.status)}</Pill>
<Pill tone={task.priority === "high" ? "amber" : undefined}>{task.priority}</Pill>
{task.risk === "high" && <Pill tone="red">high risk</Pill>}
{task.requiresFilesystem && <Pill>workspace</Pill>}
</div>
{task.description && (
<p style={{ margin: "0 0 16px", color: "var(--text-2)", fontSize: 13, lineHeight: 1.55, whiteSpace: "pre-wrap" }}>
{task.description}
</p>
)}
<div style={{ display: "grid", gridTemplateColumns: "100px 1fr", gap: "10px 14px", fontSize: 12.5 }}>
<MetaLabel>Assignee</MetaLabel>
<div className="row" style={{ gap: 8 }}>
{assignee ? (
<>
<Avatar name={assignee.name} size="sm" bot />
<span>{assignee.name}</span>
{provider && <span style={{ color: "var(--text-3)" }}>· {provider.label}</span>}
</>
) : (
<span style={{ color: "var(--text-4)" }}>Unassigned</span>
)}
</div>
{project && (<><MetaLabel>Project</MetaLabel><div>{project.name}</div></>)}
{goal && (<><MetaLabel>Goal</MetaLabel><div>{goal.title}</div></>)}
{workspace && (<><MetaLabel>Workspace</MetaLabel><div>{workspace.name}</div></>)}
</div>
{runs.length > 0 && (
<div style={{ marginTop: 18 }}>
<div className="section-label">Runs</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{runs.map((run) => (
<div key={run.id} className="row" style={{ gap: 8 }}>
<Pill tone={runTone(run.state)}>{prettyRunState(run.state)}</Pill>
<span style={{ color: "var(--text-3)", fontSize: 12 }}>{run.trigger}</span>
<span className="num" style={{ marginLeft: "auto", color: "var(--text-3)", fontSize: 12 }}>{formatRelative(run.createdAt)}</span>
{run.state === "failed" && run.errorMessage && (
<span style={{ color: "var(--red)", fontSize: 12, flexBasis: "100%" }}>{run.errorMessage}</span>
)}
</div>
))}
</div>
</div>
)}
<div className="modal-actions">
{assignee && (
<Button onClick={() => onRun(task)} disabled={!canRun(provider)}>
<Icon name="send" size={13} /> Run
</Button>
)}
{advanceTo && (
<Button tone="primary" onClick={() => onAdvance(task)}>
<Icon name="check" size={13} /> Advance to {prettyStatus(advanceTo)}
</Button>
)}
</div>
</div>
</div>
);
}
function MetaLabel({ children }: { children: string }) {
return <div style={{ color: "var(--text-3)" }}>{children}</div>;
}
@@ -0,0 +1,50 @@
import {
TASK_STATUS,
type AgentRecord,
type ProviderRecord,
type TaskRunRecord,
type TaskStatus,
} from "../../../../shared/domain";
export function nextStatus(status: TaskStatus): TaskStatus | null {
if (status === TASK_STATUS.BACKLOG) return TASK_STATUS.IN_PROGRESS;
if (status === TASK_STATUS.IN_PROGRESS) return TASK_STATUS.REVIEW;
if (status === TASK_STATUS.REVIEW) return TASK_STATUS.DONE;
return null;
}
export function prettyStatus(status: TaskStatus): string {
return status.replaceAll("_", " ");
}
export function prettyRunState(state: string): string {
return state.replaceAll("_", " ");
}
export function runTone(state: string): "amber" | "green" | "red" | undefined {
if (state === "running" || state === "queued") return "amber";
if (state === "completed") return "green";
if (state === "failed") return "red";
return undefined;
}
export function providerFor(assignee: AgentRecord | null, providers: ProviderRecord[]): ProviderRecord | null {
if (!assignee?.modelProviderId) return null;
return providers.find((provider) => provider.id === assignee.modelProviderId) ?? null;
}
export function canRun(provider: ProviderRecord | null): boolean {
return provider?.enabled === true && provider.authState === "configured";
}
export function latestTaskRun(taskId: string, taskRuns: TaskRunRecord[]): TaskRunRecord | null {
for (let index = taskRuns.length - 1; index >= 0; index -= 1) {
const run = taskRuns[index];
if (run?.taskId === taskId) return run;
}
return null;
}
export function runsForTask(taskId: string, taskRuns: TaskRunRecord[]): TaskRunRecord[] {
return taskRuns.filter((run) => run.taskId === taskId);
}
@@ -517,6 +517,8 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
border-bottom: 1px solid var(--border-soft);
}
.table-row:last-child { border-bottom: 0; }
.table-row.clickable { cursor: pointer; }
.table-row.clickable:hover { background: var(--hover); }
.split-main { display: flex; flex-direction: row; min-height: 0; flex: 1; }
.secondary-pane {