Open a task detail view from the Work board and list
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user