AkurAI Build
Menu

popagent

public

Latest change baffb3c2ac834de138dc9ac86330273851896d24 - Move task board to root overview by AkurAI Build

import { useEffect, useRef, useState } from "react";
import { Activity, FolderGit2, LayoutDashboard, Menu, MessageSquare, Plus, Trash2, X } from "lucide-react";
import type { AgentTask, AgentWorkspace, SessionSummary } from "../api-types";
import { apiFetch } from "./api";
import { useFocusTrap } from "./use-focus-trap";
import { TaskKanbanBoard } from "./TaskKanban";

type WorkspaceOverviewProps = {
  workspaces: AgentWorkspace[];
  sessions: SessionSummary[];
  tasks: AgentTask[];
  taskStepLimit?: number;
  onCreateTask: (workspaceId: string, prompt: string) => Promise<boolean>;
  onCancelTask: (task: AgentTask) => void;
  onOpen: (workspaceId: string) => void;
  onManage: (workspace?: AgentWorkspace) => void;
  onOpenNavigation: () => void;
};

function latestActivity(workspace: AgentWorkspace, sessions: SessionSummary[], tasks: AgentTask[]): string {
  const timestamps = [
    workspace.updatedAt,
    ...sessions.filter((session) => session.workspaceId === workspace.id).map((session) => session.updatedAt),
    ...tasks.filter((task) => task.workspaceId === workspace.id).map((task) => task.completedAt ?? task.startedAt ?? task.createdAt),
  ].filter(Boolean).map((value) => new Date(value).getTime());
  const latest = Math.max(...timestamps);
  return Number.isFinite(latest) ? new Date(latest).toLocaleString() : "No activity";
}

