Files
PaperClipAI/ui/src/components/task-side-panel/TaskSidePanel.tsx
T
DottaandPaperclip 762d680daf feat(ui): upload files directly in task artifacts (#15964)
## Thinking Path

> - Paperclip helps people manage agent work and retain its context.
> - Task documents and attachments keep that context with the task.
> - The Artifacts panel showed output but could not accept a new file.
> - Uploading through a message can require resuming a paused task.
> - Existing document and attachment APIs can save files directly.
> - This PR adds direct uploads and a drop target to the task panel.
> - People can add context while keeping work paused and unassigned.

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The task Artifacts panel and its existing document and attachment APIs.

**Subsystem affected**
ui/ — React board UI. No server, schema, or API changes.

**Current behavior**
The Artifacts panel cannot upload a file. It also excludes files
uploaded by people. The message composer can require a task to resume
before it submits attachments.

**Proposed behavior**
Use the upload icon or drop files anywhere in the open Artifacts panel.
The target includes the header, padding, and existing cards. Files save
directly to the task without a message or a pause change.

**Reason and benefit**
People can prepare a handoff on a paused, unassigned task without
starting agent work. Markdown becomes a task document. Other supported
files use attachment storage.

**Breaking changes**
Files from people now appear in Artifacts alongside agent output.
Promoted attachments still appear once. A repeated Markdown filename
creates a separate document instead of replacing an existing revision.
Existing APIs and limits stay the same.

**Additional context**
Related upload API work: Refs #7472 and Refs #7248. This PR adds the
missing task-panel entry point. ROADMAP.md lists Artifacts and Work
Products as available; this is a scoped UI improvement.

## What Changed

- Add an upload icon with no visible label or background.
- Accept file drops across the full task panel, including the desktop
portal header and mobile tabs.
- Show per-file progress and inline Retry and Dismiss actions.
- Import Markdown under a new document key and retain existing
revisions.
- Include files from people in Artifacts and dedupe promoted
attachments.
- Add component and task-journey stories for empty, populated, loading,
error, mobile, and drag-over states.
- Add real browser tests for persistence, pause preservation, duplicate
document names, full-panel drops, and partial failure recovery.

## Verification

- Five Playwright tests passed against an isolated instance started by
the source `test-drive` command. The document API, attachment storage,
pause holds, downloads, and task UI were real. Only the explicit upload
failure was injected.
- The browser tests verified desktop and mobile uploads, document
reading, persistence after reload, no task comments or runs, header
drops, preservation of original and hidden internal documents, reserved
filename handling, and upload retry.
- 163 focused UI tests passed.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- `pnpm check:token-gates` passed.
- The full Vitest suite is running after repair of an exhausted local
PostgreSQL shared-memory pool. The initial run failed to initialize
PostgreSQL. Six unattached segments had no living creator or last-user
process; removing them restored free slots.
- CI and the current-head Greptile review are pending.

## Risks

- Artifacts now includes user files, so existing tasks can show more
cards.
- Uploads retain the server's authorization, file-type, size, and
revision checks. No migration is needed.
- Binary attachment uploads use the existing API. A transport failure
with an unknown save outcome can retain the existing API's retry
ambiguity.

## Model Used

OpenAI GPT-6 through Codex. The exact runtime snapshot ID and
context-window size were not exposed to this session. The model used
repository inspection, code editing, shell tools, and browser
automation.

## 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
#` / `Refs #` 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
- [ ] All Paperclip CI gates are green
- [ ] 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-12 08:11:31 -05:00

869 lines
36 KiB
TypeScript

import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { TaskAttachmentPanel } from "./TaskAttachmentPanel";
import { useTaskBrowsers } from "@/hooks/useTaskBrowsers";
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,
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,
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;
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[] = [];
function tabIcon(tab: SidePanelTabRecord<TaskSidePanelTabPayload>): ReactNode {
switch (tab.payload.kind) {
case "browser": return <Globe />;
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({
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 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 canUploadArtifacts = activeTab?.payload.kind === "artifacts" && !conversationAgentId && !issue.id.startsWith("chat:");
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);
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}`) });
}
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;
}, [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 === "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 === "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}{canUploadArtifacts ? <ArtifactUploadButton onUpload={artifactUploads.upload} /> : 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>
);
}