diff --git a/README.md b/README.md index ebb7c6a..064a07a 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,14 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-15 16:49 (KST) +- 런처 `~` 워크스페이스 복원 경로를 검토하고, 여러 브라우저/앱 창이 있을 때 같은 `exe`의 첫 창 하나만 반복 재사용하던 매칭 문제를 수정했습니다. 기존 [ContextManager.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/ContextManager.cs)는 저장된 스냅샷마다 `exe`만 보고 첫 HWND를 잡아 여러 Chrome/Edge 창 배치가 쉽게 꼬일 수 있었습니다. +- 이제 [ContextManager.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Core/ContextManager.cs)는 열린 창 후보를 수집한 뒤 `exe + 제목 유사도 + 이미 배정된 창 제외` 기준으로 복원 대상을 고릅니다. 이미 다른 스냅샷에 배정된 핸들은 다시 쓰지 않아, 같은 프로세스의 다중 창이 한 창으로 덮여 이동되던 문제를 줄였습니다. +- [WorkspaceHandler.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Handlers/WorkspaceHandler.cs)는 도움말과 실제 동작이 어긋나던 `~restore <이름>`, `~list`를 실제로 지원하도록 정리했고, 프로필 목록도 최근 저장 순으로 복원 액션을 직접 반환하도록 맞췄습니다. +- 테스트: [WorkspaceHandlerTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Handlers/WorkspaceHandlerTests.cs), [ContextManagerTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Core/ContextManagerTests.cs) 추가 +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_workspace_restore_review\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "WorkspaceHandlerTests|ContextManagerTests" -p:OutputPath=bin\\verify_workspace_restore_review_tests\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review_tests\\` 통과 6 + - 업데이트: 2026-04-15 16:12 (KST) - Code/Cowork 권한 팝업과 승인 재사용이 상대 경로에서 잘못 동작하던 문제를 수정했습니다. 상대 경로 `index.html` 같은 대상이 워크스페이스가 아닌 프로세스 현재 폴더(`dist`) 기준으로 해석되면서, 권한 팝업 미리보기와 사내 모드 외부 경로 판정이 잘못되고 `이번 실행 동안 허용`도 재사용되지 않던 상태였습니다. - [IAgentTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/IAgentTool.cs)는 새 workspace-aware 경로 해석을 사용해 `IsPathAllowed(...)`, `IsOutsideWorkspace(...)`가 상대 경로를 항상 현재 워크스페이스 기준으로 판정하도록 바꿨습니다. 그 결과 사내 모드에서도 워크스페이스 하위 상대 경로는 외부 접근으로 오판하지 않습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 893ecda..3ffa223 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,5 +1,12 @@ 업데이트: 2026-04-14 19:50 (KST) 업데이트: 2026-04-15 12:51 (KST) +- 업데이트: 2026-04-15 16:49 (KST) +- 런처 `~` 워크스페이스 복원 경로를 재검토하고, 같은 `exe`의 첫 번째 창 하나를 모든 스냅샷에 재사용하던 복원 매칭 결함을 수정했습니다. 기존 `src/AxCopilot/Core/ContextManager.cs`는 각 스냅샷마다 `exe`만 비교해 HWND를 찾았기 때문에, Chrome/Edge처럼 여러 창이 떠 있을 때 한 창만 반복 이동되며 배치가 무너지기 쉬웠습니다. +- `src/AxCopilot/Core/ContextManager.cs`는 열린 창 후보를 수집한 뒤 `exe + 제목 매칭 점수 + 이미 사용한 핸들 제외` 기준으로 창을 배정하도록 바꿨습니다. exact title을 최우선으로 두고, 브라우저 suffix 제거/토큰 비교를 통해 비슷한 제목도 보조적으로 매칭합니다. 이미 다른 스냅샷에 배정된 핸들은 다시 쓰지 않아 multi-window 복원 품질을 높였습니다. +- `src/AxCopilot/Handlers/WorkspaceHandler.cs`는 문서에만 있었던 `~restore <이름>`과 `~list`를 실제 핸들러에서도 지원하도록 보완했습니다. 프로필 목록은 최근 생성 순으로 정렬하고 각 항목이 곧바로 restore action을 가지도록 정리했습니다. +- 테스트: `src/AxCopilot.Tests/Handlers/WorkspaceHandlerTests.cs`, `src/AxCopilot.Tests/Core/ContextManagerTests.cs` +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_workspace_restore_review\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "WorkspaceHandlerTests|ContextManagerTests" -p:OutputPath=bin\\verify_workspace_restore_review_tests\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review_tests\\` 통과 6 - 업데이트: 2026-04-15 16:12 (KST) - 권한 경로 해석과 세션 승인 재사용을 workspace-aware 기준으로 정리했습니다. 상대 경로 `index.html` 같은 대상이 권한 팝업/사내 모드 외부 경로 판정에서 프로세스 현재 폴더(`dist`) 기준으로 잘못 절대경로화되면서, 팝업 표시가 틀어지고 `이번 실행 동안 허용`도 raw/absolute 경로 불일치로 재사용되지 않던 문제를 수정했습니다. - `src/AxCopilot/Services/Agent/IAgentTool.cs`는 `ResolvePathForWorkspaceCheck(...)`를 추가해 `IsPathAllowed(...)`, `IsOutsideWorkspace(...)`가 상대 경로를 현재 `WorkFolder` 기준으로 절대경로화한 뒤 판정하도록 변경했습니다. 사내 모드에서도 워크스페이스 하위 상대 경로는 외부 경로로 오판하지 않습니다. diff --git a/src/AxCopilot.Tests/Core/ContextManagerTests.cs b/src/AxCopilot.Tests/Core/ContextManagerTests.cs new file mode 100644 index 0000000..51bf600 --- /dev/null +++ b/src/AxCopilot.Tests/Core/ContextManagerTests.cs @@ -0,0 +1,65 @@ +using AxCopilot.Core; +using AxCopilot.Models; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Core; + +public class ContextManagerTests +{ + [Fact] + public void NormalizeWindowTitle_StripsKnownBrowserSuffix() + { + var normalized = ContextManager.NormalizeWindowTitle("Inbox - Google Chrome"); + + normalized.Should().Be("inbox"); + } + + [Fact] + public void CalculateTitleMatchScore_ExactTitle_BeatsPartialTitle() + { + var exact = ContextManager.CalculateTitleMatchScore("Inbox - Google Chrome", "Inbox - Google Chrome"); + var partial = ContextManager.CalculateTitleMatchScore("Inbox - Google Chrome", "Inbox - Chrome"); + + exact.Should().BeGreaterThan(partial); + } + + [Fact] + public void SelectBestMatchingWindow_PrefersExactTitleAmongSameExe() + { + var snapshot = new WindowSnapshot + { + Exe = @"C:\Program Files\Google\Chrome\Application\chrome.exe", + Title = "Inbox - Google Chrome" + }; + + var candidates = new[] + { + new ContextManager.WindowCandidate(new IntPtr(1), snapshot.Exe, "Docs - Google Chrome"), + new ContextManager.WindowCandidate(new IntPtr(2), snapshot.Exe, "Inbox - Google Chrome") + }; + + var selected = ContextManager.SelectBestMatchingWindow(snapshot, candidates); + + selected.Should().Be(new IntPtr(2)); + } + + [Fact] + public void SelectBestMatchingWindow_SkipsAlreadyUsedHandle() + { + var snapshot = new WindowSnapshot + { + Exe = @"C:\Program Files\Google\Chrome\Application\chrome.exe", + Title = "Inbox - Google Chrome" + }; + + var candidates = new[] + { + new ContextManager.WindowCandidate(new IntPtr(1), snapshot.Exe, "Inbox - Google Chrome") + }; + + var selected = ContextManager.SelectBestMatchingWindow(snapshot, candidates, new HashSet { new(1) }); + + selected.Should().Be(IntPtr.Zero); + } +} diff --git a/src/AxCopilot.Tests/Handlers/WorkspaceHandlerTests.cs b/src/AxCopilot.Tests/Handlers/WorkspaceHandlerTests.cs new file mode 100644 index 0000000..0f528ad --- /dev/null +++ b/src/AxCopilot.Tests/Handlers/WorkspaceHandlerTests.cs @@ -0,0 +1,64 @@ +using AxCopilot.Core; +using AxCopilot.Handlers; +using AxCopilot.Models; +using AxCopilot.Services; +using FluentAssertions; +using Xunit; + +namespace AxCopilot.Tests.Handlers; + +public class WorkspaceHandlerTests +{ + [Fact] + public async Task GetItemsAsync_RestoreSubcommand_ReturnsRestoreAction() + { + var settings = new SettingsService(); + settings.Settings.Profiles.Add(new WorkspaceProfile + { + Name = "업무", + Windows = new List { new() { Exe = "chrome.exe", Title = "메일" } }, + CreatedAt = new DateTime(2026, 4, 15, 9, 0, 0) + }); + + var handler = new WorkspaceHandler(new ContextManager(settings), settings); + + var items = (await handler.GetItemsAsync("restore 업무", CancellationToken.None)).ToList(); + + items.Should().ContainSingle(); + items[0].Title.Should().Contain("업무"); + items[0].Data.Should().BeOfType(); + + var action = (WorkspaceAction)items[0].Data!; + action.Type.Should().Be(WorkspaceActionType.Restore); + action.Name.Should().Be("업무"); + } + + [Fact] + public async Task GetItemsAsync_ListSubcommand_ReturnsSavedProfiles() + { + var settings = new SettingsService(); + settings.Settings.Profiles.Add(new WorkspaceProfile + { + Name = "업무", + Windows = new List { new() { Exe = "chrome.exe", Title = "메일" } }, + CreatedAt = new DateTime(2026, 4, 15, 9, 0, 0) + }); + settings.Settings.Profiles.Add(new WorkspaceProfile + { + Name = "개발", + Windows = new List { new() { Exe = "code.exe", Title = "AX Copilot" } }, + CreatedAt = new DateTime(2026, 4, 15, 10, 0, 0) + }); + + var handler = new WorkspaceHandler(new ContextManager(settings), settings); + + var items = (await handler.GetItemsAsync("list", CancellationToken.None)).ToList(); + + items.Should().HaveCount(2); + items[0].Title.Should().Be("~개발"); + items[1].Title.Should().Be("~업무"); + items.All(item => item.Data is WorkspaceAction action && action.Type == WorkspaceActionType.Restore) + .Should() + .BeTrue(); + } +} diff --git a/src/AxCopilot/Core/ContextManager.cs b/src/AxCopilot/Core/ContextManager.cs index 9e440ac..a752b7f 100644 --- a/src/AxCopilot/Core/ContextManager.cs +++ b/src/AxCopilot/Core/ContextManager.cs @@ -110,13 +110,14 @@ public class ContextManager var results = new List(); var monitorCount = GetMonitorCount(); + var usedHandles = new HashSet(); foreach (var snapshot in profile.Windows) { ct.ThrowIfCancellationRequested(); // 1. 실행 중인 창 찾기 - var hWnd = FindMatchingWindow(snapshot); + var hWnd = FindMatchingWindow(snapshot, usedHandles); // 2. 창이 없으면 EXE 실행 후 대기 if (hWnd == IntPtr.Zero && File.Exists(snapshot.Exe)) @@ -124,7 +125,7 @@ public class ContextManager try { Process.Start(new ProcessStartInfo(snapshot.Exe) { UseShellExecute = true }); - hWnd = await WaitForWindowAsync(snapshot.Exe, TimeSpan.FromSeconds(3), ct); + hWnd = await WaitForWindowAsync(snapshot, usedHandles, TimeSpan.FromSeconds(3), ct); } catch (Exception ex) { @@ -140,6 +141,8 @@ public class ContextManager continue; } + usedHandles.Add(hWnd); + // 3. 모니터 불일치 처리 if (snapshot.Monitor >= monitorCount) { @@ -207,35 +210,164 @@ public class ContextManager // ─── Helpers ───────────────────────────────────────────────────────────── - private static IntPtr FindMatchingWindow(WindowSnapshot snapshot) + private static IntPtr FindMatchingWindow(WindowSnapshot snapshot, ISet usedHandles) { - IntPtr found = IntPtr.Zero; - EnumWindows((hWnd, _) => - { - var path = GetProcessPath(hWnd); - if (string.Equals(path, snapshot.Exe, StringComparison.OrdinalIgnoreCase)) - { - found = hWnd; - return false; // 첫 번째 매칭 창에서 중단 - } - return true; - }, IntPtr.Zero); - return found; + var candidates = GetOpenWindowCandidates(); + return SelectBestMatchingWindow(snapshot, candidates, usedHandles); } - private static async Task WaitForWindowAsync(string exePath, TimeSpan timeout, CancellationToken ct) + private static async Task WaitForWindowAsync( + WindowSnapshot snapshot, + ISet usedHandles, + TimeSpan timeout, + CancellationToken ct) { var deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { ct.ThrowIfCancellationRequested(); - var hWnd = FindMatchingWindow(new WindowSnapshot { Exe = exePath }); + var hWnd = FindMatchingWindow(snapshot, usedHandles); if (hWnd != IntPtr.Zero) return hWnd; await Task.Delay(200, ct); } return IntPtr.Zero; } + private static List GetOpenWindowCandidates() + { + var candidates = new List(); + + EnumWindows((hWnd, _) => + { + if (!IsWindowVisible(hWnd)) return true; + if (IsSystemWindow(hWnd)) return true; + + var title = GetWindowTitle(hWnd); + if (string.IsNullOrWhiteSpace(title)) return true; + + var exePath = GetProcessPath(hWnd); + if (string.IsNullOrWhiteSpace(exePath)) return true; + + candidates.Add(new WindowCandidate(hWnd, exePath, title)); + return true; + }, IntPtr.Zero); + + return candidates; + } + + internal static IntPtr SelectBestMatchingWindow( + WindowSnapshot snapshot, + IReadOnlyCollection candidates, + ISet? usedHandles = null) + { + var matches = candidates + .Where(candidate => string.Equals(candidate.Exe, snapshot.Exe, StringComparison.OrdinalIgnoreCase)) + .Where(candidate => usedHandles == null || !usedHandles.Contains(candidate.Handle)) + .Select(candidate => new + { + candidate.Handle, + Score = CalculateWindowMatchScore(snapshot, candidate) + }) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.Handle) + .ToList(); + + if (matches.Count == 0) + return IntPtr.Zero; + + return matches[0].Handle; + } + + internal static int CalculateWindowMatchScore(WindowSnapshot snapshot, WindowCandidate candidate) + { + var score = 0; + score += CalculateTitleMatchScore(snapshot.Title, candidate.Title); + + if (string.IsNullOrWhiteSpace(snapshot.Title)) + score += 100; + + return score; + } + + internal static int CalculateTitleMatchScore(string expectedTitle, string actualTitle) + { + if (string.IsNullOrWhiteSpace(expectedTitle) || string.IsNullOrWhiteSpace(actualTitle)) + return 0; + + if (string.Equals(expectedTitle, actualTitle, StringComparison.OrdinalIgnoreCase)) + return 2200; + + var normalizedExpected = NormalizeWindowTitle(expectedTitle); + var normalizedActual = NormalizeWindowTitle(actualTitle); + + if (normalizedExpected.Length == 0 || normalizedActual.Length == 0) + return 0; + + var score = 0; + + if (string.Equals(normalizedExpected, normalizedActual, StringComparison.OrdinalIgnoreCase)) + score += 900; + + if (normalizedActual.Contains(normalizedExpected, StringComparison.OrdinalIgnoreCase) || + normalizedExpected.Contains(normalizedActual, StringComparison.OrdinalIgnoreCase)) + score += 400; + + var expectedTokens = TokenizeWindowTitle(normalizedExpected); + var actualTokens = TokenizeWindowTitle(normalizedActual); + var overlap = expectedTokens.Intersect(actualTokens, StringComparer.OrdinalIgnoreCase).Count(); + + if (overlap > 0) + score += overlap * 120; + + if (expectedTokens.Count > 0 && overlap == expectedTokens.Count) + score += 180; + + score += Math.Min(GetSharedPrefixLength(normalizedExpected, normalizedActual), 60); + + return score; + } + + internal static string NormalizeWindowTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) + return string.Empty; + + var normalized = title.Trim() + .Replace('—', '-') + .Replace('–', '-'); + + foreach (var suffix in KnownWindowTitleSuffixes) + { + if (normalized.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized[..^suffix.Length].Trim(); + break; + } + } + + return string.Join(' ', normalized + .Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries)) + .ToLowerInvariant(); + } + + internal static List TokenizeWindowTitle(string title) + { + return title + .Split(TokenSeparators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(token => token.Length > 1) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static int GetSharedPrefixLength(string left, string right) + { + var max = Math.Min(left.Length, right.Length); + var length = 0; + while (length < max && left[length] == right[length]) + length++; + return length; + } + private static string GetWindowTitle(IntPtr hWnd) { var sb = new StringBuilder(256); @@ -296,6 +428,20 @@ public class ContextManager private const uint SWP_NOZORDER = 0x0004; private const uint SWP_NOACTIVATE = 0x0010; private const uint MONITOR_DEFAULTTONEAREST = 0x00000002; + private static readonly char[] TokenSeparators = [' ', '-', '|', ':', '·', '_', '/', '\\', '.', ',', '(', ')', '[', ']']; + private static readonly string[] KnownWindowTitleSuffixes = + [ + "- google chrome", + "- chrome", + "- microsoft edge", + "- edge", + "- mozilla firefox", + "- firefox", + "- whale", + "- microsoft visual studio", + "- visual studio code", + "- vscode" + ]; [StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left, Top, Right, Bottom; } @@ -327,6 +473,8 @@ public class ContextManager [DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags); [DllImport("user32.dll")] private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData); + + internal readonly record struct WindowCandidate(IntPtr Handle, string Exe, string Title); } public record RestoreResult(bool Success, string Message); diff --git a/src/AxCopilot/Handlers/WorkspaceHandler.cs b/src/AxCopilot/Handlers/WorkspaceHandler.cs index d81268c..d6a4c13 100644 --- a/src/AxCopilot/Handlers/WorkspaceHandler.cs +++ b/src/AxCopilot/Handlers/WorkspaceHandler.cs @@ -30,22 +30,14 @@ public class WorkspaceHandler : IActionHandler // 서브 커맨드 분기 if (parts.Length == 0 || string.IsNullOrEmpty(query)) { - // 프로필 목록 표시 - var items = _settings.Settings.Profiles - .Select(p => new LauncherItem( - $"~{p.Name}", - $"{p.Windows.Count}개 창 | {p.CreatedAt:MM/dd HH:mm}", - null, p, Symbol: Symbols.Workspace)) - .ToList(); - - if (items.Count == 0) - items.Add(new LauncherItem("저장된 프로필 없음", "~save <이름> 으로 현재 배치를 저장하세요", null, null, Symbol: Symbols.Info)); - - return Task.FromResult>(items); + return Task.FromResult>(BuildProfileListItems()); } var cmd = parts[0].ToLowerInvariant(); + if (cmd == "list") + return Task.FromResult>(BuildProfileListItems()); + if (cmd == "save") { var name = parts.Length > 1 ? parts[1] : "default"; @@ -56,6 +48,15 @@ public class WorkspaceHandler : IActionHandler }); } + if (cmd == "restore" && parts.Length > 1) + { + return Task.FromResult>(new[] + { + new LauncherItem($"프로필 '{parts[1]}' 복원", "Enter로 확인", null, + new WorkspaceAction(WorkspaceActionType.Restore, parts[1]), Symbol: Symbols.Restore) + }); + } + if (cmd == "delete" && parts.Length > 1) { return Task.FromResult>(new[] @@ -87,6 +88,31 @@ public class WorkspaceHandler : IActionHandler return Task.FromResult>(matched); } + private IEnumerable BuildProfileListItems() + { + var items = _settings.Settings.Profiles + .OrderByDescending(p => p.CreatedAt) + .Select(p => new LauncherItem( + $"~{p.Name}", + $"{p.Windows.Count}개 창 | {p.CreatedAt:MM/dd HH:mm}", + null, + new WorkspaceAction(WorkspaceActionType.Restore, p.Name), + Symbol: Symbols.Workspace)) + .ToList(); + + if (items.Count == 0) + { + items.Add(new LauncherItem( + "저장된 프로필 없음", + "~save <이름> 으로 현재 배치를 저장하세요", + null, + null, + Symbol: Symbols.Info)); + } + + return items; + } + public async Task ExecuteAsync(LauncherItem item, CancellationToken ct) { if (item.Data is not WorkspaceAction action) return;