From 08b46f1deedb9c9e1a3176a5b96fa18a0517576c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 28 Jul 2026 20:38:55 -0700 Subject: [PATCH 1/3] fix(worktree): scope catalog and simplify management --- .../assembly/core/src/service/worktree/mod.rs | 110 +++++++--- .../config/components/WorktreesConfig.scss | 191 +++++++++--------- .../components/WorktreesConfig.test.tsx | 55 ++++- .../config/components/WorktreesConfig.tsx | 128 ++++++------ src/web-ui/src/locales/en-US/worktrees.json | 16 +- src/web-ui/src/locales/zh-CN/worktrees.json | 16 +- src/web-ui/src/locales/zh-TW/worktrees.json | 16 +- 7 files changed, 319 insertions(+), 213 deletions(-) diff --git a/src/crates/assembly/core/src/service/worktree/mod.rs b/src/crates/assembly/core/src/service/worktree/mod.rs index edd174f9de..e0b82a4d54 100644 --- a/src/crates/assembly/core/src/service/worktree/mod.rs +++ b/src/crates/assembly/core/src/service/worktree/mod.rs @@ -283,12 +283,20 @@ impl WorktreeService { } pub async fn list(request: WorktreeListRequest) -> Result, WorktreeError> { + Self::list_scoped(request, None).await + } + + async fn list_scoped( + request: WorktreeListRequest, + managed_root: Option<&Path>, + ) -> Result, WorktreeError> { let context = Self::repository_context(Path::new(&request.project_workspace_path)).await?; let lock = repository_lock(&context.common_git_dir); let _guard = lock.lock().await; let _process_guard = Self::acquire_repository_process_lock(&context).await?; let mut registry = Self::load_registry(&context).await?; - let (summaries, changed) = Self::reconcile(&context, &mut registry).await?; + let (summaries, changed) = + Self::reconcile_scoped(&context, &mut registry, managed_root).await?; if changed { Self::save_registry(&context, ®istry).await?; } @@ -296,18 +304,25 @@ impl WorktreeService { } /// Lists local Git projects known to the workspace service together with - /// their non-main worktrees. Invalid and non-Git workspace records are - /// ignored so one stale project cannot hide the rest of the catalog. + /// worktrees beneath the configured BitFun worktree root. Invalid and + /// non-Git workspace records are ignored so one stale project cannot hide + /// the rest of the catalog. pub async fn list_projects( _request: WorktreeProjectListRequest, ) -> Result, WorktreeError> { + let settings = load_settings().await; + let path_manager = get_path_manager_arc(); + let managed_root = resolve_managed_root(&settings, path_manager.as_ref())?; let project_paths = known_project_workspace_paths().await; let mut projects = Vec::new(); for project_path in project_paths { - match Self::list(WorktreeListRequest { - project_workspace_path: path_string(&project_path), - }) + match Self::list_scoped( + WorktreeListRequest { + project_workspace_path: path_string(&project_path), + }, + Some(&managed_root), + ) .await { Ok(worktrees) => { @@ -876,6 +891,14 @@ impl WorktreeService { async fn reconcile( context: &RepositoryContext, registry: &mut WorktreeRegistry, + ) -> Result<(Vec, bool), WorktreeError> { + Self::reconcile_scoped(context, registry, None).await + } + + async fn reconcile_scoped( + context: &RepositoryContext, + registry: &mut WorktreeRegistry, + managed_root: Option<&Path>, ) -> Result<(Vec, bool), WorktreeError> { let git_worktrees = GitService::list_worktrees(&context.project_workspace_path) .await @@ -896,6 +919,11 @@ impl WorktreeService { let mut changed = false; for git_worktree in git_worktrees { + if managed_root.is_some_and(|root| { + git_worktree.is_main || !path_is_within_root(Path::new(&git_worktree.path), root) + }) { + continue; + } let lookup_path = normalized_lookup_path(Path::new(&git_worktree.path)); let missing = git_worktree.is_prunable || !Path::new(&git_worktree.path).is_dir(); let registered = registered_by_path.get(&lookup_path); @@ -947,6 +975,10 @@ impl WorktreeService { if seen_registered_ids.contains(&record.worktree_id) { continue; } + if managed_root.is_some_and(|root| !path_is_within_root(Path::new(&record.path), root)) + { + continue; + } let missing_info = GitWorktreeInfo { path: record.path.clone(), branch: record.branch.clone(), @@ -1163,7 +1195,7 @@ impl WorktreeService { let Some(summary) = summaries_by_id.get(&candidate_id) else { continue; }; - if let Err(protected_reason) = validate_removal(summary, false) { + if let Err(protected_reason) = validate_automatic_removal(summary) { log::debug!( "Skipping automatic worktree deletion for {}: {}", summary.path, @@ -1617,6 +1649,12 @@ fn normalized_lookup_path(path: &Path) -> String { path_string(&path) } +fn path_is_within_root(path: &Path, root: &Path) -> bool { + let normalized_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let normalized_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + normalized_path != normalized_root && normalized_path.starts_with(normalized_root) +} + fn path_string(path: &Path) -> String { path.to_string_lossy().replace('\\', "/") } @@ -1667,12 +1705,6 @@ fn validate_removal(summary: &WorktreeSummary, force: bool) -> Result<(), Worktr "The worktree is locked by Git", )); } - if summary.associated_session_count > 0 { - return Err(error( - WorktreeErrorCode::WorktreeBusy, - "The worktree has associated sessions; delete them before removing the worktree", - )); - } if !force && summary.dirty { return Err(error( WorktreeErrorCode::DirtyWorktree, @@ -1694,6 +1726,16 @@ fn validate_removal(summary: &WorktreeSummary, force: bool) -> Result<(), Worktr Ok(()) } +fn validate_automatic_removal(summary: &WorktreeSummary) -> Result<(), WorktreeError> { + if summary.associated_session_count > 0 { + return Err(error( + WorktreeErrorCode::WorktreeBusy, + "The worktree has associated sessions", + )); + } + validate_removal(summary, false) +} + fn map_base_ref_error(git_error: GitError, base_ref: &str) -> WorktreeError { let text = git_error.to_string(); if text.to_ascii_lowercase().contains("unborn") @@ -1748,9 +1790,9 @@ fn map_git_error(git_error: GitError) -> WorktreeError { mod tests { use super::{ automatic_delete_candidate_ids, managed_target_path, managed_worktree_directory_name, - repository_id, resolve_managed_root, sanitize_worktree_project_label, validate_removal, - RegisteredWorktree, RepositoryContext, WorktreeOperationReceipt, WorktreeRegistry, - WorktreeService, AUTO_DELETE_MIN_AGE_MS, + path_is_within_root, repository_id, resolve_managed_root, sanitize_worktree_project_label, + validate_automatic_removal, validate_removal, RegisteredWorktree, RepositoryContext, + WorktreeOperationReceipt, WorktreeRegistry, WorktreeService, AUTO_DELETE_MIN_AGE_MS, }; use crate::infrastructure::PathManager; use bitfun_core_types::{ @@ -1908,6 +1950,23 @@ mod tests { assert!(first.starts_with("worktree-session-")); } + #[test] + fn catalog_scope_only_accepts_descendants_of_the_configured_root() { + let root = tempfile::tempdir().expect("worktree root"); + let managed = root.path().join("repository-id").join("worktree-id"); + std::fs::create_dir_all(&managed).expect("managed worktree"); + let sibling = root + .path() + .parent() + .expect("temporary root parent") + .join("other-worktrees") + .join("worktree-id"); + + assert!(path_is_within_root(&managed, root.path())); + assert!(!path_is_within_root(root.path(), root.path())); + assert!(!path_is_within_root(&sibling, root.path())); + } + #[test] fn safe_removal_rejects_every_protected_state() { let mut summary = removable_summary(); @@ -1924,13 +1983,6 @@ mod tests { WorktreeErrorCode::WorktreeLocked ); - let mut summary = removable_summary(); - summary.associated_session_count = 1; - assert_eq!( - validate_removal(&summary, true).unwrap_err().code, - WorktreeErrorCode::WorktreeBusy - ); - let mut summary = removable_summary(); summary.dirty = true; assert_eq!( @@ -1961,6 +2013,18 @@ mod tests { assert!(validate_removal(&summary, true).is_ok()); } + #[test] + fn manual_removal_allows_associated_sessions_but_automatic_cleanup_does_not() { + let mut summary = removable_summary(); + summary.associated_session_count = 1; + + assert!(validate_removal(&summary, false).is_ok()); + assert_eq!( + validate_automatic_removal(&summary).unwrap_err().code, + WorktreeErrorCode::WorktreeBusy + ); + } + #[test] fn automatic_cleanup_only_selects_managed_worktrees_older_than_the_limit() { let project = Path::new("/repo"); diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss index 2424cc6aa4..06c88ccd85 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss @@ -17,48 +17,71 @@ &__projects { display: flex; flex-direction: column; - gap: var(--size-gap-5); + gap: var(--size-gap-6); } &__project { min-width: 0; - overflow: hidden; - border: 1px solid var(--border-base); - border-radius: var(--size-radius-md); - background: var(--element-bg-subtle); } &__project-header { display: flex; - align-items: center; + align-items: flex-end; justify-content: space-between; - gap: var(--size-gap-3); - padding: var(--size-gap-3) var(--size-gap-4); - border-bottom: 1px solid var(--border-subtle); - background: var(--element-bg-base); + gap: var(--size-gap-4); + padding: 0 var(--size-gap-1) var(--size-gap-2); + + > span { + flex-shrink: 0; + padding-bottom: 1px; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: var(--line-height-base); + } + } + + &__project-identity { + min-width: 0; h4 { - min-width: 0; margin: 0; - overflow: hidden; color: var(--color-text-primary); - font-family: var(--font-family-mono); font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-semibold); line-height: var(--line-height-base); - text-overflow: ellipsis; - white-space: nowrap; } - span { - flex-shrink: 0; + code { + display: block; + margin-top: 2px; + overflow: hidden; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; color: var(--color-text-muted); + font-family: var(--font-family-mono); font-size: var(--font-size-xs); + line-height: var(--line-height-base); + text-overflow: ellipsis; + white-space: nowrap; } } + &__worktree-list { + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: var(--size-radius-md); + background: var(--element-bg-subtle); + } + &__worktree { - padding: var(--size-gap-4); + padding: var(--size-gap-3) var(--size-gap-4); + transition: background var(--motion-fast) var(--easing-standard); + + &:hover { + background: var(--element-bg-base); + } & + & { border-top: 1px solid var(--border-subtle); @@ -68,100 +91,87 @@ &__worktree-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - gap: var(--size-gap-4); + align-items: center; + gap: var(--size-gap-3); } &__worktree-copy { min-width: 0; } + &__worktree-heading { + display: flex; + align-items: baseline; + gap: var(--size-gap-3); + min-width: 0; + } + &__worktree-title { + min-width: 0; margin: 0; + overflow: hidden; color: var(--color-text-primary); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); line-height: var(--line-height-base); + text-overflow: ellipsis; + white-space: nowrap; + } + + &__metadata { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: var(--size-gap-2); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: var(--line-height-base); } &__path { display: block; max-width: 100%; - margin-top: var(--size-gap-1); + margin-top: 2px; overflow: hidden; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; color: var(--color-text-secondary); font-family: var(--font-family-mono); font-size: var(--font-size-xs); - line-height: var(--line-height-relaxed); + line-height: var(--line-height-base); text-overflow: ellipsis; white-space: nowrap; } - &__metadata { + &__sessions-summary { display: flex; - flex-wrap: wrap; - gap: var(--size-gap-1) var(--size-gap-3); + align-items: center; + gap: var(--size-gap-1); + min-width: 0; margin-top: var(--size-gap-2); color: var(--color-text-muted); font-size: var(--font-size-xs); line-height: var(--line-height-base); - } - - &__delete-control { - flex-shrink: 0; - } - - &__sessions { - margin-top: var(--size-gap-4); - } - - &__sessions-label { - color: var(--color-text-secondary); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-medium); - line-height: var(--line-height-base); - } - &__session-list { - display: grid; - gap: var(--size-gap-1); - margin: var(--size-gap-2) 0 0; - padding: 0; - list-style: none; - - li { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: var(--size-gap-3); - align-items: baseline; - padding-left: var(--size-gap-2); - color: var(--color-text-primary); - font-size: var(--font-size-xs); - line-height: var(--line-height-relaxed); + svg { + flex-shrink: 0; } } - &__session-status { - color: var(--color-text-muted); + &__session-names { + min-width: 0; + overflow: hidden; + color: var(--color-text-secondary); + text-overflow: ellipsis; white-space: nowrap; } - &__sessions-empty { - margin-top: var(--size-gap-1); - color: var(--color-text-muted); - font-size: var(--font-size-xs); - line-height: var(--line-height-relaxed); - } - - &__protection-note { - margin-top: var(--size-gap-2); - color: var(--color-text-muted); - font-size: var(--font-size-xs); - line-height: var(--line-height-relaxed); - - &--warning { - color: var(--color-warning); - } + &__delete-control { + display: flex; + align-items: center; + justify-content: center; } &__empty { @@ -205,34 +215,31 @@ } } + &__projects { + gap: var(--size-gap-5); + } + &__project-header { align-items: flex-start; - padding: var(--size-gap-3); - - h4 { - white-space: normal; - overflow-wrap: anywhere; - } + gap: var(--size-gap-2); } &__worktree { padding: var(--size-gap-3); } - &__worktree-main { - grid-template-columns: minmax(0, 1fr); - gap: var(--size-gap-3); + &__worktree-heading { + align-items: flex-start; + flex-direction: column; + gap: var(--size-gap-1); } - &__delete-control { - .btn { - width: 100%; - } + &__metadata { + gap: var(--size-gap-2); } - &__session-list li { - grid-template-columns: minmax(0, 1fr); - gap: 0; + &__session-names { + display: none; } } } diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx index ea40da58c7..d651c4080d 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx @@ -61,6 +61,29 @@ vi.mock('@/component-library', () => ({ }: { onClick: () => void; }) => , + IconButton: ({ + 'aria-label': ariaLabel, + children, + disabled, + onClick, + title, + }: { + 'aria-label'?: string; + children: React.ReactNode; + disabled?: boolean; + onClick?: () => void; + title?: string; + }) => ( + + ), ConfirmDialog: ({ confirmText, isOpen, @@ -293,8 +316,9 @@ describe('WorktreesConfig', () => { }); await flushPromises(); - const deleteButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('management.delete.action')); + const deleteButton = container.querySelector( + 'button[aria-label="management.delete.actionLabel"]', + ); act(() => deleteButton?.click()); expect(container.querySelector('[role="dialog"]')?.textContent) @@ -315,7 +339,7 @@ describe('WorktreesConfig', () => { ); }); - it('disables deletion while a worktree still has associated archived sessions', async () => { + it('allows manual deletion while preserving associated archived sessions', async () => { listProjectsMock.mockResolvedValueOnce([{ projectWorkspacePath: '/repo', worktrees: [worktree()], @@ -326,10 +350,27 @@ describe('WorktreesConfig', () => { }); await flushPromises(); - const deleteButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('management.delete.action')); + const deleteButton = container.querySelector( + 'button[aria-label="management.delete.actionLabel"]', + ); + act(() => deleteButton?.click()); + + expect(deleteButton?.disabled).toBe(false); + expect(container.querySelector('[role="dialog"]')?.textContent) + .toContain('management.delete.messageWithSessions'); - expect(deleteButton?.disabled).toBe(true); - expect(container.textContent).toContain('management.protection.associatedSessions'); + const confirmButton = container.querySelector('[data-testid="confirm-delete"]'); + await act(async () => { + confirmButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(removeMock).toHaveBeenCalledWith( + '/repo', + 'wt-1', + expect.any(String), + false, + ); }); }); diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx index 12d3faa30c..21cf1e10f7 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { FolderGit2, GitBranch, + MessageSquareText, RotateCcw, Save, Trash2, @@ -12,6 +13,7 @@ import { ConfigPageMessage, ConfigPageRefreshButton, ConfirmDialog, + IconButton, Input, NumberInput, Switch, @@ -88,15 +90,19 @@ function createDeleteRequestId(): string { ?? `worktree-settings-delete-${Date.now()}-${Math.random()}`; } -type DeletionBlockReason = 'associatedSessions' | 'locked' | 'missing'; +type DeletionBlockReason = 'locked' | 'missing'; function deletionBlockReason(worktree: WorktreeSummary): DeletionBlockReason | null { - if (worktree.associatedSessionCount > 0) return 'associatedSessions'; if (worktree.locked) return 'locked'; if (worktree.missing) return 'missing'; return null; } +function workspaceName(path: string): string { + const parts = path.replace(/\\/g, '/').split('/').filter(Boolean); + return parts.at(-1) ?? path; +} + const WorktreesConfig: React.FC = () => { const { t } = useI18n('worktrees'); const [settings, setSettings] = useState(DEFAULT_SETTINGS); @@ -207,8 +213,6 @@ const WorktreesConfig: React.FC = () => { const code = (error as Partial | null)?.code; const text = (() => { switch (code) { - case 'worktree_busy': - return t('management.errors.associatedSessions'); case 'worktree_locked': return t('management.errors.locked'); case 'dirty_worktree': @@ -348,8 +352,6 @@ const WorktreesConfig: React.FC = () => { const blockCode = deletionBlockReason(worktree); const blockReason = (() => { switch (blockCode) { - case 'associatedSessions': - return t('management.protection.associatedSessions'); case 'locked': return t('management.protection.locked'); case 'missing': @@ -358,9 +360,11 @@ const WorktreesConfig: React.FC = () => { return null; } })(); + const sessionNames = worktree.sessions + .map(session => session.sessionName) + .join(', '); const branchLabel = worktree.branch ?? t('labels.detached', { commit: worktree.head.slice(0, 7) }); - const forceDelete = worktree.dirty || worktree.hasUnpublishedCommits; const lifecycleLabel = (() => { switch (worktree.lifecycle) { case 'permanent': @@ -380,33 +384,46 @@ const WorktreesConfig: React.FC = () => { >
-
{branchLabel}
+
+
{branchLabel}
+
+ {worktree.lifecycle !== 'managed' && {lifecycleLabel}} + {worktree.dirty && {t('management.state.dirty')}} + {worktree.hasUnpublishedCommits && ( + {t('management.state.unpublishedCommits')} + )} + {worktree.locked && {t('management.state.locked')}} + {worktree.missing && {t('management.state.missing')}} +
+
{worktree.path} -
- {lifecycleLabel} - {worktree.dirty && {t('management.state.dirty')}} - {worktree.hasUnpublishedCommits && ( - {t('management.state.unpublishedCommits')} - )} - {worktree.locked && {t('management.state.locked')}} - {worktree.missing && {t('management.state.missing')}} - {worktree.runningSessionCount > 0 && ( + {worktree.associatedSessionCount > 0 && ( +
+ - {t('management.state.activeSessions', { - count: worktree.runningSessionCount, + {t('management.sessions.summary', { + count: worktree.associatedSessionCount, })} - )} -
+ + {sessionNames} + +
+ )}
-
- +
- -
-
- {t('management.sessions.title')} -
- {worktree.sessions.length > 0 ? ( -
    - {worktree.sessions.map(session => ( -
  • - {session.sessionName} - - {session.status === 'archived' - ? t('management.sessions.status.archived') - : session.status === 'completed' - ? t('shared:statuses.done') - : t('management.sessions.status.active')} - -
  • - ))} -
- ) : ( -
- {t('management.sessions.empty')} -
- )} - {blockReason && ( -
- {blockReason} -
- )} - {forceDelete && !blockReason && ( -
- {t('management.delete.forceHint')} -
- )} -
); }; @@ -485,9 +465,12 @@ const WorktreesConfig: React.FC = () => { key={project.projectWorkspacePath} >
-

- {project.projectWorkspacePath} -

+
+

{workspaceName(project.projectWorkspacePath)}

+ + {project.projectWorkspacePath} + +
{t('management.worktreeCount', { count: project.worktrees.length })} @@ -504,6 +487,19 @@ const WorktreesConfig: React.FC = () => { const deletingWithLocalWork = Boolean( deleteTarget?.worktree.dirty || deleteTarget?.worktree.hasUnpublishedCommits, ); + const deletingWithSessions = Boolean(deleteTarget?.worktree.associatedSessionCount); + const deleteMessage = (() => { + if (deletingWithLocalWork && deletingWithSessions) { + return t('management.delete.forceMessageWithSessions'); + } + if (deletingWithLocalWork) { + return t('management.delete.forceMessage'); + } + if (deletingWithSessions) { + return t('management.delete.messageWithSessions'); + } + return t('management.delete.message'); + })(); return ( @@ -548,9 +544,7 @@ const WorktreesConfig: React.FC = () => { title={deletingWithLocalWork ? t('management.delete.forceTitle') : t('management.delete.title')} - message={deletingWithLocalWork - ? t('management.delete.forceMessage') - : t('management.delete.message')} + message={deleteMessage} preview={deleteTarget?.worktree.path} type={deletingWithLocalWork ? 'error' : 'warning'} confirmDanger diff --git a/src/web-ui/src/locales/en-US/worktrees.json b/src/web-ui/src/locales/en-US/worktrees.json index e5245ae1df..5aab090968 100644 --- a/src/web-ui/src/locales/en-US/worktrees.json +++ b/src/web-ui/src/locales/en-US/worktrees.json @@ -49,15 +49,15 @@ }, "management": { "title": "Workspace worktrees", - "description": "Local workspaces and their linked worktrees. Refresh to rescan Git and session associations.", - "loading": "Scanning local worktrees…", - "loadFailed": "Could not load the local worktree list.", + "description": "Worktrees inside the configured BitFun root, grouped by owning workspace.", + "loading": "Scanning BitFun worktrees…", + "loadFailed": "Could not load the BitFun worktree list.", "refresh": "Refresh worktrees", "retry": "Try again", - "worktreeCount": "Worktrees: {{count}}", + "worktreeCount": "{{count}} worktrees", "deleted": "Deleted {{path}}.", "empty": { - "title": "No linked worktrees", + "title": "No BitFun worktrees", "description": "Worktrees created for isolated sessions will appear here." }, "lifecycle": { @@ -74,6 +74,7 @@ }, "sessions": { "title": "Conversations", + "summary": "{{count}} conversations", "empty": "No associated conversations.", "status": { "active": "Active", @@ -81,7 +82,6 @@ } }, "protection": { - "associatedSessions": "Delete all associated conversations before deleting this worktree.", "locked": "Unlock this worktree in Git before deleting it.", "missing": "The worktree directory is missing. Clean up the stale Git record manually." }, @@ -90,14 +90,14 @@ "actionLabel": "Delete worktree {{path}}", "title": "Delete this worktree?", "message": "The checkout will be removed. This action cannot be undone.", + "messageWithSessions": "The checkout will be removed. Associated conversations stay in history, but their checkout will no longer be available.", "forceTitle": "Delete and discard local work?", "forceMessage": "This worktree contains local changes or detached commits. Deleting it will permanently discard that local work.", - "forceHint": "Deleting this worktree requires confirmation to discard its local work.", + "forceMessageWithSessions": "Local changes or detached commits will be permanently discarded. Associated conversations stay in history without their checkout.", "forceAction": "Delete and discard", "cancel": "Cancel" }, "errors": { - "associatedSessions": "This worktree has associated conversations and cannot be deleted.", "locked": "This worktree is locked by Git and cannot be deleted.", "dirty": "This worktree still contains local changes.", "unpublishedCommits": "This worktree still contains unpublished detached commits.", diff --git a/src/web-ui/src/locales/zh-CN/worktrees.json b/src/web-ui/src/locales/zh-CN/worktrees.json index e950460ec4..b62c4af5c9 100644 --- a/src/web-ui/src/locales/zh-CN/worktrees.json +++ b/src/web-ui/src/locales/zh-CN/worktrees.json @@ -49,15 +49,15 @@ }, "management": { "title": "工作区 Worktrees", - "description": "查看本地工作区及其关联的 worktree。刷新后会重新扫描 Git 状态和会话关联。", - "loading": "正在扫描本地 Worktree…", - "loadFailed": "无法加载本地 Worktree 列表。", + "description": "仅显示 BitFun Worktree 根目录内的 worktree,并按所属工作区分组。", + "loading": "正在扫描 BitFun Worktree…", + "loadFailed": "无法加载 BitFun Worktree 列表。", "refresh": "刷新 Worktree", "retry": "重试", - "worktreeCount": "Worktree:{{count}}", + "worktreeCount": "{{count}} 个 Worktree", "deleted": "已删除 {{path}}。", "empty": { - "title": "暂无关联的 Worktree", + "title": "暂无 BitFun Worktree", "description": "为隔离会话创建的 worktree 会显示在这里。" }, "lifecycle": { @@ -74,6 +74,7 @@ }, "sessions": { "title": "会话", + "summary": "{{count}} 个会话", "empty": "没有关联会话。", "status": { "active": "进行中", @@ -81,7 +82,6 @@ } }, "protection": { - "associatedSessions": "请先删除所有关联会话,再删除此 worktree。", "locked": "请先在 Git 中解锁此 worktree,再执行删除。", "missing": "Worktree 目录已缺失,请手动清理过期的 Git 记录。" }, @@ -90,14 +90,14 @@ "actionLabel": "删除 Worktree {{path}}", "title": "删除此 Worktree?", "message": "当前检出目录将被删除,此操作无法撤销。", + "messageWithSessions": "当前检出目录将被删除。关联会话会保留在历史记录中,但无法再使用此检出目录。", "forceTitle": "删除并丢弃本地工作?", "forceMessage": "此 worktree 包含本地改动或游离提交。删除后,这些本地工作将永久丢失。", - "forceHint": "删除此 worktree 需要再次确认,并会丢弃其中的本地工作。", + "forceMessageWithSessions": "本地改动或游离提交将永久丢失。关联会话会保留在历史记录中,但不再拥有对应的检出目录。", "forceAction": "删除并丢弃", "cancel": "取消" }, "errors": { - "associatedSessions": "此 worktree 包含关联会话,无法删除。", "locked": "此 worktree 已被 Git 锁定,无法删除。", "dirty": "此 worktree 仍包含本地改动。", "unpublishedCommits": "此 worktree 仍包含未发布的游离提交。", diff --git a/src/web-ui/src/locales/zh-TW/worktrees.json b/src/web-ui/src/locales/zh-TW/worktrees.json index 840943dc47..c052131cff 100644 --- a/src/web-ui/src/locales/zh-TW/worktrees.json +++ b/src/web-ui/src/locales/zh-TW/worktrees.json @@ -49,15 +49,15 @@ }, "management": { "title": "工作區 Worktrees", - "description": "檢視本機工作區及其關聯的 worktree。重新整理後會再次掃描 Git 狀態和工作階段關聯。", - "loading": "正在掃描本機 Worktree…", - "loadFailed": "無法載入本機 Worktree 清單。", + "description": "僅顯示 BitFun Worktree 根目錄內的 worktree,並依所屬工作區分組。", + "loading": "正在掃描 BitFun Worktree…", + "loadFailed": "無法載入 BitFun Worktree 清單。", "refresh": "重新整理 Worktree", "retry": "重試", - "worktreeCount": "Worktree:{{count}}", + "worktreeCount": "{{count}} 個 Worktree", "deleted": "已刪除 {{path}}。", "empty": { - "title": "目前沒有關聯的 Worktree", + "title": "目前沒有 BitFun Worktree", "description": "為隔離工作階段建立的 worktree 會顯示在這裡。" }, "lifecycle": { @@ -74,6 +74,7 @@ }, "sessions": { "title": "工作階段", + "summary": "{{count}} 個工作階段", "empty": "沒有關聯的工作階段。", "status": { "active": "進行中", @@ -81,7 +82,6 @@ } }, "protection": { - "associatedSessions": "請先刪除所有關聯工作階段,再刪除此 worktree。", "locked": "請先在 Git 中解鎖此 worktree,再執行刪除。", "missing": "Worktree 目錄已遺失,請手動清理過期的 Git 記錄。" }, @@ -90,14 +90,14 @@ "actionLabel": "刪除 Worktree {{path}}", "title": "刪除此 Worktree?", "message": "目前的簽出目錄將被刪除,此操作無法復原。", + "messageWithSessions": "目前的簽出目錄將被刪除。關聯工作階段會保留在歷史記錄中,但無法再使用此簽出目錄。", "forceTitle": "刪除並捨棄本機工作?", "forceMessage": "此 worktree 包含本機變更或游離提交。刪除後,這些本機工作將永久遺失。", - "forceHint": "刪除此 worktree 需要再次確認,並會捨棄其中的本機工作。", + "forceMessageWithSessions": "本機變更或游離提交將永久遺失。關聯工作階段會保留在歷史記錄中,但不再擁有對應的簽出目錄。", "forceAction": "刪除並捨棄", "cancel": "取消" }, "errors": { - "associatedSessions": "此 worktree 包含關聯工作階段,無法刪除。", "locked": "此 worktree 已被 Git 鎖定,無法刪除。", "dirty": "此 worktree 仍包含本機變更。", "unpublishedCommits": "此 worktree 仍包含未發布的游離提交。", From d3f0e8660c4086a17e4d28cf730796f6474d20d2 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 28 Jul 2026 21:09:03 -0700 Subject: [PATCH 2/3] fix(worktree): smooth management interactions --- .../config/components/WorktreesConfig.scss | 268 +++++++++++++++- .../components/WorktreesConfig.test.tsx | 221 ++++++++++++- .../config/components/WorktreesConfig.tsx | 290 ++++++++++++++++-- src/web-ui/src/locales/en-US/worktrees.json | 4 + src/web-ui/src/locales/zh-CN/worktrees.json | 4 + src/web-ui/src/locales/zh-TW/worktrees.json | 4 + 6 files changed, 764 insertions(+), 27 deletions(-) diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss index 06c88ccd85..27cd9ba6f9 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.scss @@ -1,3 +1,54 @@ +@keyframes bitfun-worktree-results-enter { + from { + opacity: 0; + transform: translate3d(0, 4px, 0); + } + + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@keyframes bitfun-worktree-row-remove { + from { + opacity: 1; + transform: translate3d(0, 0, 0); + } + + to { + opacity: 0; + transform: translate3d(6px, 0, 0); + } +} + +@keyframes bitfun-worktree-loading-pulse { + 0%, + 100% { + opacity: 0.45; + } + + 50% { + opacity: 0.82; + } +} + +@keyframes bitfun-worktree-refresh-progress { + from { + transform: translate3d(-130%, 0, 0); + } + + to { + transform: translate3d(360%, 0, 0); + } +} + +@keyframes bitfun-worktree-session-spin { + to { + transform: rotate(360deg); + } +} + .bitfun-worktrees-config { &__actions { display: flex; @@ -18,6 +69,110 @@ display: flex; flex-direction: column; gap: var(--size-gap-6); + animation: bitfun-worktree-results-enter 180ms var(--easing-standard) both; + transition: opacity var(--motion-fast) var(--easing-standard); + } + + &__results { + position: relative; + min-height: 176px; + overflow-anchor: none; + + &--refreshing { + .bitfun-worktrees-config__projects, + .bitfun-worktrees-config__empty { + opacity: 0.7; + } + } + } + + &__refresh-progress { + position: absolute; + z-index: 1; + top: -1px; + right: 0; + left: 0; + height: 2px; + overflow: hidden; + border-radius: var(--size-radius-sm); + pointer-events: none; + + span { + display: block; + width: 28%; + height: 100%; + border-radius: inherit; + background: var(--color-accent-500); + opacity: 0.7; + animation: bitfun-worktree-refresh-progress 900ms ease-in-out infinite; + } + } + + &__skeleton { + min-height: 176px; + } + + &__skeleton-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--size-gap-4); + padding: 0 var(--size-gap-1) var(--size-gap-2); + + span { + display: block; + height: 11px; + border-radius: var(--size-radius-sm); + background: var(--element-bg-soft); + animation: bitfun-worktree-loading-pulse 1.2s ease-in-out infinite; + + &:first-child { + width: min(220px, 42%); + } + + &:last-child { + width: 72px; + } + } + } + + &__skeleton-list { + overflow: hidden; + border: 1px solid var(--border-subtle); + border-radius: var(--size-radius-md); + background: var(--element-bg-subtle); + } + + &__skeleton-row { + display: grid; + gap: var(--size-gap-2); + padding: var(--size-gap-3) var(--size-gap-4); + + & + & { + border-top: 1px solid var(--border-subtle); + } + + span { + display: block; + height: 9px; + border-radius: var(--size-radius-sm); + background: var(--element-bg-soft); + animation: bitfun-worktree-loading-pulse 1.2s ease-in-out infinite; + + &:first-child { + width: min(280px, 42%); + } + + &:nth-child(2) { + width: min(620px, 78%); + animation-delay: 80ms; + } + + &:last-child { + width: min(180px, 34%); + animation-delay: 160ms; + } + } } &__project { @@ -86,6 +241,14 @@ & + & { border-top: 1px solid var(--border-subtle); } + + &--removing { + pointer-events: none; + animation: bitfun-worktree-row-remove + 180ms + cubic-bezier(0.4, 0, 1, 1) + both; + } } &__worktree-main { @@ -160,14 +323,77 @@ } } - &__session-names { + &__session-links { min-width: 0; overflow: hidden; - color: var(--color-text-secondary); text-overflow: ellipsis; white-space: nowrap; } + &__session-link { + display: inline-flex; + align-items: center; + min-width: 0; + max-width: 100%; + padding: 0; + border: 0; + border-radius: 2px; + background: transparent; + color: var(--color-text-secondary); + font: inherit; + line-height: inherit; + vertical-align: bottom; + cursor: pointer; + transition: + color var(--motion-fast) var(--easing-standard), + opacity var(--motion-fast) var(--easing-standard), + transform var(--motion-fast) var(--easing-standard); + + & + & { + margin-left: var(--size-gap-2); + } + + > span:first-of-type { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &:hover:not(:disabled) { + color: var(--color-text-primary); + text-decoration: underline; + text-underline-offset: 2px; + } + + &:active:not(:disabled) { + transform: translate3d(0, 1px, 0); + } + + &:disabled { + cursor: wait; + } + } + + &__session-link-spinner { + flex-shrink: 0; + margin-right: var(--size-gap-1); + animation: bitfun-worktree-session-spin 700ms linear infinite; + } + + &__session-link-state { + flex-shrink: 0; + margin-left: var(--size-gap-1); + color: var(--color-text-muted); + + &::before { + content: '('; + } + + &::after { + content: ')'; + } + } + &__delete-control { display: flex; align-items: center; @@ -182,6 +408,8 @@ border: 1px dashed var(--border-base); border-radius: var(--size-radius-md); color: var(--color-text-muted); + animation: bitfun-worktree-results-enter 180ms var(--easing-standard) both; + transition: opacity var(--motion-fast) var(--easing-standard); svg { flex-shrink: 0; @@ -238,8 +466,40 @@ gap: var(--size-gap-2); } - &__session-names { - display: none; + &__sessions-summary { + flex-wrap: wrap; + } + + &__session-links { + flex-basis: 100%; + padding-left: 17px; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .bitfun-worktrees-config { + &__projects, + &__empty, + &__worktree--removing, + &__skeleton-header span, + &__skeleton-row span, + &__session-link-spinner { + animation: none; + } + + &__refresh-progress span { + width: 100%; + opacity: 0.45; + animation: none; + transform: none; + } + + &__session-link, + &__empty, + &__projects, + &__worktree { + transition: none; } } } diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx index d651c4080d..cb40cf0ff4 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.test.tsx @@ -10,6 +10,12 @@ const setConfigMock = vi.hoisted(() => vi.fn()); const listProjectsMock = vi.hoisted(() => vi.fn()); const removeMock = vi.hoisted(() => vi.fn()); const onChangedMock = vi.hoisted(() => vi.fn(() => vi.fn())); +const openAgentCompanionSessionMock = vi.hoisted(() => vi.fn()); +const refreshWorkspaceSessionsMock = vi.hoisted(() => vi.fn()); +const unarchiveSessionMock = vi.hoisted(() => vi.fn()); +const confirmWarningMock = vi.hoisted(() => vi.fn()); +const notificationSuccessMock = vi.hoisted(() => vi.fn()); +const notificationErrorMock = vi.hoisted(() => vi.fn()); const translateMock = vi.hoisted(() => vi.fn( (key: string, params?: Record) => { if (key === 'labels.detached') return `detached ${params?.commit}`; @@ -36,6 +42,33 @@ vi.mock('@/infrastructure/i18n', () => ({ }), })); +vi.mock('@/app/services/openAgentCompanionSession', () => ({ + openAgentCompanionSession: openAgentCompanionSessionMock, +})); + +vi.mock('@/flow_chat/services/FlowChatManager', () => ({ + flowChatManager: { + refreshWorkspaceSessions: refreshWorkspaceSessionsMock, + }, +})); + +vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ + sessionAPI: { + unarchiveSession: unarchiveSessionMock, + }, +})); + +vi.mock('@/component-library/components/ConfirmDialog/confirmService', () => ({ + confirmWarning: confirmWarningMock, +})); + +vi.mock('@/shared/notification-system', () => ({ + notificationService: { + success: notificationSuccessMock, + error: notificationErrorMock, + }, +})); + vi.mock('@/component-library', () => ({ Button: ({ children, @@ -151,7 +184,9 @@ vi.mock('./common', () => ({

{subtitle}

), - ConfigPageLayout: ({ children }: { children: React.ReactNode }) =>
{children}
, + ConfigPageLayout: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), ConfigPageRow: ({ children, description, @@ -218,6 +253,16 @@ async function flushPromises() { }); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + describe('WorktreesConfig', () => { let container: HTMLDivElement; let root: Root; @@ -229,6 +274,12 @@ describe('WorktreesConfig', () => { document.body.appendChild(container); root = createRoot(container); vi.clearAllMocks(); + Object.defineProperty(globalThis, 'matchMedia', { + configurable: true, + value: vi.fn(() => ({ + matches: true, + })), + }); getConfigMock.mockResolvedValue({ rootPath: '/custom/worktrees', @@ -242,6 +293,10 @@ describe('WorktreesConfig', () => { }]); removeMock.mockResolvedValue({ worktreeId: 'wt-1', removed: true }); onChangedMock.mockReturnValue(vi.fn()); + openAgentCompanionSessionMock.mockResolvedValue(true); + refreshWorkspaceSessionsMock.mockResolvedValue(undefined); + unarchiveSessionMock.mockResolvedValue(undefined); + confirmWarningMock.mockResolvedValue(true); }); afterEach(() => { @@ -301,6 +356,170 @@ describe('WorktreesConfig', () => { }); }); + it('opens an associated conversation from the worktree row', async () => { + listProjectsMock.mockResolvedValueOnce([{ + projectWorkspacePath: '/repo', + worktrees: [worktree({ + sessions: [{ + sessionId: 'session-1', + sessionName: 'Ship worktree management', + status: 'active', + archived: false, + }], + })], + }]); + + await act(async () => { + root.render(); + }); + await flushPromises(); + + const sessionButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('Ship worktree management')); + await act(async () => { + sessionButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(openAgentCompanionSessionMock).toHaveBeenCalledWith('session-1'); + expect(refreshWorkspaceSessionsMock).not.toHaveBeenCalled(); + }); + + it('refreshes session metadata before retrying a conversation that is not loaded', async () => { + openAgentCompanionSessionMock + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + listProjectsMock.mockResolvedValueOnce([{ + projectWorkspacePath: '/repo', + worktrees: [worktree({ + sessions: [{ + sessionId: 'session-1', + sessionName: 'Ship worktree management', + status: 'active', + archived: false, + }], + })], + }]); + + await act(async () => { + root.render(); + }); + await flushPromises(); + + const sessionButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('Ship worktree management')); + await act(async () => { + sessionButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(refreshWorkspaceSessionsMock).toHaveBeenCalledWith({ rootPath: '/repo' }); + expect(openAgentCompanionSessionMock).toHaveBeenCalledTimes(2); + }); + + it('restores an archived associated conversation before opening it', async () => { + await act(async () => { + root.render(); + }); + await flushPromises(); + + const sessionButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('Ship worktree management')); + await act(async () => { + sessionButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(confirmWarningMock).toHaveBeenCalled(); + expect(unarchiveSessionMock).toHaveBeenCalledWith('session-1', '/repo'); + expect(refreshWorkspaceSessionsMock).toHaveBeenCalledWith({ rootPath: '/repo' }); + expect(openAgentCompanionSessionMock).toHaveBeenCalledWith('session-1'); + }); + + it('keeps the current list mounted while a background refresh is pending', async () => { + const refresh = deferred[]; + }>>(); + + await act(async () => { + root.render(); + }); + await flushPromises(); + listProjectsMock.mockReturnValueOnce(refresh.promise); + + const refreshButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent === 'refresh'); + act(() => refreshButton?.click()); + + const results = container.querySelector('.bitfun-worktrees-config__results'); + expect(results?.getAttribute('aria-busy')).toBe('true'); + expect(container.textContent).toContain('/managed/BitFun-wt-1'); + + await act(async () => { + refresh.resolve([{ + projectWorkspacePath: '/repo', + worktrees: [worktree()], + }]); + await refresh.promise; + await Promise.resolve(); + }); + + expect(results?.getAttribute('aria-busy')).toBe('false'); + expect(container.textContent).toContain('/managed/BitFun-wt-1'); + }); + + it('keeps the settings scroll position while deletion refreshes the list', async () => { + const refresh = deferred[]; + }>>(); + + await act(async () => { + root.render(); + }); + await flushPromises(); + listProjectsMock.mockReturnValueOnce(refresh.promise); + + const layout = container.querySelector('.bitfun-config-page-layout'); + expect(layout).not.toBeNull(); + if (layout) { + layout.scrollTop = 420; + } + + const deleteButton = container.querySelector( + 'button[aria-label="management.delete.actionLabel"]', + ); + act(() => deleteButton?.click()); + const confirmButton = container.querySelector('[data-testid="confirm-delete"]'); + await act(async () => { + confirmButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const results = container.querySelector('.bitfun-worktrees-config__results'); + expect(results?.getAttribute('aria-busy')).toBe('true'); + expect(container.querySelector('.bitfun-worktrees-config__skeleton')).toBeNull(); + expect(layout?.scrollTop).toBe(420); + + await act(async () => { + refresh.resolve([]); + await refresh.promise; + await Promise.resolve(); + }); + + expect(results?.getAttribute('aria-busy')).toBe('false'); + expect(container.textContent).toContain('management.empty.title'); + expect(layout?.scrollTop).toBe(420); + }); + it('requires confirmation and uses force only when local work would be discarded', async () => { listProjectsMock.mockResolvedValueOnce([{ projectWorkspacePath: '/repo', diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx index 21cf1e10f7..d972df85f5 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx @@ -1,12 +1,20 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { FolderGit2, GitBranch, + LoaderCircle, MessageSquareText, RotateCcw, Save, Trash2, } from 'lucide-react'; +import { openAgentCompanionSession } from '@/app/services/openAgentCompanionSession'; import { Button, ConfigPageLoading, @@ -18,14 +26,19 @@ import { NumberInput, Switch, } from '@/component-library'; +import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; +import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { configAPI, worktreeAPI } from '@/infrastructure/api'; +import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import type { WorktreeCommandError, WorktreeProjectSummary, + WorktreeSessionSummary, WorktreeSettings, WorktreeSummary, } from '@/infrastructure/api/service-api/WorktreeAPI'; import { useI18n } from '@/infrastructure/i18n'; +import { notificationService } from '@/shared/notification-system'; import { ConfigPageContent, ConfigPageHeader, @@ -37,6 +50,7 @@ import './WorktreesConfig.scss'; const AUTO_DELETE_LIMIT_MIN = 1; const AUTO_DELETE_LIMIT_MAX = 100; +const WORKTREE_REMOVE_ANIMATION_MS = 180; const DEFAULT_SETTINGS: WorktreeSettings = { rootPath: '~/.bitfun/worktrees', @@ -51,6 +65,12 @@ interface DeleteTarget { worktree: WorktreeSummary; } +interface ScrollSnapshot { + anchorTop: number; + scrollContainer: HTMLElement; + scrollTop: number; +} + type PageMessage = { type: 'success' | 'error' | 'info' | 'warning'; text: string; @@ -103,6 +123,33 @@ function workspaceName(path: string): string { return parts.at(-1) ?? path; } +function removeWorktreeFromProjects( + projects: WorktreeProjectSummary[], + projectWorkspacePath: string, + worktreeId: string, +): WorktreeProjectSummary[] { + return projects + .map(project => project.projectWorkspacePath === projectWorkspacePath + ? { + ...project, + worktrees: project.worktrees.filter(worktree => worktree.worktreeId !== worktreeId), + } + : project) + .filter(project => project.worktrees.length > 0); +} + +function removalAnimationDuration(): number { + return globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches + ? 0 + : WORKTREE_REMOVE_ANIMATION_MS; +} + +function waitFor(ms: number): Promise { + return ms > 0 + ? new Promise(resolve => globalThis.setTimeout(resolve, ms)) + : Promise.resolve(); +} + const WorktreesConfig: React.FC = () => { const { t } = useI18n('worktrees'); const [settings, setSettings] = useState(DEFAULT_SETTINGS); @@ -111,9 +158,18 @@ const WorktreesConfig: React.FC = () => { const [settingsMessage, setSettingsMessage] = useState(null); const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(true); + const [projectsInitialized, setProjectsInitialized] = useState(false); const [projectsMessage, setProjectsMessage] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deletingWorktreeId, setDeletingWorktreeId] = useState(null); + const [removingWorktreeId, setRemovingWorktreeId] = useState(null); + const [openingSessionId, setOpeningSessionId] = useState(null); + const [projectsLayoutRevision, setProjectsLayoutRevision] = useState(0); + const projectsResultsRef = useRef(null); + const projectsRequestIdRef = useRef(0); + const pendingScrollSnapshotRef = useRef(null); + const worktreeMutationInFlightRef = useRef(false); + const pendingWorktreeRefreshRef = useRef(false); const loadSettings = useCallback(async () => { setSettingsLoading(true); @@ -130,22 +186,74 @@ const WorktreesConfig: React.FC = () => { } }, [t]); - const loadProjects = useCallback(async () => { + const captureScrollSnapshot = useCallback((): ScrollSnapshot | null => { + const anchor = projectsResultsRef.current; + const scrollContainer = anchor?.closest('.bitfun-config-page-layout'); + if (!anchor || !scrollContainer) { + return null; + } + return { + anchorTop: anchor.getBoundingClientRect().top, + scrollContainer, + scrollTop: scrollContainer.scrollTop, + }; + }, []); + + const commitProjectsLayoutChange = useCallback((update: () => void) => { + pendingScrollSnapshotRef.current = captureScrollSnapshot(); + update(); + setProjectsLayoutRevision(current => current + 1); + }, [captureScrollSnapshot]); + + useLayoutEffect(() => { + const snapshot = pendingScrollSnapshotRef.current; + const anchor = projectsResultsRef.current; + if (!snapshot || !anchor || !snapshot.scrollContainer.isConnected) { + pendingScrollSnapshotRef.current = null; + return; + } + + const anchorDelta = anchor.getBoundingClientRect().top - snapshot.anchorTop; + snapshot.scrollContainer.scrollTop = snapshot.scrollTop + anchorDelta; + pendingScrollSnapshotRef.current = null; + }, [projectsLayoutRevision]); + + const loadProjects = useCallback(async (): Promise => { + const requestId = ++projectsRequestIdRef.current; setProjectsLoading(true); - setProjectsMessage(null); try { - setProjects(await worktreeAPI.listProjects()); + const nextProjects = await worktreeAPI.listProjects(); + if (requestId !== projectsRequestIdRef.current) { + return false; + } + commitProjectsLayoutChange(() => { + setProjects(nextProjects); + setProjectsMessage(null); + setProjectsInitialized(true); + setProjectsLoading(false); + }); + return true; } catch { - setProjectsMessage({ type: 'error', text: t('management.loadFailed') }); - } finally { - setProjectsLoading(false); + if (requestId !== projectsRequestIdRef.current) { + return false; + } + commitProjectsLayoutChange(() => { + setProjectsMessage({ type: 'error', text: t('management.loadFailed') }); + setProjectsInitialized(true); + setProjectsLoading(false); + }); + return false; } - }, [t]); + }, [commitProjectsLayoutChange, t]); useEffect(() => { void loadSettings(); void loadProjects(); return worktreeAPI.onChanged(() => { + if (worktreeMutationInFlightRef.current) { + pendingWorktreeRefreshRef.current = true; + return; + } void loadProjects(); }); }, [loadProjects, loadSettings]); @@ -194,7 +302,8 @@ const WorktreesConfig: React.FC = () => { setDeleteTarget(null); setDeletingWorktreeId(target.worktree.worktreeId); - setProjectsMessage(null); + worktreeMutationInFlightRef.current = true; + pendingWorktreeRefreshRef.current = false; try { const discardLocalWork = target.worktree.dirty || target.worktree.hasUnpublishedCommits; @@ -204,11 +313,22 @@ const WorktreesConfig: React.FC = () => { createDeleteRequestId(), discardLocalWork, ); - await loadProjects(); - setProjectsMessage({ - type: 'success', - text: t('management.deleted', { path: target.worktree.path }), + setRemovingWorktreeId(target.worktree.worktreeId); + await waitFor(removalAnimationDuration()); + commitProjectsLayoutChange(() => { + setProjects(current => removeWorktreeFromProjects( + current, + target.projectWorkspacePath, + target.worktree.worktreeId, + )); + setRemovingWorktreeId(null); }); + notificationService.success( + t('management.deleted', { path: target.worktree.path }), + { duration: 2400 }, + ); + await loadProjects(); + pendingWorktreeRefreshRef.current = false; } catch (error) { const code = (error as Partial | null)?.code; const text = (() => { @@ -227,15 +347,64 @@ const WorktreesConfig: React.FC = () => { return t('management.errors.deleteFailed'); } })(); - setProjectsMessage({ - type: 'error', - text, + commitProjectsLayoutChange(() => { + setProjectsMessage({ + type: 'error', + text, + }); }); } finally { + worktreeMutationInFlightRef.current = false; setDeletingWorktreeId(null); + setRemovingWorktreeId(null); + if (pendingWorktreeRefreshRef.current) { + pendingWorktreeRefreshRef.current = false; + void loadProjects(); + } } }; + const openAssociatedSession = useCallback(async ( + projectWorkspacePath: string, + session: WorktreeSessionSummary, + ) => { + if (openingSessionId) return; + + setOpeningSessionId(session.sessionId); + try { + if (session.archived) { + const shouldRestore = await confirmWarning( + t('management.sessions.restoreTitle'), + t('management.sessions.restoreMessage', { name: session.sessionName }), + ); + if (!shouldRestore) { + return; + } + await sessionAPI.unarchiveSession(session.sessionId, projectWorkspacePath); + await flowChatManager.refreshWorkspaceSessions({ + rootPath: projectWorkspacePath, + }); + } + + let opened = await openAgentCompanionSession(session.sessionId); + if (!opened) { + await flowChatManager.refreshWorkspaceSessions({ + rootPath: projectWorkspacePath, + }); + opened = await openAgentCompanionSession(session.sessionId); + } + if (!opened) { + throw new Error('Associated session was not found after refreshing the workspace'); + } + } catch { + notificationService.error(t('management.sessions.openFailed'), { + duration: 3200, + }); + } finally { + setOpeningSessionId(null); + } + }, [openingSessionId, t]); + const renderSettings = () => { if (settingsLoading) { return ; @@ -378,7 +547,11 @@ const WorktreesConfig: React.FC = () => { return (
@@ -410,8 +583,36 @@ const WorktreesConfig: React.FC = () => { count: worktree.associatedSessionCount, })} - - {sessionNames} + + {worktree.sessions.map(session => ( + + ))} )} @@ -438,9 +639,30 @@ const WorktreesConfig: React.FC = () => { ); }; + const renderProjectsSkeleton = () => ( +
+ + {t('management.loading')} + + + +
+ ); + const renderProjects = () => { - if (projectsLoading) { - return ; + if (!projectsInitialized && projectsLoading) { + return renderProjectsSkeleton(); } if (projects.length === 0 && !projectsMessage) { return ( @@ -533,7 +755,31 @@ const WorktreesConfig: React.FC = () => { {t('management.retry')} )} - {renderProjects()} +
+ {projectsLoading && projectsInitialized && ( + <> + + + {t('management.loading')} + + + )} + {renderProjects()} +
diff --git a/src/web-ui/src/locales/en-US/worktrees.json b/src/web-ui/src/locales/en-US/worktrees.json index 5aab090968..f5799f38b9 100644 --- a/src/web-ui/src/locales/en-US/worktrees.json +++ b/src/web-ui/src/locales/en-US/worktrees.json @@ -75,6 +75,10 @@ "sessions": { "title": "Conversations", "summary": "{{count}} conversations", + "openLabel": "Open conversation {{name}}", + "openFailed": "Could not open this conversation. Refresh the list and try again.", + "restoreTitle": "Restore and open this conversation?", + "restoreMessage": "{{name}} is archived. Restore it before opening?", "empty": "No associated conversations.", "status": { "active": "Active", diff --git a/src/web-ui/src/locales/zh-CN/worktrees.json b/src/web-ui/src/locales/zh-CN/worktrees.json index b62c4af5c9..a87f367361 100644 --- a/src/web-ui/src/locales/zh-CN/worktrees.json +++ b/src/web-ui/src/locales/zh-CN/worktrees.json @@ -75,6 +75,10 @@ "sessions": { "title": "会话", "summary": "{{count}} 个会话", + "openLabel": "打开会话 {{name}}", + "openFailed": "无法打开此会话,请刷新列表后重试。", + "restoreTitle": "恢复并打开此会话?", + "restoreMessage": "{{name}} 已归档,是否先恢复再打开?", "empty": "没有关联会话。", "status": { "active": "进行中", diff --git a/src/web-ui/src/locales/zh-TW/worktrees.json b/src/web-ui/src/locales/zh-TW/worktrees.json index c052131cff..a2ad5a74b3 100644 --- a/src/web-ui/src/locales/zh-TW/worktrees.json +++ b/src/web-ui/src/locales/zh-TW/worktrees.json @@ -75,6 +75,10 @@ "sessions": { "title": "工作階段", "summary": "{{count}} 個工作階段", + "openLabel": "開啟工作階段 {{name}}", + "openFailed": "無法開啟此工作階段,請重新整理清單後再試。", + "restoreTitle": "恢復並開啟此工作階段?", + "restoreMessage": "{{name}} 已封存,是否先恢復再開啟?", "empty": "沒有關聯的工作階段。", "status": { "active": "進行中", From 3d428d660afd315280a4892538d3cc1748fd4f29 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 28 Jul 2026 21:40:46 -0700 Subject: [PATCH 3/3] fix(worktree): remove ghost management border --- .../config/components/WorktreesConfig.tsx | 1 + .../common/ConfigPageLayout.test.tsx | 19 +++++++++++++++++++ .../components/common/ConfigPageLayout.tsx | 9 +++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx index d972df85f5..e6a636d7d1 100644 --- a/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/WorktreesConfig.tsx @@ -734,6 +734,7 @@ const WorktreesConfig: React.FC = () => { {renderSettings()} { expect(stack?.classList.contains('bitfun-config-page-section-stack')).toBe(true); expect(stack?.querySelectorAll(':scope > .bitfun-config-page-section')).toHaveLength(2); }); + + it('can omit the mouse glow surface from a borderless section body', () => { + act(() => { + root.render( + <> + +
Standard body
+
+ +
Borderless body
+
+ , + ); + }); + + const bodies = container.querySelectorAll('.bitfun-config-page-section__body'); + expect(bodies[0]?.hasAttribute('data-mouse-glow-surface')).toBe(true); + expect(bodies[1]?.hasAttribute('data-mouse-glow-surface')).toBe(false); + }); }); diff --git a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.tsx b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.tsx index f2c883b625..f63d679ad8 100644 --- a/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.tsx +++ b/src/web-ui/src/infrastructure/config/components/common/ConfigPageLayout.tsx @@ -78,6 +78,8 @@ export interface ConfigPageSectionProps { extra?: React.ReactNode; children: React.ReactNode; className?: string; + /** Disable when the section body removes its standard bordered surface chrome. */ + mouseGlowSurface?: boolean; } export const ConfigPageSection: React.FC = ({ @@ -87,6 +89,7 @@ export const ConfigPageSection: React.FC = ({ extra, children, className = '', + mouseGlowSurface = true, }) => { return (
@@ -106,7 +109,10 @@ export const ConfigPageSection: React.FC = ({ )} -
+
{children}
@@ -174,4 +180,3 @@ export const ConfigPageRow: React.FC = ({ }; export default ConfigPageLayout; -