qwen-code/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx
ytahdn 0a3098a279
feat(web-shell): add contextual task panels (#7929)
* feat(web-shell): add contextual task panels

* fix(web-shell): harden contextual task panels

* fix(web-shell): preserve side task titles

* fix(web-shell): address review feedback on context panels PR (#7929)

- Add POST /session/:id/side-task to telemetry route catalog (51 routes)
- Increase SDK browser bundle size limit to 184KB
- Fix duplicated data-testid="chat-pane" → "chat-pane-container" on container
- Gate sourceType behind session_source_metadata capability check
- Add removeSession cleanup after killSession in !res.writable path
- Add i18n key sideTask.renameFailed for error fallback
- Add unit tests for selectVisibleHistoryRecords invariant

* fix(cli): update telemetry-catalog route drift guard to 51 routes (#7929)

* fix(web-shell): address review feedback round 2 on context panels PR (#7929)

- Fix /fork sider discarding createSideTask() return value: show toast
  when side tasks are unavailable
- Fix layout feedback loop: availableWidth no longer depends on
  environmentPanelVisible since the CSS overlay does not change the
  chat pane DOM width
- Remove dead environmentPanelSuppressed state (never set to true)
- Restore setArtifactPanelOpen(false) in closeArtifactPanelTab when
  the last tab is closed
- Extract agentDisplayName(task) to a local variable to avoid triple
  invocation per render

* fix(web-shell): dedupe completed background agents in environment panel (#7929)

getEnvironmentAgentTasks correlated a transcript tool card with the live
/tasks snapshot only on toolUseId, the notification taskId, and a
<subagentType>-<callId> derived id. A completed background agent can lose
that linkage (its live task carries no usable toolUseId and its daemon id
is general-purpose-<internalId>), so the trailing loop appended the live
task as a second entry. Add a conservative content fallback (prompt, or
description+subagentType) mirroring the daemon's legacy resolver.

* feat(web-shell): support side tasks during active turns

* fix(web-shell): deduplicate completed subagents and gate sourceType on capability (#7929)

* fix(web-shell): restore background agent reconciliation and fix agent dedupe (#7929)

Restore the one-shot subagent reconciliation for inline background Agent tool
cards. Persisted notification records do not always retain a toolUseId, so the
SSE discrete-notification path alone can leave a card stuck in Running; the
documented fallback resolves pending cards through the subagent endpoint after
catch-up, reconnect, and terminal notifications.

Also stop the loose description content fallback in getEnvironmentAgentTasks
from claiming a live task that another transcript tool call already links
precisely (by toolUseId, message taskId, or derived id). Two agents sharing a
description previously collapsed into one: the fallback stole the linked task,
its owner re-matched the same task, and the orphan was dropped.

* fix(web-shell): address critical review feedback on context panels (#7929)

* fix(web-shell): reconcile side-task state across sessions and listings (#7929)

* fix(web-shell): preserve contextual panel fallbacks

---------

Co-authored-by: 钉萁 <dingqi.jww@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
2026-07-30 13:45:30 +00:00

491 lines
17 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import type { DaemonClient } from '@qwen-code/sdk/daemon';
import type {
DaemonSessionGroup,
DaemonSessionSummary,
DaemonWorkspaceCapability,
DaemonWorkspaceGitStatus,
} from '@qwen-code/sdk/daemon';
import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react';
import { GitBranchIndicator } from '../GitBranchIndicator';
import { BranchPickerPopover } from '../BranchPickerPopover';
import { useI18n } from '../../i18n';
import {
SESSION_LIST_PAGE_SIZE,
WEB_SHELL_SESSION_SOURCE_TYPE,
} from '../../constants/sessions';
import {
readWorkspaceCollapsedGroupIds,
writeWorkspaceCollapsedGroupIds,
} from './collapsedSessionSections';
import { workspaceLabel } from '../../utils/workspace';
import { SessionGroupSection } from './SessionGroupSection';
import styles from './WorkspaceSection.module.css';
function cx(...classes: Array<string | false | undefined>): string {
return classes.filter(Boolean).join(' ');
}
// The cwd-qualified daemon route only accepts a workspace id or absolute path.
// A synthetic fallback workspace (daemon reports no workspaces and the
// connection has no cwd) carries a display name in `cwd`, which is neither, so
// qualifying a request with it would only ever 400.
function isAbsolutePath(cwd: string): boolean {
return (
cwd.startsWith('/') || cwd.startsWith('\\') || /^[a-zA-Z]:[\\/]/.test(cwd)
);
}
function getSessionLabel(session: DaemonSessionSummary): string {
const displayName = session.displayName?.trim();
return displayName || session.sessionId.slice(0, 8);
}
function WorkspaceFolderIcon({ open }: { open: boolean }) {
const Icon = open ? FolderOpenIcon : FolderClosedIcon;
return (
<Icon
className={styles.folderIcon}
size={16}
strokeWidth={1.2}
aria-hidden="true"
/>
);
}
interface WorkspaceSectionProps {
workspace: DaemonWorkspaceCapability;
renderHeader?: (expanded: boolean) => ReactNode;
client: DaemonClient;
reloadToken: number;
untrustedLabel: string;
readOnlyLabel: string;
trustToOpenLabel: string;
noSessionsLabel: string;
loadErrorLabel: string;
organizationEnabled: boolean;
sourceMetadataEnabled?: boolean;
ungroupedLabel: string;
formatTime: (iso: string) => string;
searchQuery?: string;
expanded?: boolean;
autoExpandKey?: string;
onExpandedChange?: (expanded: boolean) => void;
renderSessions?: boolean;
/**
* Render one session row. The sidebar passes its shared `renderSessionRow`
* so per-workspace sessions match the single-workspace list exactly — same
* type scale, hover actions (pin, archive, export, more…), and states —
* instead of a bespoke, feature-poor row.
*/
renderSession: (session: DaemonSessionSummary) => ReactNode;
headerActions?: (visible: boolean) => ReactNode;
onRenameGroup?: (group: DaemonSessionGroup, workspaceCwd: string) => void;
onDeleteGroup?: (group: DaemonSessionGroup, workspaceCwd: string) => void;
renameGroupLabel?: string;
deleteGroupLabel?: string;
groupActionsDisabled?: boolean;
excludePinned?: boolean;
/**
* Open the working-tree Changes dialog for this workspace. When provided, the
* folder header shows a live git chip (branch + dirty/ahead-behind state) that
* fires this on click. Omitted for untrusted workspaces (no git surface).
*/
onOpenGitDiff?: (workspaceCwd: string) => void;
onOpenCommit?: (workspaceCwd: string) => void;
}
export function WorkspaceSection({
workspace,
renderHeader,
client,
reloadToken,
untrustedLabel,
readOnlyLabel,
trustToOpenLabel,
noSessionsLabel,
loadErrorLabel,
organizationEnabled,
sourceMetadataEnabled = false,
ungroupedLabel,
formatTime,
searchQuery = '',
expanded: controlledExpanded,
autoExpandKey,
onExpandedChange,
renderSessions = true,
renderSession,
headerActions,
onRenameGroup,
onDeleteGroup,
renameGroupLabel,
deleteGroupLabel,
groupActionsDisabled,
excludePinned = false,
onOpenGitDiff,
onOpenCommit,
}: WorkspaceSectionProps) {
const [sessions, setSessions] = useState<DaemonSessionSummary[]>([]);
const [groups, setGroups] = useState<DaemonSessionGroup[]>([]);
const [loadError, setLoadError] = useState(false);
const [internalExpanded, setInternalExpanded] = useState(false);
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(() =>
readWorkspaceCollapsedGroupIds(workspace.id),
);
const [actionsVisible, setActionsVisible] = useState(false);
const [gitStatus, setGitStatus] = useState<DaemonWorkspaceGitStatus>();
const [branchPickerOpen, setBranchPickerOpen] = useState(false);
const { t } = useI18n();
const expanded = controlledExpanded ?? internalExpanded;
const readOnly = !workspace.primary && !workspace.trusted;
const disabled = workspace.primary && !workspace.trusted;
// A workspace always starts collapsed, including the primary workspace.
useEffect(() => {
if (controlledExpanded === undefined) setInternalExpanded(false);
}, [controlledExpanded, workspace.id]);
// The render site keys this component by workspace id, so an id change
// always remounts and the lazy useState initializer re-reads storage.
useEffect(() => {
writeWorkspaceCollapsedGroupIds(workspace.id, collapsedGroupIds);
}, [collapsedGroupIds, workspace.id]);
useEffect(() => {
if (controlledExpanded === undefined && autoExpandKey) {
setInternalExpanded(true);
}
}, [autoExpandKey, controlledExpanded]);
const loadSessions = useCallback(async () => {
if (disabled) return;
try {
const result = await client
.workspaceByCwd(workspace.cwd)
.listWorkspaceSessions({
pageSize: SESSION_LIST_PAGE_SIZE,
archiveState: 'active',
...(sourceMetadataEnabled
? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE }
: {}),
...(organizationEnabled
? { view: 'organized' as const, group: 'all' }
: {}),
});
setSessions(result);
setLoadError(false);
} catch (err) {
// Surface connectivity failures so users can distinguish a broken
// daemon from genuinely zero sessions.
console.warn('[WorkspaceSection] session poll failed:', err);
setLoadError(true);
}
}, [
client,
disabled,
organizationEnabled,
sourceMetadataEnabled,
workspace.cwd,
]);
useEffect(() => {
if (!renderSessions || disabled || !organizationEnabled) {
setGroups([]);
return;
}
let cancelled = false;
void client
.workspaceByCwd(workspace.cwd)
.listSessionGroups()
.then((catalog) => {
if (!cancelled) setGroups(catalog.groups);
})
.catch((err: unknown) => {
console.warn('[WorkspaceSection] group catalog load failed:', err);
});
return () => {
cancelled = true;
};
}, [
client,
disabled,
organizationEnabled,
reloadToken,
renderSessions,
workspace.cwd,
]);
useEffect(() => {
if (!renderSessions) return;
if (!expanded && !searchQuery.trim()) return;
void loadSessions();
if (readOnly) return;
const timer = setInterval(() => void loadSessions(), 10_000);
return () => clearInterval(timer);
}, [
expanded,
loadSessions,
readOnly,
reloadToken,
renderSessions,
searchQuery,
]);
// Undefined when `cwd` is not a real path (synthetic fallback workspace), so
// the poll — which qualifies the route with the cwd — is skipped entirely.
const gitPollCwd = isAbsolutePath(workspace.cwd) ? workspace.cwd : undefined;
const gitStatusEnabled = Boolean(onOpenGitDiff);
// Log a poll failure only on the success→failure transition, not on every
// 60s/focus tick, so an unreachable workspace doesn't spam a long-lived tab.
const gitPollFailed = useRef(false);
const loadGitStatus = useCallback(async () => {
if (!gitStatusEnabled || !workspace.trusted || !gitPollCwd) return;
try {
// wait: the sidebar chip shows the enriched counters and has no SSE
// fill-in path, so it keeps the blocking semantics instead of the
// composer's last-known fast path.
const status = await client
.workspaceByCwd(gitPollCwd)
.workspaceGit({ wait: true });
gitPollFailed.current = false;
setGitStatus(status);
} catch (err) {
// Keep the last known status on a transient failure so a brief network
// or daemon blip doesn't blank the chip for a whole poll interval; log
// only on the success→failure transition.
if (!gitPollFailed.current) {
console.warn('[WorkspaceSection] git status poll failed:', err);
gitPollFailed.current = true;
}
}
}, [client, gitPollCwd, gitStatusEnabled, workspace.trusted]);
// The git chip lives in the always-visible folder header, so it polls
// independently of session expansion: on mount/trust, on window focus, and on
// a visibility-gated 60s tick (the daemon recomputes the working-tree summary
// per call, so the cadence stays gentle). Skipped entirely when no diff
// handler is wired, since the chip — its only consumer — would not render.
useEffect(() => {
if (!gitStatusEnabled || !workspace.trusted || !gitPollCwd) {
setGitStatus(undefined);
return;
}
void loadGitStatus();
const onFocus = () => void loadGitStatus();
window.addEventListener('focus', onFocus);
const timer = window.setInterval(() => {
if (document.visibilityState === 'visible') void loadGitStatus();
}, 60_000);
return () => {
window.removeEventListener('focus', onFocus);
window.clearInterval(timer);
};
}, [
gitPollCwd,
gitStatusEnabled,
loadGitStatus,
reloadToken,
workspace.trusted,
]);
const visibleSessions = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
return sessions.filter((session) => {
if (excludePinned && session.isPinned) return false;
if (!query) return true;
const label = (session.displayName || '').toLowerCase();
return (
label.includes(query) || session.sessionId.toLowerCase().includes(query)
);
});
}, [excludePinned, searchQuery, sessions]);
const groupedSessions = useMemo(() => {
if (!organizationEnabled || groups.length === 0) return null;
const assigned = new Set<string>();
const sections = groups.map((group) => {
const items = visibleSessions.filter(
(session) => session.groupId === group.id,
);
items.forEach((session) => assigned.add(session.sessionId));
return { group, sessions: items };
});
return {
sections,
ungrouped: visibleSessions.filter(
(session) => !assigned.has(session.sessionId),
),
};
}, [groups, organizationEnabled, visibleSessions]);
return (
<div className={styles.section}>
<div
className={cx(styles.headerRow, disabled && styles.headerDisabled)}
onMouseEnter={() => setActionsVisible(true)}
onMouseLeave={() => setActionsVisible(false)}
onFocus={() => setActionsVisible(true)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setActionsVisible(false);
}
}}
>
<button
className={styles.header}
type="button"
disabled={disabled}
aria-expanded={expanded}
onClick={() => {
const nextExpanded = !expanded;
setInternalExpanded(nextExpanded);
onExpandedChange?.(nextExpanded);
}}
>
{renderHeader ? (
renderHeader(expanded)
) : (
<>
<span
className={cx(styles.chevron, expanded && styles.chevronOpen)}
>
<WorkspaceFolderIcon open={expanded} />
</span>
<span className={styles.headerContent}>
<span className={styles.name}>{workspaceLabel(workspace)}</span>
</span>
{!workspace.trusted && (
<span className={styles.badge}>{untrustedLabel}</span>
)}
{readOnly && (
<span className={styles.badge}>{readOnlyLabel}</span>
)}
</>
)}
</button>
{onOpenGitDiff && workspace.trusted && gitStatus?.branch && (
<BranchPickerPopover
open={branchPickerOpen}
onOpenChange={setBranchPickerOpen}
workspaceCwd={workspace.cwd}
onOpenDiff={() => onOpenGitDiff(workspace.cwd)}
onOpenCommit={
onOpenCommit ? () => onOpenCommit(workspace.cwd) : undefined
}
>
<button
type="button"
className={styles.gitPill}
aria-label={`${t('branchPicker.label')}${gitStatus.branch}`}
>
<GitBranchIndicator
branch={gitStatus.branch}
status={gitStatus}
compact
/>
</button>
</BranchPickerPopover>
)}
{headerActions?.(actionsVisible)}
</div>
{renderSessions &&
(expanded || Boolean(searchQuery.trim())) &&
!disabled && (
<div className={styles.sessions}>
{loadError ? (
<div className={styles.error} role="status">
{loadErrorLabel}
</div>
) : visibleSessions.length === 0 ? (
<div className={styles.empty}>{noSessionsLabel}</div>
) : groupedSessions ? (
<>
{groupedSessions.sections.map(({ group, sessions }) => (
<SessionGroupSection
id={`group:${group.id}`}
key={group.id}
label={group.name}
count={sessions.length}
color={group.color}
expanded={!collapsedGroupIds.has(group.id)}
onToggle={() => {
setCollapsedGroupIds((current) => {
const next = new Set(current);
if (next.has(group.id)) next.delete(group.id);
else next.add(group.id);
return next;
});
}}
onRename={
onRenameGroup
? () => onRenameGroup(group, workspace.cwd)
: undefined
}
onDelete={
onDeleteGroup
? () => onDeleteGroup(group, workspace.cwd)
: undefined
}
renameLabel={renameGroupLabel}
deleteLabel={deleteGroupLabel}
actionsDisabled={groupActionsDisabled}
>
{sessions.map((session) => renderSession(session))}
</SessionGroupSection>
))}
{groupedSessions.ungrouped.length > 0 && (
<SessionGroupSection
id="ungrouped"
label={ungroupedLabel}
count={groupedSessions.ungrouped.length}
expanded={!collapsedGroupIds.has('ungrouped')}
onToggle={() => {
setCollapsedGroupIds((current) => {
const next = new Set(current);
if (next.has('ungrouped')) next.delete('ungrouped');
else next.add('ungrouped');
return next;
});
}}
>
{groupedSessions.ungrouped.map((session) =>
renderSession(session),
)}
</SessionGroupSection>
)}
</>
) : (
visibleSessions.map((session) => {
if (!readOnly) return renderSession(session);
const label = getSessionLabel(session);
const time = session.createdAt
? formatTime(session.createdAt)
: '';
return (
<div
key={session.sessionId}
className={styles.sessionItemReadOnly}
role="note"
aria-label={`${label}${time ? `, ${time}` : ''}. ${trustToOpenLabel}`}
>
<span className={styles.sessionName} title={label}>
{label}
</span>
{time && <span className={styles.sessionTime}>{time}</span>}
</div>
);
})
)}
</div>
)}
</div>
);
}