Files
PaperClipAI/ui/src/components/task-side-panel/TaskSidePanel.tsx
T
DottaandPaperclip 322fa6b65c fix(ui): restore task document authoring and add persistence journey (#18356)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Task documents keep versioned work beside each conversation.
> - The default chat interface could read general documents but hid
their editor.
> - People also need unfinished edits to survive a tab switch or reload.
> - This pull request restores the shared editor in Artifacts.
> - A portable browser journey checks exact saved content and revision
history.
> - The benefit is a usable document flow with independent deployment
checks.

## Linked Issues or Issue Description

**What happened?**

A person using the default task interface could read a general task
document but could not create or edit one. The existing editor was
available in Classic mode. A pending create could also leave newer text
as a new form after its editor unmounted, causing a conflict against the
person's own successful creation.

**Expected behavior**

A person can create and edit general documents through Artifacts.
Titles, bodies and revision history persist after reopening and
reloading the task. Unfinished text survives tab switches. A successful
create makes retained newer text an edit of that confirmed revision.
Later writes from another actor still cause a conflict.

**Steps to reproduce**

1. Open an ordinary task in the default chat interface, then open
Artifacts.
2. Try to create a general document. The previous interface has no
authoring control.
3. With authoring restored, submit a new document, keep typing, and
switch to Properties before the request finishes.
4. Reopen Artifacts. The previous draft path restores a new form despite
the successful create.

Related public work: #17598 adds document copy/download and #18157 adds
image galleries. The selected actual merged parent is
`f1d780838218f975064f38333946805e0ec103a5` from #18427. It includes the
stable profile-entry ordering repair and the exact recovery-fixture
prerequisite. The resulting PR diff contains 32 document and reporting
files.

## What Changed

- Reuse `IssueDocumentsSection` for general documents in default-chat
Artifacts. Keep Classic authoring in its existing task body. Ordinary
tasks, conversations and Coordinator mode share the same editor options
and signed-in permission wrapper.
- Preserve Plans, file-review documents, attachments, uploads and work
products. Hide duplicate general-document cards. Use the same
general-document key rules for visible rows, validation and refused
writes.
- Forward image uploads, mentions, references, author maps and feedback
to both editor mounts. Keep empty mention/project values stable while
queries load.
- Back up unfinished forms within their signed-in account, task and
editor scope. Preserve text entered before its key. Record a successful
create on its matching retained newer buffer, including after unmount or
an early reopen. Preserve newer title/body edits, real remote conflicts
and matching cancellation cleanup.
- Add the model-free `task-document-persistence` journey. Setup creates
only an owned empty backlog task. The browser creates and edits the
document; normal APIs independently check company, task, actor, exact
content and both revision records.
- Reopen the owned task through the explicit Backlog view, then reload
it. Require exact UI readback and zero native runs. Add only the Doc
registration, definition-hash inputs, report mapping and four closed
checks to the shared harness.
- Count explicit zero-run browser receipts after private journey
metadata is removed for publication. Preserve missing billing evidence,
positive usage and native/protocol accounting.
- Update browser assertions for the restored inline editor while
retaining exact download bytes, revision collisions, paused-state and
no-run checks. Update the feature map, usage docs and shared editor
stories.

## Verification

- Latest focused checks use official Node 24.22.2: 568 journey, catalog,
report, billing and attempt-accounting tests across 25 suites pass. All
four admitted journeys remain registered. The seven relevant UI suites
pass 529 tests, including the task-page container after the parent
merge. Maps, token gates, the PR-scope diff check and the owned
credential-pattern scan pass. The normal QA import preflight also passes
with an empty environment and network access blocked.
- Billing regression: before the repair, the explicit empty public
receipt counted one run, and two positive-usage receipts were discarded
by browser metadata. Those three cases fail before and pass after the
repair. Two additional red regressions cover aggregate usage beside an
empty run list. That ambiguous shape now keeps an unavailable receipt
and a minimum count of one. Missing receipts retain unavailable billing,
and native/protocol one-or-more run totals retain their measurements.
- The previous fresh CI run failed its unchanged snapshot-only recovery
fixture: one global sweep did not call its callback. The original
failure remains recorded. An isolated PGlite probe with all canonical
migrations exercises the real reconciler: it processes two earlier
sources, then reaches the owned third source on the next sweep. It
preserves source evidence, files and run IDs. This proves bounded
pagination, not full native snapshot repair.
- Local native recovery tests could not start PostgreSQL because the
host semaphore limit was reached. That run executed zero cases.
Canonical Linux CI later passed all 143 fixture cases, including the
original snapshot-only case and the new competing-candidate control. The
exact fixture repair is now inherited from #18427 and is absent from
this PR diff.
- The portable oracle rejects 19 stale, missing, foreign or incorrect
outcomes. Result validation also rejects model/provider metadata and
native runs for this case.
- Five real Artifacts-to-Properties unmount regressions fail before the
repair and pass after it. They cover both editor modes, reopening before
and after completion, later typing, and a foreign revision that must
still conflict. The edit-storage failure case also checks that Cancel
clears the promoted fallback copy.
- UI coverage includes default chat, Classic, conversations,
Coordinator, Plan rendering, document-card deduplication, reserved keys,
pending-query stability, permissions and rich editor options.
- Earlier real browser checks passed five upload cases and two
artifact-arrival cases. They retain exact CSV bytes, original/revised
content, collision, paused-state and no-run assertions. Current-head
normal CI runs the updated browser suites again.
- The branch merges only the selected actual parent
`f1d780838218f975064f38333946805e0ec103a5`. The merge has no conflicts
or manual resolution. The SQL service and recovery fixture match that
parent exactly. The own document editor, persistence flow, saved-state
oracle and billing repair retain their previously qualified source
blobs. Follow-up, clarification, question/resumption, screenshot helpers
and the Steering map are byte-identical to the merged parent. Existing
project/search/question/follow-up registrations remain present.
- Required document source blobs remain exact: Doc UI
`22e80663b053363e5f75e1432dd9043dc0e70243`, its unmount tests
`16f3f4ed0dc9b11aaff129e06e6cf2bbf4522713`, readiness helper
`800a747284db19b8484650b6759e8bd1f356839d`, and budget service
`3a15d489f0aa55c22e8f7fe6a6bc6a0e14e5914e`.
- Local typing is limited by borrowed declarations from another
checkout. They omit current `skill_test` and sandbox creation-operation
fields. No dependency declarations or repository config were changed.
The authoritative clean CI Typecheck + Release Registry gate passes on
exact head `2ffce29827ae170420d740940c1c069c7225a74c`.
- Full monorepo typecheck, tests and build were not run locally.
Borrowed build declarations from another checkout limit local typing.
Authoritative [clean
CI](https://github.com/paperclipai/paperclip/actions/runs/41171717008)
passes on exact head `82d15946c5844d3ae41c6ade62fa224053f20a9a`: 55
successful checks, 3 skipped and 1 neutral, with zero pending or failed
checks. Typecheck, build, Runner, server and browser lanes pass.
Greptile gives that same head a fresh 5/5. All 15 review threads are
resolved, with no further thread page. The PR is mergeable and clean.
- Failure history remains recorded. The earlier recovery failure was
repaired with a bounded, owned-source fixture. A later CI worker shut
down after 2,375 Runner unit tests passed. Its one authorized narrow
retry passed those tests and all five native integration suites. The
separate AgentConfig browser failure was reproduced and fixed by the
merged #18427 parent. These earlier passes do not replace current-head
CI.
- Final-source live qualification passed on exact harness and served
application `82d15946c5844d3ae41c6ade62fa224053f20a9a`. QA run
`41177696858` completed all four closed checks and all five phases with
zero native runs. Cleanup passed; the durable manifest is complete with
no pending evidence. The coordinator verified the original grade and
manifest.
- The [public qualification
report](https://pages.paperclip.ing/evals-qa-smoke-41177696858-1/)
records zero agent runs, empty run IDs and empty run usage. Pricing
remains unavailable and billing remains incomplete; the report does not
claim measured zero cost.
- The earlier harness `36c8d9ddd76d0b7ddb28a9a6c674d9e538f6df88` pass
against application `2ffce29827ae170420d740940c1c069c7225a74c` remains
historical evidence. It is separate from the final-source measurement
above.
- The selected parent brings profile-entry ordering, proactive chat, a
database migration, Runner updates, and task-page chat/read behavior.
Its new task-page container is included in the final application
measurement. The coordinator retains the original evidence and owns the
primary merge and daily-plan admission.

## Risks

Artifacts now mounts the existing shared editor. Its autosave, conflict,
revision, annotation and server access contracts remain in place. Tests
cover both interface modes, shared scopes, later remote writes and
unavailable edit storage. Plans and generated file-review documents keep
their existing flows. The PR adds no database schema or API change. The
shared billing repair requires explicit captured empty run IDs and an
unambiguous usage object containing only an empty run list for public
browser-only receipts. Other fields beside an empty list keep
conservative unavailable billing. Missing measurements remain
unavailable. Contradicting positive usage remains counted. The inherited
recovery fixture keeps the production sweep limit and its source,
readback and no-agent-work assertions.

The live journey must fail when authoring controls or saved outcomes are
absent. It must not create the expected document through setup APIs or
switch to another interface to pass. Final deployment verification must
retain the actual app and harness revisions separately.

## Model Used

OpenAI Codex, based on GPT-6, with tool use and code execution. The
exact runtime model ID and context window were not exposed in this
session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `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>
2026-10-31 01:49:24 -05:00

971 lines
42 KiB
TypeScript

import { CoordinatorConversationTasksPanel } from "@/components/coordinator/CoordinatorConversationTasksPanel";
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
import { TextAttachmentContext } from "@/context/TextAttachmentContext";
import { TaskAttachmentPanel } from "./TaskAttachmentPanel";
import { useTaskBrowsers } from "@/hooks/useTaskBrowsers";
import { taskPluginPanelActivationKey, taskPluginPanelId, type TaskPluginPanel } from "@/hooks/useTaskPluginPanels";
import { PluginSlotMount } from "@/plugins/slots";
import { TaskBrowserPanel } from "./TaskBrowserPanel";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import {
isArtifactReviewDocumentKey,
type Issue,
type IssueDocument,
} from "@paperclipai/shared";
import {
Globe,
AppWindow,
Box,
FileCode2,
FileText,
FolderOpen,
Lightbulb,
ListChecks,
ListTree,
Plus,
SlidersHorizontal,
X,
} from "lucide-react";
import { fileResourcesApi } from "@/api/file-resources";
import { IssueProperties } from "@/components/IssueProperties";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "@/components/PropertiesPanel";
import { WorkspaceFileBrowser } from "@/components/WorkspaceFileBrowser";
import { AgentArtifactsPanel, AgentTasksPanel } from "@/components/chat/AgentWorkPanels";
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
import { IssuePropertiesArtifactsTab, 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);
const newestLiveBrowser = browsersQuery.data?.findLast((browser) => browser.status === "running" || browser.status === "idle");
useEffect(() => {
if (!openBrowserId) return;
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
setLauncherOpen(false);
controller.openTab(taskPanelBrowserTab(openBrowserId));
}, [openBrowserId, controller.openTab]);
useEffect(() => {
// Acknowledge only after selection and persistence commit. Clearing the
// parent request earlier can replace the panel before its tab state lands.
if (openBrowserId && controller.activeTabId === `browser:${openBrowserId}` &&
readTaskSidePanelState(accountScope, issue.companyId, issue.id, fileTabsEnabled)?.state.activeTabId === controller.activeTabId) {
onBrowserOpened?.();
}
}, [openBrowserId, controller.activeTabId, onBrowserOpened, accountScope, issue.companyId, issue.id, fileTabsEnabled]);
const activeTab = controller.tabs.find((tab) => tab.id === controller.activeTabId) ?? null;
const hasArtifactUploadSlot = !conversationAgentId && !issue.id.startsWith("chat:")
&& controller.tabs.some((tab) => tab.payload.kind === "artifacts");
const canUploadArtifacts = hasArtifactUploadSlot && activeTab?.payload.kind === "artifacts";
const subtasksAvailable = showRelatedTasks && (taskCount > 0 || tasksTab?.hasError === true);
const hasSubtasksTab = controller.tabs.some((tab) => tab.id === "subtasks");
useEffect(() => {
if (!openSkillId) return;
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
setLauncherOpen(false);
controller.openTab(taskPanelSkillTab(openSkillId, openSkillName ?? "Skill"));
onSkillOpened?.(openSkillId);
}, [controller.openTab, onSkillOpened, openSkillId, openSkillName]);
useEffect(() => {
if (!subtasksAvailable) {
subtasksDismissedRef.current = false;
if (hasSubtasksTab) controller.closeTab("subtasks");
return;
}
if (!coordinatorWorkspaceEnabled && !hasSubtasksTab && !subtasksDismissedRef.current) {
controller.resetTabs({
tabs: insertSubtasksAfterProperties(controller.tabs),
activeTabId: controller.activeTabId,
});
}
}, [
controller.activeTabId,
controller.closeTab,
controller.resetTabs,
controller.tabs,
hasSubtasksTab,
subtasksAvailable,
coordinatorWorkspaceEnabled,
]);
useEffect(() => {
if (inline) {
setPaneHeaderSlot(null);
return;
}
setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID));
}, [inline]);
// Older clients persisted an empty Plan tab merely because the task was in
// planning mode. Remove that stale tab once the plan lookup confirms there
// is no document; a real plan will be opened by the materialization effect
// below when it arrives.
useEffect(() => {
if (planDocument !== null || !controller.tabs.some((tab) => tab.id === "document:plan")) return;
autoPlanHandledRef.current = false;
controller.closeTab("document:plan");
}, [controller.closeTab, controller.tabs, planDocument]);
// Surface a newly materialized plan exactly once until the user takes manual
// control of the tab set. Persisted empty/custom states therefore stay put.
useEffect(() => {
if (coordinatorWorkspaceEnabled || !planDocument || autoPlanHandledRef.current || userInteractedRef.current) return;
autoPlanHandledRef.current = true;
controller.openTab(taskPanelDocumentTab("plan", documentDisplayTitle(planDocument)));
}, [controller.openTab, planDocument, coordinatorWorkspaceEnabled]);
useEffect(() => {
if (!documentDeepLink || handledDocumentRequestRef.current === documentDeepLink.requestId) return;
const parsedTarget = parseDocumentAnnotationHash(location.hash);
const target = parsedTarget?.documentKey === documentDeepLink.documentKey ? parsedTarget : null;
const documentIssueId = target?.documentIssueId && target.documentIssueId !== issue.id ? target.documentIssueId : undefined;
if (
documentDeepLink.documentKey === "plan" &&
planDocument === null && !target?.revisionId && !documentIssueId
) return;
const document = documentIssueId ? undefined : documents.find((candidate) => candidate.key === documentDeepLink.documentKey);
const label = document ? documentDisplayTitle(document) : documentDeepLink.documentKey === "plan" ? "Plan" : documentDeepLink.documentKey;
// A refresh must not replay a link after the user selects another tab.
handledDocumentRequestRef.current = documentDeepLink.requestId;
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
controller.openTab(taskPanelDocumentTab(documentDeepLink.documentKey, label, target?.revisionId, documentIssueId));
}, [controller.openTab, documentDeepLink, documents, planDocument, location.hash, issue.id]);
// Existing URL-backed workspace links remain the external integration API.
useEffect(() => {
if (!fileTabsEnabled) return;
if (viewer.state) {
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
controller.openTab(taskPanelWorkspaceFileTab(viewer.state));
} else if (viewer.browse) {
if (coordinatorWorkspaceEnabled) userInteractedRef.current = true;
controller.openTab(taskPanelFilesTab());
controller.updateTab("files", {
payload: {
kind: "files-browser",
query: viewer.query,
folderPath: viewer.folderPath,
projectId: viewer.browseProjectId,
workspaceId: viewer.browseWorkspaceId,
},
});
}
}, [
controller.openTab,
controller.updateTab,
fileTabsEnabled,
viewer.browse,
viewer.browseProjectId,
viewer.browseWorkspaceId,
viewer.folderPath,
viewer.query,
viewer.state,
]);
useEffect(() => {
if (artifactsOpenRequestId === undefined || handledArtifactsRequestRef.current === artifactsOpenRequestId) return;
handledArtifactsRequestRef.current = artifactsOpenRequestId;
// Background outputs add a discoverable tab without interrupting the
// current document, file, or launcher. Opening the pane is a user action.
if (!coordinatorWorkspaceEnabled) controller.openTab(taskPanelArtifactsTab(), false);
onArtifactsOpened?.(artifactsOpenRequestId);
}, [artifactsOpenRequestId, controller.openTab, onArtifactsOpened, coordinatorWorkspaceEnabled]);
useEffect(() => {
if (!openAttachment) return;
userInteractedRef.current = true;
setLauncherOpen(false);
controller.openTab(taskPanelAttachmentTab(openAttachment.id, openAttachment.title));
if (viewer.state || viewer.browse) viewer.close();
onAttachmentOpened?.();
}, [controller.openTab, openAttachment, onAttachmentOpened]);
const recentFilesQuery = useQuery({
queryKey: queryKeys.issues.fileResources(issue.id, {
workspace: "auto",
mode: "recent",
limit: 5,
offset: 0,
}),
queryFn: () => fileResourcesApi.list(issue.id, { workspace: "auto", mode: "recent", limit: 5, offset: 0 }),
enabled: fileTabsEnabled && (launcherOpen || !activeTab),
retry: false,
staleTime: 15_000,
});
function markInteracted() {
userInteractedRef.current = true;
}
function captureScroll() {
if (controller.activeTabId && bodyRef.current) {
scrollPositionsRef.current.set(controller.activeTabId, bodyRef.current.scrollTop);
}
}
function selectTab(tabId: string) {
captureScroll();
markInteracted();
controller.selectTab(tabId);
const tab = controller.tabs.find((candidate) => candidate.id === tabId);
if (!tab) return;
if (tab.payload.kind === "workspace-file") {
viewer.open(tab.payload);
} else if (tab.payload.kind === "files-browser") {
viewer.openBrowse({ q: tab.payload.query ?? undefined });
} else if (viewer.state || viewer.browse) {
viewer.close();
}
}
useEffect(() => {
const top = controller.activeTabId ? scrollPositionsRef.current.get(controller.activeTabId) ?? 0 : 0;
window.requestAnimationFrame(() => bodyRef.current?.scrollTo({ top, behavior: "auto" }));
}, [controller.activeTabId]);
useEffect(() => {
if (activeTab) return;
window.requestAnimationFrame(() => {
bodyRef.current?.querySelector<HTMLInputElement>('input[aria-label="Search tabs and resources…"]')?.focus();
});
}, [activeTab]);
function closeTab(tabId: string) {
markInteracted();
if (tabId === "subtasks") subtasksDismissedRef.current = true;
const tab = controller.tabs.find((candidate) => candidate.id === tabId);
if (tab?.payload.kind === "plugin-panel") {
const panel = pluginPanels.find(panel => taskPluginPanelId(panel) === tabId);
if (panel) dismissedPluginPanels.add(pluginPanelKey(panel));
}
controller.closeTab(tabId);
if (tabId === controller.activeTabId && (tab?.payload.kind === "workspace-file" || tab?.payload.kind === "files-browser")) {
viewer.close();
}
}
function openDocument(document: Pick<IssueDocument, "key" | "title">) {
markInteracted();
controller.openTab(taskPanelDocumentTab(document.key, documentDisplayTitle(document)));
}
function openWorkspaceFile(ref: {
path: string;
workspace: "auto" | "execution" | "project";
line?: number | null;
column?: number | null;
projectId?: string | null;
workspaceId?: string | null;
}) {
markInteracted();
const tab = taskPanelWorkspaceFileTab(ref);
controller.openTab(tab);
viewer?.open({
...ref,
projectId: ref.projectId ?? null,
workspaceId: ref.workspaceId ?? null,
line: ref.line ?? null,
column: ref.column ?? null,
}, { fromBrowse: true });
}
const documentByKey = useMemo(() => new Map(documents.map((document) => [document.key, document])), [documents]);
const visualTabs = useMemo<SidePanelTabItem[]>(() => controller.tabs.map((tab) => {
const document = tab.payload.kind === "issue-document" && (!tab.payload.documentIssueId || tab.payload.documentIssueId === issue.id) ? documentByKey.get(tab.payload.documentKey) : null;
const browserIndex = browsersQuery.data?.findIndex((browser) => tab.payload.kind === "browser" && (browser.id === tab.payload.browserId || browser.sessionId === tab.payload.browserId)) ?? -1;
const browserLabel = browserIndex >= 0 && (browsersQuery.data?.length ?? 0) > 1 ? `Browser ${browserIndex + 1}` : null;
return {
id: tab.id,
type: tab.type,
label: browserLabel ?? (tab.payload.kind === "subtasks" && tasksTab ? "Tasks" : document ? documentDisplayTitle(document) : tab.label),
ariaLabel: tab.payload.kind === "subtasks" ? taskLabel : tab.ariaLabel,
closable: true,
contentMode: tab.contentMode,
icon: tabIcon(tab),
};
}), [controller.tabs, documentByKey, taskCount, taskLabel, tasksTab, browsersQuery.data, issue.id]);
const launcherSections = useMemo<SidePanelLauncherSection[]>(() => {
const primary: SidePanelLauncherItem[] = conversationAgentId ? [
{ id: "agent-tasks", label: "Tasks", description: "Tasks this agent worked on", icon: <ListChecks />, alreadyOpen: controller.tabs.some((tab) => tab.id === "agent-tasks") },
{ id: "artifacts", label: "Artifacts", description: "Files this agent produced", icon: <Box />, alreadyOpen: controller.tabs.some((tab) => tab.id === "artifacts") },
{ id: "properties", label: "Properties", icon: <SlidersHorizontal />, alreadyOpen: controller.tabs.some((tab) => tab.id === "properties") },
] : [
{ id: "properties", label: "Properties", icon: <SlidersHorizontal />, alreadyOpen: controller.tabs.some((tab) => tab.id === "properties") },
...(subtasksAvailable ? [{ id: "subtasks", label: taskLabel, description: tasksTab?.hasError ? "Could not load all tasks" : `${taskCount} total`, icon: <ListTree />, alreadyOpen: controller.tabs.some((tab) => tab.id === "subtasks") }] : []),
{ id: "artifacts", label: "Artifacts", icon: <Box />, alreadyOpen: controller.tabs.some((tab) => tab.id === "artifacts") },
];
for (const [index, browser] of (browsersQuery.data ?? []).entries()) {
primary.push({ id: `browser:${browser.id}`, label: (browsersQuery.data?.length ?? 0) > 1 ? `Browser ${index + 1}` : "Browser", description: browser.status, icon: <Globe />, alreadyOpen: controller.tabs.some(tab => tab.id === `browser:${browser.id}`) });
}
for (const panel of pluginPanels) primary.push({ id: taskPluginPanelId(panel), label: panel.slot.displayName, icon: <AppWindow />, alreadyOpen: controller.tabs.some(tab => tab.id === taskPluginPanelId(panel)) });
if (fileTabsEnabled) {
primary.push({ id: "files", label: "Files", icon: <FolderOpen />, shortcut: "G F", alreadyOpen: controller.tabs.some((tab) => tab.id === "files") });
}
const documentItems: SidePanelLauncherItem[] = [
...(planDocument ? [{
id: "document:plan",
label: documentDisplayTitle(planDocument),
description: `Revision ${planDocument.latestRevisionNumber ?? 1}`,
icon: <Lightbulb />,
alreadyOpen: controller.tabs.some((tab) => tab.id === "document:plan"),
}] : []),
...documents
.filter((document) => document.key !== "plan" && !isArtifactReviewDocumentKey(document.key))
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
.map((document) => ({
id: `document:${document.key}`,
label: documentDisplayTitle(document),
description: `Revision ${document.latestRevisionNumber ?? 1}`,
icon: <FileText />,
alreadyOpen: controller.tabs.some((tab) => tab.id === `document:${document.key}`),
})),
];
const sections: SidePanelLauncherSection[] = [
{ id: "open", label: "Open", items: primary },
];
if (documentItems.length > 0) {
sections.push({ id: "documents", label: "Task documents", items: documentItems });
}
if (fileTabsEnabled) {
const recentItems = recentFilesQuery.data?.state === "available"
? recentFilesQuery.data.items.filter((item) => item.kind === "file").slice(0, 5).map((item) => ({
id: `recent-file:${item.workspaceId}:${item.relativePath}`,
label: item.title,
description: item.displayPath,
searchText: item.relativePath,
icon: <FileCode2 />,
}))
: [];
sections.push({
id: "recent-files",
label: "Recent workspace files",
items: recentItems,
loading: recentFilesQuery.isLoading,
error: recentFilesQuery.isError ? "Recent files are temporarily unavailable." : null,
});
}
return sections;
}, [pluginPanels, browsersQuery.data, conversationAgentId, taskCount, taskLabel, tasksTab?.hasError, controller.tabs, documents, fileTabsEnabled, planDocument, recentFilesQuery.data, recentFilesQuery.isError, recentFilesQuery.isLoading, subtasksAvailable]);
function selectLauncherItem(item: SidePanelLauncherItem) {
markInteracted();
if (item.id.startsWith("browser:")) controller.openTab(taskPanelBrowserTab(item.id.slice(8)));
else if (item.id.startsWith("plugin:")) {
const panel = pluginPanels.find(panel => taskPluginPanelId(panel) === item.id);
if (panel) {
dismissedPluginPanels.delete(pluginPanelKey(panel));
controller.openTab(taskPanelPluginTab(panel));
}
}
else if (item.id === "properties") controller.openTab(taskPanelPropertiesTab());
else if (item.id === "subtasks") {
subtasksDismissedRef.current = false;
controller.resetTabs({
tabs: insertSubtasksAfterProperties(controller.tabs),
activeTabId: "subtasks",
});
}
else if (item.id === "artifacts") controller.openTab(taskPanelArtifactsTab());
else if (item.id === "agent-tasks") controller.openTab(taskPanelAgentTasksTab());
else if (item.id === "files") {
controller.openTab(taskPanelFilesTab());
viewer.openBrowse();
} else if (item.id.startsWith("document:")) {
const key = item.id.slice("document:".length);
controller.openTab(taskPanelDocumentTab(key, item.label));
} else if (item.id.startsWith("recent-file:") && recentFilesQuery.data?.state === "available") {
const recent = recentFilesQuery.data.items.find((candidate) =>
item.id === `recent-file:${candidate.workspaceId}:${candidate.relativePath}`,
);
if (recent?.kind === "file") {
openWorkspaceFile({
path: recent.relativePath,
workspace: selectorForWorkspaceKind(recent.workspaceKind),
projectId: recent.projectId ?? null,
workspaceId: recent.workspaceId,
});
}
}
}
const launcherControl = (
<SidePanelLauncher
sections={launcherSections}
onSelect={selectLauncherItem}
presentation="popover"
open={launcherOpen}
onOpenChange={setLauncherOpen}
trigger={(
<Button
type="button"
variant="ghost"
size="icon-sm"
className={cn(
"shrink-0 text-muted-foreground hover:text-foreground focus-visible:text-foreground",
mobile
? "size-(--sz-44px) rounded-md"
: streamlinedTabs
? "h-(--side-panel-tab-height) w-(--side-panel-tab-height) rounded-md"
: "h-(--side-panel-tab-height) w-(--side-panel-tab-height) rounded-(--side-panel-control-radius)",
)}
aria-label="Open a new tab"
>
<Plus aria-hidden />
</Button>
)}
/>
);
const tabStrip = mobile ? (
<SidePanelMobileTabs
tabs={visualTabs}
activeTabId={controller.activeTabId}
onActiveTabChange={selectTab}
onCloseTab={closeTab}
addControl={launcherControl}
/>
) : (
<SidePanelTabs
tabs={visualTabs}
activeTabId={controller.activeTabId}
onActiveTabChange={selectTab}
onCloseTab={closeTab}
onReorderTabs={(ordered) => {
markInteracted();
controller.reorderTabs(ordered);
}}
addControl={launcherControl}
appearance={streamlinedTabs ? "streamlined-task" : "default"}
/>
);
let content: ReactNode;
if (!activeTab) {
content = <SidePanelLauncher sections={launcherSections} onSelect={selectLauncherItem} />;
} else if (activeTab.payload.kind === "browser") {
content = null; // Mounted below independently of the selected tab.
} else if (activeTab.payload.kind === "plugin-panel") {
const panel = pluginPanels.find(panel => taskPluginPanelId(panel) === activeTab.id);
content = panel ? (
<PluginSlotMount key={taskPluginPanelId(panel)} slot={panel.slot} context={{ companyId: issue.companyId, projectId: issue.projectId, entityId: panel.entityId, entityType: "agent" }} />
) : null;
} else if (activeTab.payload.kind === "properties") {
content = (
<IssueProperties
issue={issue}
childIssues={childIssues}
issueLinkState={issueLinkState}
onAddSubIssue={onAddSubIssue}
onUpdate={onUpdate}
inline={inline}
hasActiveRun={hasActiveRun}
externalObjects={externalObjects}
externalObjectsLoading={externalObjectsLoading}
externalObjectsError={externalObjectsError}
onRetryExternalObjects={onRetryExternalObjects}
onCheckMonitorNow={onCheckMonitorNow}
checkingMonitorNow={checkingMonitorNow}
sidePanelContentOnly
/>
);
} else if (activeTab.payload.kind === "subtasks") {
content = tasksTab?.content ?? (
<TaskDetailSubtasksPanel
items={childIssues}
onAddSubtask={onAddSubIssue}
issueLinkState={issueLinkState}
/>
);
} else if (activeTab.payload.kind === "agent-tasks") {
content = conversationAgentId ? (
coordinatorWorkspaceEnabled
? <CoordinatorConversationTasksPanel conversationIssueId={issue.id} />
: <AgentTasksPanel companyId={issue.companyId} agentId={conversationAgentId} excludeIssueId={issue.id} />
) : null;
} else if (activeTab.payload.kind === "artifacts" && conversationAgentId) {
content = (
<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>
);
}