mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## 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>
962 lines
42 KiB
TypeScript
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>
|
|
);
|
|
}
|