런처 워크스페이스 복원 매칭과 ~restore 명령을 보강

변경 목적:
- 런처 ~ 예약어의 창 복원 품질을 점검하고, 같은 exe의 첫 창만 반복 재사용되어 브라우저/다중 창 배치가 꼬이던 문제를 줄입니다.
- 도움말에만 있던 ~restore, ~list 명령을 실제 핸들러 동작과 맞춥니다.

핵심 수정사항:
- ContextManager가 열린 창 후보를 exe + 제목 유사도 기준으로 매칭하고, 이미 다른 스냅샷에 배정된 핸들은 재사용하지 않도록 변경했습니다.
- WorkspaceHandler에 ~restore <이름>, ~list 지원과 최근 저장 순 프로필 목록 복원 액션 정리를 추가했습니다.
- WorkspaceHandlerTests, ContextManagerTests를 추가해 명령 파싱과 창 매칭 우선순위를 회귀 검증합니다.
- README.md, docs/DEVELOPMENT.md에 검토 결과와 검증 이력을 기록했습니다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_workspace_restore_review\\ -p:IntermediateOutputPath=obj\\verify_workspace_restore_review\\
- 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\\
- 경고 0 / 오류 0, 테스트 6건 통과
This commit is contained in:
2026-04-15 16:54:06 +09:00
parent 78b3aa2801
commit 964e40718f
6 changed files with 347 additions and 29 deletions

View File

@@ -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(...)`가 상대 경로를 항상 현재 워크스페이스 기준으로 판정하도록 바꿨습니다. 그 결과 사내 모드에서도 워크스페이스 하위 상대 경로는 외부 접근으로 오판하지 않습니다.

View File

@@ -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` 기준으로 절대경로화한 뒤 판정하도록 변경했습니다. 사내 모드에서도 워크스페이스 하위 상대 경로는 외부 경로로 오판하지 않습니다.

View File

@@ -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<IntPtr> { new(1) });
selected.Should().Be(IntPtr.Zero);
}
}

View File

@@ -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<WindowSnapshot> { 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<WorkspaceAction>();
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<WindowSnapshot> { new() { Exe = "chrome.exe", Title = "메일" } },
CreatedAt = new DateTime(2026, 4, 15, 9, 0, 0)
});
settings.Settings.Profiles.Add(new WorkspaceProfile
{
Name = "개발",
Windows = new List<WindowSnapshot> { 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();
}
}

View File

@@ -110,13 +110,14 @@ public class ContextManager
var results = new List<string>();
var monitorCount = GetMonitorCount();
var usedHandles = new HashSet<IntPtr>();
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<IntPtr> 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<IntPtr> WaitForWindowAsync(string exePath, TimeSpan timeout, CancellationToken ct)
private static async Task<IntPtr> WaitForWindowAsync(
WindowSnapshot snapshot,
ISet<IntPtr> 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<WindowCandidate> GetOpenWindowCandidates()
{
var candidates = new List<WindowCandidate>();
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<WindowCandidate> candidates,
ISet<IntPtr>? 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<string> 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);

View File

@@ -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<IEnumerable<LauncherItem>>(items);
return Task.FromResult<IEnumerable<LauncherItem>>(BuildProfileListItems());
}
var cmd = parts[0].ToLowerInvariant();
if (cmd == "list")
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(new[]
@@ -87,6 +88,31 @@ public class WorkspaceHandler : IActionHandler
return Task.FromResult<IEnumerable<LauncherItem>>(matched);
}
private IEnumerable<LauncherItem> 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;