mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## 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>
931 lines
39 KiB
TypeScript
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>
|
|
);
|
|
}
|