mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-12 18:26:26 +00:00
* 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>
491 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|