mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI keeps an open task current with a shared company
websocket and with polling fallbacks
> - In a busy company, one open task sent 35 to 60 API requests in 17
seconds, and each request waits on the database
> - Most of these requests were repeated reads: task summaries 22 times,
the task run list 11 times, task browsers 6 times, and the 60 KB agent
list 6 times
> - The cause is not the open task. Other agents' run and task events
invalidate company-wide lists on every 300 ms batch, and the task view
polls runs and browsers on fixed intervals even while the websocket is
open
> - This pull request spaces company-wide refreshes, and it lets the
websocket replace run and browser polling while it is connected
> - The benefit is about 79% fewer reads from an open task in a busy
company, with the same polling fallback when the connection is down
## Linked Issues or Issue Description
Refs #17608
Refs #9627
**What happened?**
An open task in a busy company sent the same reads again and again. In
one 17-second visit, the UI requested `issues/summaries` 22 times, the
task live runs 11 times, the task browsers 6 times, and the company
agent list 6 times. The company live runs and the task's child lists
(`parentId`, `createdFromIssueId`, `descendantOf`) were requested 2 to 3
times each.
**Expected behavior**
A busy company must not make an open task refetch company-wide lists on
every event batch. While the live connection is open, the UI must use
events, not fixed fast intervals, for task runs and browsers. The UI
must keep its polling fallback when the connection is closed.
**Steps to reproduce**
1. Open a task in a company where other agents run work.
2. Keep the task open for 17 seconds.
3. Count the API requests in the browser network panel.
**Paperclip version or commit**
Based on master at bd30ef8510.
## What Changed
- `ui/src/lib/query-invalidation-batcher.ts`: Add a minimum spacing
option to the invalidation batcher. A quiet lane flushes after the
normal batch interval. A busy lane flushes at most once per spacing
window. Add `createLaneRoutingBatcher`, which sends each invalidation to
one of two batchers by query key.
- `ui/src/context/LiveUpdatesProvider.tsx`: Route company-wide keys to a
5-second ambient lane. These keys are task lists and summaries, the
agent list, heartbeats, company live runs, sidebar badges, the
dashboard, costs and the company activity feed. All other keys, such as
task detail, comments, task runs and browsers, keep the 300 ms lane.
- `ui/src/lib/live-runs-cache.ts`: While the company connection is open,
a successful active run list or active run polls every 15 seconds
instead of every 1 or 3 seconds. Run lifecycle events already refresh
these queries, and progress events patch them.
- `ui/src/hooks/useTaskBrowsers.ts`: While the company connection is
open, stop browser polling when no browser can change state. Keep the
3-second poll for a starting, running, idle or stopping browser, and
when the connection is closed. The two callers pass the task's company.
Socket open and reconnect already reconcile this active query.
- `ui/src/lib/task-browsers-refresh.ts` and `LiveUpdatesProvider.tsx`:
On `browser.*` activity for a task, refresh that task's browser list. If
a read is in flight, one shared follow-up read runs after it settles, so
an older empty result cannot stop polling. Two views of the same task
share that follow-up. A list that no view observes becomes stale, so a
returning view reads it. When the connection opens or resyncs, each
browser list with a read in flight also gets one follow-up read, because
missed activity is not replayed.
- `doc/DATABASE.md`: Document the ambient lane and the new polling
contract.
- Tests: Add a fan-out test that replays a busy-company event stream for
17 seconds against an open task, plus unit tests for spacing, lane
routing, key classification, the run interval and the browser interval.
Update the recovery test for the connected active-run interval.
## Verification
Request count over a fixed 17-second open window
(`ui/src/context/LiveUpdatesProvider.fanout.test.ts`). The test mounts
the reads that an open, running task keeps active. It replays a busy
company: one run status event every second and one task activity event
every 0.7 seconds, none for the open task. Fake timers make each run
deterministic, so the spread is zero. The baseline used the same test
with master's batcher, run interval and browser interval.
| Read | Before | After |
| --- | ---: | ---: |
| Task summaries (sidebar) | 24 | 5 |
| Company agent list | 17 | 5 |
| Company live runs | 17 | 5 |
| Child list: parent | 24 | 5 |
| Child list: created from | 24 | 5 |
| Child list: descendants | 24 | 5 |
| Task live runs | 18 | 2 |
| Task browsers | 6 | 1 |
| **Total** | **154** | **33** |
The baseline numbers match the staging capture well (summaries 22,
browsers 6).
- `pnpm exec vitest run src/context/LiveUpdatesProvider*.test.*
src/lib/live-runs-cache.test.ts
src/lib/query-invalidation-batcher.test.ts
src/hooks/useTaskBrowsers.test.ts src/lib/task-browsers-refresh.test.ts
src/hooks/useTaskPluginPanels.test.tsx
src/components/task-side-panel/TaskSidePanel.test.tsx
src/pages/IssueDetail.test.tsx` (in `ui/`): all pass.
- `pnpm exec tsc --noEmit -p ui`: no errors in `ui/src`.
- `pnpm check:token-gates`: clean.
- Tests also prove these cases:
- With the connection closed, task runs still poll every second and
browsers every 3 seconds.
- With a quiet company, one event refreshes the summaries and child
lists after one normal batch.
- `browser.started` on the open task refreshes its browsers. The same
event on another task does not.
- A browser that starts during a slow empty read appears after that read
settles (`ui/src/lib/task-browsers-refresh.test.ts`). This test fails
when the refresh joins the older read.
- Two views and repeated events during one read cause one follow-up
read. This test fails without the shared queue.
- A list cached while the task was closed is read again when a view
returns.
- A connection that opens during an older empty browser read causes one
more read, so a browser that started while the socket was closed
appears. This test fails without the follow-up.
- Task detail, comments, task runs, browsers, attention and another
company's keys stay on the fast lane.
- An unrecognized active result keeps the fallback interval.
This is a local request-count proof. It does not include a timed browser
journey on a deployed instance.
## Risks
- Company-wide lists refresh at most once every 5 seconds from live
events, so they can be up to 5 seconds late. After 5 quiet seconds, an
event refreshes them in about 300 ms. Own mutations invalidate their
queries directly, so they do not wait.
- An active task run refreshes from events. Fields without an event,
such as output silence, can be up to 15 seconds late while the
connection is open. Reconnect still reconciles missed events.
- A browser that appears without activity is not shown until the next
activity or reconnect. Every browser lifecycle step logs activity on the
task, so this case is not expected.
- No server, schema or API change.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, with tool use and code
execution in Claude Code.
## 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
973 lines
42 KiB
TypeScript
973 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 { useQueryView } from "@/components/QueryView";
|
|
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, TaskGeneralDocuments, type TaskDocumentEditorOptions } 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;
|
|
documentEditor?: TaskDocumentEditorOptions;
|
|
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,
|
|
documentEditor,
|
|
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, issue.companyId);
|
|
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),
|
|
staleTime: 15_000,
|
|
});
|
|
|
|
const recentFilesView = useQueryView(recentFilesQuery);
|
|
|
|
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: recentFilesView.kind === "error" ? "Recent files are temporarily unavailable." : null,
|
|
});
|
|
}
|
|
return sections;
|
|
}, [pluginPanels, browsersQuery.data, conversationAgentId, taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesView.kind, 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 = (
|
|
<div className="space-y-4">
|
|
<section aria-label="Conversation documents">
|
|
<TaskGeneralDocuments issue={issue} documentEditor={documentEditor ?? {}} />
|
|
</section>
|
|
<AgentArtifactsPanel companyId={issue.companyId} agentId={conversationAgentId} />
|
|
</div>
|
|
);
|
|
} 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} documentEditor={documentEditor ?? {}} 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>
|
|
);
|
|
}
|