mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/247/changes/paperclip/247 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task documents hold plans, reports, and image evidence. > - Task attachments already have an image gallery. > - Inline document images had no gallery navigation or image links. > - This change gives each document a gallery that uses the existing modal. > - People can review the image sequence and share a link to one image. ## Linked Issues or Issue Description **What existing behavior does this improve?** Image review in task documents, including the side panel and Classic interface. **Current behavior** Inline document images do not open a gallery. People cannot move through them with arrow keys or share a link to one image. **Proposed behavior** Select an image to open the gallery. Use arrow keys or Previous and Next. The URL records its one-based position, for example `#document-report&image=2`. Reloading that URL opens the selected image. Revision links keep their exact saved content. **Reason and benefit** People can inspect a document's image evidence in reading order and share the exact view. No matching issue was found. The latest 300 open PR titles were checked for related document gallery work. ## What Changed - Add a document renderer that collects rendered images in reading order, including reference-style and repeated images. - Reuse the gallery modal with optional index-change and focus-return controls. - Keep the document owner in the saved tab identity when people switch between local and other-task documents. - Match explicit current-task document owners with local document tabs. Keep review and viewer parameters during gallery navigation. - Treat another Classic review receipt as a new request while image navigation retains the selected revision. - Make inline image controls work with a keyboard. Keep focus in the modal and restore focus on close. - Keep document, revision, review, and maximized-view link parameters during image navigation. - Fix a Classic revision-cache race found by the browser test. Recheck a pinned revision when the saved document head changes. - Update the feature and agent access maps. No new server API or permission is needed. ## Verification - Passed 119 focused tests for document galleries, the shared gallery, document rendering, the revision panel, and hash routing. - Passed all 3 Chromium journeys: desktop, mobile, and Classic. These use real uploads and saved document revisions. - The browser journeys also check explicit current-task owners, review parameters after reopening, other-task links after tab switches, and a second Classic review link after Return to latest. - Browser checks cover keyboard and button navigation, wraparound, shared-link reload, exact historical content, invalid positions, focus return and containment, scroll locking, and Escape, Close, and backdrop dismissal. - UI typecheck, token gates, map checks, and diff whitespace checks passed. - The UI production build passed in the browser harness. - Earlier browser runs found and then verified the Classic revision-cache fix. A later build exited 137 while the full test suite ran. The browser run passed after that suite stopped. - Full repository typecheck and build could not complete because Cargo is absent. The full repository test run did not complete within this sandbox. These checks remain subject to CI. ## Risks - Image positions follow document reading order. Editing an unpinned document can change those positions. A revision link retains the saved sequence. - The shared modal now uses Radix Escape handling. The existing gallery tests and nested mobile dialog journey pass. - No schema change or migration. ## Model Used OpenAI GPT-6 through Codex. The runtime did not expose a more specific model ID or context window size. Used reasoning, code editing, shell commands, and browser automation. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [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>
959 lines
42 KiB
TypeScript
959 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 { 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[];
|
|
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,
|
|
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) => 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;
|
|
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={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 ? (
|
|
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>
|
|
);
|
|
}
|