Files
PaperClipAI/ui/src/components/task-side-panel/TaskSidePanel.tsx
T
Nicky LeachandPaperclip b056026407 fix(ui): keep task plugin views available for manual access (#18363)
## Thinking Path

> - Paperclip helps people manage agents and their tasks.
> - The task side panel can display a plugin's agent view.
> - Users need that view even while a task is idle.
> - Activity currently controls both manual discovery and automatic
opening.
> - This pull request keeps manual access available and uses activity to
request automatic opening.
> - The same authorized agent viewer serves both entry points.

## Linked Issues or Issue Description

**What happened?**

A task view disappeared when its run ended. Users could not select it
until the current run reported matching activity and its readiness check
passed. This also excluded declared manual views without an activity
mapping.

**Expected behavior**

The task launcher offers declared views for its running or assigned
agent. Selecting one uses the same viewer as the agent detail page.
Matching activity and readiness open the pane and select the view by
default, once per run.

**Steps to reproduce**

Open an idle task assigned to an agent with an installed task-panel
contribution. Open the panel launcher. The view is currently missing.
Related work: #18131 and #18239.

## What Changed

- Return manual panel declarations independently of run activity and
readiness.
- Mark panels that should open automatically after matching activity and
readiness. Keep existing run attribution, retry bounds, and dismissal
behavior.
- Keep an open viewer mounted when a run ends. Focus an already open
manual view when activity starts.
- Preserve saved manual tabs while plugin and initial run discovery
load; remove contributions after discovery confirms they are absent.
- Share the run-scoped dismissal key between the task page and panel
controller so dismissed views do not reveal an empty pane.
- Use the current running agent for declared manual views that have no
activity mapping.
- Update the plugin authoring contract and feature recipe.

## Verification

- Two new regressions failed before the fix: idle discovery returned no
view, and a manual contribution opened automatically.
- 105 focused hook and panel tests pass. They cover idle/manual access,
declarations without mappings, completion, saved-tab restoration,
automatic focus, activity attribution, retry bounds, dismissal, and
missing/disabled plugins.
- Thirteen task-page panel integration tests pass. New regressions
reproduce and fix a dismissed manual view revealing the outer pane. They
cover desktop/mobile and both workspace modes, and verify that a later
run can still open the view automatically.
- `node scripts/check-token-gates.mjs`, `node scripts/check-maps.mjs`,
and `git diff --check` pass.
- Local UI typecheck failed with the reused dependency cache: workspace
modules/exports and generated Storybook catalog files are missing or
stale. It reported no errors in the changed files. The final head passed
the clean GitHub typecheck. No broad local build or repository test run
was attempted.
- The preceding head passed [GitHub
CI](https://github.com/paperclipai/paperclip/actions/runs/41116028688),
with 48 jobs succeeding and one skipped. Its final review scored 5/5 and
identified a narrow saved-view restoration race. This revision adds the
missing initial-run loading guard. Two delayed-response regressions
failed before the correction and now pass; they cover a different
assignee and an unassigned task.
- The final head `bbde3a28f7ac0390da8f3fe81eb9abd2f3020695` passed
[GitHub
CI](https://github.com/paperclipai/paperclip/actions/runs/41119772438)
and the additional [full CI
run](https://github.com/paperclipai/paperclip/actions/runs/41121827023),
each with 48 successful jobs and one skipped. Standard Greptile scored
5/5 on this head; both review threads are resolved. The small final
loading correction used the permitted exception to another final-review
cycle.
- The additional full CI run initially failed the canary bundle install
because npm advertised a newly published `tslib@2.9.1` archive before
its tarball was available (HTTP 404). After the archive became
available, one retry of the failed canary and dependent aggregate
passed. No dependency or lockfile change was needed.
- Hosted acceptance of this follow-up requires merge and deployment; the
earlier hosted activation checks cover the previously merged behavior.

## Risks

Manual views can now open while idle or before readiness. Each viewer
retains its normal authorization and status handling. Declarations
without an activity mapping now appear in the launcher. Automatic
opening still requires authenticated activity and the authorized
readiness result. Saved tabs wait for plugin discovery before removal.

## Model Used

OpenAI GPT-6 through Codex, with repository inspection, terminal tools,
browser verification, and code execution. The exact serving variant and
context window size are not exposed in 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 either (a) linked existing issues with `Fixes: #` / `Closes
#` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run 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
- [x] 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-30 13:44:41 -07:00

962 lines
42 KiB
TypeScript

import { CoordinatorConversationTasksPanel } from "@/components/coordinator/CoordinatorConversationTasksPanel";
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { TaskAttachmentPanel } from "./TaskAttachmentPanel";
import { useTaskBrowsers } from "@/hooks/useTaskBrowsers";
import { taskPluginPanelActivationKey, 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 { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
import { TaskWorkspaceFilePanel } from "./TaskWorkspaceFilePanel";
import { TaskSkillPanel } from "./TaskSkillPanel";
export interface TaskSidePanelProps {
issue: Issue;
pluginPanels?: TaskPluginPanel[];
pluginPanelsLoading?: boolean;
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(props: TaskSidePanelProps) {
// Restoration happens once in the stateful host. A preference/account
// change must replace that host before its persistence callback can write
// the previous scope's tabs under the new scope's storage key.
return <TaskSidePanelForScope key={JSON.stringify([props.accountScope, props.issue.companyId, props.issue.id])} {...props} />;
}
function TaskSidePanelForScope({
pluginPanels = EMPTY_PLUGIN_PANELS,
pluginPanelsLoading = false,
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 location = useLocation();
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 { enabled: coordinatorWorkspaceEnabled } = useCoordinatorWorkspaceEnabled();
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 = coordinatorWorkspaceEnabled
? restoredRef.current?.userInteracted ? restored?.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 (!coordinatorWorkspaceEnabled && 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 (!coordinatorWorkspaceEnabled && 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 (!coordinatorWorkspaceEnabled && !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) => taskPluginPanelActivationKey(panel, accountScope, issue), [accountScope, issue.companyId, issue.id]);
useEffect(() => {
const ids = new Set(pluginPanels.map(taskPluginPanelId));
for (const tab of controller.tabs) {
if (!pluginPanelsLoading && tab.payload.kind === "plugin-panel" && !ids.has(tab.id)) controller.closeTab(tab.id);
}
for (const panel of pluginPanels) {
if (!panel.autoOpen || !panel.runId) continue;
const key = pluginPanelKey(panel);
if (dismissedPluginPanels.has(key)) continue;
const firstArrival = !openedPluginPanels.has(key);
openedPluginPanels.add(key);
if (!firstArrival && controller.tabs.some(tab => tab.id === taskPluginPanelId(panel))) continue;
if (firstArrival) setLauncherOpen(false);
controller.openTab(taskPanelPluginTab(panel), firstArrival);
}
}, [dismissedPluginPanels, openedPluginPanels, pluginPanelKey, pluginPanels, pluginPanelsLoading, 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;
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
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;
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
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 (!coordinatorWorkspaceEnabled && !hasSubtasksTab && !subtasksDismissedRef.current) {
controller.resetTabs({
tabs: insertSubtasksAfterProperties(controller.tabs),
activeTabId: controller.activeTabId,
});
}
}, [
controller.activeTabId,
controller.closeTab,
controller.resetTabs,
controller.tabs,
hasSubtasksTab,
subtasksAvailable,
coordinatorWorkspaceEnabled,
]);
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 (coordinatorWorkspaceEnabled || !planDocument || autoPlanHandledRef.current || userInteractedRef.current) return;
autoPlanHandledRef.current = true;
controller.openTab(taskPanelDocumentTab("plan", documentDisplayTitle(planDocument)));
}, [controller.openTab, planDocument, coordinatorWorkspaceEnabled]);
useEffect(() => {
if (!documentDeepLink || handledDocumentRequestRef.current === documentDeepLink.requestId) return;
const parsedTarget = parseDocumentAnnotationHash(location.hash);
const target = parsedTarget?.documentKey === documentDeepLink.documentKey ? parsedTarget : null;
const documentIssueId = target?.documentIssueId && target.documentIssueId !== issue.id ? target.documentIssueId : undefined;
if (
documentDeepLink.documentKey === "plan" &&
planDocument === null && !target?.revisionId && !documentIssueId
) return;
const document = documentIssueId ? undefined : 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;
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
controller.openTab(taskPanelDocumentTab(documentDeepLink.documentKey, label, target?.revisionId, documentIssueId));
}, [controller.openTab, documentDeepLink, documents, planDocument, location.hash, issue.id]);
// Existing URL-backed workspace links remain the external integration API.
useEffect(() => {
if (!fileTabsEnabled) return;
if (viewer.state) {
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
controller.openTab(taskPanelWorkspaceFileTab(viewer.state));
} else if (viewer.browse) {
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
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.
if (!coordinatorWorkspaceEnabled) controller.openTab(taskPanelArtifactsTab(), false);
onArtifactsOpened?.(artifactsOpenRequestId);
}, [artifactsOpenRequestId, controller.openTab, onArtifactsOpened, coordinatorWorkspaceEnabled]);
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" && (!tab.payload.documentIssueId || tab.payload.documentIssueId === issue.id) ? 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, issue.id]);
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={taskPluginPanelId(panel)} 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 ? (
coordinatorWorkspaceEnabled
? <CoordinatorConversationTasksPanel conversationIssueId={issue.id} />
: <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" && !activeTab.payload.revisionId && !activeTab.payload.documentIssueId ? (
<IssuePropertiesPlansTab issue={issue} inline={inline} />
) : (
<TaskDocumentPanel
issueId={activeTab.payload.documentIssueId ?? issue.id}
documentKey={activeTab.payload.documentKey}
revisionId={activeTab.payload.revisionId}
documentIssueId={activeTab.payload.documentIssueId}
currentIssueId={issue.id}
initialDocument={!activeTab.payload.documentIssueId || activeTab.payload.documentIssueId === issue.id ? documentByKey.get(activeTab.payload.documentKey) : undefined}
/>
);
} 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>
);
}