diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md
index 5c3ba9e..35736d4 100644
--- a/docs/LAUNCHER_ROADMAP.md
+++ b/docs/LAUNCHER_ROADMAP.md
@@ -132,7 +132,7 @@
| L5-1 | **항목별 전용 핫키** ✅ | 앱·URL·폴더에 `Ctrl+Alt+숫자` 등 글로벌 단축키 직접 할당. `hotkey` 프리픽스로 관리. `HotkeyAssignment` 모델 + `InputListener` 확장 + 설정창 "전용 핫키" 탭 | 높음 |
| L5-2 | **OCR 화면 텍스트 추출** ✅ | `ocr` 프리픽스 + F4 글로벌 단축키. RegionSelectWindow 재사용, Windows.Media.Ocr 로컬 엔진. 결과 → 클립보드 복사 + 런처 입력창 자동 채움 | 높음 |
| L5-3 | **QuickLook 인라인 편집** ✅ | F3 미리보기 → Ctrl+E 편집 모드 토글. 텍스트/코드 전체 읽기(300줄 제한 없음). Ctrl+S 저장, ● 수정 마커, Esc 취소 확인, 저장 후 미리보기 새로고침 | 중간 |
-| L5-4 | **앱 세션 스냅** | 여러 앱을 지정 레이아웃으로 한번에 열기. `snap 세션이름` → 등록된 앱 목록을 각 레이아웃에 배치 | 중간 |
+| L5-4 | **앱 세션 스냅** ✅ | `session` 프리픽스. `AppSession/SessionApp` 모델 추가 + `AppSettings.AppSessions` 저장. `SessionHandler`: 목록·실행·new/edit/del 서브커맨드. `SessionEditorWindow`: 세션 이름·설명·앱 행(경로+라벨+스냅 팝업 14종+삭제) 인라인 편집. 실행 시 Process.Start → 창 핸들 대기(6초) → ApplySnapToWindow(P/Invoke SetWindowPos+ShowWindow) | 중간 |
| L5-5 | **배치 파일 이름 변경** ✅ | `batchren` 프리픽스로 BatchRenameWindow 오픈. 변수 패턴(`{name}`, `{n:3}`, `{date:format}`, `{ext}`) + 정규식 모드(`/old/new/`). 드래그 앤 드롭·폴더/파일 추가, DataGrid 실시간 미리보기, 충돌 감지(배경 붉은 강조), 확장자 유지 토글, 시작 번호 지정, 적용 후 엔트리 갱신 | 중간 |
| L5-6 | **자동화 스케줄러** | `sched` 프리픽스로 시간·앱 기반 트리거 등록. "매일 09:00 = 크롬 열기", "캐치 앱 실행 시 = 알림" | 낮음 |
diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index ddc1153..60f5a86 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -182,6 +182,8 @@ public partial class App : System.Windows.Application
commandResolver.RegisterHandler(new HotkeyHandler(settings));
// Phase L5-2: OCR 화면 텍스트 추출 (prefix=ocr)
commandResolver.RegisterHandler(new OcrHandler());
+ // Phase L5-4: 앱 세션 스냅 (prefix=session)
+ commandResolver.RegisterHandler(new SessionHandler(settings));
// Phase L5-5: 배치 파일 이름변경 (prefix=batchren)
commandResolver.RegisterHandler(new BatchRenameHandler());
diff --git a/src/AxCopilot/Handlers/SessionHandler.cs b/src/AxCopilot/Handlers/SessionHandler.cs
new file mode 100644
index 0000000..a3298b4
--- /dev/null
+++ b/src/AxCopilot/Handlers/SessionHandler.cs
@@ -0,0 +1,301 @@
+using System.IO;
+using System.Runtime.InteropServices;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+using AxCopilot.Themes;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L5-4: 앱 세션 스냅 핸들러. "session" 프리픽스로 사용합니다.
+///
+/// 예: session → 저장된 세션 목록
+/// session 개발환경 → 해당 세션 실행 (앱 + 스냅 레이아웃 적용)
+/// session new 이름 → 새 세션 편집기 열기
+/// session edit 이름 → 기존 세션 편집기 열기
+/// session del 이름 → 세션 삭제
+///
+/// 세션 = [앱 경로 + 스냅 위치] 목록. 한 번에 모든 앱을 지정 레이아웃으로 실행합니다.
+///
+public class SessionHandler : IActionHandler
+{
+ private readonly SettingsService _settings;
+
+ public SessionHandler(SettingsService settings) { _settings = settings; }
+
+ public string? Prefix => "session";
+
+ public PluginMetadata Metadata => new(
+ "AppSession",
+ "앱 세션 스냅 — session",
+ "1.0",
+ "AX");
+
+ // ─── 항목 목록 ──────────────────────────────────────────────────────────
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries);
+ var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : "";
+
+ // "new [이름]" — 새 세션 만들기
+ if (cmd == "new")
+ {
+ var name = parts.Length > 1 && !string.IsNullOrWhiteSpace(parts[1]) ? parts[1] : "새 세션";
+ return Task.FromResult>(new[]
+ {
+ new LauncherItem($"'{name}' 세션 만들기",
+ "편집기에서 앱 목록과 스냅 레이아웃을 설정합니다",
+ null, $"__new__{name}", Symbol: "\uE710")
+ });
+ }
+
+ // "edit 이름" — 기존 세션 편집
+ if (cmd == "edit" && parts.Length > 1)
+ {
+ return Task.FromResult>(new[]
+ {
+ new LauncherItem($"'{parts[1]}' 세션 편집",
+ "편집기 열기",
+ null, $"__edit__{parts[1]}", Symbol: "\uE70F")
+ });
+ }
+
+ // "del 이름" or "delete 이름" — 세션 삭제
+ if ((cmd == "del" || cmd == "delete") && parts.Length > 1)
+ {
+ return Task.FromResult>(new[]
+ {
+ new LauncherItem($"'{parts[1]}' 세션 삭제",
+ "Enter로 삭제 확인 (되돌릴 수 없습니다)",
+ null, $"__del__{parts[1]}", Symbol: Symbols.Delete)
+ });
+ }
+
+ // 세션 목록 (필터 적용)
+ var sessions = _settings.Settings.AppSessions;
+ var filter = q.ToLowerInvariant();
+ var items = new List();
+
+ foreach (var s in sessions)
+ {
+ if (!string.IsNullOrEmpty(filter) &&
+ !s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase) &&
+ !s.Description.Contains(filter, StringComparison.OrdinalIgnoreCase))
+ continue;
+
+ var appNames = string.Join(", ",
+ s.Apps.Take(3).Select(a =>
+ string.IsNullOrEmpty(a.Label)
+ ? Path.GetFileNameWithoutExtension(a.Path)
+ : a.Label));
+
+ items.Add(new LauncherItem(
+ s.Name,
+ $"{s.Apps.Count}개 앱 · {appNames}",
+ null, s,
+ Symbol: "\uE8A1")); // 창 레이아웃 아이콘
+ }
+
+ if (items.Count == 0 && string.IsNullOrEmpty(filter))
+ {
+ items.Add(new LauncherItem(
+ "저장된 세션 없음",
+ "'session new 이름'으로 앱 세션을 만드세요",
+ null, null,
+ Symbol: Symbols.Info));
+ }
+
+ // 새 세션 만들기 (항상 표시)
+ items.Add(new LauncherItem(
+ "새 세션 만들기",
+ "session new [이름] · 앱 목록 + 스냅 레이아웃 지정",
+ null, "__new__새 세션",
+ Symbol: "\uE710"));
+
+ return Task.FromResult>(items);
+ }
+
+ // ─── 실행 ─────────────────────────────────────────────────────────────
+ public async Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is string s)
+ {
+ if (s.StartsWith("__new__"))
+ {
+ var name = s["__new__".Length..];
+ System.Windows.Application.Current.Dispatcher.Invoke(() =>
+ {
+ var win = new Views.SessionEditorWindow(null, _settings);
+ win.InitialName = name;
+ win.Show();
+ });
+ return;
+ }
+
+ if (s.StartsWith("__edit__"))
+ {
+ var name = s["__edit__".Length..];
+ var session = _settings.Settings.AppSessions
+ .FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ System.Windows.Application.Current.Dispatcher.Invoke(() =>
+ {
+ var win = new Views.SessionEditorWindow(session, _settings);
+ win.Show();
+ });
+ return;
+ }
+
+ if (s.StartsWith("__del__"))
+ {
+ var name = s["__del__".Length..];
+ var session = _settings.Settings.AppSessions
+ .FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ if (session != null)
+ {
+ _settings.Settings.AppSessions.Remove(session);
+ _settings.Save();
+ NotificationService.Notify("AX Copilot", $"세션 '{name}' 삭제됨");
+ }
+ return;
+ }
+ }
+
+ if (item.Data is Models.AppSession appSession)
+ await LaunchSessionAsync(appSession, ct);
+ }
+
+ // ─── 세션 실행 로직 ───────────────────────────────────────────────────
+ private static async Task LaunchSessionAsync(Models.AppSession session, CancellationToken ct)
+ {
+ NotificationService.Notify("AX Copilot", $"'{session.Name}' 세션 시작...");
+ LogService.Info($"세션 실행 시작: {session.Name} ({session.Apps.Count}개 앱)");
+
+ int launched = 0, failed = 0;
+
+ foreach (var app in session.Apps)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ if (app.DelayMs > 0)
+ await Task.Delay(app.DelayMs, ct);
+
+ if (string.IsNullOrWhiteSpace(app.Path)) continue;
+
+ System.Diagnostics.Process? proc = null;
+ try
+ {
+ proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
+ {
+ FileName = app.Path,
+ Arguments = app.Arguments ?? "",
+ UseShellExecute = true
+ });
+ launched++;
+ }
+ catch (Exception ex)
+ {
+ LogService.Warn($"세션 앱 실행 실패: {app.Path} — {ex.Message}");
+ failed++;
+ continue;
+ }
+
+ // 스냅 위치 없거나 none → 그냥 실행
+ if (proc == null || string.IsNullOrEmpty(app.SnapPosition) || app.SnapPosition == "none")
+ continue;
+
+ // 창이 나타날 때까지 대기 (최대 6초)
+ var hWnd = IntPtr.Zero;
+ var deadline = DateTime.UtcNow.AddSeconds(6);
+ while (DateTime.UtcNow < deadline)
+ {
+ ct.ThrowIfCancellationRequested();
+ try { proc.Refresh(); } catch { break; }
+ hWnd = proc.MainWindowHandle;
+ if (hWnd != IntPtr.Zero) break;
+ await Task.Delay(200, ct);
+ }
+
+ if (hWnd == IntPtr.Zero)
+ {
+ LogService.Warn($"세션: 창 핸들 획득 실패 ({app.Path})");
+ continue;
+ }
+
+ // 창이 완전히 렌더링될 시간 허용
+ await Task.Delay(250, ct);
+ ApplySnapToWindow(hWnd, app.SnapPosition);
+ }
+
+ var msg = failed > 0
+ ? $"'{session.Name}' 실행 완료 ({launched}개 성공, {failed}개 실패)"
+ : $"'{session.Name}' 실행 완료 ({launched}개 앱)";
+ NotificationService.Notify("AX Copilot", msg);
+ LogService.Info($"세션 실행 완료: {msg}");
+ }
+
+ // ─── 스냅 적용 (SnapHandler와 동일한 좌표 계산) ──────────────────────
+ private static void ApplySnapToWindow(IntPtr hwnd, string snapKey)
+ {
+ if (snapKey == "full")
+ {
+ ShowWindow(hwnd, SW_MAXIMIZE);
+ return;
+ }
+
+ var hMonitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
+ var mi = new MONITORINFO { cbSize = Marshal.SizeOf() };
+ if (!GetMonitorInfo(hMonitor, ref mi)) return;
+
+ var w = mi.rcWork;
+ int mw = w.right - w.left;
+ int mh = w.bottom - w.top;
+ int mx = w.left;
+ int my = w.top;
+
+ var (x, y, cw, ch) = snapKey switch
+ {
+ "left" => (mx, my, mw / 2, mh),
+ "right" => (mx + mw / 2, my, mw / 2, mh),
+ "top" => (mx, my, mw, mh / 2),
+ "bottom" => (mx, my + mh / 2, mw, mh / 2),
+ "tl" => (mx, my, mw / 2, mh / 2),
+ "tr" => (mx + mw / 2, my, mw / 2, mh / 2),
+ "bl" => (mx, my + mh / 2, mw / 2, mh / 2),
+ "br" => (mx + mw / 2, my + mh / 2, mw / 2, mh / 2),
+ "third-l" => (mx, my, mw / 3, mh),
+ "third-c" => (mx + mw / 3, my, mw / 3, mh),
+ "third-r" => (mx + mw * 2 / 3,my, mw / 3, mh),
+ "two3-l" => (mx, my, mw * 2 / 3, mh),
+ "two3-r" => (mx + mw / 3, my, mw * 2 / 3, mh),
+ "center" => (mx + mw / 10, my + mh / 10, mw * 8 / 10, mh * 8 / 10),
+ _ => (mx, my, mw, mh)
+ };
+
+ ShowWindow(hwnd, SW_RESTORE);
+ SetWindowPos(hwnd, IntPtr.Zero, x, y, cw, ch, SWP_SHOWWINDOW | SWP_NOZORDER);
+ }
+
+ // ─── P/Invoke ─────────────────────────────────────────────────────────
+ private const uint SWP_SHOWWINDOW = 0x0040;
+ private const uint SWP_NOZORDER = 0x0004;
+ private const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
+ private const int SW_RESTORE = 9;
+ private const int SW_MAXIMIZE = 3;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RECT { public int left, top, right, bottom; }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct MONITORINFO
+ {
+ public int cbSize;
+ public RECT rcMonitor, rcWork;
+ public uint dwFlags;
+ }
+
+ [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);
+ [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 GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
+}
diff --git a/src/AxCopilot/Models/AppSettings.Models.cs b/src/AxCopilot/Models/AppSettings.Models.cs
index 657e51b..919ea78 100644
--- a/src/AxCopilot/Models/AppSettings.Models.cs
+++ b/src/AxCopilot/Models/AppSettings.Models.cs
@@ -262,6 +262,54 @@ public class HotkeyAssignment
public string Type { get; set; } = "app";
}
+// ─── 앱 세션 스냅 ────────────────────────────────────────────────────────────────
+
+///
+/// 앱 세션: 여러 앱을 지정 레이아웃으로 한번에 실행하는 세트.
+/// session 핸들러로 저장/로드/실행합니다.
+///
+public class AppSession
+{
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = "";
+
+ [JsonPropertyName("description")]
+ public string Description { get; set; } = "";
+
+ [JsonPropertyName("apps")]
+ public List Apps { get; set; } = new();
+
+ [JsonPropertyName("createdAt")]
+ public DateTime CreatedAt { get; set; } = DateTime.Now;
+}
+
+///
+/// 세션 내 개별 앱 항목. 실행 경로·인자·스냅 위치를 지정합니다.
+///
+public class SessionApp
+{
+ [JsonPropertyName("path")]
+ public string Path { get; set; } = "";
+
+ [JsonPropertyName("args")]
+ public string Arguments { get; set; } = "";
+
+ /// 표시 이름. 비어 있으면 파일명을 사용합니다.
+ [JsonPropertyName("label")]
+ public string Label { get; set; } = "";
+
+ ///
+ /// 스냅 위치. left / right / tl / tr / bl / br / full / center /
+ /// third-l / third-c / third-r / two3-l / two3-r / none
+ ///
+ [JsonPropertyName("snap")]
+ public string SnapPosition { get; set; } = "full";
+
+ /// 이 앱을 실행하기 전 대기 시간(ms). 기본값 0.
+ [JsonPropertyName("delayMs")]
+ public int DelayMs { get; set; } = 0;
+}
+
// ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────
public class ReminderSettings
diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs
index 4fc54f2..ddc2dca 100644
--- a/src/AxCopilot/Models/AppSettings.cs
+++ b/src/AxCopilot/Models/AppSettings.cs
@@ -112,6 +112,12 @@ public class AppSettings
[JsonPropertyName("customHotkeys")]
public List CustomHotkeys { get; set; } = new();
+ ///
+ /// 앱 세션 목록. session 핸들러로 여러 앱을 지정 레이아웃에 한번에 실행합니다.
+ ///
+ [JsonPropertyName("appSessions")]
+ public List AppSessions { get; set; } = new();
+
[JsonPropertyName("llm")]
public LlmSettings Llm { get; set; } = new();
}
diff --git a/src/AxCopilot/Views/SessionEditorWindow.xaml b/src/AxCopilot/Views/SessionEditorWindow.xaml
new file mode 100644
index 0000000..60de7e2
--- /dev/null
+++ b/src/AxCopilot/Views/SessionEditorWindow.xaml
@@ -0,0 +1,251 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AxCopilot/Views/SessionEditorWindow.xaml.cs b/src/AxCopilot/Views/SessionEditorWindow.xaml.cs
new file mode 100644
index 0000000..29b3fdf
--- /dev/null
+++ b/src/AxCopilot/Views/SessionEditorWindow.xaml.cs
@@ -0,0 +1,386 @@
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using AxCopilot.Models;
+using AxCopilot.Services;
+
+namespace AxCopilot.Views;
+
+///
+/// L5-4: 앱 세션 편집기.
+/// 세션 이름, 앱 목록(경로 + 라벨 + 스냅 위치)을 편집하여 저장합니다.
+///
+public partial class SessionEditorWindow : Window
+{
+ private readonly SettingsService _settings;
+ private readonly AppSession? _original; // 편집 모드 원본 (새 세션이면 null)
+ private readonly List _rows = new();
+
+ // 스냅 팝업 대상 행
+ private AppRowUi? _snapTargetRow;
+
+ // 사용 가능한 스냅 위치 목록 (키 → 표시명)
+ private static readonly (string Key, string Label)[] SnapOptions =
+ [
+ ("full", "전체화면"),
+ ("left", "왼쪽 절반"),
+ ("right", "오른쪽 절반"),
+ ("tl", "좌상단 1/4"),
+ ("tr", "우상단 1/4"),
+ ("bl", "좌하단 1/4"),
+ ("br", "우하단 1/4"),
+ ("center", "중앙 80%"),
+ ("third-l", "좌측 1/3"),
+ ("third-c", "중앙 1/3"),
+ ("third-r", "우측 1/3"),
+ ("two3-l", "좌측 2/3"),
+ ("two3-r", "우측 2/3"),
+ ("none", "스냅 없음"),
+ ];
+
+ // ──────────────────────────────────────────────────────────────────────
+ ///
+ /// 세션 편집기를 엽니다.
+ ///
+ /// 편집할 기존 세션. null이면 새로 만들기 모드.
+ /// 설정 서비스.
+ public SessionEditorWindow(AppSession? session, SettingsService settings)
+ {
+ InitializeComponent();
+ _settings = settings;
+ _original = session;
+
+ BuildSnapPopup();
+ LoadSession(session);
+ }
+
+ /// 새 세션 모드일 때 기본 이름을 설정합니다.
+ public string InitialName
+ {
+ set { if (_original == null) NameBox.Text = value; }
+ }
+
+ // ─── 초기화 ───────────────────────────────────────────────────────────
+ private void LoadSession(AppSession? session)
+ {
+ if (session == null)
+ {
+ NameBox.Text = "새 세션";
+ DescBox.Text = "";
+ }
+ else
+ {
+ NameBox.Text = session.Name;
+ DescBox.Text = session.Description;
+ foreach (var app in session.Apps)
+ AddRow(app.Path, app.Label, app.SnapPosition, app.Arguments, app.DelayMs);
+ }
+
+ RefreshEmptyState();
+ }
+
+ private void BuildSnapPopup()
+ {
+ var panel = new StackPanel { Margin = new Thickness(0) };
+
+ foreach (var (key, label) in SnapOptions)
+ {
+ var keyCapture = key;
+ var border = new Border
+ {
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(10, 5, 10, 5),
+ Cursor = Cursors.Hand
+ };
+
+ border.MouseEnter += (_, _) =>
+ border.Background = new SolidColorBrush(Color.FromArgb(0x28, 0xFF, 0xFF, 0xFF));
+ border.MouseLeave += (_, _) =>
+ border.Background = Brushes.Transparent;
+
+ var stack = new StackPanel { Orientation = Orientation.Horizontal };
+ stack.Children.Add(new TextBlock
+ {
+ Text = keyCapture,
+ FontFamily = new FontFamily("Cascadia Code, Consolas"),
+ FontSize = 11,
+ Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue,
+ VerticalAlignment = VerticalAlignment.Center,
+ MinWidth = 68,
+ });
+ stack.Children.Add(new TextBlock
+ {
+ Text = label,
+ FontSize = 11,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ VerticalAlignment = VerticalAlignment.Center,
+ });
+ border.Child = stack;
+
+ border.MouseLeftButtonUp += (_, _) =>
+ {
+ if (_snapTargetRow != null)
+ {
+ _snapTargetRow.SnapPosition = keyCapture;
+ _snapTargetRow.UpdateSnapLabel();
+ }
+ SnapPickerPopup.IsOpen = false;
+ };
+
+ panel.Children.Add(border);
+ }
+
+ SnapOptionsList.Content = panel;
+ }
+
+ // ─── 앱 행 추가 ───────────────────────────────────────────────────────
+ private void AddRow(string path = "", string label = "", string snap = "full",
+ string args = "", int delayMs = 0)
+ {
+ var row = new AppRowUi(path, label, snap, args, delayMs);
+ _rows.Add(row);
+
+ var rowGrid = BuildRowGrid(row);
+ AppListPanel.Children.Add(rowGrid);
+ RefreshEmptyState();
+ }
+
+ private Grid BuildRowGrid(AppRowUi row)
+ {
+ var grid = new Grid { Margin = new Thickness(14, 2, 4, 2) };
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(100) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(108) });
+ grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(30) });
+
+ // 경로 TextBox
+ var pathBox = new TextBox
+ {
+ Text = row.Path,
+ FontSize = 11,
+ Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
+ Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black,
+ BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(6, 4, 6, 4),
+ VerticalContentAlignment = VerticalAlignment.Center,
+ ToolTip = "앱 실행 파일 경로",
+ Margin = new Thickness(0, 0, 4, 0),
+ };
+ pathBox.TextChanged += (_, _) => row.Path = pathBox.Text;
+ Grid.SetColumn(pathBox, 0);
+
+ // 라벨 TextBox
+ var labelBox = new TextBox
+ {
+ Text = row.Label,
+ FontSize = 11,
+ Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
+ Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black,
+ BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(6, 4, 6, 4),
+ VerticalContentAlignment = VerticalAlignment.Center,
+ ToolTip = "표시 이름 (선택)",
+ Margin = new Thickness(0, 0, 4, 0),
+ };
+ labelBox.TextChanged += (_, _) => row.Label = labelBox.Text;
+ Grid.SetColumn(labelBox, 1);
+
+ // 스냅 선택 Border (클릭 시 팝업)
+ var snapBtn = new Border
+ {
+ CornerRadius = new CornerRadius(4),
+ BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(6, 4, 6, 4),
+ Cursor = Cursors.Hand,
+ Margin = new Thickness(0, 0, 4, 0),
+ ToolTip = "스냅 위치 선택",
+ };
+ snapBtn.MouseEnter += (_, _) =>
+ snapBtn.Background = new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
+ snapBtn.MouseLeave += (_, _) =>
+ snapBtn.Background = Brushes.Transparent;
+
+ var snapLabel = new TextBlock
+ {
+ FontFamily = new FontFamily("Cascadia Code, Consolas"),
+ FontSize = 10,
+ Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue,
+ VerticalAlignment = VerticalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ };
+ snapBtn.Child = snapLabel;
+
+ // AppRowUi가 라벨 TextBlock을 참조할 수 있도록 저장
+ row.SnapLabelRef = snapLabel;
+ row.SnapButtonRef = snapBtn;
+ row.UpdateSnapLabel();
+
+ snapBtn.MouseLeftButtonUp += (sender, e) =>
+ {
+ _snapTargetRow = row;
+ SnapPickerPopup.PlacementTarget = (FrameworkElement)sender;
+ SnapPickerPopup.IsOpen = true;
+ e.Handled = true;
+ };
+ Grid.SetColumn(snapBtn, 2);
+
+ // 삭제 버튼
+ var delBtn = new Border
+ {
+ Width = 24,
+ Height = 24,
+ CornerRadius = new CornerRadius(4),
+ Cursor = Cursors.Hand,
+ ToolTip = "이 앱 제거",
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ };
+ delBtn.MouseEnter += (_, _) =>
+ delBtn.Background = new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x53, 0x50));
+ delBtn.MouseLeave += (_, _) =>
+ delBtn.Background = Brushes.Transparent;
+
+ var delIcon = new TextBlock
+ {
+ Text = "\uE74D",
+ FontFamily = new FontFamily("Segoe MDL2 Assets"),
+ FontSize = 12,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50)),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ delBtn.Child = delIcon;
+
+ delBtn.MouseLeftButtonUp += (_, _) =>
+ {
+ _rows.Remove(row);
+ AppListPanel.Children.Remove(grid);
+ RefreshEmptyState();
+ };
+ Grid.SetColumn(delBtn, 3);
+
+ grid.Children.Add(pathBox);
+ grid.Children.Add(labelBox);
+ grid.Children.Add(snapBtn);
+ grid.Children.Add(delBtn);
+
+ return grid;
+ }
+
+ private void RefreshEmptyState()
+ {
+ EmptyState.Visibility = _rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ // ─── 이벤트 핸들러 ────────────────────────────────────────────────────
+ private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e)
+ {
+ if (e.LeftButton == MouseButtonState.Pressed) DragMove();
+ }
+
+ private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close();
+ private void BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close();
+
+ private void BtnAddApp_Click(object sender, MouseButtonEventArgs e)
+ {
+ using var dlg = new System.Windows.Forms.OpenFileDialog
+ {
+ Title = "앱 실행 파일 선택",
+ Filter = "실행 파일 (*.exe)|*.exe|모든 파일 (*.*)|*.*",
+ };
+ if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
+
+ var label = Path.GetFileNameWithoutExtension(dlg.FileName);
+ AddRow(dlg.FileName, label, "full");
+ }
+
+ private void BtnSave_Click(object sender, MouseButtonEventArgs e)
+ {
+ var name = NameBox.Text.Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ NotificationService.Notify("세션 편집기", "세션 이름을 입력하세요.");
+ NameBox.Focus();
+ return;
+ }
+
+ // 빈 경로 행 필터링
+ var validApps = _rows
+ .Where(r => !string.IsNullOrWhiteSpace(r.Path))
+ .Select(r => new SessionApp
+ {
+ Path = r.Path.Trim(),
+ Arguments = r.Arguments.Trim(),
+ Label = r.Label.Trim(),
+ SnapPosition = r.SnapPosition,
+ DelayMs = r.DelayMs,
+ }).ToList();
+
+ var session = new AppSession
+ {
+ Name = name,
+ Description = DescBox.Text.Trim(),
+ Apps = validApps,
+ CreatedAt = _original?.CreatedAt ?? DateTime.Now,
+ };
+
+ // 기존 세션 교체 또는 신규 추가
+ if (_original != null)
+ {
+ var idx = _settings.Settings.AppSessions.IndexOf(_original);
+ if (idx >= 0)
+ _settings.Settings.AppSessions[idx] = session;
+ else
+ _settings.Settings.AppSessions.Add(session);
+ }
+ else
+ {
+ // 동일 이름 세션이 있으면 교체
+ var existing = _settings.Settings.AppSessions
+ .FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ if (existing != null)
+ _settings.Settings.AppSessions.Remove(existing);
+ _settings.Settings.AppSessions.Add(session);
+ }
+
+ _settings.Save();
+ NotificationService.Notify("AX Copilot",
+ $"세션 '{name}' 저장됨 ({validApps.Count}개 앱)");
+ LogService.Info($"세션 저장: {name} ({validApps.Count}개 앱)");
+ Close();
+ }
+}
+
+// ─── 앱 행 UI 모델 ────────────────────────────────────────────────────────────
+internal class AppRowUi
+{
+ public string Path { get; set; }
+ public string Label { get; set; }
+ public string SnapPosition { get; set; }
+ public string Arguments { get; set; }
+ public int DelayMs { get; set; }
+
+ // UI 참조 (라벨 갱신용)
+ internal System.Windows.Controls.TextBlock? SnapLabelRef { get; set; }
+ internal System.Windows.Controls.Border? SnapButtonRef { get; set; }
+
+ public AppRowUi(string path, string label, string snap, string args, int delayMs)
+ {
+ Path = path;
+ Label = label;
+ SnapPosition = snap;
+ Arguments = args;
+ DelayMs = delayMs;
+ }
+
+ public void UpdateSnapLabel()
+ {
+ if (SnapLabelRef != null)
+ SnapLabelRef.Text = SnapPosition;
+ }
+}