export function WorkspaceOverview({
  workspaces,
  sessions,
  tasks,
  taskStepLimit,
  onCreateTask,
  onCancelTask,
  onOpen,
  onManage,
  onOpenNavigation,
}: WorkspaceOverviewProps) {
  const [taskWorkspaceId, setTaskWorkspaceId] = useState(workspaces[0]?.id ?? "");
  const [taskPrompt, setTaskPrompt] = useState("");
  const [creatingTask, setCreatingTask] = useState(false);
  useEffect(() => {
    if (!workspaces.some((workspace) => workspace.id === taskWorkspaceId)) setTaskWorkspaceId(workspaces[0]?.id ?? "");
  }, [taskWorkspaceId, workspaces]);
  const workspaceNames = Object.fromEntries(workspaces.map((workspace) => [workspace.id, workspace.name]));
  const submitTask = async () => {
    if (!taskWorkspaceId || !taskPrompt.trim() || creatingTask) return;
    setCreatingTask(true);
    try {
      if (await onCreateTask(taskWorkspaceId, taskPrompt.trim())) setTaskPrompt("");
    } finally {
      setCreatingTask(false);
    }
  };
  return (
    <main className="min-w-0 flex-1 overflow-y-auto px-4 py-6 md:px-8 md:py-10">
      <div className="mx-auto max-w-[90rem]">
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.14em] text-accent">
              <button type="button" aria-label="Open workspace navigation" onClick={onOpenNavigation} className="liquid-control flex size-10 items-center justify-center rounded lg:hidden"><Menu className="size-4" /></button>
              <LayoutDashboard className="size-4" />Master view
            </div>
            <h1 className="text-3xl font-semibold tracking-[-0.04em]">Workspace overview</h1>
            <p className="mt-2 max-w-2xl text-sm leading-relaxed text-ink-muted">
              Repositories, chats, and autonomous work in one place. Open a workspace to continue its conversations.
            </p>
          </div>
          <button type="button" onClick={() => onManage()} className="liquid-control flex min-h-11 items-center gap-2 rounded px-4 text-sm text-ink">
            <Plus className="size-4 text-accent" />New workspace
          </button>
        </div>

        <section className="mt-7 rounded-xl border border-line bg-panel p-4 md:p-5">
          <div className="flex flex-wrap items-start justify-between gap-3">
            <div><h2 className="font-medium">Start a task</h2><p className="mt-1 text-xs text-ink-dim">Send work to the agent team in a repository workspace.</p></div>
            <span className="rounded-full border border-line bg-bg px-2.5 py-1 text-[10px] uppercase tracking-wider text-ink-dim">{tasks.filter((task) => task.status === "queued" || task.status === "running" || task.status === "cancelling").length} active</span>
          </div>
          <div className="mt-4 grid gap-3 lg:grid-cols-[14rem_minmax(0,1fr)_auto] lg:items-end">
            <label className="text-xs font-medium text-ink-dim">Workspace
              <select aria-label="Task workspace" value={taskWorkspaceId} onChange={(event) => setTaskWorkspaceId(event.currentTarget.value)} className="mt-1 min-h-11 w-full rounded-md border border-line bg-bg px-3 text-sm text-ink outline-none focus:border-accent/50">
                {workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}
              </select>
            </label>
            <label className="text-xs font-medium text-ink-dim">Task
              <textarea aria-label="Task prompt" rows={2} value={taskPrompt} onChange={(event) => setTaskPrompt(event.currentTarget.value)} placeholder="Describe the outcome and evidence required…" className="mt-1 block min-h-11 w-full resize-y rounded-md border border-line bg-bg p-3 text-sm text-ink outline-none focus:border-accent/50" />
            </label>
            <button type="button" disabled={creatingTask || !taskWorkspaceId || !taskPrompt.trim()} onClick={() => void submitTask()} className="min-h-11 rounded-md bg-accent px-5 text-sm font-medium text-white disabled:opacity-50">{creatingTask ? "Starting…" : "Start task"}</button>
          </div>
        </section>

        <TaskKanbanBoard tasks={tasks} stepLimit={taskStepLimit} workspaceNames={workspaceNames} heading="Task board" onCancel={onCancelTask} />

        <div className="mt-8 flex items-center justify-between gap-4">
          <div><h2 className="font-medium">Repositories</h2><p className="mt-1 text-xs text-ink-dim">Open a workspace for chats and documentation.</p></div>
          <span className="text-xs tabular-nums text-ink-dim">{workspaces.length} workspace{workspaces.length === 1 ? "" : "s"}</span>
        </div>

        <div className="mt-3 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
          {workspaces.map((workspace) => {
            const workspaceSessions = sessions.filter((session) => session.workspaceId === workspace.id);
            const workspaceTasks = tasks.filter((task) => task.workspaceId === workspace.id);
            const activeTasks = workspaceTasks.filter((task) => task.status === "queued" || task.status === "running" || task.status === "cancelling");
            return (
              <article key={workspace.id} className="group rounded border border-line bg-panel p-5 transition-colors hover:border-accent/30">
                <div className="flex items-start gap-3">
                  <div className="brand-orb flex size-10 shrink-0 items-center justify-center rounded"><FolderGit2 className="size-5" /></div>
                  <div className="min-w-0 flex-1">
                    <h2 className="truncate font-semibold tracking-[-0.02em]">{workspace.name}</h2>
                    <code className="mt-1 block truncate text-xs text-ink-dim" title={workspace.repositoryPath}>{workspace.repositoryPath}</code>
                  </div>
                  <button type="button" onClick={() => onManage(workspace)} className="rounded px-2 py-1 text-xs text-ink-dim hover:bg-panel-2 hover:text-ink">Edit</button>
                </div>
                <div className="mt-5 grid grid-cols-2 gap-2">
                  <div className="rounded border border-line bg-bg/40 p-3">
                    <div className="flex items-center gap-1.5 text-xs text-ink-dim"><MessageSquare className="size-3.5" />Chats</div>
                    <div className="mt-1 text-xl font-semibold">{workspaceSessions.length}</div>
                  </div>
                  <div className="rounded border border-line bg-bg/40 p-3">
                    <div className="flex items-center gap-1.5 text-xs text-ink-dim"><Activity className="size-3.5" />Active work</div>
                    <div className="mt-1 text-xl font-semibold">{activeTasks.length}</div>
                  </div>
                </div>
                <div className="mt-4 text-[11px] text-ink-dim">Last activity · {latestActivity(workspace, workspaceSessions, workspaceTasks)}</div>
                <button type="button" onClick={() => onOpen(workspace.id)} className="mt-4 min-h-11 w-full rounded bg-accent-soft px-4 text-sm font-medium text-ink transition-colors hover:bg-accent/25">
                  Open workspace
                </button>
              </article>
            );
          })}
        </div>
      </div>
    </main>
  );
}

export function WorkspaceChatDeleteDialog({
  workspace,
  deleting,
  error,
  onCancel,
  onDelete,
}: {
  workspace?: AgentWorkspace;
  deleting: boolean;
  error: string;
  onCancel: () => void;
  onDelete: () => void;
}) {
  const dialogRef = useRef<HTMLDivElement>(null);
  useFocusTrap(dialogRef, Boolean(workspace));
  useEffect(() => {
    if (!workspace) return;
    dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
    const close = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !deleting) onCancel();
    };
    document.addEventListener("keydown", close);
    return () => document.removeEventListener("keydown", close);
  }, [deleting, onCancel, workspace]);
  if (!workspace) return null;
  return <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
    <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="workspace-chat-delete-title" className="w-full max-w-md rounded border border-line bg-bg p-5 shadow-2xl">
      <div className="flex items-center gap-2 text-red-300"><Trash2 className="size-5" /><h2 id="workspace-chat-delete-title" className="text-lg font-semibold text-ink">Delete all chats in “{workspace.name}”?</h2></div>
      <p className="mt-3 text-sm leading-relaxed text-ink-dim">This permanently deletes all active and archived chats in this workspace, including their transcripts and Mastra thread data. Independent long-term memories are retained.</p>
      {error ? <p role="alert" className="mt-3 text-sm text-red-300">{error}</p> : null}
      <div className="mt-5 flex justify-end gap-2"><button type="button" disabled={deleting} onClick={onCancel} className="min-h-11 rounded border border-line px-4 text-sm disabled:opacity-50">Cancel</button><button type="button" disabled={deleting} onClick={onDelete} className="min-h-11 rounded bg-red-500/90 px-4 text-sm font-medium text-white disabled:opacity-50">{deleting ? "Deleting…" : "Delete all chats"}</button></div>
    </div>
  </div>;
}

