Files
kandev/apps/web/components/task/simple/chat-activity-tabs.tsx
T
2026-09-15 09:36:47 +01:00

193 lines
6.7 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import { CompositorPulse } from "@kandev/ui/compositor-pulse";
import { useAppStore } from "@/components/state-provider";
import { selectOfficeAgentProfiles } from "@/lib/state/slices/office/selectors";
import { agentTint } from "@/app/office/components/agent-avatar";
import { TaskChat } from "./task-chat";
import { TaskActivity } from "./task-activity";
import { AdvancedChatPanel } from "@/app/office/tasks/[id]/advanced-panels/chat-panel";
import {
groupSessionsForTimeline,
isGroupLive,
isOfficeGroup,
type SessionGroup,
} from "./session-groups";
import { ApprovalActionBar } from "./components/approval-action-bar";
import type {
Task,
TaskActivityEntry,
TaskComment,
TaskSession,
TimelineEvent,
} from "@/app/office/tasks/[id]/types";
import { useTranslation } from "react-i18next";
import { TaskLaunchErrorProvider } from "@/components/task/task-launch-error-context";
import { TaskSharedError } from "@/components/task/task-shared-error";
// TAB_TRIGGER_BASE adds a 1px ring to the active tab on top of
// shadcn's default bg/text active styling, so the selected tab is
// unambiguous across Chat / Activity / agent triggers without
// depending on subtle bg contrast.
const TAB_TRIGGER_BASE =
"cursor-pointer data-[state=active]:ring-1 data-[state=active]:ring-border";
function AgentTabTrigger({ group }: { group: SessionGroup }) {
const { t } = useTranslation();
const live = isGroupLive(group);
const agentProfileId = group.representative.agentProfileId;
// Resolve the agent's display name + role from the office store so a
// rename flows through automatically and we never fall back to the
// UUID that lands in `session.agentName` when the session's profile
// snapshot is empty.
const resolved = useAppStore((s) =>
agentProfileId ? selectOfficeAgentProfiles(s).find((a) => a.id === agentProfileId) : undefined,
);
const label = resolved?.name || group.representative.agentName || t("task:agent");
// Apply the per-agent tint only when the tab is active, so the
// selected state reads clearly. Inactive tabs inherit shadcn's muted
// styling — same as Chat / Activity — with a small dot in the agent
// color hinting at identity.
const activeTint = agentTint(label)
.split(/\s+/)
.filter(Boolean)
.map((c) => `data-[state=active]:${c}`)
.join(" ");
return (
<TabsTrigger
value={`agent-${group.id}`}
data-testid={`agent-tab-${group.id}`}
className={`${TAB_TRIGGER_BASE} ${activeTint}`}
>
<span className="inline-flex items-center gap-1.5">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${agentDot(label)}`} aria-hidden />
{label}
{live && (
<CompositorPulse
data-testid="agent-tab-live-dot"
className="inline-block h-1.5 w-1.5 rounded-full bg-primary animate-pulse"
aria-label={t("task:agentRunning")}
/>
)}
</span>
</TabsTrigger>
);
}
// agentDot returns just the background color class from the agent
// tint, so inactive tabs can show a tiny color dot for identification
// without bleeding the full tint into the tab background.
function agentDot(label: string): string {
const bg = agentTint(label)
.split(/\s+/)
.find((c) => c.startsWith("bg-"));
return bg ? bg.replace("/15", "") : "bg-muted-foreground/40";
}
function AgentTabContent({ taskId, group }: { taskId: string; group: SessionGroup }) {
// Pick the active session if any; otherwise fall back to the
// representative (most-recent). Mirrors `SessionTimelineEntry`.
const active = group.group.find((s) => s.state === "RUNNING");
const embedSessionId = active?.id ?? group.representative.id;
return (
<TabsContent value={`agent-${group.id}`}>
<div
data-testid={`agent-tab-embed-${group.id}`}
className="h-[500px] flex flex-col overflow-hidden"
>
<AdvancedChatPanel taskId={taskId} sessionId={embedSessionId} hideInput />
</div>
</TabsContent>
);
}
type ChatActivityTabsProps = {
task: Task;
comments: TaskComment[];
timeline?: TimelineEvent[];
activity: TaskActivityEntry[];
sessions: TaskSession[];
scrollParent: HTMLElement | null;
readOnly: boolean;
onCommentsChanged?: () => void;
};
/**
* Tab strip below the task properties: `Chat | Activity | <agent-1> | <agent-2> | …`.
*
* Office task sessions (one per (task, agent)) become per-agent sibling
* tabs whose content embeds the agent's session transcript via
* `AdvancedChatPanel`. Kanban / quick-chat sessions still render inline
* inside the Chat tab via `TaskChat`.
*/
export function ChatActivityTabs({
task,
comments,
timeline,
activity,
sessions,
scrollParent,
readOnly,
onCommentsChanged,
}: ChatActivityTabsProps) {
const { t } = useTranslation();
const officeGroups = useMemo(
() => groupSessionsForTimeline(sessions, task.reviewers, task.approvers).filter(isOfficeGroup),
[sessions, task.reviewers, task.approvers],
);
return (
<TaskLaunchErrorProvider
value={{
taskId: task.id,
workspaceId: task.workspaceId,
statusSummary: task.statusSummary,
repositories: task.repositories,
}}
>
<TaskSharedError />
<Tabs defaultValue="chat" className="mt-6">
<TabsList>
<TabsTrigger value="chat" className={TAB_TRIGGER_BASE}>
{t("task:chat")}
</TabsTrigger>
<TabsTrigger value="activity" className={TAB_TRIGGER_BASE}>
{t("task:activity")}
</TabsTrigger>
{officeGroups.map((g) => (
<AgentTabTrigger key={g.id} group={g} />
))}
</TabsList>
<TabsContent value="chat">
<ApprovalActionBar task={task} />
<TaskChat
taskId={task.id}
workspaceId={task.workspaceId}
statusSummary={task.statusSummary}
repositories={task.repositories}
comments={comments}
timeline={timeline}
sessions={sessions}
decisions={task.decisions}
reviewers={task.reviewers}
approvers={task.approvers}
scrollParent={scrollParent}
readOnly={readOnly}
onCommentsChanged={onCommentsChanged}
taskTitle={task.title}
taskDescription={task.description}
/>
</TabsContent>
<TabsContent value="activity">
<TaskActivity taskId={task.id} entries={activity} />
</TabsContent>
{officeGroups.map((g) => (
<AgentTabContent key={g.id} taskId={task.id} group={g} />
))}
</Tabs>
</TaskLaunchErrorProvider>
);
}