Files
PaperClipAI/ui/src/components/task-side-panel/TaskSidePanel.tsx
T
Nicky LeachandPaperclip c7c12dbc79 Support plugin panels in task sidebars (#17796)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task side panels keep live work beside the conversation.
> - Plugins can already provide agent detail tabs.
> - Those tabs have no generic way to appear beside the task that is
using the agent.
> - This pull request adds an optional task-side-panel declaration to
agent detail tabs.
> - The plugin owns the label, component and availability. Core supplies
the task's running agent.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Access to plugin-contributed live views from task chat.

**Subsystem affected**

Plugin manifest validation, slot discovery, and task side-panel
UI/state.

**Current behavior**

Agent detail-tab contributions appear on agent pages. A task cannot
offer the same view through a plugin-owned availability decision.

**Proposed behavior**

An agent detail tab can declare `taskSidePanel: { availabilityPath }`.
Core substitutes the running agent into the plugin-local path. A
declared board GET route scoped to that agent returns `{ available:
boolean }`. Available contributions appear in desktop and mobile task
side panels.

**Reason and benefit**

Keep the task and its live plugin views together. Preserve plugin
ownership of provider protocols and product behavior. Core contains no
concrete plugin identifiers, routes, labels or readiness rules.

**Breaking changes**

None. The field is optional. Existing contributions remain unchanged.
Missing, disabled or unavailable plugins add no panel.

**Additional context**

Related: #17111 established the agent detail-tab and scoped API
extension surfaces. Roadmap and related PR searches found no duplicate
of this extension.

## What Changed

- Extend the manifest with optional task-panel metadata. Validate safe
plugin-local paths and require a declared board GET route with
agent-scoped authorization.
- Discover and load only opted-in agent contributions. Poll their
generic availability responses and mount the existing components with
agent context.
- Add generic panel tabs, launcher entries, dismissal and inactive-view
removal in desktop/mobile task chat.
- Reuse the existing live-run polling guard so idle tasks with a healthy
live connection stop polling.
- Add provider-neutral fixtures, schema/authorization tests, task tests
and SDK/feature documentation.

## Verification

- PASS: 301 focused tests including the full task-detail file, generic
panel discovery, persistence, slot loading and manifest validation.
Regressions cover POST-before-GET route order, mobile drawer dismissal,
recovery without focus and the absence of the hidden desktop task panel
on mobile.
- PASS: UI and shared-package typechecks; production UI build; token
gates, feature-map and module-boundary checks; diff and secrets/PII
review.
- PASS on final head: all 795 UI files / 9,186 tests, and all 95
shared-package files / 1,038 tests. Commands: `node
node_modules/vitest/vitest.mjs run --project @paperclipai/ui
--maxWorkers 3` and the corresponding `--project @paperclipai/shared`
command with local socket access.
- On the final head, server shards 7/12 and 10/12 and workspace shard
a-1/2 were interrupted by runner shutdown/communication loss. GitHub
reported infrastructure failures; no assertion failure was surfaced in
the available diagnostics. The queued summary was cancelled, then `gh
run rerun --failed` successfully requested retries for only those three
jobs. They are currently queued for CI runners; the 50 successful checks
remain passing. Interrupted attempts are not counted as passing.
[Final-head CI and
retry](https://github.com/paperclipai/paperclip/actions/runs/40396870920).
- Earlier verification exposed an empty-panel rerender loop, which was
fixed. The sandboxed combined run also failed five shared port-registry
tests because it could not bind loopback listeners; the full shared
rerun with socket access passed. These earlier failures are retained
here rather than reported as passing runs.
- Full workspace typecheck/build were attempted with pinned pnpm 9 after
resolving the initial local package-manager setup. Both stop at the
unchanged Runner Rust step because `cargo` is not installed. Full local
workspace checks are not reported as passing; final CI must provide
those broader checks.
- No live provider or plugin-specific transport acceptance is claimed by
Core's synthetic fixtures.

## Risks

- Availability is polled every three seconds only while an authorized
running agent exists. Plugins own the side effects and meaning of that
read.
- A view uses the running agent's context. It is not a separate per-task
resource.
- Optional metadata adds no backend endpoint or permission bypass. The
host requires existing board and agent authorization.
- Reverting the optional contract and UI integration removes task panels
without a data migration.

## Model Used

OpenAI Codex (GPT-6), with reasoning, repository tools and code
execution. The exact backend snapshot ID and context window size are not
exposed by this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-26 14:47:15 -07:00

931 lines
39 KiB
TypeScript

import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { TaskAttachmentPanel } from "./TaskAttachmentPanel";
import { useTaskBrowsers } from "@/hooks/useTaskBrowsers";
import { taskPluginPanelId, type TaskPluginPanel } from "@/hooks/useTaskPluginPanels";
import { PluginSlotMount } from "@/plugins/slots";
import { TaskBrowserPanel } from "./TaskBrowserPanel";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import {
isArtifactReviewDocumentKey,
type Issue,
type IssueDocument,
} from "@paperclipai/shared";
import {
Globe,
AppWindow,
Box,
FileCode2,
FileText,
FolderOpen,
Lightbulb,
ListChecks,
ListTree,
Plus,
SlidersHorizontal,
X,
} from "lucide-react";
import { fileResourcesApi } from "@/api/file-resources";
import { IssueProperties } from "@/components/IssueProperties";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "@/components/PropertiesPanel";
import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
import { AgentArtifactsPanel, AgentTasksPanel } from "@/components/chat/AgentWorkPanels";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { IssuePropertiesArtifactsTab } from "@/components/issue-properties/IssuePropertiesArtifactsTab";
import { ArtifactDropzone } from "@/components/artifacts/ArtifactDropzone";
import { ArtifactUploadButton } from "@/components/artifacts/ArtifactUploadControls";
import { useIssueArtifactUploads } from "@/hooks/useIssueArtifactUploads";
import { IssuePropertiesPlansTab } from "@/components/issue-properties/IssuePropertiesPlansTab";
import { TaskDetailSubtasksPanel } from "@/components/task-detail/TaskDetailRelationsPanel";
import {
SidePanelLauncher,
SidePanelToggleButton,
SidePanelTabs,
SidePanelMobileTabs,
useScrollbarWhileScrolling,
useSidePanelTabs,
type SidePanelLauncherItem,
type SidePanelLauncherSection,
type SidePanelTabItem,
type SidePanelTabRecord,
} from "@/components/side-panel";
import { Button } from "@/components/ui/button";
import {
FILE_VIEWER_NAVIGATE_OPTIONS,
getCurrentFileViewerSearch,
readBrowseStateFromSearch,
readFileViewerStateFromSearch,
shouldNavigateFileViewerSearch,
writeBrowseStateToSearch,
writeFileViewerStateToSearch,
type FileViewerBrowseState,
type FileViewerUrlState,
} from "@/context/FileViewerContext";
import { useIssueDocuments } from "@/hooks/useIssueDocuments";
import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { documentDisplayTitle } from "@/lib/issue-artifacts";
import { queryKeys } from "@/lib/queryKeys";
import { useLocation, useNavigate } from "@/lib/router";
import {
readTaskSidePanelState,
taskPanelBrowserTab,
taskPanelPluginTab,
taskPanelAgentTasksTab,
taskPanelArtifactsTab,
taskPanelAttachmentTab,
taskPanelDocumentTab,
taskPanelFilesTab,
taskPanelPropertiesTab,
taskPanelSkillTab,
taskPanelSubtasksTab,
taskPanelWorkspaceFileTab,
writeTaskSidePanelState,
type TaskSidePanelTabPayload,
} from "@/lib/task-side-panel-state";
import { cn } from "@/lib/utils";
import { TaskDocumentPanel } from "./TaskDocumentPanel";
import { TaskWorkspaceFilePanel } from "./TaskWorkspaceFilePanel";
import { TaskSkillPanel } from "./TaskSkillPanel";
export interface TaskSidePanelProps {
issue: Issue;
pluginPanels?: TaskPluginPanel[];
openedPluginPanelKeys?: Set<string>;
dismissedPluginPanelKeys?: Set<string>;
openBrowserId?: string | null;
onBrowserOpened?: () => void;
accountScope: string;
childIssues?: Issue[];
issueLinkState?: unknown;
onAddSubIssue?: () => void;
onUpdate: (data: Record<string, unknown>) => void;
inline?: boolean;
mobile?: boolean;
hasActiveRun?: boolean;
externalObjects?: IssueExternalObjectGroup[];
externalObjectsLoading?: boolean;
externalObjectsError?: boolean;
onRetryExternalObjects?: () => void;
onCheckMonitorNow?: () => void;
checkingMonitorNow?: boolean;
fileTabsEnabled: boolean;
documentDeepLink?: { requestId: number; documentKey: string } | null;
/** A new durable output asks the host to reveal this tab once. */
artifactsOpenRequestId?: number;
onArtifactsOpened?: (requestId: number) => void;
onRequestClose?: () => void;
streamlinedTabs?: boolean;
showSubtasksTab?: boolean;
/** Optional related-work projection; the host still owns tab layout and state. */
tasksTab?: { count: number; content: ReactNode; hasError?: boolean };
onAttachmentOpened?: () => void;
openAttachment?: { id: string; title: string; requestId: number } | null;
openSkillId?: string | null;
openSkillName?: string | null;
onSkillOpened?: (skillId: string) => void;
}
const EMPTY_ISSUE_DOCUMENTS: IssueDocument[] = [];
const EMPTY_PLUGIN_PANELS: TaskPluginPanel[] = [];
function tabIcon(tab: SidePanelTabRecord<TaskSidePanelTabPayload>): ReactNode {
switch (tab.payload.kind) {
case "browser": return <Globe />;
case "plugin-panel": return <AppWindow />;
case "properties": return <SlidersHorizontal />;
case "subtasks": return <ListTree />;
case "artifacts": return <Box />;
case "agent-tasks": return <ListChecks />;
case "attachment": return <FileText />;
case "files-browser": return <FolderOpen />;
case "workspace-file": return <FileCode2 />;
case "issue-document": return tab.payload.documentKey === "plan" ? <Lightbulb /> : <FileText />;
}
}
function insertSubtasksAfterProperties(
tabs: SidePanelTabRecord<TaskSidePanelTabPayload>[],
) {
if (tabs.some((tab) => tab.id === "subtasks")) return tabs;
const propertiesIndex = tabs.findIndex((tab) => tab.id === "properties");
const next = [...tabs];
next.splice(propertiesIndex < 0 ? 0 : propertiesIndex + 1, 0, taskPanelSubtasksTab());
return next;
}
function selectorForWorkspaceKind(kind: "execution_workspace" | "project_workspace") {
return kind === "execution_workspace" ? "execution" as const : "project" as const;
}
/**
* The desktop panel is rendered beside the route outlet, so task file routing
* deliberately lives in this adapter instead of depending on provider ancestry.
*/
function useTaskSidePanelFileRouting() {
const location = useLocation();
const navigate = useNavigate();
const state = useMemo(() => readFileViewerStateFromSearch(location.search), [location.search]);
const browseState = useMemo(() => readBrowseStateFromSearch(location.search), [location.search]);
const navigateSearch = useCallback((nextSearch: string, replace = false) => {
if (!shouldNavigateFileViewerSearch(nextSearch, location.search)) return;
navigate(
{ pathname: location.pathname, hash: location.hash, search: nextSearch },
{ ...FILE_VIEWER_NAVIGATE_OPTIONS, replace, state: location.state },
);
}, [location.hash, location.pathname, location.search, location.state, navigate]);
const open = useCallback((ref: FileViewerUrlState, options?: {
fromBrowse?: boolean;
browseState?: Partial<FileViewerBrowseState>;
}) => {
let nextSearch = writeFileViewerStateToSearch(location.search, ref);
if (options?.fromBrowse) {
const params = new URLSearchParams(nextSearch);
const previous = new URLSearchParams(location.search);
params.set("browse", "1");
const query = Object.prototype.hasOwnProperty.call(options.browseState ?? {}, "q")
? options.browseState?.q
: previous.get("q");
const folder = Object.prototype.hasOwnProperty.call(options.browseState ?? {}, "folderPath")
? options.browseState?.folderPath
: previous.get("folder");
if (query) params.set("q", query);
else params.delete("q");
if (folder) params.set("folder", folder);
else params.delete("folder");
nextSearch = params.toString() ? `?${params.toString()}` : "";
}
navigateSearch(nextSearch);
}, [location.search, navigateSearch]);
const openBrowse = useCallback((options?: { q?: string }) => {
const params = new URLSearchParams(location.search);
params.delete("file");
params.delete("line");
params.delete("column");
params.delete("folder");
params.set("browse", "1");
if (options?.q) params.set("q", options.q);
else params.delete("q");
navigateSearch(params.toString() ? `?${params.toString()}` : "");
}, [location.search, navigateSearch]);
const updateBrowseState = useCallback((next: Partial<FileViewerBrowseState>) => {
navigateSearch(writeBrowseStateToSearch(location.search, next), true);
}, [location.search, navigateSearch]);
const close = useCallback(() => {
const currentSearch = getCurrentFileViewerSearch(location.search);
const params = new URLSearchParams(writeFileViewerStateToSearch(currentSearch, null).replace(/^\?/, ""));
params.delete("browse");
params.delete("q");
params.delete("folder");
navigateSearch(params.toString() ? `?${params.toString()}` : "");
}, [location.search, navigateSearch]);
return {
state,
browse: browseState !== null,
query: browseState?.q ?? null,
folderPath: browseState?.folderPath ?? null,
browseProjectId: browseState?.projectId ?? null,
browseWorkspaceId: browseState?.workspaceId ?? null,
open,
openBrowse,
updateBrowseState,
close,
};
}
export function TaskSidePanel({
pluginPanels = EMPTY_PLUGIN_PANELS,
openedPluginPanelKeys,
dismissedPluginPanelKeys,
openBrowserId,
onBrowserOpened,
issue,
accountScope,
childIssues = [],
issueLinkState,
onAddSubIssue,
onUpdate,
inline = false,
mobile = false,
hasActiveRun = false,
externalObjects,
externalObjectsLoading,
externalObjectsError,
onRetryExternalObjects,
onCheckMonitorNow,
checkingMonitorNow = false,
fileTabsEnabled,
documentDeepLink,
artifactsOpenRequestId,
onArtifactsOpened,
onRequestClose,
streamlinedTabs = false,
showSubtasksTab = false,
tasksTab,
openAttachment,
onAttachmentOpened,
openSkillId,
openSkillName,
onSkillOpened,
}: TaskSidePanelProps) {
const handleScroll = useScrollbarWhileScrolling();
// Agent Chat: agent chats swap the conversation issue's own panels for
// agent-scoped ones — the agent's tasks lead, and Artifacts lists the
// agent's output. With the flag off a chat keeps the issue's own panels.
const { enabled: agentChatEnabled } = useAgentChatEnabled();
const conversationAgentId = agentChatEnabled ? issue.conversationAgentId ?? null : null;
const showRelatedTasks = showSubtasksTab && !conversationAgentId;
const viewer = useTaskSidePanelFileRouting();
const artifactUploads = useIssueArtifactUploads(issue);
const { data: documentsData } = useIssueDocuments(issue.id);
const documents = documentsData ?? EMPTY_ISSUE_DOCUMENTS;
const { data: planDocument } = useIssuePlanDocument(issue.id);
const restoredRef = useRef(
readTaskSidePanelState(accountScope, issue.companyId, issue.id, fileTabsEnabled),
);
const taskCount = tasksTab?.count ?? childIssues.length;
const taskLabel = tasksTab ? "Tasks" : "Subtasks";
const initialSubtasksAvailableRef = useRef(showRelatedTasks && (taskCount > 0 || tasksTab?.hasError === true));
const subtasksDismissedRef = useRef(
restoredRef.current?.userInteracted === true
&& restoredRef.current.state.tabs.length === 0,
);
const [launcherOpen, setLauncherOpen] = useState(restoredRef.current?.launcherOpen ?? false);
const [paneHeaderSlot, setPaneHeaderSlot] = useState<HTMLElement | null>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const scrollPositionsRef = useRef(new Map<string, number>());
const userInteractedRef = useRef(restoredRef.current?.userInteracted ?? false);
const autoPlanHandledRef = useRef(restoredRef.current?.autoPlanHandled ?? false);
const handledArtifactsRequestRef = useRef<number | undefined>(undefined);
const handledDocumentRequestRef = useRef<number | undefined>(undefined);
const initialState = useMemo(() => {
const restored = restoredRef.current?.state;
let tabs = restored?.tabs ?? (conversationAgentId
? [taskPanelAgentTasksTab()]
: issue.conversationAgentId ? [taskPanelArtifactsTab()] : [taskPanelPropertiesTab()]);
// A tab saved while Agent Chat was on has nothing to render without it;
// a chat left with no tabs falls back to its Agent-Chat-off default.
if (!conversationAgentId) {
const kept = tabs.filter((tab) => tab.payload.kind !== "agent-tasks");
if (kept.length === 0 && tabs.length > 0 && issue.conversationAgentId) tabs = [taskPanelArtifactsTab()];
else tabs = kept;
}
// Chats used to open on Artifacts by default; move an untouched default
// onto the agent's tasks. An Artifacts tab the user chose to keep stays.
if (conversationAgentId && !restoredRef.current?.userInteracted
&& tabs.length === 1 && tabs[0]!.id === "artifacts") {
tabs = [taskPanelAgentTasksTab()];
}
if (!initialSubtasksAvailableRef.current) {
tabs = tabs.filter((tab) => tab.payload.kind !== "subtasks");
} else if (!subtasksDismissedRef.current) {
tabs = insertSubtasksAfterProperties(tabs);
}
const requestedActive = restored?.activeTabId ?? "properties";
return {
tabs,
activeTabId: tabs.some((tab) => tab.id === requestedActive)
? requestedActive
: tabs[0]?.id ?? null,
};
}, []);
const persist = useCallback((state: { tabs: SidePanelTabRecord<TaskSidePanelTabPayload>[]; activeTabId: string | null }) => {
writeTaskSidePanelState(accountScope, issue.companyId, issue.id, {
state,
launcherOpen,
userInteracted: userInteractedRef.current,
autoPlanHandled: autoPlanHandledRef.current,
updatedAt: Date.now(),
});
}, [accountScope, issue.companyId, issue.id, launcherOpen]);
const controller = useSidePanelTabs<TaskSidePanelTabPayload>({ initialState, onStateChange: persist });
const localOpenedPluginPanels = useRef(new Set<string>());
const openedPluginPanels = openedPluginPanelKeys ?? localOpenedPluginPanels.current;
const localDismissedPluginPanels = useRef(new Set<string>());
const dismissedPluginPanels = dismissedPluginPanelKeys ?? localDismissedPluginPanels.current;
const pluginPanelKey = useCallback((panel: TaskPluginPanel) => JSON.stringify([accountScope, issue.companyId, issue.id, panel.runId, taskPluginPanelId(panel)]), [accountScope, issue.companyId, issue.id]);
useEffect(() => {
const ids = new Set(pluginPanels.map(taskPluginPanelId));
for (const tab of controller.tabs) {
if (tab.payload.kind === "plugin-panel" && !ids.has(tab.id)) controller.closeTab(tab.id);
}
for (const panel of pluginPanels) {
const key = pluginPanelKey(panel);
if (dismissedPluginPanels.has(key)) continue;
const firstArrival = !openedPluginPanels.has(key);
openedPluginPanels.add(key);
if (controller.tabs.some(tab => tab.id === taskPluginPanelId(panel))) continue;
if (firstArrival) setLauncherOpen(false);
controller.openTab(taskPanelPluginTab(panel), firstArrival);
}
}, [dismissedPluginPanels, openedPluginPanels, pluginPanelKey, pluginPanels, controller.tabs, controller.closeTab, controller.openTab]);
const browsersQuery = useTaskBrowsers(issue.id);
const newestLiveBrowser = browsersQuery.data?.findLast((browser) => browser.status === "running" || browser.status === "idle");
useEffect(() => {
if (!openBrowserId) return;
setLauncherOpen(false);
controller.openTab(taskPanelBrowserTab(openBrowserId));
}, [openBrowserId, controller.openTab]);
useEffect(() => {
// Acknowledge only after selection and persistence commit. Clearing the
// parent request earlier can replace the panel before its tab state lands.
if (openBrowserId && controller.activeTabId === `browser:${openBrowserId}` &&
readTaskSidePanelState(accountScope, issue.companyId, issue.id, fileTabsEnabled)?.state.activeTabId === controller.activeTabId) {
onBrowserOpened?.();
}
}, [openBrowserId, controller.activeTabId, onBrowserOpened, accountScope, issue.companyId, issue.id, fileTabsEnabled]);
const activeTab = controller.tabs.find((tab) => tab.id === controller.activeTabId) ?? null;
const hasArtifactUploadSlot = !conversationAgentId && !issue.id.startsWith("chat:")
&& controller.tabs.some((tab) => tab.payload.kind === "artifacts");
const canUploadArtifacts = hasArtifactUploadSlot && activeTab?.payload.kind === "artifacts";
const subtasksAvailable = showRelatedTasks && (taskCount > 0 || tasksTab?.hasError === true);
const hasSubtasksTab = controller.tabs.some((tab) => tab.id === "subtasks");
useEffect(() => {
if (!openSkillId) return;
setLauncherOpen(false);
controller.openTab(taskPanelSkillTab(openSkillId, openSkillName ?? "Skill"));
onSkillOpened?.(openSkillId);
}, [controller.openTab, onSkillOpened, openSkillId, openSkillName]);
useEffect(() => {
if (!subtasksAvailable) {
subtasksDismissedRef.current = false;
if (hasSubtasksTab) controller.closeTab("subtasks");
return;
}
if (!hasSubtasksTab && !subtasksDismissedRef.current) {
controller.resetTabs({
tabs: insertSubtasksAfterProperties(controller.tabs),
activeTabId: controller.activeTabId,
});
}
}, [
controller.activeTabId,
controller.closeTab,
controller.resetTabs,
controller.tabs,
hasSubtasksTab,
subtasksAvailable,
]);
useEffect(() => {
if (inline) {
setPaneHeaderSlot(null);
return;
}
setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID));
}, [inline]);
// Older clients persisted an empty Plan tab merely because the task was in
// planning mode. Remove that stale tab once the plan lookup confirms there
// is no document; a real plan will be opened by the materialization effect
// below when it arrives.
useEffect(() => {
if (planDocument !== null || !controller.tabs.some((tab) => tab.id === "document:plan")) return;
autoPlanHandledRef.current = false;
controller.closeTab("document:plan");
}, [controller.closeTab, controller.tabs, planDocument]);
// Surface a newly materialized plan exactly once until the user takes manual
// control of the tab set. Persisted empty/custom states therefore stay put.
useEffect(() => {
if (!planDocument || autoPlanHandledRef.current || userInteractedRef.current) return;
autoPlanHandledRef.current = true;
controller.openTab(taskPanelDocumentTab("plan", documentDisplayTitle(planDocument)));
}, [controller.openTab, planDocument]);
useEffect(() => {
if (!documentDeepLink || handledDocumentRequestRef.current === documentDeepLink.requestId) return;
if (
documentDeepLink.documentKey === "plan" &&
planDocument === null
) return;
const document = documents.find((candidate) => candidate.key === documentDeepLink.documentKey);
const label = document ? documentDisplayTitle(document) : documentDeepLink.documentKey === "plan" ? "Plan" : documentDeepLink.documentKey;
// A refresh must not replay a link after the user selects another tab.
handledDocumentRequestRef.current = documentDeepLink.requestId;
controller.openTab(taskPanelDocumentTab(documentDeepLink.documentKey, label));
}, [controller.openTab, documentDeepLink, documents, planDocument]);
// Existing URL-backed workspace links remain the external integration API.
useEffect(() => {
if (!fileTabsEnabled) return;
if (viewer.state) {
controller.openTab(taskPanelWorkspaceFileTab(viewer.state));
} else if (viewer.browse) {
controller.openTab(taskPanelFilesTab());
controller.updateTab("files", {
payload: {
kind: "files-browser",
query: viewer.query,
folderPath: viewer.folderPath,
projectId: viewer.browseProjectId,
workspaceId: viewer.browseWorkspaceId,
},
});
}
}, [
controller.openTab,
controller.updateTab,
fileTabsEnabled,
viewer.browse,
viewer.browseProjectId,
viewer.browseWorkspaceId,
viewer.folderPath,
viewer.query,
viewer.state,
]);
useEffect(() => {
if (artifactsOpenRequestId === undefined || handledArtifactsRequestRef.current === artifactsOpenRequestId) return;
handledArtifactsRequestRef.current = artifactsOpenRequestId;
// Background outputs add a discoverable tab without interrupting the
// current document, file, or launcher. Opening the pane is a user action.
controller.openTab(taskPanelArtifactsTab(), false);
onArtifactsOpened?.(artifactsOpenRequestId);
}, [artifactsOpenRequestId, controller.openTab, onArtifactsOpened]);
useEffect(() => {
if (!openAttachment) return;
userInteractedRef.current = true;
setLauncherOpen(false);
controller.openTab(taskPanelAttachmentTab(openAttachment.id, openAttachment.title));
if (viewer.state || viewer.browse) viewer.close();
onAttachmentOpened?.();
}, [controller.openTab, openAttachment, onAttachmentOpened]);
const recentFilesQuery = useQuery({
queryKey: queryKeys.issues.fileResources(issue.id, {
workspace: "auto",
mode: "recent",
limit: 5,
offset: 0,
}),
queryFn: () => fileResourcesApi.list(issue.id, { workspace: "auto", mode: "recent", limit: 5, offset: 0 }),
enabled: fileTabsEnabled && (launcherOpen || !activeTab),
retry: false,
staleTime: 15_000,
});
function markInteracted() {
userInteractedRef.current = true;
}
function captureScroll() {
if (controller.activeTabId && bodyRef.current) {
scrollPositionsRef.current.set(controller.activeTabId, bodyRef.current.scrollTop);
}
}
function selectTab(tabId: string) {
captureScroll();
markInteracted();
controller.selectTab(tabId);
const tab = controller.tabs.find((candidate) => candidate.id === tabId);
if (!tab) return;
if (tab.payload.kind === "workspace-file") {
viewer.open(tab.payload);
} else if (tab.payload.kind === "files-browser") {
viewer.openBrowse({ q: tab.payload.query ?? undefined });
} else if (viewer.state || viewer.browse) {
viewer.close();
}
}
useEffect(() => {
const top = controller.activeTabId ? scrollPositionsRef.current.get(controller.activeTabId) ?? 0 : 0;
window.requestAnimationFrame(() => bodyRef.current?.scrollTo({ top, behavior: "auto" }));
}, [controller.activeTabId]);
useEffect(() => {
if (activeTab) return;
window.requestAnimationFrame(() => {
bodyRef.current?.querySelector<HTMLInputElement>('input[aria-label="Search tabs and resources…"]')?.focus();
});
}, [activeTab]);
function closeTab(tabId: string) {
markInteracted();
if (tabId === "subtasks") subtasksDismissedRef.current = true;
const tab = controller.tabs.find((candidate) => candidate.id === tabId);
if (tab?.payload.kind === "plugin-panel") {
const panel = pluginPanels.find(panel => taskPluginPanelId(panel) === tabId);
if (panel) dismissedPluginPanels.add(pluginPanelKey(panel));
}
controller.closeTab(tabId);
if (tabId === controller.activeTabId && (tab?.payload.kind === "workspace-file" || tab?.payload.kind === "files-browser")) {
viewer.close();
}
}
function openDocument(document: Pick<IssueDocument, "key" | "title">) {
markInteracted();
controller.openTab(taskPanelDocumentTab(document.key, documentDisplayTitle(document)));
}
function openWorkspaceFile(ref: {
path: string;
workspace: "auto" | "execution" | "project";
line?: number | null;
column?: number | null;
projectId?: string | null;
workspaceId?: string | null;
}) {
markInteracted();
const tab = taskPanelWorkspaceFileTab(ref);
controller.openTab(tab);
viewer?.open({
...ref,
projectId: ref.projectId ?? null,
workspaceId: ref.workspaceId ?? null,
line: ref.line ?? null,
column: ref.column ?? null,
}, { fromBrowse: true });
}
const documentByKey = useMemo(() => new Map(documents.map((document) => [document.key, document])), [documents]);
const visualTabs = useMemo<SidePanelTabItem[]>(() => controller.tabs.map((tab) => {
const document = tab.payload.kind === "issue-document" ? documentByKey.get(tab.payload.documentKey) : null;
const browserIndex = browsersQuery.data?.findIndex((browser) => tab.payload.kind === "browser" && (browser.id === tab.payload.browserId || browser.sessionId === tab.payload.browserId)) ?? -1;
const browserLabel = browserIndex >= 0 && (browsersQuery.data?.length ?? 0) > 1 ? `Browser ${browserIndex + 1}` : null;
return {
id: tab.id,
type: tab.type,
label: browserLabel ?? (tab.payload.kind === "subtasks" && tasksTab ? "Tasks" : document ? documentDisplayTitle(document) : tab.label),
ariaLabel: tab.payload.kind === "subtasks" ? taskLabel : tab.ariaLabel,
closable: true,
contentMode: tab.contentMode,
icon: tabIcon(tab),
};
}), [controller.tabs, documentByKey, taskCount, taskLabel, tasksTab, browsersQuery.data]);
const launcherSections = useMemo<SidePanelLauncherSection[]>(() => {
const primary: SidePanelLauncherItem[] = conversationAgentId ? [
{ id: "agent-tasks", label: "Tasks", description: "Tasks this agent worked on", icon: <ListChecks />, alreadyOpen: controller.tabs.some((tab) => tab.id === "agent-tasks") },
{ id: "artifacts", label: "Artifacts", description: "Files this agent produced", icon: <Box />, alreadyOpen: controller.tabs.some((tab) => tab.id === "artifacts") },
{ id: "properties", label: "Properties", icon: <SlidersHorizontal />, alreadyOpen: controller.tabs.some((tab) => tab.id === "properties") },
] : [
{ id: "properties", label: "Properties", icon: <SlidersHorizontal />, alreadyOpen: controller.tabs.some((tab) => tab.id === "properties") },
...(subtasksAvailable ? [{ id: "subtasks", label: taskLabel, description: tasksTab?.hasError ? "Could not load all tasks" : `${taskCount} total`, icon: <ListTree />, alreadyOpen: controller.tabs.some((tab) => tab.id === "subtasks") }] : []),
{ id: "artifacts", label: "Artifacts", icon: <Box />, alreadyOpen: controller.tabs.some((tab) => tab.id === "artifacts") },
];
for (const [index, browser] of (browsersQuery.data ?? []).entries()) {
primary.push({ id: `browser:${browser.id}`, label: (browsersQuery.data?.length ?? 0) > 1 ? `Browser ${index + 1}` : "Browser", description: browser.status, icon: <Globe />, alreadyOpen: controller.tabs.some(tab => tab.id === `browser:${browser.id}`) });
}
for (const panel of pluginPanels) primary.push({ id: taskPluginPanelId(panel), label: panel.slot.displayName, icon: <AppWindow />, alreadyOpen: controller.tabs.some(tab => tab.id === taskPluginPanelId(panel)) });
if (fileTabsEnabled) {
primary.push({ id: "files", label: "Files", icon: <FolderOpen />, shortcut: "G F", alreadyOpen: controller.tabs.some((tab) => tab.id === "files") });
}
const documentItems: SidePanelLauncherItem[] = [
...(planDocument ? [{
id: "document:plan",
label: documentDisplayTitle(planDocument),
description: `Revision ${planDocument.latestRevisionNumber ?? 1}`,
icon: <Lightbulb />,
alreadyOpen: controller.tabs.some((tab) => tab.id === "document:plan"),
}] : []),
...documents
.filter((document) => document.key !== "plan" && !isArtifactReviewDocumentKey(document.key))
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
.map((document) => ({
id: `document:${document.key}`,
label: documentDisplayTitle(document),
description: `Revision ${document.latestRevisionNumber ?? 1}`,
icon: <FileText />,
alreadyOpen: controller.tabs.some((tab) => tab.id === `document:${document.key}`),
})),
];
const sections: SidePanelLauncherSection[] = [
{ id: "open", label: "Open", items: primary },
];
if (documentItems.length > 0) {
sections.push({ id: "documents", label: "Task documents", items: documentItems });
}
if (fileTabsEnabled) {
const recentItems = recentFilesQuery.data?.state === "available"
? recentFilesQuery.data.items.filter((item) => item.kind === "file").slice(0, 5).map((item) => ({
id: `recent-file:${item.workspaceId}:${item.relativePath}`,
label: item.title,
description: item.displayPath,
searchText: item.relativePath,
icon: <FileCode2 />,
}))
: [];
sections.push({
id: "recent-files",
label: "Recent workspace files",
items: recentItems,
loading: recentFilesQuery.isLoading,
error: recentFilesQuery.isError ? "Recent files are temporarily unavailable." : null,
});
}
return sections;
}, [pluginPanels, browsersQuery.data, conversationAgentId, taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
function selectLauncherItem(item: SidePanelLauncherItem) {
markInteracted();
if (item.id.startsWith("browser:")) controller.openTab(taskPanelBrowserTab(item.id.slice(8)));
else if (item.id.startsWith("plugin:")) {
const panel = pluginPanels.find(panel => taskPluginPanelId(panel) === item.id);
if (panel) {
dismissedPluginPanels.delete(pluginPanelKey(panel));
controller.openTab(taskPanelPluginTab(panel));
}
}
else if (item.id === "properties") controller.openTab(taskPanelPropertiesTab());
else if (item.id === "subtasks") {
subtasksDismissedRef.current = false;
controller.resetTabs({
tabs: insertSubtasksAfterProperties(controller.tabs),
activeTabId: "subtasks",
});
}
else if (item.id === "artifacts") controller.openTab(taskPanelArtifactsTab());
else if (item.id === "agent-tasks") controller.openTab(taskPanelAgentTasksTab());
else if (item.id === "files") {
controller.openTab(taskPanelFilesTab());
viewer.openBrowse();
} else if (item.id.startsWith("document:")) {
const key = item.id.slice("document:".length);
controller.openTab(taskPanelDocumentTab(key, item.label));
} else if (item.id.startsWith("recent-file:") && recentFilesQuery.data?.state === "available") {
const recent = recentFilesQuery.data.items.find((candidate) =>
item.id === `recent-file:${candidate.workspaceId}:${candidate.relativePath}`,
);
if (recent?.kind === "file") {
openWorkspaceFile({
path: recent.relativePath,
workspace: selectorForWorkspaceKind(recent.workspaceKind),
projectId: recent.projectId ?? null,
workspaceId: recent.workspaceId,
});
}
}
}
const launcherControl = (
<SidePanelLauncher
sections={launcherSections}
onSelect={selectLauncherItem}
presentation="popover"
open={launcherOpen}
onOpenChange={setLauncherOpen}
trigger={(
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
"shrink-0 text-muted-foreground hover:text-foreground focus-visible:text-foreground",
mobile
? "size-(--sz-44px) rounded-md"
: streamlinedTabs
? "h-(--side-panel-tab-height) w-(--side-panel-tab-height) rounded-md"
: "h-(--side-panel-tab-height) w-(--side-panel-tab-height) rounded-(--side-panel-control-radius)",
)}
aria-label="Open a new tab"
>
<Plus aria-hidden />
</Button>
)}
/>
);
const tabStrip = mobile ? (
<SidePanelMobileTabs
tabs={visualTabs}
activeTabId={controller.activeTabId}
onActiveTabChange={selectTab}
onCloseTab={closeTab}
addControl={launcherControl}
/>
) : (
<SidePanelTabs
tabs={visualTabs}
activeTabId={controller.activeTabId}
onActiveTabChange={selectTab}
onCloseTab={closeTab}
onReorderTabs={(ordered) => {
markInteracted();
controller.reorderTabs(ordered);
}}
addControl={launcherControl}
appearance={streamlinedTabs ? "streamlined-task" : "default"}
/>
);
let content: ReactNode;
if (!activeTab) {
content = <SidePanelLauncher sections={launcherSections} onSelect={selectLauncherItem} />;
} else if (activeTab.payload.kind === "browser") {
content = null; // Mounted below independently of the selected tab.
} else if (activeTab.payload.kind === "plugin-panel") {
const panel = pluginPanels.find(panel => taskPluginPanelId(panel) === activeTab.id);
content = panel ? (
<PluginSlotMount key={panel.runId} slot={panel.slot} context={{ companyId: issue.companyId, projectId: issue.projectId, entityId: panel.entityId, entityType: "agent" }} />
) : null;
} else if (activeTab.payload.kind === "properties") {
content = (
<IssueProperties
issue={issue}
childIssues={childIssues}
issueLinkState={issueLinkState}
onAddSubIssue={onAddSubIssue}
onUpdate={onUpdate}
inline={inline}
hasActiveRun={hasActiveRun}
externalObjects={externalObjects}
externalObjectsLoading={externalObjectsLoading}
externalObjectsError={externalObjectsError}
onRetryExternalObjects={onRetryExternalObjects}
onCheckMonitorNow={onCheckMonitorNow}
checkingMonitorNow={checkingMonitorNow}
sidePanelContentOnly
/>
);
} else if (activeTab.payload.kind === "subtasks") {
content = tasksTab?.content ?? (
<TaskDetailSubtasksPanel
items={childIssues}
onAddSubtask={onAddSubIssue}
issueLinkState={issueLinkState}
/>
);
} else if (activeTab.payload.kind === "agent-tasks") {
content = conversationAgentId ? (
<AgentTasksPanel companyId={issue.companyId} agentId={conversationAgentId} excludeIssueId={issue.id} />
) : null;
} else if (activeTab.payload.kind === "artifacts" && conversationAgentId) {
content = <AgentArtifactsPanel companyId={issue.companyId} agentId={conversationAgentId} />;
} else if (activeTab.payload.kind === "artifacts") {
content = <TextAttachmentContext.Provider value={(id, title) => {
markInteracted();
controller.openTab(taskPanelAttachmentTab(id, title));
if (viewer.state || viewer.browse) viewer.close();
}}><IssuePropertiesArtifactsTab issue={issue} onOpenDocument={openDocument} uploadState={artifactUploads} /></TextAttachmentContext.Provider>;
} else if (activeTab.payload.kind === "issue-document") {
content = activeTab.payload.documentKey === "plan" ? (
<IssuePropertiesPlansTab issue={issue} inline={inline} />
) : (
<TaskDocumentPanel
issueId={issue.id}
documentKey={activeTab.payload.documentKey}
initialDocument={documentByKey.get(activeTab.payload.documentKey)}
/>
);
} else if (activeTab.payload.kind === "attachment") {
content = <TaskAttachmentPanel key={activeTab.payload.attachmentId} issueId={issue.id} attachmentId={activeTab.payload.attachmentId} />;
} else if (activeTab.payload.kind === "skill") {
content = <TaskSkillPanel companyId={issue.companyId} skillId={activeTab.payload.skillId} />;
} else if (activeTab.payload.kind === "files-browser") {
content = (
<WorkspaceFileBrowser
issueId={issue.id}
companyId={issue.companyId}
onOpen={openWorkspaceFile}
onBrowseStateChange={(next) => {
controller.updateTab(activeTab.id, {
payload: {
kind: "files-browser",
query: next.q,
folderPath: next.folderPath,
projectId: next.projectId,
workspaceId: next.workspaceId,
},
});
viewer.updateBrowseState(next);
}}
initialQuery={activeTab.payload.query}
initialFolderPath={activeTab.payload.folderPath}
initialProjectId={activeTab.payload.projectId}
initialWorkspaceId={activeTab.payload.workspaceId}
active
className="h-full min-h-0 p-3"
/>
);
} else {
const filePayload = activeTab.payload;
content = (
<TaskWorkspaceFilePanel
issueId={issue.id}
payload={filePayload}
onFallbackToProject={filePayload.workspace !== "project" && !filePayload.projectId && !filePayload.workspaceId
? () => openWorkspaceFile({ ...filePayload, workspace: "project" })
: undefined}
/>
);
}
const contentMode = activeTab?.contentMode ?? "full-bleed";
const header = <>{tabStrip}{hasArtifactUploadSlot ? (
// Keep the header footprint stable when selection changes. Hidden controls
// retain their space but cannot receive focus or open the file picker.
<div
data-artifact-upload-slot
className={cn("shrink-0", !canUploadArtifacts && "invisible")}
aria-hidden={!canUploadArtifacts}
inert={!canUploadArtifacts}
>
<ArtifactUploadButton onUpload={artifactUploads.upload} />
</div>
) : null}</>;
return (
<ArtifactDropzone enabled={canUploadArtifacts} target={paneHeaderSlot?.closest<HTMLElement>("[data-presentation]")} onUpload={artifactUploads.upload}>
<div className="flex h-full min-h-0 flex-col">
<span className="sr-only" role="status" aria-live="polite">{artifactUploads.announcement}</span>
{paneHeaderSlot ? createPortal(header, paneHeaderSlot) : (
<div className="flex h-(--side-panel-header-height) shrink-0 items-center gap-1 px-2">
{header}
{onRequestClose ? (
mobile ? (
<Button variant="ghost" size="icon" className="size-(--sz-44px) shrink-0" aria-label="Close side panel" onClick={onRequestClose}>
<X aria-hidden />
</Button>
) : <SidePanelToggleButton open onToggle={onRequestClose} />
) : null}
</div>
)}
<div
ref={bodyRef}
data-side-panel-content-viewport="true"
onScroll={handleScroll}
role={activeTab ? "tabpanel" : undefined}
id={activeTab ? `side-panel-content-${activeTab.id}` : undefined}
aria-labelledby={activeTab ? `side-panel-tab-${activeTab.id}` : undefined}
className={cn(
"min-h-0 flex-1",
contentMode === "full-bleed" ? "overflow-hidden" : "overflow-auto",
contentMode !== "full-bleed" && "scrollbar-while-scrolling",
contentMode === "padded" && "p-4",
)}
>
{controller.tabs.map(tab => tab.payload.kind === "browser" ? (
<div key={tab.id} hidden={tab.id !== controller.activeTabId} className={cn("h-full min-h-0", tab.id !== controller.activeTabId && "hidden")}>
<TaskBrowserPanel active={tab.id === controller.activeTabId} issueId={issue.id} browser={browsersQuery.data?.find(b => tab.payload.kind === "browser" && (b.id === tab.payload.browserId || b.sessionId === tab.payload.browserId))} accessError={browsersQuery.isError} onOpenActiveBrowser={newestLiveBrowser ? () => controller.openTab(taskPanelBrowserTab(newestLiveBrowser.id)) : undefined} />
</div>
) : null)}
{contentMode === "prose" ? (
<div data-side-panel-prose-content="true" className="mx-auto w-full max-w-4xl px-6 py-4">
{content}
</div>
) : content}
</div>
</div>
</ArtifactDropzone>
);
}