type WorkspaceDialogProps = {
  workspace?: AgentWorkspace;
  open: boolean;
  onClose: () => void;
  onSaved: (workspace?: AgentWorkspace) => void;
};

export function WorkspaceDialog({ workspace, open, onClose, onSaved }: WorkspaceDialogProps) {
  const [name, setName] = useState("");
  const [repositoryPath, setRepositoryPath] = useState("");
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string>();
  const dialogRef = useRef<HTMLDivElement>(null);
  useFocusTrap(dialogRef, open);

  useEffect(() => {
    if (!open) return;
    setName(workspace?.name ?? "");
    setRepositoryPath(workspace?.repositoryPath ?? "");
    setError(undefined);
    const close = (event: KeyboardEvent) => {
      if (event.key === "Escape") onClose();
    };
    document.addEventListener("keydown", close);
    return () => document.removeEventListener("keydown", close);
  }, [onClose, open, workspace]);

  if (!open) return null;
  const save = async () => {
    setSaving(true);
    setError(undefined);
    const response = await apiFetch(workspace ? `/api/workspaces/${workspace.id}` : "/api/workspaces", {
      method: workspace ? "PATCH" : "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ name, repositoryPath }),
    });
    if (response.ok) {
      onSaved((await response.json()) as AgentWorkspace);
    } else {
      const body = await response.json().catch(() => ({})) as { error?: string };
      setError(body.error ?? `Unable to save workspace: HTTP ${response.status}`);
    }
    setSaving(false);
  };
  const remove = async () => {
    if (!workspace || !confirm(`Delete the “${workspace.name}” workspace?`)) return;
    setSaving(true);
    setError(undefined);
    const response = await apiFetch(`/api/workspaces/${workspace.id}`, { method: "DELETE" });
    if (response.ok) onSaved();
    else {
      const body = await response.json().catch(() => ({})) as { error?: string };
      setError(body.error ?? `Unable to delete workspace: HTTP ${response.status}`);
    }
    setSaving(false);
  };

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
      <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="workspace-dialog-title" className="w-full max-w-lg rounded border border-line bg-panel p-5 shadow-2xl">
        <div className="flex items-center gap-3">
          <div className="brand-orb flex size-9 items-center justify-center rounded"><FolderGit2 className="size-4" /></div>
          <div className="min-w-0 flex-1"><h2 id="workspace-dialog-title" className="font-semibold">{workspace ? "Edit workspace" : "New workspace"}</h2><p className="text-xs text-ink-dim">Repository paths are relative to the configured workspace root.</p></div>
          <button type="button" aria-label="Close workspace editor" onClick={onClose} className="liquid-control flex size-10 items-center justify-center rounded"><X className="size-4" /></button>
        </div>
        <label className="mt-5 block text-xs font-medium text-ink-dim">Name<input autoFocus value={name} onChange={(event) => setName(event.currentTarget.value)} className="mt-1.5 min-h-11 w-full rounded border border-line bg-bg px-3 text-sm text-ink outline-none focus:border-accent/40" /></label>
        <label className="mt-4 block text-xs font-medium text-ink-dim">Repository path<input value={repositoryPath} onChange={(event) => setRepositoryPath(event.currentTarget.value)} placeholder="repository-name" className="mt-1.5 min-h-11 w-full rounded border border-line bg-bg px-3 font-mono text-sm text-ink outline-none focus:border-accent/40" /></label>
        {error ? <p className="mt-3 text-sm text-red-300">{error}</p> : null}
        <div className="mt-5 flex items-center justify-between gap-3">
          {workspace && workspace.id !== "default" ? <button type="button" disabled={saving} onClick={() => void remove()} className="min-h-11 rounded px-3 text-sm text-red-300 hover:bg-red-500/10 disabled:opacity-50">Delete workspace</button> : <span />}
          <div className="flex gap-2"><button type="button" disabled={saving} onClick={onClose} className="min-h-11 rounded border border-line px-4 text-sm disabled:opacity-50">Cancel</button><button type="button" disabled={saving || !name.trim() || !repositoryPath.trim()} onClick={() => void save()} className="min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-40">{saving ? "Saving…" : "Save workspace"}</button></div>
        </div>
      </div>
    </div>
  );
}